從程式碼生成說起,帶你深入理解 mybatis generator 原始碼

老馬嘯西風發表於2021-07-22

枯燥的任務

這一切都要從多年前說起。

那時候剛入職一家新公司,專案經理給我分配了一個比較簡單的工作,為所有的資料庫欄位整理一張後設資料表。

因為很多接手的專案文件都不全,所以需要統一整理一份基本的字典表。

如果是你,你會怎麼處理這個任務呢?

重複的工作

一開始我是直接準備人工把所有的欄位整理一遍,然後整理出對應的 SQL 插入到後設資料庫管理表中。

meta_table 後設資料表資訊

meta_field 後設資料欄位資訊

一開始還有點激情,後來就是無盡的重複,感覺十分無聊。

於是,我自己動手寫了一個開源的小工具。

github.com/houbb/metad…

後設資料管理

metadata 可以自動將所有的表資訊和欄位資訊存入後設資料表中,便於統一查閱。

(註釋需要保證庫本身已經包含了對於表和欄位的註釋)

資料庫表設計

一開始實現了 3 種常見的資料庫:mysql oracle sql-server。

以 mysql 為例,對應的建表語句為:

drop table if exists meta_field;

drop table if exists meta_model;

/*==============================================================*/
/* Table: meta_field                                            */
/*==============================================================*/
create table meta_field
(
   ID                   int not null auto_increment comment '自增長主鍵',
   uid                  varchar(36) comment '唯一標識',
   name                 varchar(125) comment '名稱',
   dbObjectName         varchar(36) comment '資料庫表名',
   alias                varchar(125) comment '別名',
   description          varchar(255) comment '描述',
   isNullable           bool comment '是否可為空',
   dataType             varchar(36) comment '資料型別',
   createTime           datetime comment '建立時間',
   updateTime           datetime comment '更新時間',
   primary key (ID)
)
auto_increment = 1000
DEFAULT CHARSET=utf8;

alter table meta_field comment '後設資料欄位表';

/*==============================================================*/
/* Table: meta_model                                            */
/*==============================================================*/
create table meta_model
(
   ID                   int not null auto_increment comment '自增長主鍵',
   uid                  varchar(36) comment '唯一標識',
   name                 varchar(125) comment '名稱',
   dbObjectName         varchar(36) comment '資料庫表名',
   alias                varchar(125) comment '別名',
   description          varchar(255) comment '描述',
   category             varchar(36) comment '分類',
   isVisible            bool comment '是否可查詢',
   isEditable           bool comment '是否可編輯',
   createTime           datetime comment '建立時間',
   updateTime           datetime comment '更新時間',
   primary key (ID)
)
DEFAULT CHARSET=utf8;

alter table meta_model comment '後設資料實體表';
複製程式碼

資料初始化

metadata 是一個 web 應用,部署啟動後,頁面指定資料庫連線資訊,就可以完成所有資料的初始化。

metadata

以測試指令碼

CREATE DATABASE `metadata-test`
  DEFAULT CHARACTER SET UTF8;
USE `metadata-test`;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '唯一標識',
  `username` varchar(255) DEFAULT NULL COMMENT '使用者名稱',
  `password` varchar(255) DEFAULT NULL COMMENT '密碼',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=UTF8 COMMENT='使用者表';
複製程式碼

為例,可以將對應的表和欄位資訊全部初始化到對應的表中。

一切看起來都很棒,幾分鐘就搞定了。不是嗎?

程式碼自動生成

本來 metadata 沒有意外的話,我幾乎不會再去修改他了。

直接前不久,我基於 mybatis-plus-generator 實現了一個程式碼自動生成的低程式碼平臺。

開源地址如下:

github.com/houbb/low-c…

我發現了 metadata 這個應用雖然作為 web 應用還不錯,但是本身的複用性很差,我無法在這個基礎上實現一個程式碼生成工具。

於是,就誕生了實現一個最基礎的 jdbc 後設資料管理工具的想法。

他山之石,可以攻玉。

我們就直接以 MPG 的原始碼為例,學習並且改造。

