动态数据源切换

(本文基于Springboot+MyBatis)

背景:业务数据和其他业务的数据都在一个库中,而库中的表也很多,因此需要为自己的业务独立建库,并在新库中创建和老库中结构相同的业务表(自己业务使用到的表)。

整体流程:

  1. 用户 -> 新库: 新建业务表
  2. 老库 -> 新库: 同步数据
  3. 用户 –> 新库: 暂停新库写操作
  4. 新库 -> 老库: 反向同步数据
  5. 用户 -> 新库: 恢复新库写操作

1.方案

在Mapper层接口方法执行前进行动态数据源的切换。即使用AOP切面,切点是Mapper接口中的所有方法,并在mapper层的AOP切面中修改线程变量进行数据源切换。

2.实现步骤

2.1 数据源配置

    <!-- 老库数据源1的配置 -->
    <bean id="dataSource1" class="com.zaxxer.hikari.HikariDataSource">
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test1"/>
        <property name="username" value="root"/>
        <property name="password" value="123456"/>
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
    </bean>
    <bean id="ds1SqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource1" />
        <property name="configLocation" value="classpath:com/example/demo/common/dal/mybatis-config.xml"/>
        <property name="mapperLocations" value="classpath:com/example/demo/common/dal/ds1/mapper/*Mapper.xml"/>
    </bean>
    <bean id="ds1SqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">
        <constructor-arg index="0" ref="ds1SqlSessionFactory"/>
    </bean>

    <!-- 新库数据源2的配置 -->
    <bean id="dataSource2" class="com.zaxxer.hikari.HikariDataSource">
        <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test2"/>
        <property name="username" value="root"/>
        <property name="password" value="123456"/>
        <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/>
    </bean>
    <bean id="ds2SqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource2" />
        <property name="configLocation" value="classpath:com/example/demo/common/dal/mybatis-config.xml"/>
        <!-- 这里使用通配符是因为要通用ds1的mapper -->
        <property name="mapperLocations" value="classpath:com/example/demo/common/dal/**/mapper/*Mapper.xml"/>
    </bean>
    <bean id="ds2SqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">
        <constructor-arg index="0" ref="ds2SqlSessionFactory"/>
    </bean>

2.2 通用代码部分

import lombok.Getter;

@Getter
public enum DataSourceType {
    DS_1("dataSource1"),
    DS_2("dataSource2");

    private String value;
    DataSourceType(String value) {
        this.value = value;
    }

    public static DataSourceType getByValue(String value) {
        for (DataSourceType type : DataSourceType.values()) {
            if (type.value.equals(value)) {
                return type;
            }
        }
        return DS_1;
    }
}

// 线程变量管理
public class DynamicDataSourceContextHolder {

    private static final ThreadLocal<DataSourceType> CURRENT_MAPPER_CONTEXT = new ThreadLocal<>();

    public static void setCurrentMapperType(DataSourceType dataSourceType) {
        CURRENT_MAPPER_CONTEXT.set(dataSourceType);
    }

    public static DataSourceType getCurrentMapperType() {
        return CURRENT_MAPPER_CONTEXT.get();
    }

    public static void clearCurrentMapperType() {
        CURRENT_MAPPER_CONTEXT.remove();
    }
}

2.3 实现动态数据源

动态数据源需要实现org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource抽象类,并实现其determineCurrentLookupKey方法。

import com.google.common.base.MoreObjects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;


@Slf4j
public class DynamicDataSource extends AbstractRoutingDataSource {
    private static Map<Object,Object> dataSourceMap = new HashMap<Object, Object>();

    @Override
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        super.setTargetDataSources(targetDataSources);
        dataSourceMap.putAll(targetDataSources);
        super.afterPropertiesSet();// 必须添加该句,否则新添加数据源无法识别到
    }

    @Override
    protected Object determineCurrentLookupKey() {
        // Mapper层方法切面设值
        DataSourceType currentMapper = DynamicDataSourceContextHolder.getCurrentMapperType();
        if (Objects.isNull(currentMapper)) {
            // 默认使用老库的数据源
            log.info("determineCurrentLookupKey, dataSourceType: {}", DataSourceType.DS_1);
            return null;
        }

        // 如果currentMapper不为空,则使用currentMapper
        log.info("determineCurrentLookupKey, dataSourceType:{}", currentMapper);
        return dataSourceType1.getValue();
    }
}

2.4 配置动态数据源

将多个数据源都配置到动态数据源中。并将SqlSessionFactory、SqlSessionTemplate和PlatformTransactionManager都指向动态数据源的实例。

