hade的v1.0.6版本发布,增加根据model自动生成api的功能
之前给hade增加过一个model gen的功能,就是配置好数据库的配置文件,自动生成数据表的gorm的模型文件。但是其实还可以更近一步,我可以生成model模型文件,那么我肯定就能生成这个model对应的api,当然前提是这个api是“按照某种规范”的。
所以hade的v1.0.6就迭代了下这个功能。官方文档更新如下:http://hade.funaio.cn/guide/model.html
在./hade model下增加了两个命令,形成3个命令
./hade model api 通过数据表自动生成api代码
./hade model gen 通过数据表自动生成model代码
./hade model test 测试某个数据库是否可以连接
最复杂的就是./hade model api的命令,这里记录下开发这个命令的心路历程。
要输入一个表名字,输出这个表的api等,首先我要获取这个表的所有字段和类型。
那么怎么获取一个表的所有字段和类型呢?我也先询问了下ChatGPT:
我们可以用gorm来获取一个表的所有字段到一个map[string]string 中。
具体使用的是这个sql语句:SHOW COLUMNS FROM student
可以使用GORM的Raw
方法和Scan
方法将SHOW COLUMNS FROM
语句查询到的结果解析为一个map类型。具体的代码如下:
goCopy code
// 定义一个用于存储字段名和字段类型的结构体
type Column struct {
Field string `gorm:"column:Field"`
Type string `gorm:"column:Type"`
}
// 定义一个map类型,用于存储字段名和字段类型
columns := make(map[string]string)
// 执行原始的SQL语句
rows, err := db.Raw("SHOW COLUMNS FROM student").Rows()
if err != nil {
// 处理错误
}
// 逐行解析查询结果
for rows.Next() {
var column Column
if err := db.ScanRows(rows, &column); err != nil {
// 处理错误
}
columns[column.Field] = column.Type
}
// 输出结果
fmt.Println(columns)
上述代码中,首先定义了一个结构体Column
,用于存储每个字段的名称和类型。接着定义了一个map[string]string
类型的变量columns
,用于存储所有字段的名称和类型。
然后,使用Raw
方法执行原始的SQL语句SHOW COLUMNS FROM student
,并通过Rows
方法获取查询结果的行数据。对于每一行数据,使用ScanRows
方法将其解析为一个Column
结构体,再将结构体中的Field
字段作为columns
的键,Type
字段作为columns
的值,存储到columns
变量中。
最后,输出columns
变量的内容,即可得到包含所有字段名称和类型的map。注意,在使用完查询结果之后,需要及时关闭rows,避免资源泄露。可以在循环结束后通过调用rows.Close()
来关闭rows。
上述代码中的columns
是一个map[string]string
类型的变量,用于存储所有字段的名称和类型。它的输出结果类似于下面这个样子:
goCopy code
map[id:uint name:string age:uint class_id:uint]
输出结果中,map
表示这是一个map类型的变量,而id
、name
、age
和class_id
则是这个map中的键,它们的值分别是uint
和string
类型,表示对应字段的数据类型。注意,输出结果中各个键值对的顺序可能会因为内部实现原因而不同。
====
尝试了一下确实可行,但是在hade中,一切皆接口,于是我修改了一下之前的orm的接口协议。
type TableColumn struct {
Field string `gorm:"column:Field"`
Type string `gorm:"column:Type"`
Null string `gorm:"column:Null"`
Key string `gorm:"column:key"`
Default string `gorm:"column:Default"`
Extra string `gorm:"column:Extra"`
}
// ORMService 表示传入的参数
type ORMService interface {
// GetDB 获取某个db
GetDB(option ...DBOption) (*gorm.DB, error)
// CanConnect 是否可以连接
CanConnect(ctx context.Context, db *gorm.DB) (bool, error)
// Table 相关
GetTables(ctx context.Context, db *gorm.DB) ([]string, error)
HasTable(ctx context.Context, db *gorm.DB, table string) (bool, error)
GetTableColumns(ctx context.Context, db *gorm.DB, table string) ([]TableColumn, error)
}
增加了
CanConnect - 用ping方法测试数据库是否可连接
GetTables - 获取一个DB的所有表格
HasTable - 判断一个表名是否存在这个DB中
GetTableColumns - 获取一个表的所有字段。
它们的实现原理其实很简单:
CanConnect是使用ping命令
GetTables是使用sql语句“SELECT TABLE_NAME FROM information_schema.tables”
GetTableColumns是使用sql语句“SHOW COLUMNS FROM TABLE”
研究了一下,其实用Go生成Go代码有下列几种方式:
使用text/template或html/template库:这两个库可以根据模板生成代码。模板中包含占位符,可以在运行时将占位符替换成具体的值,从而生成代码。使用模板生成代码比较方便,但是需要学习模板语法。
使用代码生成工具:Go中有一些代码生成工具,比如go generate、go tool yacc、go tool cgo等,可以根据特定的规则自动生成代码。这种方式比较高效,但是需要熟悉工具的使用方法和规则。
使用代码生成库:Go中有一些代码生成库,比如goast、go/ast、github.com/dave/jennifer、github.com/gobuffalo/flect等,可以根据Go语法树或者其他方式生成代码。这种方式比较灵活,可以直接生成Go代码,而不需要使用模板或者手动拼接字符串。
之前hade也有用第一种方式template的方式来生成代码,但是后来觉得这种方式确实算不上优雅,因为这种方式就不是很灵活,比如要定义某个包含很多字段的Go数据结构,在写代码的时候我只能用拼接字符串的方式来做。
第二种方式调研下来又觉得要附带很多工具,且更符合比如在编译期加上某些代码的场景。
所以综合下来,我还是决定使用第三种方式来实现自动生成api的功能。
但实际上第三种方式也有很多库,我尝试了一下go原生的ast库和jennifer库,其实都挺麻烦的,如果给一段go代码,哪怕只有几行,实际上也需要一段类似:
jen.Id("logger").Op(":=").Id("c").Dot("MustMakeLog").Call(),
jen.Var().Id(table).Qual("", tableModel),
jen.If(
jen.Err().Op(":=").Id("c").Dot("BindJSON").Call(jen.Op("&").Id(table)),
jen.Err().Op("!=").Nil(),
).Block(
jen.Id("c").Dot("JSON").Call(jen.Lit(400), jen.Op("&").Qual("github.com/gohade/hade/framework/gin", "H").Values(jen.Dict{
jen.Lit("code"): jen.Lit("Invalid parameter"),
})),
jen.Return(),
),
jen.Var().Id("db").Op("*").Qual("gorm.io/gorm", "DB"),
jen.Var().Err().Error(),
这样复杂的代码。所以这个功能之前早调研过了,但是一直没有动手。
直到最近比较火的GPT出来了,我就想,给GPT一段代码,让它用某个库来实现是否可行呢?
于是我先写一段接口代码,比如创建的接口,然后使用下列promot输入到GPT中,
请使用github.com/dave/jennifer 库来写一段代码,能产出下列文本代码:
func (api *StudentApi) Create(c *gin.Context) {
logger := c.MustMakeLog()
// 绑定JSON数据到student结构体中
var student StudentModel
if err := c.BindJSON(&student); err != nil {
c.JSON(400, gin.H{"error": "Invalid JSON"})
return
}
// 初始化一个orm.DB
gormService := c.MustMake(contract.ORMKey).(contract.ORMService)
db, err := gormService.GetDB(orm.WithConfigPath("database.default"))
if err != nil {
logger.Error(c, err.Error(), nil)
_ = c.AbortWithError(50001, err)
return
}
// 向数据库中添加新的学生模型
if err := db.Create(&student).Error; err != nil {
c.JSON(500, gin.H{"error": "Server error"})
return
}
// 返回新创建的学生模型
c.JSON(200, student)
}
它的结果:
可以使用下面的代码使用 github.com/dave/jennifer
库生成所需的文本代码:
goCopy code
package main
import (
"github.com/dave/jennifer/jen"
)
func generateCode() string {
// 创建函数头
funcHeader := jen.Func().Params(
jen.Id("api").Op("*").Id("StudentApi"),
).Id("Create").Params(
jen.Id("c").Op("*").Qual("github.com/gin-gonic/gin", "Context"),
).Block(
// 创建日志记录器
jen.Var().Id("logger").Op(":=").Id("c").Dot("MustMakeLog").Call(),
// 绑定JSON数据到student结构体中
jen.Var().Id("student").Qual("", "StudentModel"),
jen.If(
jen.Err().Op(":=").Id("c").Dot("BindJSON").Call(jen.Op("&").Id("student")),
jen.Err().Op("!=").Nil(),
).Block(
jen.Id("c").Dot("JSON").Call(
jen.Lit(400),
jen.Map(jen.String()).Interface().Values(jen.Dict{
jen.Id("error"): jen.Lit("Invalid JSON"),
}),
),
jen.Return(),
),
// 初始化一个orm.DB
jen.Var().Id("gormService").Op(":=").Id("c").Dot("MustMake").Call(
jen.Qual("github.com/gin-gonic/gin", "contract.ORMKey"),
).Assert(
jen.Qual("github.com/gin-gonic/gin", "contract.ORMService"),
),
jen.Var().Id("db").Op("*").Qual("gorm.io/gorm", "DB"),
jen.Var().Err().Error(),
jen.If(
jen.Err().Op("=").Id("gormService").Dot("GetDB").Call(
jen.Qual("github.com/gin-gonic/gin", "orm.WithConfigPath").Call(
jen.Lit("database.default"),
),
),
jen.Err().Op("!=").Nil(),
).Block(
jen.Id("logger").Dot("Error").Call(
jen.Id("c"),
jen.Err().Dot("Error").Call(),
jen.Nil(),
),
jen.Id("_").Op("=").Id("c").Dot("AbortWithError").Call(
jen.Lit(50001),
jen.Err(),
),
jen.Return(),
).Else().Block(
jen.Id("db").Op("=").Id("gormService").Dot("DB"),
),
// 向数据库中添加新的学生模型
jen.If(
jen.Err().Op(":=").Id("db").Dot("Create").Call(jen.Op("&").Id("student")).Dot("Error"),
jen.Err().Op("!=").Nil(),
).Block(
jen.Id("c").Dot("JSON").Call(
jen.Lit(500),
jen.Map(jen.String()).Interface().Values(jen.Dict{
jen.Id("error"): jen.Lit("Server error"),
}),
),
jen.Return(),
),
// 返回新创建的学生模型
jen.Id("c").Dot("JSON").Call(jen.Lit(200), jen.Id("student")),
)
nice,GPT把最繁琐的部分给我生成出来了。
于是,经过尝试,发现这个路子完全可行。于是我就先按照一个student表写了它的5个接口:CURD和List 还有 另外2个问题:model文件和接口路由文件。
然后使用GPT写了7个函数,分别生成这7个文件。
https://github.com/gohade/hade/blob/main/framework/command/model/api_gen.go
在这个过程中当然GPT生成的代码并不是0bug的,还是需要人工一个个语句确认的,不过已经节省了我不少的工作量了。
有了数据表的读取能力,也有了GO代码自动生成能力,后续的写命令的逻辑其实就是粘合的工作了。
为了易用性,整个命令还是设计为交互式
第一步自动检测配置的数据库是否有错误。如果没有错误,弹出数据库的所有表,让用户主动选择一个表。
第二步,告知用户会做如下操作,这个操作列表要展示出来,是会新建一个文件还是替换原有的问题。
第三步,就是真正的生成文件了。
最终的效果如图所示。
这个命令的代码和其他hade命令一样,使用survey和cobra来生成,没有什么特别的了。
https://github.com/gohade/hade/blob/main/framework/command/model/api.go
本来故事到这里就结束了,但是我又突发奇想,GPT能不能帮忙写个单元测试呢?于是我也做了如下尝试promot
请帮下列的代码写一段单元测试:
func (gen *ApiGenerator) GenModelFile(ctx context.Context, file string) error {
// table lower case
tableLower := strings.ToLower(gen.table)
// table camel title case
tableCamel := strings.Title(tableLower)
// model struct
tableModel := tableCamel + "Model"
f := jen.NewFile(gen.packageName)
structs := make([]jen.Code, 0, len(gen.columns)+1)
for _, column := range gen.columns {
field := jen.Id(word.ToTitleCamel(column.Field))
switch column.Type {
case "int", "tinyint", "smallint", "mediumint", "bigint":
field.Int64()
case "int unsigned", "tinyint unsigned", "smallint unsigned", "mediumint unsigned", "bigint unsigned":
field.Uint64()
case "float", "double", "decimal":
field.Float64()
case "float unsigned", "double unsigned", "decimal unsigned":
field.Float64()
case "char", "varchar", "tinytext", "text", "mediumtext", "longtext":
field.String()
case "date", "time", "datetime", "timestamp":
field.Qual("time", "Time")
default:
field.String()
}
field.Tag(map[string]string{"gorm": column.Field, "json": column.Field + ",omitempty"})
structs = append(structs, field)
}
f.Type().Id(tableModel).Struct(structs...)
f.Line()
f.Func().Params(jen.Id(tableModel)).Id("TableName").Params().String().Block(
jen.Return(jen.Lit(gen.table)),
)
fp, err := os.Create(file)
if err != nil {
return errors.Wrap(err, "create file error")
}
if err := f.Render(fp); err != nil {
return errors.Wrap(err, "render file error")
}
return nil
}
它自动为我生成了如下单元测试:
func TestApiGenerator_GenModelFile(t *testing.T) {
gen := &ApiGenerator{
table: "user",
columns: []contract.TableColumn{
{Field: "id", Type: "int"},
{Field: "name", Type: "varchar"},
{Field: "age", Type: "int"},
},
}
// Create a temporary file for the model code
tmpfile, err := ioutil.TempFile("", "model.go")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpfile.Name())
// Generate the model code
if err := gen.GenModelFile(context.Background(), tmpfile.Name()); err != nil {
t.Fatal(err)
}
// Read the generated code from the file
bytes, err := ioutil.ReadFile(tmpfile.Name())
if err != nil {
t.Fatal(err)
}
code := string(bytes)
fmt.Println(code)
// Check that the code contains the expected model name
expectedModelName := "UserModel"
if !strings.Contains(code, "type "+expectedModelName+" struct") {
t.Errorf("Generated code does not contain expected model name %q", expectedModelName)
}
// Check that the code contains the expected table name
expectedTableName := "user"
if !strings.Contains(code, "return "+strconv.Quote(expectedTableName)) {
t.Errorf("Generated code does not contain expected table name %q", expectedTableName)
}
// Check that the code contains the expected fields
for _, column := range gen.columns {
expectedFieldName := strings.Title(column.Field)
if !strings.Contains(code, ""+expectedFieldName+" ") {
t.Errorf("Generated code does not contain expected field name %q", expectedFieldName)
}
}
}
这里非常惊喜的是,它给我演示了如何使用tmp目录文件来测试一段会生成文件的代码。真是太香了。
于是这个命令就这么愉快的完成了。
这个是我在hade上第一次使用GPT的帮助。
当然在实际的过程中,并没有我说的这么顺利:
由于GPT每次给出的结果都是不一样的,这个不一样就是说同样使用 github.com/dave/jennifer 生成代码,它会变化好几种写法,需要你多尝试几次,在它尝试正确的时候,给予正向的激励。
并且我发现GPT又好几次的尝试会在里面偷偷埋下bug,就像是一个刚入门的新手,无知且无畏。所以对于GPT的代码输出,还是需要人工一行行review过去的。
和GPT模型的协作过程,就像是封面中的狗和猫的第一次见面,互相陌生,但是又互相好奇,我为它提供思路和框架,它为我补充细节。
总之,这个功能的开发过程,是个非常愉快的体验。