資料庫後設資料

核心原理

後設資料管理最核心的一點在於所有的資料庫本身就有後設資料管理。

我們以 mysql 為例,檢視所有表資訊。

show table status;
複製程式碼

如下:

+------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+-------------+------------+-----------------+----------+----------------+--------------+
| Name | Engine | Version | Row_format | Rows | Avg_row_length | Data_length | Max_data_length | Index_length | Data_free | Auto_increment | Create_time         | Update_time | Check_time | Collation       | Checksum | Create_options | Comment      |
+------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+-------------+------------+-----------------+----------+----------------+--------------+
| word | InnoDB |      10 | Compact    |    0 |              0 |       16384 |               0 |            0 |         0 |              1 | 2021-07-22 19:39:13 | NULL        | NULL       | utf8_general_ci |     NULL |                | 敏 感詞表     |
+------+--------+---------+------------+------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+-------------+------------+-----------------+----------+----------------+--------------+
1 row in set (0.00 sec)
複製程式碼

對應的欄位資訊檢視

show full fields from word;
複製程式碼

輸出如下:

mysql> show full fields from word;
+-------------+------------------+-----------------+------+-----+-------------------+-----------------------------+---------------------------------+--------------------+
| Field       | Type             | Collation       | Null | Key | Default           | Extra                       | Privileges                      | Comment            |
+-------------+------------------+-----------------+------+-----+-------------------+-----------------------------+---------------------------------+--------------------+
| id          | int(10) unsigned | NULL            | NO   | PRI | NULL              | auto_increment              | select,insert,update,references | 應用自增主鍵       |
| word        | varchar(128)     | utf8_general_ci | NO   | UNI | NULL              |                             | select,insert,update,references | 單詞               |
| type        | varchar(8)       | utf8_general_ci | NO   |     | NULL              |                             | select,insert,update,references | 型別               |
| status      | char(1)          | utf8_general_ci | NO   |     | S                 |                             | select,insert,update,references | 狀態               |
| remark      | varchar(64)      | utf8_general_ci | NO   |     |                   |                             | select,insert,update,references | 配置描述           |
| operator_id | varchar(64)      | utf8_general_ci | NO   |     | system            |                             | select,insert,update,references | 操作員名稱         |
| create_time | timestamp        | NULL            | NO   |     | CURRENT_TIMESTAMP |                             | select,insert,update,references | 建立時間戳         |
| update_time | timestamp        | NULL            | NO   |     | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP | select,insert,update,references | 更新時間戳         |
+-------------+------------------+-----------------+------+-----+-------------------+-----------------------------+---------------------------------+--------------------+
8 rows in set (0.01 sec)
複製程式碼

可以獲取到非常全面的資訊,程式碼生成就是基於這些基本資訊,生成對應的程式碼文字

其中,word 的建表語句如下:

create table word
(
    id int unsigned auto_increment comment '應用自增主鍵' primary key,
    word varchar(128) not null comment '單詞',
    type varchar(8) not null comment '型別',
    status char(1) not null default 'S' comment '狀態',
    remark varchar(64) not null comment '配置描述' default '',
    operator_id varchar(64) not null default 'system' comment '操作員名稱',
    create_time timestamp default CURRENT_TIMESTAMP not null comment '建立時間戳',
    update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新時間戳'
) comment '敏感詞表' ENGINE=Innodb default charset=UTF8 auto_increment=1;
create unique index uk_word on word (word) comment '唯一索引';
複製程式碼

擴充性

雖然上面介紹後設資料獲取,是以 mysql 為例。

但是我們在實現工具的時候,一定要考慮對應的可擴充性。

可以是 mysql,也可以是常見的 oracle/sql-server。

每一種資料庫的獲取方式都是不同的,所以需要根據配置不同,實現也要不同。

獲取到後設資料之後,處理的方式也可以非常多樣化。

可以控臺輸出,可以入庫,可以生成對應的 markdown/html/pdf/word/excel 不同形式的文件等。

易用性

好的工具,應該對使用者遮蔽複雜的實現細節。

