从 information_schema 到自动生成的 web dao

一般的 web 项目大概会被分为这么几层:

dao/model

service/logic/repository

controller

view

大多数项目会做前后分离,所以后端作为 api 就剩下了三层。实际上这三层里除了 logic/service 之外都和具体的业务逻辑没什么关系。controller 负责参数绑定/校验,dao 负责和存储打交道。之前在 ast 一文中已经讲了怎么样减少 controller 中的工作。这里来说说怎么能够自动生成 dao 层代码。

可能平常只在固定的框架和 orm 下写代码的同学没有太关注过 db 层面的事情。先来讲讲 MySQL 里 information_schema 这个数据库的情况。援引官网的说明:

INFORMATION_SCHEMA provides access to database metadata, information about the MySQL server such as the name of a database or table, the data type of a column, or access privileges. Other terms that are sometimes used for this information are data dictionary and system catalog.

数据库的元数据:数据库名,表名,各表内的列详情,以及一些访问权限信息。

这里面对我们来说最有用的就是数据库名、表名和列的详情。日常开发 90% 的情况我们都是针对单表进行 CRUD,所以 dao 层大多数情况下也是为单表的 CRUD 来编写代码。

在 golang 的 web 系统里,一个比较靠谱的 dao 层的子模块可能长这样(这里用我们组正在重构使用的 sql builder 来举个例子):

package cardmapper

import (
    "errors"
    "time"
    "upper.io/db.v3/lib/sqlbuilder"
)

const (
    CardTableNamePrefix = "card"
    CardDBName = "card_shop"
)

type Card struct{
	ID	int	`db:"id"`	//
	Name	string	`db:"name"`	//卡片名
	PicURL	string	`db:"pic_url"`	//图片链接
}

func CardGetOne(tableName string, fields []interface{}, where map[string]interface{}) (Card, error) {
    var res Card
    sess := db.Registry[CardDBName]

    q := sess.Select(fields...).From(tableName)

    if len(fields) == 1 {
        if fieldStr, ok := fields[0].(string); ok && fieldStr == "*" {
            q = sess.SelectFrom(tableName)
        }
    }

    for key, val := range where {
        q = q.And(key, val)
    }

    err := q.One(&res)
    if err != nil {
        return Card{}, err
    }
    return res, nil
}

func CardGetList(tableName string, fields []interface{}, where map[string]interface{}, orderBy []string) ([]Card, error) {
    //略
}

func CardUpdate(tableName string, set map[string]interface{}, where map[string]interface{}) (int, error) {
    //略
}

func CardCreate(tableName string, fieldMap map[string]interface{}) (int, error) {
    //略
}

func CardCreateBatch(tableName string, fieldMapList []map[string]interface{}) (int, error) {
    //略
}

func CardDelete(tableName string, where map[string]interface{}) (int, error) {
    //略
}

为了不在查询完再断言来断言去,我们需要对每一张表都编写这样一份差不多的代码。可能有的程序员把这件事情当成了命运,就这么随便地接受了苦逼地重复搬砖,或者耍点小聪明,我从别的地方拷贝一份代码过来然后改一改。但遗憾的是,在大多数公司代码量都不会是你的 kpi。

我们来看看怎么用 information_schema 里的信息直接生成这部分代码。

mysql> desc columns;
+--------------------------+---------------------+------+-----+---------+-------+
| Field                    | Type                | Null | Key | Default | Extra |
+--------------------------+---------------------+------+-----+---------+-------+
| TABLE_SCHEMA             | varchar(64)         | NO   |     |         |       |
| TABLE_NAME               | varchar(64)         | NO   |     |         |       |
| COLUMN_NAME              | varchar(64)         | NO   |     |         |       |
| DATA_TYPE                | varchar(64)         | NO   |     |         |       |
| COLUMN_TYPE              | longtext            | NO   |     | NULL    |       |
| COLUMN_COMMENT           | varchar(1024)       | NO   |     |         |       |
+--------------------------+---------------------+------+-----+---------+-------+
21 rows in set (0.01 sec)

上面的结果只挑出了我们需要的列,来看看内部都存了些什么东西:

mysql> select table_schema,table_name,column_name,data_type,column_type,column_comment from columns where table_schema='card_shop' and table_name = 'card';
+--------------+------------+-------------+-----------+---------------+----------------+
| table_schema | table_name | column_name | data_type | column_type   | column_comment |
+--------------+------------+-------------+-----------+---------------+----------------+
| card_shop    | card       | id          | int       | int(11)       |                |
| card_shop    | card       | name        | varchar   | varchar(30)   | 卡片名         |
| card_shop    | card       | pic_url     | varchar   | varchar(1024) | 图片链接       |
+--------------+------------+-------------+-----------+---------------+----------------+
3 rows in set (0.00 sec)

喔~这下我们代码里想要的要素都全了~

