Spring如何在運行期動態註冊新的數據源? | Java Debug 筆記

本文正在參加「Java主題月 - Java Debug筆記活動」,詳情查看掘力計劃 Java 主題月 - Java Debug筆記java

2021 年已通過去 1/3 了,阿熊怎麼還不寫文章呢?mysql

哎,不是不寫,是一直在更新着新的 MyBatis 小冊嘛,正好這段時間處在換工做的階段,白天除了找找招聘崗位就是寫寫小冊和文章,相對也悠閒一點(可是快要餓死了呀 ~ )。最近在翻底稿的時候找到了一個以前跟小冊交流羣的羣友討論的話題,感受這個主題還不錯,因此本篇文章,咱們就來研究一下本文標題所述的這個話題:SpringFramework 如何在運行期動態註冊新的數據源?git

Spring動態註冊數據源封面.png

需求來源

這個需求的起源是來自一個 SpringBoot 自動裝配的數據源註冊,由於一個項目中須要註冊的數據源不肯定,因此須要在啓動時根據配置文件的內容動態註冊多個數據源。後來聊着聊着,就演變成運行時動態註冊新的數據源了。雖然看上去這兩個事情好像差很少,但實際上兩件事差了不少哈。github

  • 啓動時動態初始化數據源:在基於 SpringFramework / SpringBoot 的應用初始化,也即 IOC 容器初始化時,讀取並解析配置文件,構造多個數據源,並註冊到 IOC 容器中
    • 此時一般狀況下 IOC 容器尚未刷新完畢,項目尚未啓動完成
  • 運行期動態註冊新的數據源:在項目的運行期間,動態的構造數據源,並註冊到 Spring 的 IOC 容器中
    • 此時項目已經在正常運行中了

前者的處理方式相對比較簡單,經過聲明一個標註了 @ConfigurationProperties 的類,並用 Map 接收數據源的參數就能夠把數據源的定義信息都獲取到了:web

@ConfigurationProperties(prefix = "spring.datasource.dynamic")
public class DynamicDataSourceProperties {
    
    private Map<String, DataSource> dataSourceMap = new HashMap<>();
    
    // ......
}
複製代碼

而後,再編寫一個 ImportBeanDefinitionRegistrar ,讀取這個 DynamicDataSourceProperties 的內容,就能夠把這些數據源都註冊到 IOC 容器中。spring

可是後者就麻煩了,運行期動態註冊新的數據源應該如何實現才行呢?下面咱們來經過幾個方案,講解該需求的實現。sql

編碼環境搭建

首先,咱們先來搭建一下編碼環境。數據庫

數據庫準備

首先,咱們先來建立 3 個不一樣的數據庫(固然也能夠只建立一個數據庫,這裏咱們搞的更真實一點吧):apache

CREATE DATABASE db1;
CREATE DATABASE db2;
CREATE DATABASE db3;
複製代碼

接下來給每個數據庫中都初始化一張相同的表:編程

CREATE TABLE tbl_user  (
  id int(11) NOT NULL AUTO_INCREMENT,
  name varchar(32) NOT NULL,
  tel varchar(16) NULL,
  PRIMARY KEY (id)
);
複製代碼

OK 就這麼簡單的準備一下就能夠了。

初始代碼編寫

爲了快速編碼,咱們仍然採用 SpringBoot 構建項目,直接使用 SpringInitializer 就挺好,固然也能夠經過 Maven 構建項目,這裏咱們就省去那些麻煩的構建步驟了,只把代碼貼一下哈。

項目名稱:dynamic-register-datasource

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.8.RELEASE</version>
        <relativePath/>
    </parent>
    <groupId>com.linkedbear.spring</groupId>
    <artifactId>dynamic-register-datasource</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
複製代碼

application.yml

spring:
  datasource:
    db1:
      driver-class-name: com.mysql.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3306/db1?characterEncoding=utf8 # 注意這裏是jdbc-url而不是url
      username: root
      password: 123456
    db2:
      driver-class-name: com.mysql.jdbc.Driver
      jdbc-url: jdbc:mysql://localhost:3306/db2?characterEncoding=utf8
      username: root
      password: 123456
