使用场景


多数据源的使用在分库的情况下并不稀奇,而平时的项目需求就很少见了。以前我也没去琢磨过,只是前阵子项目新的需求刚好需要,而且还不是同一种数据库。


我的奇葩新需求需要实现三个数据库动态切换,一个是大家都知道的mysql,一个是亚马逊的Redshift,还有一个也是亚马逊的服务Athena。为了都能使用mybatis,需要实现自动识别数据库从而切换数据源。有一个特殊,由于Athena的jdbc奇葩,mybatis并不兼容,只能使用古版的jdbc直操方式,但也不影响使用动态数据源。


Spring Boot项目中多数据源的配置


为了简单,我会缩减一些大家不用看也明白的代码。我将以实现三个不同数据库的数据源动态切换为例子,更贴近真实需求,讲述如何在spring boot项目中配置多数据源。希望各位看官能够耐心看完。


我选择抛弃spring boot提供的jdbc配置,自己定义多数据源的jdbc连接参数配置。并且我也加入了连接池的支持,多个数据源可以使用同一份连接池配置,前提是多个数据源都使用同一种连接池,如阿里云的druid连接池。网上很多都没有介绍到多数据源连接池的配置,没关系,看我这篇就够了。


1、配置jdbc连接信息


Mysql数据源的配置

mysql:
    driverClassName: com.mysql.jdbc.Driver
    jdbcUrl: jdbc:mysql://localhost:3306/xxxx?characterEncoding=utf8&useSSL=false
    username: root
    password:

Redshift数据源配置

redshift:
   driverClassName: com.amazon.redshift.jdbc42.Driver
   jdbcUrl: jdbc:redshift://xxx.xxxx.com:5439/xxxx
   username: xxxxx
   password: xxxxx

AWS Athena数据源配置

athena:
  username: xxxx
  password: xxxx
  aws-region: xxxx
  s3-outputlocation: s3://xxxxx/
  url: jdbc:awsathena://AwsRegion=%s;UID=%s;PWD=%s;S3OutputLocation=%s;
  driver: com.simba.athena.jdbc.Driver


2、配置连接池信息


在resources下创建一个连接池配置文件,我选择使用properties文件,因为我要实现自己读取配置信息。

druid.properties 数据库连接池配置信息。因为mysql和redshift都可以使用druid连接池,而Athena是个奇葩,就不管了,因为也很少用到,所以不给Athena配置连接池。

dataSource.initialSize=20
dataSource.minIdle=1
dataSource.maxIdle=20
dataSource.maxActive=100
dataSource.maxWait=60000


我还为此写了一个properties配置读取工具,实现自动读取指定配置信息,并映射为java对象返回。这里涉及到两个类,一个是PropertiesAnnotation,另一个是PropertiesUtils。前者是一个注解,后者实现读取配置信息并使用反射创建对象为对象的字段赋值,详细介绍请往下看。

先来创建一个用于接收数据库连接池配置信息的java类。字段名需要与配置文件中的字段名相同。并使用@PropertiesAnnotation注解声明需要从哪个文件读取,配置的前缀是什么。你可能不理解前缀是什么,比如前面的数据库连接池配置:dataSource.initialSize,那么前缀就是dataSource,如果没有前缀则写""。

    @PropertiesAnnotation(filePath = "database/druid.properties", prefix = "dataSource")
    @NoArgsConstructor
    @Data
    public class DruidConfig {
        private Integer initialSize;
        private Integer minIdle;
        private Integer maxIdle;
        private Integer maxActive;
        private Integer maxWait;
    }

创建PropertiesAnnotation注解,本应该是先创建该注解的,但我为了习惯大家的理解顺序(按浏览顺序理解)。PropertiesAnnotation声明为运行时使用在类上的注解,只有两个属性,一个是文件基于classpath的路径,另一个就是配置信息的前缀,已经在前面说过了。

/**
 * @author wujiuye
 * @version 1.0 on 2019/4/24 {描述:}
 */

@Target({ElementType.TYPE})
@Retention(RUNTIME)
@Documented
public @interface PropertiesAnnotation {
    /**
     * 文件路径,基于classpath
     */

