现在一般的数据库读写分离都有很多现成的工具去做了,但是有时候,在业务量不是特别特别大,又需要做读写分离的时候,我们可以自己动手一个基于AOP切面的数据源动态选择。
既然提到多数据源了,那就会涉及到数据源的选择时机问题。在自己动手做的时候,也现在网速百度了下,基本有以下几种情况出现:
1.mapper接口定义两套
2.在mapper层做动态选择
3.在serivce层做动态选择
先简单比较一下三种方法:
第一种,定义两套接口的方式,可以实现功能,而且强保证增删改走写库,查走读库,若系统很小,又不会其他的方式实现,可以尝试这样用,但是开发的工作量会很大,后期的接口维护也会令你头大的,因此,此种方法要慎重。
第二种,在mapper层做数据源选择,和第一种相比,开发量会减少,写的好点的代码,也能强保证增删改走写库,查走读库,但是事务方面,因为夸库了,所以不能保证事务的正确性。可能有同学会说,写操作已经在同一个库实现了,怎么会出现事务问题?
那就得这样想了:主从的数据库,数据同步会有延迟,如果在某些特性条件下,需要是查询结果来判断是否需要回滚,那是不是就会出现错误的事务了。这是其一;其二:如果有两个写库呢,你在哪执行事务?因此这个方式也不推荐。
第三种,也就是我们推荐的方式了,在service层选择数据源,我们可以就在一个事务中执行代码了,不会存在夸库的情况。但是这个也不是十分完美,或者说是百分百读写分离的。因为在同一个代码块或者事务中,是可能有读也有写的。
下面就来具体实现啦:
步驟一:創建我們的DataSource注解,包含一个DataSourceTypeEnum类型的属性。默认为MASTER
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DataSource {
DataSourceTypeEnum value() default DataSourceTypeEnum.MASTER;
}
/**
*
* MASTER:WRITE<BR>
* SLAVE:READ
*
* @author licw
*
* @version 1.0.0
*/
public enum DataSourceTypeEnum {
MASTER, SLAVE;
}
用法:在我们的service层的方法,添加我们自定义的数据源注解,MASTER表示写库,SLAVE表示读库。
步骤二:继承spring中的AbstractRoutingDataSource(数据源路由器)并重写determineCurrentLookupKey方法
public class DynamicDataSource extends AbstractRoutingDataSource {
/**
* 获取与数据源相关的key. <br>
* 此key是Map<String,DataSource> resolvedDataSources 中与数据源绑定的key值<br>
* 在通过determineTargetDataSource获取目标数据源时使用
*/
@Override
protected Object determineCurrentLookupKey() {
return HandleDataSource.getDataSource();
}
}
看AbstractRoutingDataSource的源码可以发现,多数据源是存放在一个map中的,我们设置的key就是对象map中数据源的key
源码如下:
public abstract class AbstractRoutingDataSource extends AbstractDataSource implements InitializingBean {
@Nullable
private Map<Object, Object> targetDataSources;
@Nullable
private Object defaultTargetDataSource;
private boolean lenientFallback = true;
private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();
@Nullable
private Map<Object, DataSource> resolvedDataSources;
@Nullable
private DataSource resolvedDefaultDataSource;
。。。。
protected DataSource determineTargetDataSource() {
Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
Object lookupKey = determineCurrentLookupKey();
DataSource dataSource = this.resolvedDataSources.get(lookupKey);
if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
dataSource = this.resolvedDefaultDataSource;
}
if (dataSource == null) {
throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
}
return dataSource;
}
}
所有配置的数据源,均是保存在Map<Object, DataSource> resolvedDataSources中,
determineTargetDataSource方法返回map的key,后面我们就是通过key获取的具体数据源。
接下来再看一下HandleDataSource是个什么鬼?
/**
* 保存当前数据源的key
*
* @author licw
*
* @version 1.0.0
*/
public class HandleDataSource {
public static final ThreadLocal<String> holder = new ThreadLocal<String>();
/**
* 获取当前线程的数据源
*
* @param dataSourceType 读写类型判断
*/
public static void putDataSource(DataSourceTypeEnum dataSourceType) {
if (DataSourceTypeEnum.MASTER.equals(dataSourceType)) {
holder.set("write01");
} else {
holder.set("read01");
}
}
/**
* 获取当前线程的数据源路由的key
*
* @return
*/
public static String getDataSource() {
return holder.get();
}
}
发现这个是一个用ThreadLocal<String>保存我们map中DataSource对应的key,包含两个静态方法:
putDataSource(DataSourceTypeEnum dataSourceType)和getDataSource(),
putDataSource是设置我们当前线程的key,getDataSource是获取我们当前线程的key。获取的我们已经在上面的代码里看到了,那设置是在哪里设置的呢?(很多人知道ThreadLocal却从来没有用过,今天是不是用到了哈)
步骤三:通过切面拦截,设置我们的DataSource的key
先看代码:
public class DataSourceAspect {
private static final Logger logger = LoggerFactory.getLogger(DataSourceAspect.class);
/**
* 在dao层方法之前获取datasource对象之前在切面中指定当前线程数据源路由的key
*/
public void before(JoinPoint point) {
// 获取切点
Object target = point.getTarget();
if (logger.isDebugEnabled()) {
logger.info("start to select datasource.");
}
// 获取方法的名字
String method = point.getSignature().getName();
Class<?> classz = target.getClass();
// 获取方法上的参数
Class<?>[] parameterTypes = ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
try {
// 获取方法
Method m = classz.getMethod(method, parameterTypes);
// 判断方法是否存在,并且判断是否有DataSource这个注释。
if (m != null && m.isAnnotationPresent(DataSource.class)) {
// 获取注解
DataSource data = m.getAnnotation(DataSource.class);
if (logger.isDebugEnabled()) {
logger.info("target class is {},method is {},selected datasource type is {}", target.toString(),
m.getName(), data.value());
}
// 设置数据源
HandleDataSource.putDataSource(data.value());
}
} catch (Exception e) {
e.printStackTrace();
logger.error("target class is {},method is {},selected datasource throw a exception:{} ", target.toString(),
method, e);
}
}
}
大概的意思也就是先获得我们拦截的方法,继而获得方法上的@DataSource注解的值,然后调用HandleDataSource的putDataSource方法,设置当前线程要选择的具体数据源的key。
步骤四:配置我们的读写数据源
这个就直接上配置了
<bean id="dataSource" class="com.ddc.mcn.service.util.DynamicDataSource">
<property name="targetDataSources">
<map key-type="java.lang.String">
<!-- write -->
<entry key="write01" value-ref="write01DataSource" />
<!--
<entry key="write02" value-ref="write02DataSource" />
-->
<!-- read -->
<entry key="read01" value-ref="read01DataSource" />
<!--
<entry key="read02" value-ref="read02DataSource" />
-->
</map>
</property>
<property name="defaultTargetDataSource" ref="write01DataSource" />
</bean>
DynamicDataSource继承了AbstractRoutingDataSource,targetDataSources是我们提供的多个数据源,defaultTargetDataSource是我们默认走的数据源。
上面的代码说了,数据源是保存在resolvedDataSources属性中的,这里怎么又成了targetDataSources属性?我们可看一下源码,里面有这样一段:
@Override
public void afterPropertiesSet() {
if (this.targetDataSources == null) {
throw new IllegalArgumentException("Property 'targetDataSources' is required");
}
this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
this.targetDataSources.forEach((key, value) -> {
Object lookupKey = resolveSpecifiedLookupKey(key);
DataSource dataSource = resolveSpecifiedDataSource(value);
this.resolvedDataSources.put(lookupKey, dataSource);
});
if (this.defaultTargetDataSource != null) {
this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
}
}
由于AbstractRoutingDataSource实现了InitializingBean接口,在spring容器其中时,在初始化的过程中就会调用afterPropertiesSet方法,而这个方法,就是将配置的targetDataSources,循环设置到了resolvedDataSources中,将
defaultTargetDataSource设置到resolvedDefaultDataSource中。
下面具体数据源的配置样例:
<!-- 读数据源:基于Druid数据库链接池的数据源配置 -->
<bean id="read01DataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close">
<!-- 基本属性driverClassName、 url、user、password -->
<property name="driverClassName" value="com.mysql.cj.jdbc.Driver" />
<property name="url" value="${apollo.dataSource.read01.jdbc.url}" />
<property name="username" value="${apollo.dataSource.read01.jdbc.username}" />
<property name="password" value="${apollo.dataSource.read01.jdbc.password}" />
<!-- 配置初始化大小、最小、最大 -->
<!-- 通常来说,只需要修改initialSize、minIdle、maxActive -->
<property name="initialSize" value="5" />
<property name="maxActive" value="50" />
<property name="minIdle" value="5" />
<!-- 配置获取连接等待超时的时间 -->
<property name="maxWait" value="60000" />
<property name="poolPreparedStatements" value="false" />
<property name="maxPoolPreparedStatementPerConnectionSize" value="0" />
<property name="validationQuery" value="SELECT 'x'" />
<property name="validationQueryTimeout" value="5" />
<property name="testOnBorrow" value="false" />
<property name="testOnReturn" value="false" />
<property name="testWhileIdle" value="true" />
<!-- 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 -->
<property name="timeBetweenEvictionRunsMillis" value="60000" />
<!-- 配置一个连接在池中最小生存的时间,单位是毫秒 -->
<property name="minEvictableIdleTimeMillis" value="300000" />
<property name="maxEvictableIdleTimeMillis" value="1800000" />
<property name="removeAbandoned" value="true" />
<property name="filters" value="stat" />
<!-- 解密密码必须要配置的项
<property name="filters" value="config" />
<property name="connectionProperties" value="config.decrypt=true" />
-->
</bean>
最后,我们只要将我们的切面加入到容器中就可以了。
<!-- 为业务逻辑层的方法解析@DataSource注解 为当前线程的routeholder注入数据源key -->
<bean id="dataSourceAspect" class="com.ddc.mcn.service.aop.DataSourceAspect" />
<aop:config proxy-target-class="true" >
<aop:aspect ref="dataSourceAspect" order="1">
<aop:pointcut expression="execution(* com.ddc.mcn.service..*.*(..)) || @annotation(com.ddc.mcn.service.annotation.DataSource)" id="tx"/>
<aop:before method="before" pointcut-ref="tx"/>
</aop:aspect>
</aop:config>
到此,我们手写的数据源动态选择功能就完成了。
@Override
@DataSource(DataSourceTypeEnum.SLAVE)
public List<MenuResponseDTO> list() {
List<MenuResponseDTO> res = new ArrayList<MenuResponseDTO>();
List<MenuDO> menus = menuMapper.list(new HashMap<String, Object>(16));
if (menus != null && menus.size() != 0) {
for (MenuDO m : menus) {
MenuResponseDTO t = new MenuResponseDTO();
BeanUtils.copyProperties(m, t);
res.add(t);
}
}
return res;
}
在我们的方法上加上我们自定义注解,就ok了。
总结:
实现这个功能需要以下几个小技能
1.线程相关的ThreadLocal使用
2.spring的切面
3.要了解AbstractRoutingDataSource抽象类
4.自定义注解