import com.google.common.collect.Lists;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Configuration
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
public class DynamicDataSourceConfig {
    // 将多个数据源统一放置到动态数据源中
    @Primary
    @Bean("dynamicDataSource")
    public DynamicDataSource dynamicDataSource(@Qualifier("dataSource1") DataSource master,
                                    @Qualifier("dataSource2") DataSource slave) {
        DynamicDataSource ds = new DynamicDataSource();
        // 设置默认数据源
        ds.setDefaultTargetDataSource(master);
        // 设置数据源集合
        Map<Object, Object> dataSourceList = new HashMap<>();
        // 旧数据源
        dataSourceList.put("dataSource1", master);
        // 新数据源
        dataSourceList.put("dataSource2", slave);
        ds.setTargetDataSources(dataSourceList);
        return ds;
    }

    // 将SqlSessionFactory指向动态数据源
    @Bean("dynamicSqlSessionFactory")
    public SqlSessionFactory dynamicSqlSessionFactory(@Qualifier("dynamicDataSource")
                                                                  DynamicDataSource dynamicDataSource) throws Exception {
        SqlSessionFactoryBean fb = new SqlSessionFactoryBean();
        fb.setDataSource(dynamicDataSource);
        // 设置Mybatis的配置文件
        fb.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:com/example/demo/common/dal/mybatis-config.xml"));
        // 设置Mapper XML文件的位置
        List<Resource> resources = Lists.newArrayList();
        resources.add(new PathMatchingResourcePatternResolver().getResource("classpath:com/example/demo/common/dal/ds1/mapper/*Mapper.xml"));
        resources.add(new PathMatchingResourcePatternResolver().getResource("classpath:com/example/demo/common/dal/ds2/mapper/*Mapper.xml"));
        fb.setMapperLocations(resources.toArray(new Resource[resources.size()]));
        //返回对象
        return fb.getObject();
    }

    // SqlSession
    @Bean("dynamicSqlSessionTemplate")
    public SqlSessionTemplate dynamicSqlSessionTemplate(@Qualifier("dynamicSqlSessionFactory")
                                                                SqlSessionFactory dynamicSqlSessionFactory) {
        return new  SqlSessionTemplate(dynamicSqlSessionFactory);
    }

    // 扫描指定包路径下的Mapper接口,并注册成Spring Bean,并绑定到数据源上
    // BeanDefinitionRegistryPostProcessor:Mybatis-spring的后置处理器
    // 推荐使用 @MapperScan 方式,简洁
    @Bean
    public MapperScannerConfigurer mapperScannerCOnfigurer() {
        MapperScannerConfigurer msc = new MapperScannerConfigurer();
        // 指定mapper接口的包路径,多个时使用逗号分隔
        msc.setBasePackage("com.example.demo.common.dal.ds1.mapper,com.example.demo.common.dal.ds2.mapper");
        // 绑定数据源
        msc.setSqlSessionTemplateBeanName("dynamicSqlSessionTemplate");
        return msc;
    }

    // 主事务管理器
    @Primary
    @Bean(name="dynamicTransactionManager")
    public PlatformTransactionManager dynamicTransactionManager(@Qualifier("dynamicDataSource")
                                                                            DynamicDataSource dynamicDataSource) {
        DataSourceTransactionManager mg = new DataSourceTransactionManager(dynamicDataSource);
        mg.setNestedTransactionAllowed(true);
        return mg;
    }
}

2.5 Mapper层切面

import com.example.demo.common.dal.config.DataSourceType;
import com.example.demo.common.dal.config.DynamicDataSourceContextHolder;
import com.example.demo.common.dal.config.MultiSqlSessionManager;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

@Slf4j
@Aspect
@Component
public class MapperTestAspect {
    @Pointcut("execution(* com.example.demo.common.dal.ds1.mapper.*.*(..))")
    public void mapperInterface1() {
    }

    @Pointcut("execution(* com.example.demo.common.dal.ds2.mapper.*.*(..))")
    public void mapperInterface2() {
    }

    private final Map<String, String> mapps = new HashMap(){{
        put("com.example.demo.common.dal.ds1.mapper.StudentInfoMapper", "dataSource1");
        put("com.example.demo.common.dal.ds2.mapper.TeacherInfoMapper", "dataSource2");
    }};
    
    @Around("mapperInterface1() || mapperInterface2()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        String mapperClassName = method.getDeclaringClass().getName();
        DataSourceType currentMapper = DynamicDataSourceContextHolder.getCurrentMapperType();
        // 根据Mapper接口的全限定名获取对应的数据源名称,动态配置中获取,Nacos或drm
        String dataSourceName = mapps.get(mapperClassName);
        DataSourceType dataSourceType = DataSourceType.getByValue(dataSourceName);
        log.info("当前使用的数据源,currentMapperDataSource={},toMapperDataSource:{},mapperClassName:{}#{}", currentMapper,
                Objects.isNull(dataSourceType) ? "null" : dataSourceType.getValue(), mapperClassName, method.getName());
        try {
            DynamicDataSourceContextHolder.setCurrentMapperType(dataSourceType);
            return joinPoint.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearCurrentMapperType();
        }
    }
}

