一、基础介绍
适用场景
大部分的程序都有一个登录功能,当我们拥有了一个登录服务的时候,能否做到让所有的程序都调用此服务提供登录功能?
核心问题就在于不同的程序使用的是不同的数据库(数据源),那么只要让登录服务具有切换不同数据库的能力就可以了
涉及到的内容
- JWT(令牌)
- Multi-Tenant(多租户)
依赖
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.17</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.6.1.Final</version>
</dependency>
<!-- MySQL8.x -->
<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.23</version>
</dependency>
二、核心代码实现(基于AOP)
自定义工具
-
ApplicationContextUtil类
/* * 用于获取所有的配置 */ @Component public class ApplicationContextUtil implements ApplicationContextAware{ private static ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext applicationContext1) { applicationContext = applicationContext1; } public static ApplicationContext getApplicationContext() { return applicationContext; } public static <T> T getBean(Class<T> clazz) { return (T) applicationContext.getBean(clazz); } }
-
TenantContext类
/** * 维护租户标识信息 */ @Component public class TenantContext { // 本地线程,用于存储TenantId private static ThreadLocal<String> currentTenant = new ThreadLocal<>(); public static void setCurrentTenant(String tenant) { currentTenant.set(tenant); } public static String getCurrentTenant() { return currentTenant.get(); } public static void clear() { currentTenant.set(null); } }
-
TenantTable类
/** * 数据连接所需要的属性 */ @Getter @Setter @Entity @Table(name = "tenant_able") @NoArgsConstructor @AllArgsConstructor public class TenantTable implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Column(name = "tenant_id") private String tenantId; @Column(name = "data_source_url") private String dataSourceUrl; @Column(name = "data_source_username") private String dataSourceUsername; @Column(name = "data_source_password") private String dataSourcePassword; @Column(name = "data_source_driver") private String dataSourceDriver; }
通过拦截器获取Token令牌
为什么需要令牌?不同的程序拥有不同的数据源,我们将当前程序所属的数据源的关键信息(TenantId)保存在令牌之中
-
WebMvcConfig类
/** * 拦截配置 */ @Configuration public class WebMvcConfig extends WebMvcConfigurationSupport { @Autowired private TenantInterceptor tenantInterceptor; @Override protected void addInterceptors(InterceptorRegistry registry) { // 登录、注册之类的无需拦截,这些使用的都是默认数据源 registry.addInterceptor(this.tenantInterceptor). addPathPatterns("/**").excludePathPatterns("/login"); super.addInterceptors(registry); } }
-
TenantInterceptor类
@Component public class TenantInterceptor extends HandlerInterceptorAdapter { @Autowired private JwtUtil jwtUtil; // 自定义的令牌工具类 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String authToken = request.getHeader("Authorization"); //TenantContext为本地线程,它保存了TenantId(数据源的关键信息) TenantContext.setCurrentTenant(jwtUtil.getTenantId(authToken)); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { TenantContext.clear(); } }
核心配置
Spring提供了AbstractRoutingDataSource方法,该提供了程序运行时动态切换数据源的方法
更加具体的来说,AbstractRoutingDataSource方法内部维护了一个Map类型的resolvedDataSources,它会根据determineCurrentLookupKey()抽象方法所返回的key找到在resolvedDataSources中的value,而这个value就是用于连接数据的DataSource。
步骤总结为:从数据库得到据的数据源,将这些数据源以<tenantId,dataSource>的形式存储到resolvedDataSources中,之后的运行只需要根据tenantId得到所需的数据源,即可完成数据的访问
-
DynamicDataSource类
/** * 数据源的替换 */ @Component public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return TenantContext.getCurrentTenant(); } }
-
DataSourceConfig类
/** * 配置数据源 */ @Configuration public class DataSourceConfig { /** * 配置数据源,将从配置文件中获取信息 * @return */ @Bean(name = "defaultDataSource") @ConfigurationProperties(prefix = "spring.datasource") public DataSource defaultDataSource() { return DataSourceBuilder.create().build(); } /** * 获取动态数据源 * @return */ @Bean(name = "dynamicDataSource") @Primary public DataSource dynamicDataSource() { // 数据源的切换核心在于对AbstractRoutingDataSource中的resolvedDataSources进行操作 // 即可以对dynamicDataSource进行持久化操作 DynamicDataSource dynamicDataSource = TenantDataSource.getDynamicDataSource(); // 设置默认的数据源 dynamicDataSource.setDefaultTargetDataSource(defaultDataSource()); // 初始化resolvedDataSources,此时resolvedDataSources不存在任何数据 dynamicDataSource.setTargetDataSources(TenantDataSource.getDataSourceMap()); return dynamicDataSource; } @Bean public PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(dynamicDataSource()); } }
使用AOP进行多租户配置
-
AnoDot注解接口
/** * 自定义注解,表示拥有此注解的查询需要切换数据源 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface AnoDot { }
-
DataSourceAspect类
/** * AOP,负责拦截需要切换数据源的查询语句 */ @Aspect @Component @Slf4j @Order(1) public class DataSourceAspect { @Pointcut("@annotation(com.gaia.cloud.arithmetic.annotation.AnoDot)") public void point() {} @Before("point()") public void before(JoinPoint joinPoint) { // 动态增加数据源 TenantDataSource.setDataSource(TenantContext.getCurrentTenant()); } }
数据源是如何动态增加的
-
TenantDataSource类
@Component public class TenantDataSource { // 实际的resolvedDataSources,将dataSourceMap中的数据存入resolvedDataSources中 private static Map<Object, Object> dataSourceMap = new HashMap<>(); // 实际的数据源切换类 private static DynamicDataSource dynamicDataSource; // 使用线程安全的单例模式 public static DynamicDataSource getDynamicDataSource() { if (dynamicDataSource == null) { synchronized (TenantDataSource.class) { if (dynamicDataSource == null) dynamicDataSource = new DynamicDataSource(); } } return dynamicDataSource; } public static Map getDataSourceMap() { return dataSourceMap; } public static void setDataSourceMap(String dataSourceName, DataSource dataSource) { dataSourceMap.put(dataSourceName, dataSource); } public static void setDataSource(String tenantId) { // 采用懒汉式加载数据源 if (!dataSourceMap.containsKey(tenantId)) { // 通过上下文获取tenantInformationRepository类,通过此类查询数据库中的数据源 TenantInformationRepository tenantInformationRepository = ApplicationContextUtil.getBean(TenantInformationRepository.class); TenantTable tenantTable = tenantInformationRepository.findByTenantId(tenantId); if (tenantTable != null){ dataSourceMap.put(tenantId, TenantDataSource.getDataSourceUtil(tenantTable)); // 将dataSourceMap中的数据存入resolvedDataSources中 dynamicDataSource.setTargetDataSources(dataSourceMap); // AbstractRoutingDataSource中不仅仅维护了resolvedDataSources这一个map // 具体如何操作可以查看源码 dynamicDataSource.afterPropertiesSet(); } } } // 创建DATaSource public static DataSource getDataSourceUtil(TenantTable tenantInfo) { DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create(); dataSourceBuilder.url(tenantInfo.getDataSourceUrl()); dataSourceBuilder.username(tenantInfo.getDataSourceUsername()); dataSourceBuilder.password(tenantInfo.getDataSourcePassword()); dataSourceBuilder.driverClassName(tenantInfo.getDataSourceDriver()); dataSourceBuilder.build(); return dataSourceBuilder.build(); } }
三、实现方式
整体上与普通的Spring boot项目并无区别
-
区别1
在登录表中额外增加一列tenant_id数据,用来表示此用户使用是的哪一个数据源
-
区别2
增加一个数据源表,保存所需的数据源
-
区别3
增加若干数据库,连接数据与数据源表一致即可
test1与test2中都有一张employee表
-
在查询employee表中数据时,增加自定义注解:@AnoDot注解
public interface EmployeeInformationRepository extends JpaRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> { @AnoDot Employee findByEmployeeName(String employeeName); }
现在切换数据源就变成了 哪些查询需要切换数据源,增加一个@AnoDot注解即可