    String filePath();

    /**
     * 属性名前缀
     */

    String prefix();
}

PropertiesUtils实现的功能就是自动读取解析properties配置文件,只需要传递你用来接收配置信息的java类即可。getPropertiesConfig方法会读取java类(Class)上的@PropertiesAnnotation注解,并从注解中获取到文件的路径,以及配置的前缀信息,如database,然后读取配置文件,从配置文件中找到前缀为database的配置。根据反射生成一个java对象,并将配置信息赋值给java对象中对应的字段。一时看不明白没关系,可以跳过往后看。

/**
 * @author wujiuye
 * @version 1.0 on 2019/4/24 {描述:}
 */

public class PropertiesUtils {
    /**
     * 获取配置文件内容
     *
     * @return
     */

    public static <T> T getPropertiesConfig(Class<T> configClass) throws Exception {
        ClassLoader loader = configClass.getClassLoader();
        T obj = configClass.newInstance();
        Properties properties = new Properties();
        //获取注解信息
        PropertiesAnnotation propertiesAnnotation = configClass.getAnnotation(PropertiesAnnotation.class);
        if (propertiesAnnotation == null) {
            throw new Exception("not found @PropertiesAnnotation annotation!!!");
        }
        if (StringUtil.isEmpty(propertiesAnnotation.filePath())) {
            throw new Exception("file path is null!!!");
        }
        String prefix = propertiesAnnotation.prefix();
        if (StringUtil.isEmpty(prefix)) {
            prefix = "";
        } else {
            prefix += '.';
        }
        try (InputStream in = loader.getResourceAsStream(propertiesAnnotation.filePath())) {
            properties.load(new InputStreamReader(in"utf-8"));
            Field[] fields = configClass.getDeclaredFields();
            if (fields == null || fields.length == 0) {
                return obj;
            }
            for (Field field : fields) {
                field.setAccessible(true);
                String value = properties.getProperty(prefix + field.getName());
                if (field.getType() == Integer.class || field.getType() == int.class) {
                    field.set(obj, Integer.valueOf(value));
                } else if (field.getType() == Long.class || field.getType() == long.class) {
                    field.set(obj, Long.valueOf(value));
                } else if (field.getType() == Boolean.class || field.getType() == boolean.class) {
                    field.set(obj, Boolean.valueOf(value));
                } else {
                    field.set(obj, value);
                }
            }
        } catch (Exception e) {
            throw e;
        }
        return obj;
    }

}



3、先配置这三个数据源


如何获取数据源的配置信息不用说了吧,最简单的可以使用@Value。如mysql数据源配置信息,其它的自己花几秒钟脑补。

    @Value("${mysql.driverClassName}")
    private String driverClassName;
    @Value("${mysql.jdbcUrl}")
    private String jdbcUrl;
    @Value("${mysql.username}")
    private String username;
    @Value("${mysql.password}")
    private String password;

创建一个数据源的配置类DataSourceConfiguration。
a、配置mysql数据源

    //mysql数据源
    @Bean(name = "mysql-database")
    public DataSource mysqlDatabase() {
        DruidDataSource druidDataSource = new DruidDataSource();
        //配置jdbc
        druidDataSource.setDriverClassName(driverClassName);
        druidDataSource.setUrl(jdbcUrl);
        druidDataSource.setUsername(username);
        druidDataSource.setPassword(password);
        //配置连接池信息
        PropertiesUtils.DruidConfig druidConfig = PropertiesUtils.getPropertiesConfig(PropertiesUtils.DruidConfig.class);
       druidDataSource.setMinIdle(druidConfig.getMinIdle());
       druidDataSource.setMaxWait(druidConfig.getMaxWait());
       druidDataSource.setMaxActive(druidConfig.getMaxActive());
       druidDataSource.setInitialSize(druidConfig.getInitialSize());
       .....
        return druidDataSource;
    }