以上代码在无事务操作的方法中,可以做到随时切换数据源操作

3. 事务中切换数据源

使用上面的代码时虽然日志会显示切换了数据源但其实并没有,这是由于事务开启时已经绑定了数据源,并调用了DataSource.getConnection()方法,将数据源绑定到了当前线程。要解决这个怎么整呢?下文讲述了一种不使用分布式事务的方式来实现多数据源事务中正确使用对应数据源的方法,即使用SqlSession

3.1 SqlSession管理类

import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class MultiSqlSessionManager {
    @Autowired
    @Qualifier("ds1SqlSessionTemplate")
    private SqlSession sqlSession1;  // 对应dataSource1

    @Autowired
    @Qualifier("ds2SqlSessionTemplate")
    private SqlSession sqlSession2;  // 对应dataSource2

    public SqlSession getSqlSession(String dataSourceKey) {
        if ("dataSource1".equals(dataSourceKey)) {
            return sqlSession1;
        } else if ("dataSource2".equals(dataSourceKey)) {
            return sqlSession2;
        }
        return sqlSession1;
    }
}

3.2 改造Mapper切面

注入SqlSession管理类,并新增了是否启用多数据源事务的判断。

    @Autowired
    private MultiSqlSessionManager multiSqlSessionManager;
    
    @Around("mapperInterface1() || mapperInterface2()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        String mapperClassName = method.getDeclaringClass().getName();
        DataSourceType currentMapper = DynamicDataSourceContextHolder.getCurrentMapperType();
        // 根据Mapper接口的全限定名获取对应的数据源名称,动态配置中获取,Nacos或drm
        String dataSourceName = mapps.get(mapperClassName);
        DataSourceType dataSourceType = DataSourceType.getByValue(dataSourceName);
        log.info("当前使用的数据源,currentMapperDataSource={},toMapperDataSource:{},mapperClassName:{}#{}", currentMapper,
                Objects.isNull(dataSourceType) ? "null" : dataSourceType.getValue(), mapperClassName, method.getName());
        
        // 是否启用了多数据源事务
        Boolean enableMultiTransaction = TransactionManageAspect.getEnableMultiTransaction();
        if (Objects.nonNull(enableMultiTransaction) && enableMultiTransaction) {

            // 获取对应的SqlSession
            SqlSession targetSqlSession = multiSqlSessionManager.getSqlSession(dataSourceType.getValue());
            // 获取Mapper接口
            Class<?> mapperInterface = method.getDeclaringClass();
            // 从目标SqlSession中获取Mapper实例
            Object targetMapper = targetSqlSession.getMapper(mapperInterface);

            // 执行方法
            Object[] args = joinPoint.getArgs();

            return method.invoke(targetMapper, args);
        }
        try {
            DynamicDataSourceContextHolder.setCurrentMapperType(dataSourceType);
            return joinPoint.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearCurrentMapperType();
        }
    }

3.3 改造多数据源事务切面

本切面在上一篇文章已经介绍过,下面改造后的全部代码。新增了线程变量ENABLE_MULTI_DS和获取其值的方法:getEnableMultiTransaction。

package com.example.demo.common.dal.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.Stack;
import java.util.stream.Collectors;

@Slf4j
@Aspect
@Component
@Order(0)
public class TransactionManageAspect {

    @Resource
    private ApplicationContext applicationContext;

    private static final ThreadLocal<Boolean> ENABLE_MULTI_DS = new ThreadLocal<>();

    public static Boolean getEnableMultiTransaction() {
        return ENABLE_MULTI_DS.get();
    }

    /**
     * 切点
     */
    @Pointcut("@annotation(com.example.demo.common.dal.aspect.MultiTransactionManager)")
    public void pointCut() {
    }

