在GPT帮助下,我为hade增加了model自动生成api的功能

前言:我为hade增加了model自动生成api的功能,这篇记录下我完成这个功能过程中是如何使用GPT来做协助的。

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等,首先我要获取这个表的所有字段和类型。

问题1:怎么获取一个表的所有字段

那么怎么获取一个表的所有字段和类型呢?我也先询问了下ChatGPT:

我们可以用gorm来获取一个表的所有字段到一个map[string]string 中。

扫描二维码关注公众号,回复: 15008547 查看本文章

具体使用的是这个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类型的变量,而idnameageclass_id则是这个map中的键,它们的值分别是uintstring类型,表示对应字段的数据类型。注意,输出结果中各个键值对的顺序可能会因为内部实现原因而不同。

====

尝试了一下确实可行,但是在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”

问题2 怎么生成用Go生成一段Go代码

研究了一下,其实用Go生成Go代码有下列几种方式:

  1. 使用text/template或html/template库:这两个库可以根据模板生成代码。模板中包含占位符,可以在运行时将占位符替换成具体的值,从而生成代码。使用模板生成代码比较方便,但是需要学习模板语法。

  2. 使用代码生成工具:Go中有一些代码生成工具,比如go generate、go tool yacc、go tool cgo等,可以根据特定的规则自动生成代码。这种方式比较高效,但是需要熟悉工具的使用方法和规则。

  3. 使用代码生成库: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文件和接口路由文件。

4b79d66a2a2f932ed1be9a9c3566ce50.png
image-20230317210730567

然后使用GPT写了7个函数,分别生成这7个文件。

https://github.com/gohade/hade/blob/main/framework/command/model/api_gen.go

在这个过程中当然GPT生成的代码并不是0bug的,还是需要人工一个个语句确认的,不过已经节省了我不少的工作量了。

问题3 命令编写

有了数据表的读取能力,也有了GO代码自动生成能力,后续的写命令的逻辑其实就是粘合的工作了。

为了易用性,整个命令还是设计为交互式

第一步自动检测配置的数据库是否有错误。如果没有错误,弹出数据库的所有表,让用户主动选择一个表。

第二步,告知用户会做如下操作,这个操作列表要展示出来,是会新建一个文件还是替换原有的问题。

第三步,就是真正的生成文件了。

最终的效果如图所示。

0cf211d6d93f5f91b8595e87852abebc.png
image-20230317211316309

这个命令的代码和其他hade命令一样,使用survey和cobra来生成,没有什么特别的了。

https://github.com/gohade/hade/blob/main/framework/command/model/api.go

问题4 测试

本来故事到这里就结束了,但是我又突发奇想,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模型的协作过程,就像是封面中的狗和猫的第一次见面,互相陌生,但是又互相好奇,我为它提供思路和框架,它为我补充细节。

总之,这个功能的开发过程,是个非常愉快的体验。

8476c69166e23ea9f91f56bddcc0a49f.jpeg

猜你喜欢

转载自blog.csdn.net/qq_42015552/article/details/129659327