不过这里还存在一个问题,MySQL 里的数据类型和我们程序语言里的数据类型并不是一回事,在生成代码的时候还需要进行一次转换,下面是从 beego 里扒出来的一份类型转换表,仅供参考:

var typeMapping = map[string]string{
	"int":                "int", // int signed
	"integer":            "int",
	"tinyint":            "int8",
	"smallint":           "int16",
	"mediumint":          "int32",
	"bigint":             "int64",
	"int unsigned":       "uint", // int unsigned
	"integer unsigned":   "uint",
	"tinyint unsigned":   "uint8",
	"smallint unsigned":  "uint16",
	"mediumint unsigned": "uint32",
	"bigint unsigned":    "uint64",
	"bit":                "uint64",
	"bool":               "bool",   // boolean
	"enum":               "string", // enum
	"set":                "string", // set
	"varchar":            "string", // string & text
	"char":               "string",
	"tinytext":           "string",
	"mediumtext":         "string",
	"text":               "string",
	"longtext":           "string",
	"blob":               "string", // blob
	"tinyblob":           "string",
	"mediumblob":         "string",
	"longblob":           "string",
	"date":               "time.Time", // time
	"datetime":           "time.Time",
	"timestamp":          "time.Time",
	"time":               "time.Time",
	"float":              "float32", // float & decimal
	"double":             "float64",
	"decimal":            "float64",
	"binary":             "string", // binary
	"varbinary":          "string",
}

有了类型映射,万事俱备。

再来改一改上面的 dao 层的样例代码,让它变得更像一份代码模板:

package {{struct_name_lower}}mapper

import (
    "errors"
    "time"
    "upper.io/db.v3/lib/sqlbuilder"
)

const (
    {{struct_name}}TableNamePrefix = "{{table_name}}"
    {{struct_name}}DBName = "{{database_name}}"
)

{{struct_def}}

func {{struct_name}}GetOne(tableName string, fields []interface{}, where map[string]interface{}) ({{struct_name}}, error) {
    var res {{struct_name}}
    sess := db.Registry[CardDBName]

    q := sess.Select(fields...).From(tableName)

    if len(fields) == 1 {
        if fieldStr, ok := fields[0].(string); ok && fieldStr == "*" {
            q = sess.SelectFrom(tableName)
        }
    }

    for key, val := range where {
        q = q.And(key, val)
    }

    err := q.One(&res)
    if err != nil {
        return {{struct_name}}{}, err
    }
    return res, nil
}

func {{struct_name}}GetList(tableName string, fields []interface{}, where map[string]interface{}, orderBy []string) ([]{{struct_name}}, error) {
    //略
}

func {{struct_name}}Update(tableName string, set map[string]interface{}, where map[string]interface{}) (int, error) {
    //略
}

func {{struct_name}}Create(tableName string, fieldMap map[string]interface{}) (int, error) {
    //略
}

func {{struct_name}}CreateBatch(tableName string, fieldMapList []map[string]interface{}) (int, error) {
    //略
}

func {{struct_name}}Delete(tableName string, where map[string]interface{}) (int, error) {
    //略
}

剩下的不用教了吧!如果还是觉得迷惑,可以参考

https://github.com/cch123/api_code_gen/blob/master/model_generate.go

**关于争议:**本来觉得整洁规范的 dao 层和自动生成的 sql builder 代码封装是能够显著提升幸福感的事情,但是在线下和同事讨论的时候遇到了一些争议。

**争议①:**同事A 表示 sql builder 的方式欠妥,dao 层应该能够严格地控制对外提供的服务。我们应该手写 sql。否则 dba 认为这个系统完全不可控。

**争议②:**同事B 认为自动生成代码这件事情不够人性化,接手别人自动生成的代码会有抵触心理。

**争议③:**同事C 觉得你这是完全落后的贫血模型,我先表示一下对你这个垃圾设计的鄙视。其它的之后再说。

先说②,这件事情归根结底在于这份生成出来的代码大家看着爽不爽,如果生成的代码是给某个项目组来用,那大家最好能够在模板上达成一些共识。然后再基于共识的模板来生成代码。这样至少可以保证这一层的代码的规范性,问题不大。但是我的意见是这一部分一定要生成,不要手写。现在接手过的项目一旦经手的人多了,dao 层代码完全没法看。

然后是①,这件事情没有很好的解决办法。如果全手写的话,我曾经见过一个 dao 文件里写了几百行 SQL 的,很多时候你要写个新 query 都不知道原来有没有写过,代码膨胀了以后对 RD 来说很难维护。更有甚者为了偷懒,可能直接不看原来的代码写个新函数,和原来是一样的 SQL。和同事讨论了半天也没有达成共识,如果是你怎么看?

最后是③,呵呵呵。

Xargin

Xargin

If you don't keep moving, you'll quickly fall behind
Beijing