複製代碼

DataSourceConfiguration

@Configuration
public class DataSourceConfiguration {
    
    @Bean
    @Primary
    @ConfigurationProperties("spring.datasource.db1")
    public DataSource db1() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    @ConfigurationProperties("spring.datasource.db2")
    public DataSource db2() {
        return DataSourceBuilder.create().build();
    }
}
複製代碼

以上的代碼,是咱們最多見到的 SpringBoot 中定義多個數據源的方法了是吧。

測試運行一下

最後編寫 SpringBoot 主啓動類,在這裏咱們將啓動完成後的 IOC 容器拿到,並從中取出全部的 DataSource ,取一下它們其中的數據庫鏈接 Connection

@SpringBootApplication
public class DynamicRegisterDataSourceApplication {
    
    public static void main(String[] args) throws Exception {
        ConfigurableApplicationContext ctx = SpringApplication.run(DynamicRegisterDataSourceApplication.class, args);
        Map<String, DataSource> dataSourceMap = ctx.getBeansOfType(DataSource.class);
        for (Map.Entry<String, DataSource> entry : dataSourceMap.entrySet()) {
            String name = entry.getKey();
            DataSource dataSource = entry.getValue();
            System.out.println(name);
            System.out.println(dataSource.getConnection()); // 這裏會拋出異常,直接throws走了
        }
    }
}
複製代碼

運行主啓動類,能夠在控制檯中發現咱們已經註冊好的兩個 DataSource ,以及它們對應的 Connection

db1
2021-01-15 20:43:14.299  INFO 7624 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2021-01-15 20:43:14.412  INFO 7624 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
HikariProxyConnection@65982709 wrapping com.mysql.jdbc.JDBC4Connection@64030b91
db2
2021-01-15 20:43:14.414  INFO 7624 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Starting...
2021-01-15 20:43:14.418  INFO 7624 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Start completed.
HikariProxyConnection@652007616 wrapping com.mysql.jdbc.JDBC4Connection@66e889df
複製代碼

到這裏,基本的環境和代碼都就準備好了。


下面,咱們來說解兩種程序運行期動態註冊數據源的解決方案。

解決方案1:基於BeanDefinition

若是各位小夥伴有學習過我 Spring 小冊的 IOC 高級部分,應該都知道 bean 的建立來源是 BeanDefinition 吧!一般狀況下,咱們經過 <bean> 標籤、@Bean 註解,或者 @Component 配合 @ComponentScan 註解完成的 bean 註冊,都是先封裝爲一個個的 BeanDefinition ,而後纔是根據 BeanDefinition 建立 bean 對象!

使用 SpringFramework 的 BeanDefinition 元編程,咱們能夠手動構造一個 BeanDefinition ,並註冊到 DefaultListableBeanFactoryBeanDefinitionRegistry )中:

@RestController
public class RegisterDataSourceController implements BeanFactoryAware, ApplicationContextAware {
    
    private DefaultListableBeanFactory beanFactory;

    @GetMapping("/register1")
    public String register1() {
        BeanDefinitionBuilder builder = BeanDefinitionBuilder.rootBeanDefinition(HikariDataSource.class);
        builder.addPropertyReference("driverClassName", "com.mysql.jdbc.Driver");
        builder.addPropertyReference("jdbcUrl", "jdbc:mysql://localhost:3306/db3?characterEncoding=utf8");
        builder.addPropertyReference("username", "root");
        builder.addPropertyReference("password", "123456");
        // builder.setScope(ConfigurableListableBeanFactory.SCOPE_SINGLETON);
        beanFactory.registerBeanDefinition("db3", builder.getBeanDefinition());
        return "success";
    }
    
    @GetMapping("/getDataSources")
    public String getDataSources() {
        Map<String, DataSource> dataSourceMap = beanFactory.getBeansOfType(DataSource.class);
        dataSourceMap.forEach((s, dataSource) -> {
            System.out.println(s + " ======== " + dataSource);
        });
        return "success";
    }
    
    // ......
}
複製代碼