使用者只需要簡單的指定配置資訊,想要獲取的表,處理方式即可。

至於如何實現,使用者可以不關心。

原始碼實現

接下來,我們結合 MPG 的原始碼,抽取最核心的部分進行講解。

獲取資料庫連線

如何根據連線資訊獲取 connection?

希望經常使用 mybatis 等工具的你還記得:

public class DbConnection implements IDbConnection {

    /**
     * 驅動連線的URL
     */
    private String url;
    /**
     * 驅動名稱
     */
    private String driverName;
    /**
     * 資料庫連線使用者名稱
     */
    private String username;
    /**
     * 資料庫連線密碼
     */
    private String password;

    //getter&setter

    @Override
    public Connection getConnection() {
        Connection conn = null;
        try {
            Class.forName(driverName);
            conn = DriverManager.getConnection(url, username, password);
        } catch (ClassNotFoundException | SQLException e) {
            throw new JdbcMetaException(e);
        }
        return conn;
    }

}
複製程式碼

IDbConnection 介面的定義非常簡單:

public interface IDbConnection {

    /**
     * 獲取資料庫連線
     * @return 連線
     * @since 1.0.0
     */
    Connection getConnection();

}
複製程式碼

這樣便於後期替換實現,你甚至可以使用資料庫連線池:

github.com/houbb/jdbc-…

後設資料查詢指令碼

對於不同的資料庫,查詢的方式不同。

以 mysql 為例,實現如下:

public class MySqlQuery extends AbstractDbQuery {

    @Override
    public DbType dbType() {
        return DbType.MYSQL;
    }


    @Override
    public String tablesSql() {
        return "show table status";
    }


    @Override
    public String tableFieldsSql() {
        return "show full fields from `%s`";
    }


    @Override
    public String tableName() {
        return "NAME";
    }


    @Override
    public String tableComment() {
        return "COMMENT";
    }


    @Override
    public String fieldName() {
        return "FIELD";
    }


    @Override
    public String fieldType() {
        return "TYPE";
    }


    @Override
    public String fieldComment() {
        return "COMMENT";
    }


    @Override
    public String fieldKey() {
        return "KEY";
    }


    @Override
    public boolean isKeyIdentity(ResultSet results) throws SQLException {
        return "auto_increment".equals(results.getString("Extra"));
    }

    @Override
    public String nullable() {
        return "Null";
    }

    @Override
    public String defaultValue() {
        return "Default";
    }

}
複製程式碼

其中 show table status 用於檢視所有的表後設資料;show full fields from %s 可以檢視具體表的欄位後設資料。

nullable() 和 defaultValue() 這兩個屬性是老馬新增的,MPG 中是沒有的,因為程式碼生成不關心這兩個欄位。

核心實現

做好上面的準備工作之後,我們可以開始進行核心程式碼編寫。

@Override
public List<TableInfo> getTableList(TableInfoContext context) {
    // 連線
    Connection connection = getConnection(context);

    DbType dbType = DbTypeUtils.getDbType(context.getDriverName());
    IDbQuery dbQuery = DbTypeUtils.getDbQuery(dbType);

    // 構建後設資料查詢 SQL
    String tableSql = buildTableSql(context);

    // 執行查詢
    List<TableInfo> tableInfoList = queryTableInfos(connection, tableSql, dbQuery, context);
    return tableInfoList;
}
複製程式碼

具體資料庫實現

具體資料庫的實現是不同的,可以根據 driverName 獲取。

DbTypeUtils 的實現如下:

/**
 * @author binbin.hou
 * @since 1.0.0
 */
public final class DbTypeUtils {

    private DbTypeUtils(){}

    /**
     * 根據驅動獲取 dbType
     * @param driverName 驅動資訊
     * @return 結果
     * @since 1.1.0
     */
    public static DbType getDbType(final String driverName) {
        DbType dbType = null;
        if (driverName.contains("mysql")) {
            dbType = DbType.MYSQL;
        } else if (driverName.contains("oracle")) {
            dbType = DbType.ORACLE;
        } else if (driverName.contains("postgresql")) {
            dbType = DbType.POSTGRE_SQL;
        } else {
            throw new JdbcMetaException("Unknown type of database!");
        }

        return dbType;
    }

