写点什么

模仿 Activiti 工作流自动建表机制,实现 Springboot 项目启动后自动创建多表关联的数据库与表的方案

用户头像
朱季谦
关注
发布于: 2021 年 03 月 26 日

原创/朱季谦

 

在一些本地化项目开发当中,存在这样一种需求,即开发完成的项目,在第一次部署启动时,需能自行构建系统需要的数据库及其对应的数据库表。

若要解决这类需求,其实现在已有不少开源框架都能实现自动生成数据库表,如 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 目录下,类似如下:

image-20210315132805036


接下来,就是实现 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 连接时,可以先连接它。

image-20210315080736373


代码如下:

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


发布于: 2021 年 03 月 26 日阅读数: 12
用户头像

朱季谦

关注

一个以编程为生的荒诞小说家 2018.06.29 加入

汤圆创作APP签约作者; 简书优秀认证作者; Java后端程序员; PMP认证人员; 学生时代著有《黑色玫瑰》一书已上架微信读书APP;

评论

发布
暂无评论
模仿Activiti工作流自动建表机制,实现Springboot项目启动后自动创建多表关联的数据库与表的方案