    @Around("pointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取方法上的注解
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        log.info("事务管理器切面start, methodName={}", method.getName());
        MultiTransactionManager multiTransactionManager = joinPoint.getTarget().getClass().getAnnotation(MultiTransactionManager.class);
        if (multiTransactionManager == null) {
            multiTransactionManager = method.getAnnotation(MultiTransactionManager.class);
        }
        // 事务管理器
        Stack<DataSourceTransactionManager> transactionManagerStack = new Stack<>();
        // 事务状态
        Stack<TransactionStatus> transactionStatusStack = new Stack<>();
        boolean isTransaction = true;
        if (multiTransactionManager.transactionManagerNames().length < 1) {
            log.info("事务管理器名称数组为空, methodName={}", method.getName());
            isTransaction = false;
        }
        if (isTransaction) {
            // 去重
            List<String> collect = Arrays.asList(multiTransactionManager.transactionManagerNames()).stream()
                    .distinct()
                    .collect(Collectors.toList());
            for (String transactionManagerName : collect) {
                DataSourceTransactionManager dataSourceTransactionManager = applicationContext.getBean(transactionManagerName,
                        DataSourceTransactionManager.class);
                if (dataSourceTransactionManager == null) {
                    log.warn("事务管理器不存在,transactionManagerName:{},methodName={}", transactionManagerName, method.getName());
                    continue;
                }
                TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(new DefaultTransactionDefinition());
                transactionManagerStack.push(dataSourceTransactionManager);
                transactionStatusStack.push(transactionStatus);
            }
        }
        if (CollectionUtils.isEmpty(transactionManagerStack)) {
            log.info("事务管理器为空, methodName={}", method.getName());
            isTransaction = false;
        }
        try {
            if (isTransaction) {
                ENABLE_MULTI_DS.set(true);
            }
            Object obj = joinPoint.proceed();
            // 未发生异常,提交事务
            while (isTransaction && !transactionManagerStack.isEmpty()) {
                DataSourceTransactionManager dataSourceTransactionManager = transactionManagerStack.pop();
                TransactionStatus transactionStatus = transactionStatusStack.pop();
                dataSourceTransactionManager.commit(transactionStatus);
            }
            log.info("事务管理器切面提交, methodName={}", method.getName());
            return obj;
        } catch (Throwable e) {
            log.error("事务执行异常, methodName={}", method.getName(), e);
            while (isTransaction && !transactionManagerStack.isEmpty()) {
                DataSourceTransactionManager dataSourceTransactionManager = transactionManagerStack.pop();
                TransactionStatus transactionStatus = transactionStatusStack.pop();
                dataSourceTransactionManager.rollback(transactionStatus);
            }
            log.info("事务管理器切面回滚, methodName={}", method.getName());
            throw e;
        } finally {
            log.info("事务管理器切面end, methodName={}", method.getName());
        }
    }
}

4.总结

  1. 在无事务的业务方法中,可以mapper层切面中可以随时切换数据源。(因为每次都会获取新的连接。)
  2. 在有事务的业务方法中,使用SqlSession可以使之正确的处理每个Mapper层接口的方法的执行。

多数据源事务提交

本文展示的代码均以Spring-AOP为基础,使用切面的方式进行多数据源事务提交。

1. 数据源配置

定义了2个数据源和2个事务管理器。

<!-- 定义数据源 -->
<bean id="dataSource1" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="${jdbc.driverClassName}" />
    <property name="url" value="${jdbc.url}" />
    <property name="username" value="${jdbc.username}" />
    <property name="password" value="${jdbc.password}" />
</bean>

<bean id="dataSource2" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="${jdbc.driverClassName}" />
    <property name="url" value="${jdbc.url2}" />
    <property name="username" value="${jdbc.username2}" />
    <property name="password" value="${jdbc.password2}" />
</bean>

<!-- 定义事务管理器 -->
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>
<bean id="txManager2" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
    <property name="dataSource" ref="dataSource2"/>
</bean>

2. 编写切面相关代码

2.1 自定义注解

此注解用于方法上面,进行事务关联。

import java.lang.annotation.ElemntType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
public @interface MultiTxManager {
    /**
     * 事务管理器名称
     */
    String[] txManagerNames() default {};
}

2.2 切面代码

用于事务提交和回滚。事务管理器的2大作用:提交和回滚。


import org.apache.commons.collections4.CollectionUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.ApplicationContext;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.util.Stack;

@Aspect
@Component
public class SelfTxManagerAspect {

    @Resource
    private ApplicationContext context;

    /**
     * 定义切点
     */
    @Pointcut("@annotation(MultiTxManager)")
    public void pointCut() {
    }

    /**
     * 环绕
     * @param point
     * @return
     * @throws Throwable
     */
    @Around("pointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        MultiTxManager tm = method.getAnnotation(MultiTxManager.class);

        // 事务管理器
        Stack<DataSourceTransactionManager> tmStack = new Stack<>();
        // 事务状态
        Stack<TransactionStatus> tsStack = new Stack<>();

        // 是否需要开启事务标识
        boolean enableTransaction = true;
        if (tm.txManagerNames().length < 1) {
            enableTransaction = false;
        }

        if (enableTransaction ) {
            for(String tmName : tm.txManagerNames()) {
                DataSourceTransactionManager dtxm = context.getBean(tmName,DataSourceTransactionManager.class);
                if (dtxm == null) { continue; }
                // 开启事务
                TransactionStatus ts = dtxm.getTransaction(new DefaultTransactionDefinition());
                tmStack.push(dtxm);
                tsStack.push(ts);
            }
        }
        if (CollectionUtils.isEmpty(tmStack)) {
            enableTransaction = false;
        }
        try {
            Object result = point.proceed();
            // 方法执行完成,若有事务则提交
            while (enableTransaction && !tmStack.isEmpty()) {
                DataSourceTransactionManager manager = tmStack.pop();
                TransactionStatus status = tsStack.pop();
                manager.commit(status);
            }
            return result;
        } catch (Throwable e) {
            // 发生异常时回滚
            while (enableTransaction && !tmStack.isEmpty()) {
                DataSourceTransactionManager manager = tmStack.pop();
                TransactionStatus status = tsStack.pop();
                manager.rollback(status);
            }
            throw e;
        }
    }
}

