自定义多数据源并使用注解动态切换

Lou.Chen2022年11月1日
大约 8 分钟

自定义多数据源实现思路

  • 自定义注解@DataSource,可以加在Service层的类上或者方法上,并设置一个默认数据源(master)
    • 加在类上,代表该类下的所有方法都使用该数据源
    • 加在方法上,表示该方法使用该数据源。
  • 自定义切面,定义切点(所有方法和类上加了@DataSource注解)和环绕通知,先获取方法上加了@DataSource,如果没有获取到,则获取类上的,即方法上的优先级高于类上的优先级。接着获取到注解上指定数据源值,并将此数据源值存到ThreadLocal中。
  • 每次Mapper执行的时候,JDBC会使用AbstractRoutingDataSource实例中的determineCurrentLookupKey方法得到指定数据源。所有只需要在该实例注入到时候设置所有的数据源信息(key为yaml中配置的一级属性,value为属性配置对应的数据源信息)和默认的数据源信息,然后通过重写determineCurrentLookupKey方法,返回ThreadLocal对应的数据源值即可

实现通过页面参数实现切换数据源思路

  • 客户端在进行页面数据源切换操作时,需要请求服务器先把客户端指定的数据源值存在一个位置,方便后续切面通过此位置拿到数据源信息,这里可以选择session,redis等位置。这里选择将客户端传来的数据源值存在session中。

  • 然后需要增加一个切面即可:先定义切面,切点范围为所有Service下的所有类和方法,定义环绕通知,拦截方法后首先拿到上一步存在Session中的数据源信息存入ThreadLocal。

怎么保证内部两个切面的优先级

  • 可以通过@Order注解指定切面执行的优先级,若@Order值小的则会先执行,@Order大的会后执行,那么在对数据源信息进行存储在ThreadLocal时,先执行的值会被后执行的覆盖。
  • 所有可以通过指定@Order注解,值大的,切面最后执行,最终配置优先级最高。

案例

依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.4</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.11</version>
        </dependency>

        <dependency>
            <groupId>org.lc.demo</groupId>
            <artifactId>spring-demo-starter</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

yaml配置

# 数据源配置
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driverClassName: com.mysql.cj.jdbc.Driver
    ds:
      # 主库数据源
      master:
        url: jdbc:mysql://localhost:3306/test01?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: 123456
      # 从库数据源
      slave:
        url: jdbc:mysql://localhost:3306/test02?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
        username: root
        password: 123456
      # 其他数据源1,2....n
    #以下是公共配置
    # 初始连接数
    initialSize: 5
    # 最小连接池数量
    minIdle: 10
    # 最大连接池数量
    maxActive: 20
    # 配置获取连接等待超时的时间
    maxWait: 60000
    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
    timeBetweenEvictionRunsMillis: 60000
    # 配置一个连接在池中最小生存的时间,单位是毫秒
    minEvictableIdleTimeMillis: 300000
    # 配置一个连接在池中最大生存的时间,单位是毫秒
    maxEvictableIdleTimeMillis: 900000
    # 配置检测连接是否有效
    validationQuery: SELECT 1 FROM DUAL
    testWhileIdle: true
    testOnBorrow: false
    testOnReturn: false
    webStatFilter:
      enabled: true
    statViewServlet:
      enabled: true
      # 设置白名单,不填则允许所有访问
      allow:
      url-pattern: /druid/*
      # 控制台管理用户名和密码
      login-username: tienchin
      login-password: 123456
    filter:
      stat:
        enabled: true
        # 慢SQL记录
        log-slow-sql: true
        slow-sql-millis: 1000
        merge-sql: true
      wall:
        config:
          multi-statement-allow: true

数据源配置

常量配置
/**
 * 公共的常量配置信息
 */
public class DataSourceConstantType {
    //默认的数据源的key
    public static final String DEFAULT_DATASOURCE = "master";

    //将要切换对数据源信息保存在session中key
    public static final String DS_SESSION_KEY = "ds_session_key";
}

数据源属性配置
@ConfigurationProperties(prefix = "spring.datasource")
@Data
public class DruidProperties {
    private String type;
    private String driverClassName;
    /**
     * 这里存储多个数据源的主要配置url、username、password。当然如果有其他配置也可以加入进来
     */
    private Map<String, Map<String, String>> ds;
    //初始连接数
    private Integer initialSize;
    //最小连接池数量
    private Integer minIdle;
    //最大连接池数量
    private Integer maxActive;
    //配置获取连接等待超时的时间
    private Integer maxWait;
    //
    //...省略其他公共配置
    //

