基於註解的springboot+mybatis的多資料來源元件的實現

jy的blog發表於2021-04-15

    通常業務開發中,我們會使用到多個資料來源,比如,部分資料存在mysql例項中,部分資料是在oracle資料庫中,那這時候,專案基於springboot和mybatis,其實只需要配置兩個資料來源即可,只需要按照

dataSource - SqlSessionFactory - SqlSessionTemplate配置好就可以了。

如下程式碼,首先我們配置一個主資料來源,通過@Primary註解標識為一個預設資料來源,通過配置檔案中的spring.datasource作為資料來源配置,生成SqlSessionFactoryBean,最終,配置一個SqlSessionTemplate。

 1 @Configuration
 2 @MapperScan(basePackages = "com.xxx.mysql.mapper", sqlSessionFactoryRef = "primarySqlSessionFactory")
 3 public class PrimaryDataSourceConfig {
 4 
 5     @Bean(name = "primaryDataSource")
 6     @Primary
 7     @ConfigurationProperties(prefix = "spring.datasource")
 8     public DataSource druid() {
 9         return new DruidDataSource();
10     }
11 
12     @Bean(name = "primarySqlSessionFactory")
13     @Primary
14     public SqlSessionFactory primarySqlSessionFactory(@Qualifier("primaryDataSource") DataSource dataSource) throws Exception {
15         SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
16         bean.setDataSource(dataSource);
17         bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
18         bean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
19         return bean.getObject();
20     }
21 
22     @Bean("primarySqlSessionTemplate")
23     @Primary
24     public SqlSessionTemplate primarySqlSessionTemplate(@Qualifier("primarySqlSessionFactory") SqlSessionFactory sessionFactory) {
25         return new SqlSessionTemplate(sessionFactory);
26     }
27 }

