天天看點

Springboot項目啟動後自動建立多表關聯的資料庫與表的方案

文/朱季謙

在一些項目開發當中,存在這樣一種需求,即開發完成的項目,在第一次部署啟動時,需能自行建構系統需要的資料庫及其對應的資料庫表。

若要解決這類需求,其實作在已有不少開源架構都能實作自動生成資料庫表,如mybatis plus、spring JPA等,但您是否有想過,若要自行建構一套能夠在系統第一次啟動時自行建構多表關聯等更為複雜的表結構時,需要如何才能實作呢?

我在前面寫過一篇 Activiti工作流學習筆記(三)——自動生成28張資料庫表的底層原理分析 ,裡面分析過工作流Activiti自動建構28資料庫表的底層原理。在我看來,學習開源架構的底層原理,其中一個原因是,須從中學到能為我所用的東西,故而,在分析了解完 工作流自動建構28資料庫表的底層原理之後,我決定也寫一個基于Springboot架構的自行建立資料庫與表的demo。我參考了工作流Activiti6.0版本的底層建表實作的邏輯,基于Springboot架構,實作項目在第一次啟動時可自動建構各種複雜如多表關聯等形式的資料庫與表的。

整體實作思路并不複雜,大概是這樣:先設計一套完整建立多表關聯的資料庫sql腳本,放到resource裡,在springboot啟動過程中,自動執行sql腳本。

首先,先一次性設計一套可行的多表關聯資料庫腳本,這裡我主要參考使用Activiti自帶的表做實作案例,因為它内部設計了衆多表關聯,就不額外設計了。

sql腳本的語句就是平常的create建表語句,類似如下:

1 create table ACT_PROCDEF_INFO (
  2    ID_ varchar(64) not null,
  3     PROC_DEF_ID_ varchar(64) not null,
  4     REV_ integer,
  5     INFO_JSON_ID_ varchar(64),
  6     primary key (ID_)
  7 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;      

增加外部主鍵、索引——

1 create index ACT_IDX_INFO_PROCDEF on ACT_PROCDEF_INFO(PROC_DEF_ID_);
  2 
  3 alter table ACT_PROCDEF_INFO
  4     add constraint ACT_FK_INFO_JSON_BA
  5     foreign key (INFO_JSON_ID_)
  6     references ACT_GE_BYTEARRAY (ID_);
  7 
  8 alter table ACT_PROCDEF_INFO
  9     add constraint ACT_FK_INFO_PROCDEF
 10     foreign key (PROC_DEF_ID_)
 11     references ACT_RE_PROCDEF (ID_);
 12 
 13 alter table ACT_PROCDEF_INFO
 14     add constraint ACT_UNIQ_INFO_PROCDEF
 15     unique (PROC_DEF_ID_);      

整體就是設計一套符合符合需求場景的sql語句,儲存在.sql的腳本檔案裡,最後統一存放在resource目錄下,類似如下:

Springboot項目啟動後自動建立多表關聯的資料庫與表的方案

接下來,就是實作CommandLineRunner的接口,重寫其run()的bean回調方法,在run方法裡開發能自動建庫與建表邏輯的功能。

目前,我已将開發的demo上傳到了我的github,感興趣的童鞋,可自行下載下傳,目前能直接下下來在本地環境運作,可根據自己的實際需求針對性參考使用。

首先,在解決這類需求時,第一個先要解決的地方是,Springboot啟動後如何實作隻執行一次建表方法。

這裡需要用到一個CommandLineRunner接口,這是Springboot自帶的,實作該接口的類,其重寫的run方法,會在Springboot啟動完成後自動執行,該接口源碼如下:

1 @FunctionalInterface
  2 public interface CommandLineRunner {
  3 
  4    /**
  5     *用于運作bean的回調
  6     */
  7    void run(String... args) throws Exception;
  8 
  9 }      

擴充一下,在Springboot中,可以定義多個實作CommandLineRunner接口類,并且可以對這些實作類中進行排序,隻需要增加@Order,其重寫的run方法就可以按照順序執行,代碼案例驗證:

1 @Component
  2 @Order(value=1)
  3 public class WatchStartCommandSqlRunnerImpl implements CommandLineRunner {
  4 
  5     @Override
  6     public void run(String... args) throws Exception {
  7         System.out.println("第一個Command執行");
  8     }
  9 
 10 
 11 @Component
 12 @Order(value = 2)
 13 public class WatchStartCommandSqlRunnerImpl2 implements CommandLineRunner {
 14     @Override
 15     public void run(String... args) throws Exception {
 16         System.out.println("第二個Command執行");
 17     }
 18 }
 19       

控制台列印的資訊如下:

1 第一個Command執行
  2 第二個Command執行      

根據以上的驗證,是以,我們可以通過實作CommandLineRunner的接口,重寫其run()的bean回調方法,用于在Springboot啟動後實作隻執行一次建表方法。實作項目啟動建表的功能,可能還需實作判斷是否已經有相應資料庫,若無,則應先建立一個資料庫,同時,得考慮還沒有對應資料庫的情況,是以,我們通過jdbc第一次連接配接MySQL時,應連接配接一個原有自帶存在的庫。每個MySql安裝成功後,都會有一個mysql庫,在第一次建立jdbc連接配接時,可以先連接配接它。

Springboot項目啟動後自動建立多表關聯的資料庫與表的方案

代碼如下:

Class.forName("com.mysql.jdbc.Driver");
String url="jdbc:mysql://127.0.0.1:3306/mysql?useUnicode=true&characterEncoding=UTF-8&ueSSL=false&serverTimezone=GMT%2B8";
Connection conn= DriverManager.getConnection(url,"root","root");      

建立與MySql軟體連接配接後,先建立一個Statement對象,該對象是jdbc中可用于執行靜态 SQL 語句并傳回它所生成結果的對象,這裡可以使用它來執行查找庫與建立庫的作用。

1  //建立Statement對象
  2  Statement statment=conn.createStatement();
  3  /**
  4  使用statment的查詢方法executeQuery("show databases like \"fte\"")
  5  檢查MySql是否有fte這個資料庫
  6  **/
  7  ResultSet resultSet=statment.executeQuery("show databases like \"fte\"");
  8  //若resultSet.next()為true,證明已存在;
  9  //若false,證明還沒有該庫,則執行statment.executeUpdate("create database fte")建立庫
 10  if(resultSet.next()){
 11      log.info("資料庫已經存在");
 12   }else {
 13   log.info("資料庫未存在,先建立fte資料庫");
 14   if(statment.executeUpdate("create database fte")==1){
 15      log.info("建立資料庫成功");
 16      }
 17    }      

在資料庫fte自動建立完成後,就可以在該fte庫裡去做建表的操作了。

我将建表的相關方法都封裝到SqlSessionFactory類裡,相關建表方法同樣需要用到jdbc的Connection連接配接到資料庫,是以,需要把已連接配接的Connection引用變量當做參數傳給SqlSessionFactory的初始構造函數:

1    public void createTable(Connection conn,Statement stat) throws SQLException {
  2         try {
  3 
  4             String url="jdbc:mysql://127.0.0.1:3306/fte?useUnicode=true&characterEncoding=UTF-8&ueSSL=false&serverTimezone=GMT%2B8";
  5             conn=DriverManager.getConnection(url,"root","root");
  6             SqlSessionFactory sqlSessionFactory=new SqlSessionFactory(conn);
  7             sqlSessionFactory.schemaOperationsBuild("create");
  8         } catch (SQLException e) {
  9             e.printStackTrace();
 10         }finally {
 11             stat.close();
 12             conn.close();
 13         }
 14     }      

初始化new SqlSessionFactory(conn)後,就可以在該對象裡使用已進行連接配接操作的Connection對象了。

1 public class SqlSessionFactory{
  2     private Connection connection ;
  3     public SqlSessionFactory(Connection connection) {
  4         this.connection = connection;
  5     }
  6 ......
  7 }      

這裡傳參可以有兩種情況,即“create”代表建立表結構的功能,“drop”代表删除表結構的功能:

1 sqlSessionFactory.schemaOperationsBuild("create");      

進入到這個方法裡,會先做一個判斷——

1 public void schemaOperationsBuild(String type) {
  2     switch (type){
  3         case "drop":
  4             this.dbSchemaDrop();break;
  5         case "create":
  6             this.dbSchemaCreate();break;
  7     }
  8 }      

若是this.dbSchemaCreate(),執行建表操作:

1 /**
  2  * 新增資料庫表
  3  */
  4 public void dbSchemaCreate() {
  5 
  6     if (!this.isTablePresent()) {
  7         log.info("開始執行create操作");
  8         this.executeResource("create", "act");
  9         log.info("執行create完成");
 10     }
 11 }      

this.executeResource("create", "act")代表建立表名為act的資料庫表——

1 public void executeResource(String operation, String component) {
  2     this.executeSchemaResource(operation, component, this.getDbResource(operation, operation, component), false);
  3 }      

其中 this.getDbResource(operation, operation, component)是擷取sql腳本的路徑,進入到方法裡,可見——

1 public String getDbResource(String directory, String operation, String component) {
  2     return "static/db/" + directory + "/mysql." + operation + "." + component + ".sql";
  3 }      

接下來,讀取路徑下的sql腳本,生成輸入流位元組流:

1 public void executeSchemaResource(String operation, String component, String resourceName, boolean isOptional) {
  2     InputStream inputStream = null;
  3 
  4     try {
  5         //讀取sql腳本資料
  6         inputStream = IoUtil.getResourceAsStream(resourceName);
  7         if (inputStream == null) {
  8             if (!isOptional) {
  9                 log.error("resource '" + resourceName + "' is not available");
 10                 return;
 11             }
 12         } else {
 13             this.executeSchemaResource(operation, component, resourceName, inputStream);
 14         }
 15     } finally {
 16         IoUtil.closeSilently(inputStream);
 17     }
 18 
 19 }      

最後,整個執行sql腳本的核心實作在this.executeSchemaResource(operation, component, resourceName, inputStream)方法裡——

1 /**
  2  * 執行sql腳本
  3  * @param operation
  4  * @param component
  5  * @param resourceName
  6  * @param inputStream
  7  */
  8 private void executeSchemaResource(String operation, String component, String resourceName, InputStream inputStream) {
  9     //sql語句拼接字元串
 10     String sqlStatement = null;
 11     Object exceptionSqlStatement = null;
 12 
 13     try {
 14         /**
 15          * 1.jdbc連接配接mysql資料庫
 16          */
 17         Connection connection = this.connection;
 18 
 19         Exception exception = null;
 20         /**
 21          * 2、分行讀取"static/db/create/mysql.create.act.sql"裡的sql腳本資料
 22          */
 23         byte[] bytes = IoUtil.readInputStream(inputStream, resourceName);
 24         /**
 25          * 3.将sql檔案裡資料分行轉換成字元串,換行的地方,用轉義符“\n”來代替
 26          */
 27         String ddlStatements = new String(bytes);
 28         /**
 29          * 4.以字元流形式讀取字元串資料
 30          */
 31         BufferedReader reader = new BufferedReader(new StringReader(ddlStatements));
 32         /**
 33          * 5.根據字元串中的轉義符“\n”分行讀取
 34          */
 35         String line = IoUtil.readNextTrimmedLine(reader);
 36         /**
 37          * 6.循環讀取的每一行
 38          */
 39         for(boolean inOraclePlsqlBlock = false; line != null; line = IoUtil.readNextTrimmedLine(reader)) {
 40             /**
 41              * 7.若下一行line還有資料,證明還沒有全部讀取,仍可執行讀取
 42              */
 43             if (line.length() > 0) {
 44                 /**
 45                  8.在沒有拼接夠一個完整建表語句時,!line.endsWith(";")會為true,
 46                  即一直循環進行拼接,當遇到";"就跳出該if語句
 47                 **/
 48                if ((!line.endsWith(";") || inOraclePlsqlBlock) && (!line.startsWith("/") || !inOraclePlsqlBlock)) {
 49                     sqlStatement = this.addSqlStatementPiece(sqlStatement, line);
 50                 } else {
 51                    /**
 52                     9.循環拼接中若遇到符号";",就意味着,已經拼接形成一個完整的sql建表語句,例如
 53                     create table ACT_GE_PROPERTY (
 54                     NAME_ varchar(64),
 55                     VALUE_ varchar(300),
 56                     REV_ integer,
 57                     primary key (NAME_)
 58                     ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin
 59                     這樣,就可以先通過代碼來将該建表語句執行到資料庫中,實作如下:
 60                     **/
 61                     if (inOraclePlsqlBlock) {
 62                         inOraclePlsqlBlock = false;
 63                     } else {
 64                         sqlStatement = this.addSqlStatementPiece(sqlStatement, line.substring(0, line.length() - 1));
 65                     }
 66                    /**
 67                     * 10.将建表語句字元串包裝成Statement對象
 68                     */
 69                     Statement jdbcStatement = connection.createStatement();
 70 
 71                     try {
 72                         /**
 73                          * 11.最後,執行建表語句到資料庫中
 74                          */
 75                         log.info("SQL: {}", sqlStatement);
 76                         jdbcStatement.execute(sqlStatement);
 77                         jdbcStatement.close();
 78                     } catch (Exception var27) {
 79                         log.error("problem during schema {}, statement {}", new Object[]{operation, sqlStatement, var27});
 80                     } finally {
 81                         /**
 82                          * 12.到這一步,意味着上一條sql建表語句已經執行結束,
 83                          * 若沒有出現錯誤話,這時已經證明第一個資料庫表結構已經建立完成,
 84                          * 可以開始拼接下一條建表語句,
 85                          */
 86                         sqlStatement = null;
 87                     }
 88                 }
 89             }
 90         }
 91 
 92         if (exception != null) {
 93             throw exception;
 94         } 
 97     } catch (Exception var29) {
 98         log.error("couldn't " + operation + " db schema: " + exceptionSqlStatement, var29);
 99     }
100 }      

這部分代碼主要功能是,先用位元組流形式讀取sql腳本裡的資料,轉換成字元串,其中有換行的地方用轉義符“/n”來代替。接着把字元串轉換成字元流BufferedReader形式讀取,按照“/n”符合來劃分每一行的讀取,循環将讀取的每行字元串進行拼接,當循環到某一行遇到“;”時,就意味着已經拼接成一個完整的create建表語句,類似這樣形式——

1 create table ACT_PROCDEF_INFO (
  2    ID_ varchar(64) not null,
  3     PROC_DEF_ID_ varchar(64) not null,
  4     REV_ integer,
  5     INFO_JSON_ID_ varchar(64),
  6     primary key (ID_)
  7 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;      

這時,就可以先将拼接好的create建表字元串,通過 jdbcStatement.execute(sqlStatement)語句來執行入庫了。當執行成功時,該ACT_PROCDEF_INFO表就意味着已經建立成功,接着以BufferedReader字元流形式繼續讀取下一行,進行下一個資料庫表結構的建構。

整個過程大概就是這個邏輯,可以在此基礎上,針對更為複雜的建表結構sql語句進行設計,在項目啟動時,自行執行相應的sql語句,來進行建表。

該demo代碼已經上傳git,可直接下載下傳運作:https://github.com/z924931408/Springboot-AutoCreateMySqlTable.git

繼續閱讀