    /**
     * 从外部构造一个DruidDataSource对象传进来:只包含 url、username、password。
     * 从外部将DruidDataSource传入该方法后相当于给每个数据源设置相同的公共配置
     */
    public DataSource setDataSource(DruidDataSource druidDataSource) {
        druidDataSource.setInitialSize(initialSize);
        druidDataSource.setMinIdle(minIdle);
        druidDataSource.setMaxActive(maxActive);
        druidDataSource.setMaxWait(maxWait);
        return druidDataSource;
    }
}
动态数据源上下文线程配置
/**
 * 此方法可以用于将每个线程的使用的数据源存入到ThreadLocal中
 */
public class DynamicDataSourceContextHolder {

    private static ThreadLocal<String> CONTEXT_HOLDER=new ThreadLocal<>();

    public static void setDataSourceType(String dataSourceType) {
        CONTEXT_HOLDER.set(dataSourceType);
    }

    public static String getDataSourceType() {
        return CONTEXT_HOLDER.get();
    }

    public static void removeDataSourceType() {
        CONTEXT_HOLDER.remove();
    }
}

加载整合数据源工具类配置
@Component
@EnableConfigurationProperties(DruidProperties.class)
public class LoadDataSource {

    @Autowired
    public DruidProperties druidProperties;

    public Map<String, DataSource> loadAllDataSource() {
        //处理完之后的完整的数据源信息集合
        Map<String, DataSource> completeDataSource = new HashMap<>();
        //获取所有的数据源配置信息
        Map<String, Map<String, String>> ds = druidProperties.getDs();
        try {
            Set<String> keyDataSource = ds.keySet();
            //遍历所有数据源
            for (String dataSource : keyDataSource) {
                //将map中的数据源信息转换为DruidDataSource对象
                DruidDataSource druidDataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(ds.get(dataSource));
                //补全DruidDataSource中其他的公共配置信息
                completeDataSource.put(dataSource, druidProperties.setDataSource(druidDataSource));
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return completeDataSource;
    }

}

JDBC执行动态查找数据源配置
@Component
public class DynamicDataSource extends AbstractRoutingDataSource {
    /**
     * 这里使用构造器注入的安全方式。
     * 如果使用@Autowired方式注入,则注入为null. 因为构造器先于属性filed注入方式执行
     */
    public DynamicDataSource(LoadDataSource loadDataSource) {
        //1.获取所有数据源
        Map<String, DataSource> allDataSource = loadDataSource.loadAllDataSource();
        //设置查找的所有数据源信息
        super.setTargetDataSources(new HashMap<>(allDataSource));
        //2.设置默认的数据源信息
        super.setDefaultTargetDataSource(allDataSource.get(DataSourceConstantType.DEFAULT_DATASOURCE));
        super.afterPropertiesSet();
    }

    /**
     * Mapper去查询数据库前,会先动态调用此方法获取数据源的key决定使用哪个数据源
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceType();
    }
}

自定义注解

/**
 * 可以将该注解加在Service层上的方法或者类上,表明使用哪个数据源
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE,ElementType.METHOD})
public @interface DataSource {
    //如果没有指定数据源信息,则默认使用master配置
    String value() default DataSourceConstantType.DEFAULT_DATASOURCE;
}

切面配置

自定义获取注解切面
@Component
@Aspect
@Order(10)
public class DataSourceAspect {

    /**
     * 定义切点,什么条件会拦截
     * @annotation(org.lc.demo.annocation.DataSource) 表示加了@DataSource注解的方法会拦截
     * @within(org.lc.demo.annocation.DataSource) 表示加了@DataSource注解的类会拦截
     */
    @Pointcut("@annotation(org.lc.demo.annocation.DataSource) || @within(org.lc.demo.annocation.DataSource)")
    public void pointcutDataSource() { }

    /**
     * 定义环绕通知
     */
    @Around("pointcutDataSource()")
    public Object around(ProceedingJoinPoint joinPoint) {
        //获取方法和类上的@DataSource注解
        DataSource dataSource=getDataSource(joinPoint);
        if (dataSource != null) {
            String value = dataSource.value();
            //将拦截到的数据源的值存储到ThreadLocal中
            DynamicDataSourceContextHolder.setDataSourceType(value);
        }
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        } finally {
            DynamicDataSourceContextHolder.removeDataSourceType();
        }
        return null;
    }

    /**
     * 获取方法或者类上的@DataSource注解
     */
    public DataSource getDataSource(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        //先查找方法上的@DataSource注解(代表方法上的优先级高于类上的优先级)
        DataSource annotation = AnnotationUtils.findAnnotation(signature.getMethod(), DataSource.class);
        if (annotation == null) {
            //若方法上找不到@DataSource注解,则去类上找。
            annotation=AnnotationUtils.findAnnotation(signature.getDeclaringType(), DataSource.class);
        }
        return annotation;
    }
}

自定义session获取切面
@Component
@Aspect
@Order(11)
public class GlobalDataSourceAspect {