    /**
     * 獲取對應的資料庫查詢型別
     * @param dbType 資料庫型別
     * @return 結果
     * @since 1.0.0
     */
    public static IDbQuery getDbQuery(final DbType dbType) {
        IDbQuery dbQuery = null;
        switch (dbType) {
            case ORACLE:
                dbQuery = new OracleQuery();
                break;
            case SQL_SERVER:
                dbQuery = new SqlServerQuery();
                break;
            case POSTGRE_SQL:
                dbQuery = new PostgreSqlQuery();
                break;
            default:
                // 預設 MYSQL
                dbQuery = new MySqlQuery();
                break;
        }
        return dbQuery;
    }

}
複製程式碼

表資料查詢 sql

根據對應的 IDbQuery 構建表資料查詢的 sql。

/**
 * 構建 table sql
 * @param context 上下文
 * @return 結果
 * @since 1.0.0
 */
private String buildTableSql(final TableInfoContext context) {
    // 獲取 dbType & DbQuery
    final String jdbcUrl = context.getDriverName();
    DbType dbType = DbTypeUtils.getDbType(jdbcUrl);
    IDbQuery dbQuery = DbTypeUtils.getDbQuery(dbType);
    String tablesSql = dbQuery.tablesSql();
    if (DbType.POSTGRE_SQL == dbQuery.dbType()) {
        //POSTGRE_SQL 使用
        tablesSql = String.format(tablesSql, "public");
    }
    
    // 簡化掉 oracle 的特殊處理

    return tablesSql;
}
複製程式碼

直接獲取對應的 tablesSql 即可,非常簡答。

表資訊構建

直接根據構建好的 tableSql 查詢,然後構建最基本的表資訊。

try(PreparedStatement preparedStatement = connection.prepareStatement(tablesSql);) {
    List<TableInfo> tableInfoList = new ArrayList<>();
    ResultSet results = preparedStatement.executeQuery();
    TableInfo tableInfo;
    while (results.next()) {
        String tableName = results.getString(dbQuery.tableName());
        if (StringUtil.isNotEmpty(tableName)) {
            String tableComment = results.getString(dbQuery.tableComment());
            tableInfo = new TableInfo();
            tableInfo.setName(tableName);
            tableInfo.setComment(tableComment);
            tableInfoList.add(tableInfo);
        } else {
            System.err.println("當前資料庫為空!!!");
        }
    }
} catch (SQLException e) {
    throw new JdbcMetaException(e);
}
複製程式碼

此處省去對錶資訊的過濾。

欄位資訊構建

表資訊構建為完成後,構建具體的欄位資訊。

try {
    String tableFieldsSql = dbQuery.tableFieldsSql();
    if (DbType.POSTGRE_SQL == dbQuery.dbType()) {
        tableFieldsSql = String.format(tableFieldsSql, "public", tableInfo.getName());
    } else {
        tableFieldsSql = String.format(tableFieldsSql, tableInfo.getName());
    }
    PreparedStatement preparedStatement = connection.prepareStatement(tableFieldsSql);
    ResultSet results = preparedStatement.executeQuery();
    while (results.next()) {
        TableField field = new TableField();

        // 省略 ID 相關的處理
        // 省略自定義欄位查詢
        
        // 處理其它資訊
        field.setName(results.getString(dbQuery.fieldName()));
        field.setType(results.getString(dbQuery.fieldType()));
        String propertyName = getPropertyName(field.getName());
        DbColumnType dbColumnType = typeConvert.getTypeConvert(field.getType());
        field.setPropertyName(propertyName);
        field.setColumnType(dbColumnType);
        field.setComment(results.getString(dbQuery.fieldComment()));
        field.setNullable(results.getString(dbQuery.nullable()));
        field.setDefaultValue(results.getString(dbQuery.defaultValue()));
        fieldList.add(field);
    }
} catch (SQLException e) {
    throw new JdbcMetaException(e);
}
複製程式碼