2.3 在方法上应用

@Service
public class xxxxServiceImpl {

    @MultiTxManager(txManagerNames={"txManager", "txManager2"})
    public Object saveOrUpdate() {
        // 做一些数据库更新操作
    }
}

如有问题请留言。

Mockito使用问题合集

1.mock泛型方法

  • 反射获取Method时,泛型类型使用 Object.class 替换。
  • 调用方法时,泛型入参,不能使用new Object(), 需要使用具体的实现类。

1.1 源代码

public abstract class Test<OUT> {
    
    private void setOutPut(OUT model, Map<String, String> params) {
            // do something
    }
}

public class TestSub extends Test<TestSubDO>{
    // do something
}

1.2 mock代码

@RunWith(MockitoJUnitRunner.class)
public class TestTest {
    @InjectMock
    private Test test = new TestSub();
    
    @Test
    public void test1(){
        // OUT 使用 Object.class 替换
        Method method = Test.class.getDeclaredMethod("setOutPut", Object.class, Map.class);
        method.setAccessible(true);
        // TestSubDO
        method.invoke(test, new TestSubDO(), new HashMap());
    }
}

2.循环中多次调用同一方法

在一个循环中多次调用同一个有返回值的方法,模拟多次返回值,需要多次使用thenReturn方法,或在thenReturn中设置多个值。

2.1源代码

while(true){
    Page<TestPO> pageList = testMapper.selectPage(pageQuery);
    if(CollectionUtils.isEmpty(pageList.getData())) {
        break;    
    }
    // todo something 
}

2.2 mock代码

Page<TestPO> page1=new Page(1,10,1,Lists.newArrayList(new TestPO()));
Page<TestPO> page2=new Page(1,10,1,Collections.emptyList());
// 第二次调用返回数据为空,则会退出while循环
when(testMapper.selectPage(any())).thenReturn(page1).thenReturn(page2);

3.MockitoException for Static Mocks异常

在test/resources文件夹下追加:

  1. 新建文件夹 mockito-extensions
  2. 在新的文件夹中新建文本文件 org.mockito.plugins.MockMaker
  3. 向文本中写入内容:mock-maker-inline

4.模拟protected方法

有2种方式可以模拟protected方法。

4.1方式1使用ReflectionSupport

ReflectionSupport类是JUnit5提供的一个类。可以使用 invokeMethod方法,执行protected方法。

public static Object 
invokeMethod(Method method, Object target, Object ...args)

4.2方式2使用Answers.CALLS_REAL_METHODS

注意:这种方式时,需要将xxxTest.java的package路径和xxx.java类的package路径保持一致,不一致时调用protected方法时报错。

Abstarct ab = mock(Abstarct.class, Answers.CALLS_REAL_METHODS);
ab.protectedMethod();

5.mybatis-plus相关问题

5.1 MyBatisPlusException:can not find lambda cache for this entity

解决方法是在单测方法第一行加入一行代码:

TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "")
, TestPO.class);

注意:TestPO是数据库表对应的实体类。

6.模拟EasyExcel读取数据

6.1源代码

读取Excel文件时使用以下方法:

EasyExcel.read(String filePath, Class head, ReadListener listener);

源代码应用:

@Data
public class TestDO {
    @ExcelProperty(value = "名称", index = 0)
    private String name;
}

public class ReadTestListener extend AnalysisEventListener<TestDO> {

    private List<TestDO> dataList = new ArrayList<>();
    public List<TestDO> getDataList() {
        return this.dataList;
    }

    // 覆写方法 invoke, doAfterAllAnalysed 
}

// 后面要mock的方法
public void readExcel(String filePath) {
    ReadTestListener listener = new ReadTestListener();
    EasyExcel.read(filePath, TestDO.class, listener).sheet().doRead();
}

6.2 mock代码