若是構造的 DataSource 須要指定做用域等額外的配置,能夠操縱 BeanDefinitionBuilder 的 API 進行設置。

以此法編寫好以後,咱們能夠重啓項目測試一下。重啓以後先訪問 /getDataSources ,能夠發現控制檯只有兩個 DataSource 的打印:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
複製代碼

而後訪問 /register1 路徑,以後再訪問 /getDataSources ,控制檯就能夠打印三個 DataSource 了:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 ======== HikariDataSource (HikariPool-3)
複製代碼

這種方法比較簡單,比較具備通用性,關鍵的點是抓住核心知識點:BeanFactory 中的 bean 絕大多數都是經過 BeanDefinition 建立而來

解決方案2:基於SingletonBeanRegistry

若是須要註冊的 bean 都是單實例 bean ,並且不須要通過 AOP 處理的話,則也可使用接下來要講的這種方式,相較於上一種而言,採用這種方法相對會更友好。

若是小夥伴有看過個人 Spring 小冊第 14 章 BeanFactory 章節,應該不會忘記 BeanFactoryApplicationContext 中惟一現役的最終實現是 DefaultListableBeanFactory 吧。那這個實現類,最終是繼承了 AbstractBeanFactory ,而它又繼承了一個叫 DefaultSingletonBeanRegistry 的類,這個類咱們在 Spring 小冊的正篇中沒有說起,現已經補充到小冊的加餐內容中了,小夥伴們能夠戳連接去學習呀。

簡單示例代碼

咱們簡單的來講哈,DefaultSingletonBeanRegistry 這個類實現了一個 SingletonBeanRegistry 接口,這個接口中定義了一個方法:registerSingleton ,它能夠直接向 IOC 容器註冊一個已經完徹底全存在的對象,使其成爲 IOC 容器中的一個 bean

void registerSingleton(String beanName, Object singletonObject);
複製代碼

又由於 DefaultListableBeanFactory 繼承自 DefaultSingletonBeanRegistry ,因此藉助這個原理以後,實現這個需求就簡單的很了。咱們只須要拿到 DefaultListableBeanFactory ,以後調用它的 registerSingleton 方法便可:

@RestController
public class RegisterDataSourceController implements BeanFactoryAware {
    
    private DefaultListableBeanFactory beanFactory;
    
    @GetMapping("/getDataSources")
    public String getDataSources() {
        Map<String, DataSource> dataSourceMap = beanFactory.getBeansOfType(DataSource.class);
        dataSourceMap.forEach((s, dataSource) -> {
            System.out.println(s + " ======== " + dataSource);
        });
        return "success";
    }

    @GetMapping("/register2")
    public String register2() throws SQLException {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db3?characterEncoding=utf8");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        dataSource.getConnection();
        System.out.println("db3 建立完成!");
        beanFactory.registerSingleton("db3", dataSource);
        return "success";
    }
    
    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        this.beanFactory = (DefaultListableBeanFactory) beanFactory;
    }
}
複製代碼

這樣註冊好了,IOC 容器中就有這個 db3 的數據源了,咱們能夠再測試一下。

測試註冊效果

重啓工程,先訪問 /getDataSources ,控制檯依然是隻有兩個 DataSource 的打印:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
複製代碼

而後訪問 /register2 路徑,控制檯能夠打印成功 db3 建立完成! ,此時再訪問 /getDataSources 路徑,控制檯也能夠打印三個 DataSource 了:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 ======== HikariDataSource (HikariPool-3)
複製代碼

依賴注入?

上面咱們看到的生效,那僅僅是咱們拿到 BeanFactory ,或者 ApplicationContext 後主動調用 getBean 系列方法,去獲取 IOC 容器的 bean 。但對於那些依賴了 DataSource 的 bean ,這種狀況就很差辦了:由於依賴注入的時機是 bean 的初始化階段,當 bean 建立完成後,沒有其餘代碼的干涉,bean 依賴的那些 bean 就不會變化

聽起來有點繞,咱們來寫一個 Service 類來解釋一下。

@Service
public class DataSourceService {
    
    @Autowired
    Map<String, DataSource> dataSourceMap;
    