然後,按照相同的流程配置一個基於oracle的資料來源,通過註解配置basePackages掃描對應的包,實現特定的包下的mapper介面,使用特定的資料來源。

 1 @Configuration
 2 @MapperScan(basePackages = "com.nbclass.oracle.mapper", sqlSessionFactoryRef = "oracleSqlSessionFactory")
 3 public class OracleDataSourceConfig {
 4 
 5     @Bean(name = "oracleDataSource")
 6     @ConfigurationProperties(prefix = "spring.secondary")
 7     public DataSource oracleDruid(){
 8         return new DruidDataSource();
 9     }
10 
11     @Bean(name = "oracleSqlSessionFactory")
12     public SqlSessionFactory oracleSqlSessionFactory(@Qualifier("oracleDataSource") DataSource dataSource) throws Exception {
13         SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
14         bean.setDataSource(dataSource);
15         bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:oracle/mapper/*.xml"));
16         return bean.getObject();
17     }
18 
19     @Bean("oracleSqlSessionTemplate")
20     public SqlSessionTemplate oracleSqlSessionTemplate(@Qualifier("oracleSqlSessionFactory") SqlSessionFactory sessionFactory) {
21         return new SqlSessionTemplate(sessionFactory);
22     }
23 }

這樣,就實現了一個工程下使用多個資料來源的功能,對於這種實現方式,其實也足夠簡單了,但是如果我們的資料庫例項有很多,並且每個例項都主從配置,那這裡維護起來難免會導致包名過多,不夠靈活。

    現在考慮實現一種對業務侵入足夠小,並且能夠在mapper方法粒度上去支援指定資料來源的方案,那自然而然想到了可以通過註解來實現,首先,自定義一個註解@DBKey:

1 @Retention(RetentionPolicy.RUNTIME)
2 @Target({ElementType.METHOD, ElementType.TYPE})
3 public @interface DBKey {
4 
5     String DEFAULT = "default"; // 預設資料庫節點
6 
7     String value() default DEFAULT;
8 }

思路和上面基於springboot原生的配置的類似,首先定義一個預設的資料庫節點,當mapper介面方法/類沒有指定任何註解的時候,預設走這個節點,註解支援傳入value參數列示選擇的資料來源節點名稱。至於註解的實現邏輯,可以通過反射來獲取mapper介面方法/類的註解值,然後指定特定的資料來源。

那在什麼時候執行這個操作獲取呢?可以考慮使用spring AOP織入mapper層,在切入點執行具體mapper方法之前,將對應的資料來源配置放入threaLocal中,有了這個邏輯,立即動手實現:

首先,定義一個db配置的上下文物件。維護所有的資料來源key例項,以及當前執行緒使用的資料來源key:

 1 public class DBContextHolder {
 2     
 3     private static final ThreadLocal<String> DB_KEY_CONTEXT = new ThreadLocal<>();
 4 
 5     //在app啟動時就載入全部資料來源,不需要考慮併發
 6     private static Set<String> allDBKeys = new HashSet<>();
 7 
 8     public static String getDBKey() {
 9         return DB_KEY_CONTEXT.get();
10     }
11 
12     public static void setDBKey(String dbKey) {
13         //key必須在配置中
14         if (containKey(dbKey)) {
15             DB_KEY_CONTEXT.set(dbKey);
16         } else {
17             throw new KeyNotFoundException("datasource[" + dbKey + "] not found!");
18         }
19     }
20 
21     public static void addDBKey(String dbKey) {
22         allDBKeys.add(dbKey);
23     }
24 
25     public static boolean containKey(String dbKey) {
26         return allDBKeys.contains(dbKey);
27     }
28 
29     public static void clear() {
30         DB_KEY_CONTEXT.remove();
31     }
32 }

然後,定義切點,在切點before方法中,根據當前mapper介面的@@DBKey註解來選取對應的資料來源key:

 1 @Aspect
 2 @Order(Ordered.LOWEST_PRECEDENCE - 1)
 3 public class DSAdvice implements BeforeAdvice {
 4 
 5     @Pointcut("execution(* com.xxx..*.repository.*.*(..))")
 6     public void daoMethod() {
 7     }
 8 
 9     @Before("daoMethod()")
10     public void beforeDao(JoinPoint point) {
11         try {
12             innerBefore(point, false);
13         } catch (Exception e) {
14             logger.error("DefaultDSAdviceException",
15                     "Failed to set database key,please resolve it as soon as possible!", e);
16         }
17     }
18 
19     /**
20      * @param isClass 攔截類還是介面
21      */
22     public void innerBefore(JoinPoint point, boolean isClass) {
23         String methodName = point.getSignature().getName();
24 
25         Class<?> clazz = getClass(point, isClass);
26         //使用預設資料來源
27         String dbKey = DBKey.DEFAULT;
28         Class<?>[] parameterTypes = ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
29         Method method = null;
30         try {
31             method = clazz.getMethod(methodName, parameterTypes);
32         } catch (NoSuchMethodException e) {
33             throw new RuntimeException("can't find " + methodName + " in " + clazz.toString());
34         }
35         //方法上存在註解,使用方法定義的datasource
36         if (method.isAnnotationPresent(DBKey.class)) {
37             DBKey key = method.getAnnotation(DBKey.class);
38             dbKey = key.value();
39         } else {
40             //方法上不存在註解,使用類上定義的註解
41             clazz = method.getDeclaringClass();
42             if (clazz.isAnnotationPresent(DBKey.class)) {
43                 DBKey key = clazz.getAnnotation(DBKey.class);
44                 dbKey = key.value();
45             }
46         }
47         DBContextHolder.setDBKey(dbKey);
48     }
49 
50 
51     private Class<?> getClass(JoinPoint point, boolean isClass) {
52         Object target = point.getTarget();
53         String methodName = point.getSignature().getName();
54 
55         Class<?> clazz = target.getClass();
56         if (!isClass) {
57             Class<?>[] clazzList = target.getClass().getInterfaces();
58 
59             if (clazzList == null || clazzList.length == 0) {
60                 throw new MutiDBException("找不到mapper class,methodName =" + methodName);
61             }
62             clazz = clazzList[0];
63         }
64 
65         return clazz;
66     }
67 }

既然在執行mapper之前,該mapper介面最終使用的資料來源已經被放入threadLocal中,那麼,只需要重寫新的路由資料來源介面邏輯即可:

 1 public class RoutingDatasource extends AbstractRoutingDataSource {
 2 
 3     @Override
 4     protected Object determineCurrentLookupKey() {
 5         String dbKey = DBContextHolder.getDBKey();
 6         return dbKey;
 7     }
 8 
 9     @Override
10     public void setTargetDataSources(Map<Object, Object> targetDataSources) {
11         for (Object key : targetDataSources.keySet()) {
12             DBContextHolder.addDBKey(String.valueOf(key));
13         }
14         super.setTargetDataSources(targetDataSources);
15         super.afterPropertiesSet();
16     }
17 }

另外,我們在服務啟動,配置mybatis的時候,將所有的db配置載入:

 1 @Bean
 2     @ConditionalOnMissingBean(DataSource.class)
 3     @Autowired
 4     public DataSource dataSource(MybatisProperties mybatisProperties) {
 5         Map<Object, Object> dsMap = new HashMap<>(mybatisProperties.getNodes().size());
 6         for (String nodeName : mybatisProperties.getNodes().keySet()) {
 7             dsMap.put(nodeName, buildDataSource(nodeName, mybatisProperties));
 8             DBContextHolder.addDBKey(nodeName);
 9         }
10         RoutingDatasource dataSource = new RoutingDatasource();
11         dataSource.setTargetDataSources(dsMap);
12         if (null == dsMap.get(DBKey.DEFAULT)) {
13             throw new RuntimeException(
14                     String.format("Default DataSource [%s] not exists", DBKey.DEFAULT));
15         }
16         dataSource.setDefaultTargetDataSource(dsMap.get(DBKey.DEFAULT));
17         return dataSource;
18     }
19 
20 
21 
22 @ConfigurationProperties(prefix = "mybatis")
23 @Data
24 public class MybatisProperties {
25 
26     private Map<String, String> params;
27 
28     private Map<String, Object> nodes;
29 
30     /**
31      * mapper檔案路徑:多個location以,分隔
32      */
33     private String mapperLocations = "classpath*:com/iqiyi/xiu/**/mapper/*.xml";
34 
35     /**
36      * Mapper類所在的base package
37      */
38     private String basePackage = "com.iqiyi.xiu.**.repository";
39 
40     /**
41      * mybatis配置檔案路徑
42      */
43     private String configLocation = "classpath:mybatis-config.xml";
44 }

那threadLocal中的key什麼時候進行銷燬呢,其實可以自定義一個基於mybatis的攔截器,在攔截器中主動調DBContextHolder.clear()方法銷燬這個key。具體程式碼就不貼了。這樣一來,我們就完成了一個基於註解的支援多資料來源切換的中介軟體。

那有沒有可以優化的點呢?其實,可以發現,在獲取mapper介面/所在類的註解的時候,使用了反射來獲取的,那我們知道一般反射呼叫是比較耗效能的,所以可以考慮在這裡加個本地快取來優化下效能:

 1     private final static Map<String, String> METHOD_CACHE = new ConcurrentHashMap<>();
 2 //.... 
 3 public void innerBefore(JoinPoint point, boolean isClass) {
 4         String methodName = point.getSignature().getName();
 5 
 6         Class<?> clazz = getClass(point, isClass);
 7         //key為類名+方法名
 8         String keyString = clazz.toString() + methodName;
 9         //使用預設資料來源
10         String dbKey = DBKey.DEFAULT;
11         //如果快取中已經有這個mapper方法對應的資料來源的key,那直接設定
12         if (METHOD_CACHE.containsKey(keyString)) {
13             dbKey = METHOD_CACHE.get(keyString);
14         } else {
15             Class<?>[] parameterTypes =
16                     ((MethodSignature) point.getSignature()).getMethod().getParameterTypes();
17             Method method = null;
18 
19             try {
20                 method = clazz.getMethod(methodName, parameterTypes);
21             } catch (NoSuchMethodException e) {
22                 throw new RuntimeException("can't find " + methodName + " in " + clazz.toString());
23             }
24              //方法上存在註解,使用方法定義的datasource
25             if (method.isAnnotationPresent(DBKey.class)) {
26                 DBKey key = method.getAnnotation(DBKey.class);
27                 dbKey = key.value();
28             } else {
29                 clazz = method.getDeclaringClass();
30                 //使用類上定義的註解
31                 if (clazz.isAnnotationPresent(DBKey.class)) {
32                     DBKey key = clazz.getAnnotation(DBKey.class);
33                     dbKey = key.value();
34                 }
35             }
36            //先放本地快取
37             METHOD_CACHE.put(keyString, dbKey);
38         }
39         DBContextHolder.setDBKey(dbKey);
40     }

這樣一來,只有在第一次呼叫這個mapper介面的時候,才會走反射呼叫的邏輯去獲取對應的資料來源,後續,都會走本地快取,提升了效能。

 

相關文章