欄位資訊的實現也比較簡單,直接根據對應的 sql 進行查詢,然後構建即可。

結果的處理

在經過大量的刪減之後,我們可以獲取最基礎的表後設資料資訊。

但是要怎麼處理這個列表資訊呢?

我們可以定義一個介面:

public interface IResultHandler {

    /**
     * 處理
     * @param context 上下文
     * @since 1.0.0
     */
    void handle(final IResultHandlerContext context);

}
複製程式碼

context 的屬性比較簡單,目前就是 List<TableInfo>

我們可以實現一個控臺輸出:

public class ConsoleResultHandler implements IResultHandler {

    @Override
    public void handle(IResultHandlerContext context) {
        List<TableInfo> tableInfoList = context.tableInfoList();

        for(TableInfo tableInfo : tableInfoList) {
            // 資料
            System.out.println("> " + tableInfo.getName() + " " + tableInfo.getComment());
            System.out.println();

            List<TableField> tableFields = tableInfo.getFields();
            System.out.println("| 序列 | 列名 | 型別 | 是否為空 | 預設值 | 描述 |");
            System.out.println("|:---|:---|:---|:---|:---|:---|");
            String format = "| %d | %s | %s | %s | %s | %s |";
            int count = 1;
            for (TableField field : tableFields) {
                String info = String.format(format, count, field.getName(),
                        field.getType(), field.getNullable(), field.getDefaultValue(),
                        field.getComment());
                System.out.println(info);
                count++;
            }
            System.out.println("\n\n");
        }
    }

}
複製程式碼

在控臺輸出對應的 markdown 欄位資訊。

你也可以實現自己的 html/pdf/word/excel 等等。

測試驗證

我們前面寫了這麼多主要是原理實現。

那麼工具是否好用,還是要體驗一下。

測試程式碼

JdbcMetadataBs.newInstance()
                .url("jdbc:mysql://127.0.0.1:3306/test")
                .includes("word")
                .execute();
複製程式碼

指定輸出 test.word 的表資訊。

效果

對應的日誌如下:

> word 敏感詞表

| 序列 | 列名 | 型別 | 是否為空 | 預設值 | 描述 |
|:---|:---|:---|:---|:---|:---|
| 1 | id | int(10) unsigned | NO | null | 應用自增主鍵 |
| 2 | word | varchar(128) | NO | null | 單詞 |
| 3 | type | varchar(8) | NO | null | 型別 |
| 4 | status | char(1) | NO | S | 狀態 |
| 5 | remark | varchar(64) | NO |  | 配置描述 |
| 6 | operator_id | varchar(64) | NO | system | 操作員名稱 |
| 7 | create_time | timestamp | NO | CURRENT_TIMESTAMP | 建立時間戳 |
| 8 | update_time | timestamp | NO | CURRENT_TIMESTAMP | 更新時間戳 |
複製程式碼

這個就是簡單的 markdown 格式,實際效果如下:

word 敏感詞表

序列列名型別是否為空預設值描述
1idint(10) unsignedNOnull應用自增主鍵
2wordvarchar(128)NOnull單詞
3typevarchar(8)NOnull型別
4statuschar(1)NOS狀態
5remarkvarchar(64)NO配置描述
6operator_idvarchar(64)NOsystem操作員名稱
7create_timetimestampNOCURRENT_TIMESTAMP建立時間戳
8update_timetimestampNOCURRENT_TIMESTAMP更新時間戳

這樣,我們就擁有了一個最簡單的 jdbc 後設資料管理工具。

當然,這個只是 v1.0.0 版本,後續還有許多特性需要新增。

小結

MPG 基本上每一個使用 mybatis 必備的工具,大大提升了我們的效率。

知道對應的實現原理,可以讓我們更好的使用它,並且在其基礎上,實現自己的腦洞。

我是老馬,期待與你的下次重逢。

備註:涉及的程式碼較多,文中做了簡化。若你對原始碼感興趣,可以關註{老馬嘯西風},後臺回復{程式碼生成}即可獲得。

相關文章