    public void printDataSources() {
        dataSourceMap.forEach((s, dataSource) -> {
            System.out.println(s + " ======== " + dataSource);
        });
    }
}
複製代碼

這裏咱們造了一個 DataSourceService ,並經過注入一整個 Map 的方式,將 IOC 容器中的 DataSource 連帶着 bean 的 name 都注入進來。

而後咱們修改一下 Controller ,讓它取容器中的 DataSourceService ,打印它裏面的 DataSource

@GetMapping("/getDataSources")
    public String getDataSources() {
        DataSourceService dataSourceService = beanFactory.getBean(DataSourceService.class);
        dataSourceService.printDataSources();
        return "success";
    }
複製代碼

重啓工程,並重覆上面的測試效果,此次發現兩次打印的結果是同樣的:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 建立完成!
db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
複製代碼

這個現象就是上面提到的:bean 中依賴注入的屬性沒有被主動干預,則不會發生變化

怎麼解決這個問題呢?哎,仍是靠 BeanFactory

Spring 小冊第 14 章 BeanFactory 的 1.4.2 節中咱們講到了有關 AutowireCapableBeanFactory 的一個做用是框架集成,它提供了一個 autowireBean 方法,用於給現有的對象進行依賴注入:

void autowireBean(Object existingBean) throws BeansException;
複製代碼

因此咱們能夠藉助這個特性,在動態註冊完 DataSource 後,把 IOC 容器中的 DataSourceService 取出來,讓它從新執行一次依賴注入便可:

@GetMapping("/register2")
    public String register2() throws SQLException {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db3?characterEncoding=utf8");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        dataSource.getConnection();
        System.out.println("db3 建立完成!");
        beanFactory.registerSingleton("db3", dataSource);
        // 從新執行依賴注入
        beanFactory.autowireBean(beanFactory.getBean(DataSourceService.class));
        return "success";
    }
複製代碼

就這麼簡單,添加這樣一行代碼便可。

再測試

OK ,從新測試一下效果怎樣,重啓工程,按照上面的測試過程,先訪問 /getDataSources ,再訪問 /register2 ,而後從新訪問 /getDataSources ,此次控制檯打印了 DataSourceService 中的 3 個 DataSource 了:

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 建立完成!
db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 ======== HikariDataSource (HikariPool-3)
複製代碼

這樣依賴注入的問題也就解決了。

不足?

雖然上面這樣的寫法沒啥問題,但若是依賴 DataSource 的 bean 太多,那咱們一個一個的從新依賴注入,那豈不是太費勁了?有沒有更好的方案,能針對某一種特定的 bean 的類型,當 BeanFactory 動態註冊該類型的 bean 時,自動刷新 IOC 容器中依賴了該類型 bean 的 bean 。這個想法是否能實現呢?

優化方案:自定義註解+事件監聽

比較惋惜,使用普通的套路咱們沒法比較容易的獲取到 IOC 容器中哪些 bean 依賴這些 DataSource ,因此咱們能夠換一個思路:既然依賴這些 DataSource 的 bean 一般都是咱們本身編寫的(咱們本身的業務場景須要呀),因此咱們徹底能夠給這些 bean 上面添加一個自定義的註解。

自定義註解

譬如說,咱們給上面的代碼中,DataSourceService 的上面添加一個 @RefreshDependency 註解:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RefreshDependency {
    
}
複製代碼
@Service
@RefreshDependency
public class DataSourceService {
    
    @Autowired
    Map<String, DataSource> dataSourceMap;
    
    // ......
}
複製代碼

這個註解的做用,就是標識那些須要 BeanFactory 去執行依賴重注入動做的 bean 。

接下來,就是每次動態註冊完 bean 後,讓 BeanFactory 去尋找這些標有 @RefreshDependency 註解的 bean ,並執行依賴重注入:

@GetMapping("/register3")
    public String register3() throws SQLException {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db3?characterEncoding=utf8");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        dataSource.getConnection();
        System.out.println("db3 建立完成!");
        beanFactory.registerSingleton("db3", dataSource);
        
        Map<String, Object> beansMap = beanFactory.getBeansWithAnnotation(RefreshDependency.class);
        beansMap.values().forEach(bean -> beanFactory.autowireBean(bean));
        return "success";
    }