@Test
public void test1() {
    String filepath = "c:\\testdata\testDo.xlsx";
    ExcelReaderBuilder readerBuilder = Mockito.mock(ExcelReaderBuilder.class);
    try(MockedStatic mockEasyExcel = Mockito.mockStatic(EasyExcelFactory.class, invocation -> {
        if (invocation.getMethod().getName().equals("read")) {
            return readerBuilder;        
        }    
        return null;
    })) {
            ExcelReaderSheetBuilder excelReaderSheetBuilder=mock(ExcelReaderSheetBuilder.class);
            when(readerBuilder.sheet()).thenReturn(excelReaderSheetBuilder);
            doNothing().when(excelReaderSheetBuilder).doRead();
            
            // 调用mock的方法
            readExcel(filepath);
    }
}

7.模拟Class对象

2种方式:

// 方式1
Mockito.doReturn(UserInfo.class).when(method).getDeclaringClass();

// 方式2
Mockito.<Class<?>>when(method.getDeclaringClass())
                      .thenReturn(UserInfo.class);

后续遇到问题会继续补充。

EasyExcel识别表头

本文将介绍使用EasyExcel实现识别单行表头的一种实现方式。主要是通过实现AnalysisEventListener 抽象类,并覆写其 hasNext 和 invokeHead 方法。

Jar依赖

<dependency>
	<groupId>com.alibaba</groupId>
	<artifactId>easyexcel</artifactId>
	<version>3.3.4</version>
</dependency>

<dependency>
	<groupId>org.reflections</groupId>
	<artifactId>reflections</artifactId>
	<version>0.10.2</version>
</dependency>

实现代码

一个表头基类和2个子类,一个用于表头字段的类。

注意:代码使用反射,使用了@ExcelProperty(value = “地址”, index = 0)注解,index用于表头字段排序,value用于表头显示的名称。

1.表头基类
package top.jyokiyi.my.test.excel;
import lombok.Data;

@Data
public class ExcelBaseModel {
}
2.表头实现类1

继承表头基类:

package top.jyokiyi.my.test.excel;

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;

@Data
public class ExcelHeadTest1Model extends ExcelBaseModel {

    @ExcelProperty(value = "地址", index = 0)
    private String address;

    @ExcelProperty(value = "名称", index = 1)
    private String name;
}
3.表头实现类2

继承表头基类:

package top.jyokiyi.my.test.excel;

import com.alibaba.excel.annotation.ExcelProperty;
import lombok.Data;

@Data
public class ExcelHeadTest2Model extends ExcelBaseModel {

    @ExcelProperty(value = "邮件",index = 0)
    private String email;

    @ExcelProperty(value = "内容", index = 1)
    private String content;
}
4. 表头字段类
package top.jyokiyi.my.test.excel;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class ExcelHeadFieldModel {
    /**
     * 列号
     */
    private Integer column;
    /**
     * 表头描述
     */
    private String headDesc;
}
5. 使用Reflections实现查找所有子类

注意:这里直接指定了包名,且子类和基类在同一个包中。(如有需要可自行修改,以实现自己的需求)


/**
 * 获取指定类的所有子类
 * @param clazz
 * @param <T>
 * @return
 */
public static <T>Set<Class<? extends T>> getAllSubTypes(Class<T> clazz) {
	Set<Class<? extends T>> subTypeClassSet = new HashSet<>();

	Reflections reflections = new Reflections("top.jyokiyi.my.test.excel");
	Set<Class<? extends T>> subTypesOf = reflections.getSubTypesOf(clazz);
	if (CollectionUtils.isNotEmpty(subTypesOf)) {
		subTypeClassSet.addAll(subTypesOf);
	}

	return subTypeClassSet;
}
6.表头类型与表头字段集合映射
/**
 * 将表头与对于的类映射起来
 * @param sets
 * @param <T>
 * @return
 */
