杂谈--基于AOP的多租户模式的实现

一、基础介绍

适用场景

大部分的程序都有一个登录功能,当我们拥有了一个登录服务的时候,能否做到让所有的程序都调用此服务提供登录功能?

核心问题就在于不同的程序使用的是不同的数据库(数据源),那么只要让登录服务具有切换不同数据库的能力就可以了

涉及到的内容

  • 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. 区别1

    在登录表中额外增加一列tenant_id数据,用来表示此用户使用是的哪一个数据源
    在这里插入图片描述

  2. 区别2

    增加一个数据源表,保存所需的数据源
    在这里插入图片描述

  3. 区别3

    增加若干数据库,连接数据与数据源表一致即可
    在这里插入图片描述

    test1与test2中都有一张employee表

  4. 在查询employee表中数据时,增加自定义注解:@AnoDot注解

    public interface EmployeeInformationRepository extends JpaRepository<Employee, Integer>, JpaSpecificationExecutor<Employee> {
        @AnoDot
        Employee findByEmployeeName(String employeeName);
    }
    

    现在切换数据源就变成了 哪些查询需要切换数据源,增加一个@AnoDot注解即可

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值