複製代碼

固然,這兩行代碼雖然不長,但它畢竟是一個能夠抽取的邏輯。若是後續咱們的代碼中還有別的地方也須要動態註冊新的 bean 後通知其它 bean 完成依賴重注入,則相同的代碼又要再寫一次。

針對這個問題,咱們能夠繼續使用事件驅動的特性來優化。

事件驅動優化

既然要用事件驅動,而咱們又知道 ApplicationContext 自己也是一個 ApplicationEventPublisher ,它具有發佈事件的能力,因此咱們此次就沒必要在 Controller 中注入 BeanFactory 了,而是換用 ApplicationContext

@RestController
public class RegisterDataSourceController implements BeanFactoryAware, ApplicationContextAware {
    
    private ConfigurableApplicationContext ctx;
    
    // ......
    
    @Override
    public void setApplicationContext(ApplicationContext ctx) throws BeansException {
        this.ctx = (ConfigurableApplicationContext) ctx;
    }
}
複製代碼

注意這裏要用 ConfigurableApplicationContext 去接收,由於 ApplicationContext 接口並無繼承 SingletonBeanRegistry 接口,ConfigurableApplicationContext 才繼承了它。

而後,在註冊完 bean 以後,就能夠發佈一個事件,經過事件機制來觸發 bean 的依賴重注入了。咱們先來把事件和監聽器造出來:

// 繼承自ApplicationContextEvent,則能夠直接從事件中獲取ApplicationContext
public class DynamicRegisterEvent extends ApplicationContextEvent {
    
    public DynamicRegisterEvent(ApplicationContext source) {
        super(source);
    }
}
複製代碼
@Component
public class DynamicRegisterListener implements ApplicationListener<DynamicRegisterEvent> {
    
    @Override
    public void onApplicationEvent(DynamicRegisterEvent event) {
        ApplicationContext ctx = event.getApplicationContext();
        AutowireCapableBeanFactory beanFactory = ctx.getAutowireCapableBeanFactory();
        Map<String, Object> beansMap = ctx.getBeansWithAnnotation(RefreshDependency.class);
        beansMap.values().forEach(beanFactory::autowireBean);
    }
}
複製代碼

OK ,把監聽器註冊到 IOC 容器周,接下來再修改 Controller 中的動態註冊 bean 的邏輯,讓它註冊完 bean 後發佈 DynamicRegisterEvent 事件:

@GetMapping("/register3")
    public String register3() throws SQLException {
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setDriverClassName("com.mysql.jdbc.Driver");
        dataSource.setJdbcUrl("jdbc:mysql://localhost:3306/db3?characterEncoding=utf8");
        dataSource.setUsername("root");
        dataSource.setPassword("123456");
        dataSource.getConnection();
        System.out.println("db3 建立完成!");
        ctx.getBeanFactory().registerSingleton("db3", dataSource);
        
        ctx.publishEvent(new DynamicRegisterEvent(ctx));
        return "success";
    }
複製代碼

這樣一切就大功告成了,註冊 bean 的邏輯,和依賴重注入的邏輯也都經過事件驅動解耦了。

從新測試一下,瀏覽器前後訪問 /getDataSources/register3/getDataSources ,控制檯依然能夠打印 DataSourceService 中的 3 個 DataSource

db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 建立完成!
db1 ======== HikariDataSource (HikariPool-1)
db2 ======== HikariDataSource (HikariPool-2)
db3 ======== HikariDataSource (HikariPool-3)
複製代碼

說明咱們的優化方案是沒有問題的。

本文涉及到的全部源碼能夠從 GitHub 中找到:github.com/LinkedBear/…

【都看到這裏了,小夥伴們要不要關注點贊一下呀,有 Spring 、SpringBoot 、MyBatis 及相關源碼學習須要的能夠看個人柯基小冊四件套哦(對,是四件套了),學習起來 ~ 奧利給】

相關文章
相關標籤/搜索