Redshift数据源的配置同mysql数据源的配置,这里要说的是另一个奇葩数据源Athena的配置。

    //athena数据源,比较特殊
    @Bean(name = "athena-database")
    public DataSource athenaDatabase() {
        //加载athena的驱动类
        try {
            Class.forName(ATHENA_DRIVER);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        com.simba.athena.jdbc.DataSource dataSource = new com.simba.athena.jdbc.DataSource();
        String url = String.format(ATHENA_URL, ATHENA_REGION, ATHENA_USERNAME, ATHENA_PASSWORD, ATHENA_S3_OUTPUTLOCATION);
        dataSource.setURL(url);
        return dataSource;
    }



4、配置动态数据源


这里需要使用spring jdbc框架提供的一个类AbstractRoutingDataSource,这是一个让我们轻松实现多数据切换使用的抽象类,需要我们继承该类,实现具体的切换逻辑。AbstractRoutingDataSource不需要引入多余的依赖,这是spring框架本身提供的,如果你的项目使用不了这个类,那就应该是spring boot版本的问题了。


/**
 * @author wujiuye
 * @version 1.0 on 2019/4/17 {描述:
 * 使用Spring的AbstractRoutingDataSource实现多数据源切换
 * }
 */

public class DynamicDataSource extends AbstractRoutingDataSource {
    /**
     * 动态设置数据源
     * 配置多数据源时map的key
     * @return
     */

    @Override
    protected Object determineCurrentLookupKey() {
       System.out.println("数据源为" + DataSourceContextHolder.getDataSource());
        return DataSourceContextHolder.getDataSource();
    }

}

determineCurrentLookupKey方法就是用来实现具体的切换逻辑的,你会看到,这里只是用了DataSourceContextHolder.getDataSource()方法返回当前需要使用的数据源。因为一次数据库访问操作是在一个线程内完成的,所以我使用ThreadLocal来存储当前jdbc线程应该使用哪个数据源,而具体使用哪个数据需要在mapper方法执行之前使用AOP设置,后面具体讲。

determineCurrentLookupKey方法返回的是数据源的key,因为多个数据源是使用一个map存储的,你也可以理解为spring管理bean的name,但最好不要这么理解,稍后看动态数据源dynamicDataSource配置的时候就能明白了。

/**
     * 动态数据源: 通过AOP在不同数据源之间动态切换
     *
     * @return
     */

    @Primary
    @Bean(name = "dynamicDataSource")
    public DataSource dynamicDataSource() {
        DynamicDataSource dynamicDataSource = new DynamicDataSource();
        // 默认数据源,当没有使用@DataSource注解时使用,
        // 而使用了@DataSource注解如果没有设置beanName也要Aop自己配置使用默认的bean
        dynamicDataSource.setDefaultTargetDataSource(mysqlDatabase());
        // 配置多数据源
        // key -> bean
        Map<Object, Object> dsMap = new HashMap();
        dsMap.put("mysqlDatabase", mysqlDatabase());
        dsMap.put("athenaDatabase", athenaDatabase());
        dynamicDataSource.setTargetDataSources(dsMap);
        return dynamicDataSource;
    }

    /**
     * 配置@Transactional事物注解
     * 使用动态数据源
     *
     * @return
     */

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dynamicDataSource());
    }

注意,事务的管理也需要配置为使用动态数据源,否则@Transactional的使用会有意想不到的意外。但其实你配置了也有意外,就是使用事务之后动态数据源就不生效了。

/**
 * @author wujiuye
 * @version 1.0 on 2019/4/17 {描述:}
 */

public class DataSourceContextHolder {

    private final static String DEFAULT_DATASOURCE = "mysqlDatabase";

    public static String getDefaultDataSourceBeanName() {
        return DataSourceContextHolder.DEFAULT_DATASOURCE;
    }

    /**
     * 保存的是多数据源Map的key
     */

    private static final ThreadLocal<String> contextHolder = new ThreadLocal<>();