    @Autowired
    private HttpSession httpSession;

    /**
     * 对org.lc.demo.service对所有类下对所有方法进行拦截
     */
    @Pointcut("execution(* org.lc.demo.service.*.*(..))")
    public void pc() {
    }

    @Around("pc()")
    public Object around(ProceedingJoinPoint pjp) {
        //从session中拿到切换数据源信息存入到ThreadLocal中
        DynamicDataSourceContextHolder.setDataSourceType((String) httpSession.getAttribute(DataSourceConstantType.DS_SESSION_KEY));
        Object result=null;
        try {
            result = pjp.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }finally {
            DynamicDataSourceContextHolder.removeDataSourceType();
        }
        return result;
    }
}

实体

@Data
public class Person {
    private String id;
    private String name;
    private Integer age;
}

mapper

@Mapper
public interface PersonMapper {
    @Select("select * from person")
    List<Person> getAllPerson();
}

service

@Service
//默认所有方法使用主数据源
// @DataSource
public class PersonService {
    @Autowired
    private PersonMapper personMapper;

  	//方法上的优先级高于类上使用的数据源。并且生效的数据源根据两个切面配置的最终执行顺序有关
    //@DataSource("slave")
    public List<Person> getAllPerson() {
        return personMapper.getAllPerson();
    }

}

controller

@RestController
public class DataSourceChangeController {
    private static final Logger logger = LoggerFactory.getLogger(DataSourceChangeController.class);

    @Autowired
    private PersonService personService;

    /**
     * 切换数据源
     * @param dsType 指定数据源
     * @param httpSession
     */
    @PostMapping("/changeDataSource")
    public void changeDataSource(String dsType, HttpSession httpSession) {
        httpSession.setAttribute(DataSourceConstantType.DS_SESSION_KEY, dsType);
        logger.info("数据源切换成功:{}", dsType);
    }

    /**
     * 获取数据
     * @return
     */
    @PostMapping("/getAllPerson")
    public List<Person> getAllPerson() {
        return personService.getAllPerson();
    }
}

页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <!-- 引入样式 -->
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    <!-- 引入组件库 -->
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>

<div id="app">
    <el-tag>切换数据源:</el-tag>
    <el-select v-model="value" placeholder="请选择" @change="changeDataSource">
        <el-option
                v-for="item in options"
                :key="item.value"
                :label="item.label"
                :value="item.value">
        </el-option>
    </el-select>
    <el-button type="primary" @click="loadDataSource">加载数据</el-button>
    <el-input
            type="textarea"
            :rows="2"
            placeholder="数据内容"
            v-model="textarea">
    </el-input>
</div>
<script>
     new Vue({
        el: '#app',
        data: {
            value:'master',
            textarea:'',
            options:[
                {label:"主数据源",value:'master'},
                {label:"从数据源",value:'slave'}
            ]
        },
        methods:{
            changeDataSource(value) {
                axios({
                    method: 'post',
                    url: '/changeDataSource',
                    params: {
                        dsType: value
                    }
                })
            },
            loadDataSource() {
                axios({
                    method: 'post',
                    url: '/getAllPerson'
                }).then(res=>{
                    this.textarea = JSON.stringify(res.data);
                })
            }
        }
    })
</script>
</body>
</html>

测试结果分析

根据上述AOP切面信息,DataSourceAspect切面先于GlobalDataSourceAspect先执行,所以最终生效的的配置为GlobalDataSourceAspect中指定的通知,也就是存在在ThreadLocal中的数据源信息被后者覆盖了,也就是执行的方法以执行/changeDataSource请求传过来存在session中的数据源信息为准。如果想要以方法或者类上的结果为准,那么则改变切面执行的顺序即可,也就是GlobalDataSourceAspect切面先执行,DataSourceAspect切面后执行

  • 主数据信息
image-20221025233030564
  • 从数据源信息
image-20221025233323364