public static <T>Map<Class<? extends T>, List<ExcelHeadFieldModel>> getHeadMapForExcelProperty(Set<Class<? extends T>> sets) {
	Map<Class<? extends T>, List<ExcelHeadFieldModel>> maps = new HashMap<>();

	for (Class<? extends T> clz : sets) {
		List<ExcelHeadFieldModel> fieldModelList = new ArrayList<>();
		try {
			Field[] fields = clz.getDeclaredFields();
			for (Field field : fields) {
				if (field.isAnnotationPresent(ExcelProperty.class)) {
					ExcelProperty annotation = field.getAnnotation(ExcelProperty.class);
					fieldModelList.add(ExcelHeadFieldModel.builder()
							.column(annotation.index()).headDesc(annotation.value()[0]).build());
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
		if (CollectionUtils.isNotEmpty(fieldModelList)) {
			maps.put(clz, fieldModelList.stream()
                       .sorted(Comparator.comparing(ExcelHeadFieldModel::getColumn))
                       .collect(Collectors.toList()));
		}
	}
	return maps;
}
7.实现监听类

继承easy excel 的 AnalysisEventListener 抽象类,并覆写其 hasNext 和 invokeHead 方法。

package top.jyokiyi.my.test.excel;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.metadata.data.ReadCellData;
import com.alibaba.excel.util.StringUtils;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class ExcelHeaderIdentifyListener extends AnalysisEventListener<Map<Integer, String>> {

    private static ThreadLocal<Class<? extends ExcelBaseModel>> classResult = new ThreadLocal<>();

    public ThreadLocal<Class<? extends ExcelBaseModel>> getClassResult() {
        return classResult;
    }

    public void clear() {
        classResult.remove();
    }

    /**
     * 返回false,读取一行后,不再继续读取
     *
     * @param context
     * @return
     */
    @Override
    public boolean hasNext(AnalysisContext context) {
        return false;
    }

    /**
     * 根据表头解析获取对应的表头子类
     * @param headMap
     * @param context
     */
    @Override
    public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
        List<ExcelHeadFieldModel> headFieldModels = headMap.values().stream().map(column -> ExcelHeadFieldModel.builder()
                .column(column.getColumnIndex()).headDesc(column.getStringValue()).build())
                .sorted(Comparator.comparing(ExcelHeadFieldModel::getColumn))
                .collect(Collectors.toList());
        // 将表头字段拼接起来
        String joinStr = headFieldModels.stream()
                .map(ExcelHeadFieldModel::getHeadDesc)
                .collect(Collectors.joining());

        // 获取表头基类的所有子类信息
        Set<Class<? extends ExcelBaseModel>> allSubTypes = ClassUtils.getAllSubTypes(ExcelBaseModel.class);
        // 将class和表头映射起来
        Map<Class<? extends ExcelBaseModel>, List<ExcelHeadFieldModel>> listMap = ClassUtils.getHeadMapForExcelProperty(allSubTypes);
        // 查找对应的子类
        for (Map.Entry<Class<? extends ExcelBaseModel>, List<ExcelHeadFieldModel>> entry : listMap.entrySet()) {
            if (entry.getValue().size() == headFieldModels.size() && StringUtils.equals(joinStr, entry.getValue()
                    .stream().map(ExcelHeadFieldModel::getHeadDesc).collect(Collectors.joining()))) {
                classResult.set(entry.getKey());
                break;
            }
        }
    }
    

    @Override
    public void invoke(Map<Integer, String> data, AnalysisContext context) {

    }


    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {

    }
}
8.测试
package top.jyokiyi.my.test.excel;

import com.alibaba.excel.EasyExcel;

import java.io.File;
import java.io.FileInputStream;

public class ReadTest {


    public static void main(String[] args) throws Exception {
        ExcelHeaderIdentifyListener listener = new ExcelHeaderIdentifyListener();
        String dir = System.getProperty("user.dir");
        String filePath = dir + File.separator + "src" + File.separator + "test_excel.xlsx";

        EasyExcel.read(new FileInputStream(filePath), listener)
                 .sheet().doRead();

        Class<? extends ExcelBaseModel> aClass = listener.getClassResult();
        listener.clear();
        System.out.println(aClass.getName());
    }
}

测试结果:

top.jyokiyi.my.test.excel.ExcelHeadTest1Model

测试文件的表头如下:

地址名称
12

一点点之LocalDate

问题或建议,请公众号留言;
最新更新日期:2022-01-09

ISO 8601日期和时间格式

ISO 8601是国际通用的明确日期和时间格式的规范。该ISO标准有助于消除影响全球运营的各种日期约定、文化和时区可能导致的疑虑。它提供了一种显示日期和时间的方式,这种方式对人和机器来说都是明确定义和理解的。

LocalDate

LocalDateISO-8601日历系统中没有时区的日期。LocalDate是一个不可变的日期时间对象(线程安全的对象),它表示一个日期,通常展示视为【年-月-日】。
也可以访问其他的日期字段包含以下:

  • day-of-year

  • day-of-week

  • week-of-year

此类仅是对日期的描述,可用于生日,假期等。由于无关偏移量和时区,它就不能代表时间线上的一个瞬间。

比较相等是使用equals方法。
默认的格式是uuuu-MM-dd

LocalDate方法列表

方法名称 描述
now,of,from,parse,ofYearDay,ofEpochDay 这些静态方法根据指定条件生成一个LocalDate对象
plusDays,plusMonths,plusWeeks,plusYears 在当前日期上增加相应的天数,周数,月数,年数
minusDays,minusWeeks,minusMonths,minusYears 在当前日期上减少相应的天数,周数,月数,年数
plus,minus 在当前日期上 增加/减少 一个 DurationPeriod,或者ChronoUnit单位指定的值
withDayOfMonth,withDayOfYear,withMonth,withYear 以当前日期,修改为指定的日期,返回新的LocalDate对象
getDayOfMonth,getDayOfWeek,getDayOfYear 获取当前日期在月份中的第几天,周中的第几天,年中的第几天
getMonth,getMonthValue 获取当前日期所在的月份,前一个是枚举Month,后面的值是1-12
getYear 获取当前日期的年份,范围:-999999999+999999999
isAfter,isBefore 当前日期是否在指定日期之后或之前
isLeapYear 当前日期所在年份是否是闰年
lengthOfMonth,lengthOfYear 获取当前日期所在月份的天数,所在年份的天数
range 获取当前日期,指定ChronoField后的范围
until 获取2个日期之前的Period对象,或者ChronoUnit对应的数字
adjustInto,with 根据指定的条件返回调整后,新的日期.2个方法是等价的
atStartOfDay,atStartOfDay,atTime 获取带有时间的LocalDateTime

方法返回的LocalDateLocalDateTime都是新的对象,

与Calendar的不同

  1. 星期周的不同:Calendar中周日是1,周六是7。LocalDate默认的周一是1,周日是7。
  2. 月份的不同:Calendar是从0-11表示的。LocalDate中是从1-12表示的。

应用示例

LocalDate date = LocalDate.now();
System.out.println("date="+date);
System.out.println("是否为闰年:"+date.isLeapYear());
System.out.println("年中第几天:"+date.getDayOfYear());
System.out.println("月中第几天:"+date.getDayOfMonth());
System.out.println("周几:"+date.getDayOfWeek());
System.out.println("月份:"+date.getMonth());
System.out.println("月份值:"+date.getMonthValue());
System.out.println("加上3天:"+date.plusDays(3));
System.out.println("加上3天:"+date.plus(3, ChronoUnit.DAYS));
System.out.println("日期修改为1月30号:"+date.withDayOfMonth(30));
System.out.println("日期修改为3月份:"+date.withMonth(3));
System.out.println("日期修改为本年份的第50天:"+date.withDayOfYear(50));
System.out.println("当前月份的天数:"+date.lengthOfMonth());
System.out.println("当前年份的天数:"+date.lengthOfYear());
System.out.println("一周的范围值:"+date.range(ChronoField.DAY_OF_WEEK));
System.out.println("当前月天数的范围值:"+date.range(ChronoField.DAY_OF_MONTH));
System.out.println("当前年天数的范围值:"+date.range(ChronoField.DAY_OF_YEAR));
System.out.println("2个日期相差天数1:"+date.until(LocalDate.parse("2022-01-15"),ChronoUnit.DAYS));
System.out.println("2个日期相差天数2:"+ChronoUnit.DAYS.between(date,LocalDate.parse("2022-01-15")));
System.out.println("2个日期相差Period1:"+date.until(LocalDate.parse("2022-01-15")));
System.out.println("2个日期相差Period2:"+ Period.between(date,LocalDate.parse("2022-01-15")));

System.out.println("日期调整1:"+ date.with(temporal -> temporal.minus(Period.ofDays(2))));
System.out.println("日期调整2:"+ date.with(temporal -> temporal.minus(2, ChronoUnit.DAYS)));

LocalDateTime localDateTime = LocalDateTime.now();
System.out.println("当前日期时间:"+localDateTime);
System.out.println("日期调整3:"+ localDateTime.with(temporal -> temporal.minus(Duration.ofHours(2))));
System.out.println("日期调整4:"+ localDateTime.with(temporal -> temporal.minus(2, ChronoUnit.HOURS)));

运行结果:

date=2022-01-09
是否为闰年:false
年中第几天:9
月中第几天:9
周几:SUNDAY
月份:JANUARY
月份值:1
加上3天:2022-01-12
加上3天:2022-01-12
日期修改为1月30号:2022-01-30
日期修改为3月份:2022-03-09
日期修改为本年份的第50天:2022-02-19
当前月份的天数:31
当前年份的天数:365
一周的范围值:1 - 7
当前月天数的范围值:1 - 31
当前年天数的范围值:1 - 365
2个日期相差天数1:6
2个日期相差天数2:6
2个日期相差Period1:P6D
2个日期相差Period2:P6D
日期调整1:2022-01-07
日期调整2:2022-01-07
当前日期时间:2022-01-09T13:13:36.303
日期调整3:2022-01-09T11:13:36.303
日期调整2:2022-01-09T11:13:36.303

注意:Duration是基于时间的时间量,包含hours-mintues-seconds,也可以使用ChronoUnit.DAYS 单位等效于24小时 。

Period是基于日期的时间量,包含years-months-days.  ChronoUnit.YEARS / ChronoUnit.MONTHS  /  ChronoUnit.DAYS


结束。

来自博主的微信公众号。