    // 设置数据源Key
    public static void setDataSource(String dataSourceKey{
        //System.out.println("切换到{" + dataSourceKey + "}数据源");
        contextHolder.set(dataSourceKey);
    }

    // 获取当前线程应该使用的数据源的key
    public static String getDataSource() {
        return (contextHolder.get());
    }

    // 清除当前线程使用的数据源的key
    public static void clearDataSource() {
        contextHolder.remove();
    }

}

5、实现数据源的动态切换

其实,到第四步多数据源就算配置完成了,但是最关键的动态根据业务切换数据源还没有实现。如果你认为这样就完了,那么就只会永远使用默认的数据源。


如何使用AOP实现动态数据源的切换,当然是实现一个切面,定义一个切点,而切点就是所有mapper(mybatis)接口中的方法。使用基于注解的切点会很容易实现这一需求。先创建一个注解@DataSource,只需要一个属性即可,用来标明该方法使用哪个数据源。


/**
 * @author wujiuye
 * @version 1.0 on 2019/4/17 {描述:
 * 用于切换数据源
 * }
 */

@Target({ElementType.METHOD})
@Retention(RUNTIME)
@Documented
public @interface DataSource {
    //数据源的Key
    String value();
}

接着就是实现AOP类,我们并不需要干涉具体的数据库执行增删改查的操作,所以不要使用环绕增强,使用前置增强即可。同时因为使用ThreadLocal存储切换的数据源,应该在方法执行完成之后清除设置,避免污染其它未使用@database注解的mapper方法,应为线程重用问题。AOP具体的实现代码如下。

@Aspect
@Component
public class DynamicDataSourceAop {

    /**
     * 定义数据源切点
     */

    @Pointcut("@annotation(com.hippo.cayman.annotation.DataSource)")
    public void dataSourcePointcut() {
    }

    /**
     * 根据注解动态设置数据源,没有注解的使用默认数据源
     *
     * @param point
     */

    @Before("dataSourcePointcut()&&@annotation(dataSource)")
    public void beforeSettingDataSource(JoinPoint point, DataSource dataSource) {
        String dataSourceKey = DataSourceContextHolder.getDefaultDataSourceKey();
        try {
            if (dataSource.value() != null) {
                dataSourceKey = dataSource.value();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 切换数据源
        DataSourceContextHolder.setDataSource(dataSourceKey);
    }

    /**
     * 方法执行完成之后需要移除设置的数据源(ThreadLocal保存的要清除)
     */

    @After("dataSourcePointcut()")
    public void afterRemoveSetting() {
        DataSourceContextHolder.clearDataSource();
    }
}


注意一点,方法执行异常也需要清除数据源的设置。


/**
     * 执行异常也需要清除
     */

    @AfterThrowing("dataSourcePointcut()")
    public void afterExceptionSetting() {
        DataSourceContextHolder.clearDataSource();
    }


6、使用


使用很简单,在mapper类的方法上使用注解声明需要使用的数据源即可。


public interface UserMapper {

    @DataSource("athenaDatabase")
    @Select("select * from sys_account where username=#{username} limit 1")
    User selectByUsername(String username);

}


当前存在的缺点


不支持使用@Transactional事务,因为@Transactional是加在Service层的。


1、使用事务时,数据源切换失败。

使用了@Transactional,则每次都会从TransactionUtils的ThreadLocal中去拿数据源,如果为空,就创建新的连接,如果不为空的话直接使用,而ThreadLocal是线程的变量,因为ThreadLocal不为空,所以这就会导致数据源切换失败。


2、使用事务,同时也使用线程池时需要注意

如果你使用了数据库连接线程池,那么前面第一点的问题你就很难解决了。关于第一点网页有很多解决方法,但是我目前还不需要用到,如果我用到我会去研究源码再给大家分享解决方案,在此先埋下一个坑,因为确实没有时间折腾这东西。


总结


多数据源的配置就介绍到这里,如果想玩出花样,还需要自己去琢磨,不自己思考是感受不到那种成功的喜悦的。有时候也需要有种动力逼迫自己去学习,比如换工作带来的竞争压力,再比如主动挑战复杂的业务需求。也可以是主动去优化项目代码。我的代码优点是能合理的应用设计模式,将对外提供的功能尽可能封装,让使用更简单,让代码阅读更清晰,同时,也会考虑到扩展性、兼容性,能使用算法的地方尽可能的使用,多线程编程需要考虑性能,jdk新特性要用好,其实这些我之前也发过一篇文章专门聊代码优化的。




https://mp.weixin.qq.com/s/_OK3AgYoyoqu7QHY8QWLjw