2.6 模块开发:标签管理

在初步完成了业务接口的入参校验的逻辑处理后,接下来我们正式的进入业务模块的业务逻辑开发,在本章节将完成标签模块的接口代码编写,涉及的接口如下:

功能HTTP 方法路径
新增标签POST/tags
删除指定标签DELETE/tags/:id
更新指定标签PUT/tags/:id
获取标签列表GET/tags

2.6.1 新建 model 方法

首先我们需要针对标签表进行处理,并在项目的 internal/model 目录下新建 tag.go 文件,针对标签模块的模型操作进行封装,并且只与实体产生关系,代码如下:

  1. func (t Tag) Count(db *gorm.DB) (int, error) {
  2. var count int
  3. if t.Name != "" {
  4. db = db.Where("name = ?", t.Name)
  5. }
  6. db = db.Where("state = ?", t.State)
  7. if err := db.Model(&t).Where("is_del = ?", 0).Count(&count).Error; err != nil {
  8. return 0, err
  9. }
  10. return count, nil
  11. }
  12. func (t Tag) List(db *gorm.DB, pageOffset, pageSize int) ([]*Tag, error) {
  13. var tags []*Tag
  14. var err error
  15. if pageOffset >= 0 && pageSize > 0 {
  16. db = db.Offset(pageOffset).Limit(pageSize)
  17. }
  18. if t.Name != "" {
  19. db = db.Where("name = ?", t.Name)
  20. }
  21. db = db.Where("state = ?", t.State)
  22. if err = db.Where("is_del = ?", 0).Find(&tags).Error; err != nil {
  23. return nil, err
  24. }
  25. return tags, nil
  26. }
  27. func (t Tag) Create(db *gorm.DB) error {
  28. return db.Create(&t).Error
  29. }
  30. func (t Tag) Update(db *gorm.DB) error {
  31. return db.Model(&Tag{}).Where("id = ? AND is_del = ?", t.ID, 0).Update(t).Error
  32. }
  33. func (t Tag) Delete(db *gorm.DB) error {
  34. return db.Where("id = ? AND is_del = ?", t.Model.ID, 0).Delete(&t).Error
  35. }
  • Model:指定运行 DB 操作的模型实例,默认解析该结构体的名字为表名,格式为大写驼峰转小写下划线驼峰。若情况特殊,也可以编写该结构体的 TableName 方法用于指定其对应返回的表名。
  • Where:设置筛选条件,接受 map,struct 或 string 作为条件。
  • Offset:偏移量,用于指定开始返回记录之前要跳过的记录数。
  • Limit:限制检索的记录数。
  • Find:查找符合筛选条件的记录。
  • Updates:更新所选字段。
  • Delete:删除数据。
  • Count:统计行为,用于统计模型的记录数。

需要注意的是,在上述代码中,我们采取的是将 db *gorm.DB 作为函数首参数传入的方式,而在业界中也有另外一种方式,是基于结构体传入的,两者本质上都可以实现目的,读者根据实际情况(使用习惯、项目规范等)进行选用即可,其各有利弊。

2.6.2 处理 model 回调

你会发现我们在编写 model 代码时,并没有针对我们的公共字段 created_on、modified_on、deleted_on、is_del 进行处理,难道不是在每一个 DB 操作中进行设置和修改吗?

显然,这在通用场景下并不是最好的方案,因为如果每一个 DB 操作都去设置公共字段的值,那么不仅多了很多重复的代码,在要调整公共字段时工作量也会翻倍。

我们可以采用设置 model callback 的方式去实现公共字段的处理,本项目使用的 ORM 库是 GORM,GORM 本身是提供回调支持的,因此我们可以根据自己的需要自定义 GORM 的回调操作,而在 GORM 中我们可以分别进行如下的回调相关行为:

  • 注册一个新的回调。
  • 删除现有的回调。
  • 替换现有的回调。
  • 注册回调的先后顺序。

在本项目中使用到的“替换现有的回调”这一行为,我们打开项目的 internal/model 目录下的 model.go 文件,准备开始编写 model 的回调代码,下述所新增的回调代码均写入在 NewDBEngine 方法后。

  1. func NewDBEngine(databaseSetting *setting.DatabaseSettingS) (*gorm.DB, error) {}
  2. func updateTimeStampForCreateCallback(scope *gorm.Scope) {}
  3. func updateTimeStampForUpdateCallback(scope *gorm.Scope) {}
  4. func deleteCallback(scope *gorm.Scope) {}
  5. func addExtraSpaceIfExist(str string) string {}

2.6.2.1 新增行为的回调

  1. func updateTimeStampForCreateCallback(scope *gorm.Scope) {
  2. if !scope.HasError() {
  3. nowTime := time.Now().Unix()
  4. if createTimeField, ok := scope.FieldByName("CreatedOn"); ok {
  5. if createTimeField.IsBlank {
  6. _ = createTimeField.Set(nowTime)
  7. }
  8. }
  9. if modifyTimeField, ok := scope.FieldByName("ModifiedOn"); ok {
  10. if modifyTimeField.IsBlank {
  11. _ = modifyTimeField.Set(nowTime)
  12. }
  13. }
  14. }
  15. }
  • 通过调用 scope.FieldByName 方法,获取当前是否包含所需的字段。
  • 通过判断 Field.IsBlank 的值,可以得知该字段的值是否为空。
  • 若为空,则会调用 Field.Set 方法给该字段设置值,入参类型为 interface{},内部也就是通过反射进行一系列操作赋值。

2.6.2.2 更新行为的回调

  1. func updateTimeStampForUpdateCallback(scope *gorm.Scope) {
  2. if _, ok := scope.Get("gorm:update_column"); !ok {
  3. _ = scope.SetColumn("ModifiedOn", time.Now().Unix())
  4. }
  5. }
  • 通过调用 scope.Get("gorm:update_column") 去获取当前设置了标识 gorm:update_column 的字段属性。
  • 若不存在,也就是没有自定义设置 update_column,那么将会在更新回调内设置默认字段 ModifiedOn 的值为当前的时间戳。

2.6.2.3 删除行为的回调

  1. func deleteCallback(scope *gorm.Scope) {
  2. if !scope.HasError() {
  3. var extraOption string
  4. if str, ok := scope.Get("gorm:delete_option"); ok {
  5. extraOption = fmt.Sprint(str)
  6. }
  7. deletedOnField, hasDeletedOnField := scope.FieldByName("DeletedOn")
  8. isDelField, hasIsDelField := scope.FieldByName("IsDel")
  9. if !scope.Search.Unscoped && hasDeletedOnField && hasIsDelField {
  10. now := time.Now().Unix()
  11. scope.Raw(fmt.Sprintf(
  12. "UPDATE %v SET %v=%v,%v=%v%v%v",
  13. scope.QuotedTableName(),
  14. scope.Quote(deletedOnField.DBName),
  15. scope.AddToVars(now),
  16. scope.Quote(isDelField.DBName),
  17. scope.AddToVars(1),
  18. addExtraSpaceIfExist(scope.CombinedConditionSql()),
  19. addExtraSpaceIfExist(extraOption),
  20. )).Exec()
  21. } else {
  22. scope.Raw(fmt.Sprintf(
  23. "DELETE FROM %v%v%v",
  24. scope.QuotedTableName(),
  25. addExtraSpaceIfExist(scope.CombinedConditionSql()),
  26. addExtraSpaceIfExist(extraOption),
  27. )).Exec()
  28. }
  29. }
  30. }
  31. func addExtraSpaceIfExist(str string) string {
  32. if str != "" {
  33. return " " + str
  34. }
  35. return ""
  36. }
  • 通过调用 scope.Get("gorm:delete_option") 去获取当前设置了标识 gorm:delete_option 的字段属性。
  • 判断是否存在 DeletedOnIsDel 字段,若存在则调整为执行 UPDATE 操作进行软删除(修改 DeletedOn 和 IsDel 的值),否则执行 DELETE 进行硬删除。
  • 调用 scope.QuotedTableName 方法获取当前所引用的表名,并调用一系列方法针对 SQL 语句的组成部分进行处理和转移,最后在完成一些所需参数设置后调用 scope.CombinedConditionSql 方法完成 SQL 语句的组装。

2.6.2.4 注册回调行为

  1. func NewDBEngine(databaseSetting *setting.DatabaseSettingS) (*gorm.DB, error) {
  2. ...
  3. db.SingularTable(true)
  4. db.Callback().Create().Replace("gorm:update_time_stamp", updateTimeStampForCreateCallback)
  5. db.Callback().Update().Replace("gorm:update_time_stamp", updateTimeStampForUpdateCallback)
  6. db.Callback().Delete().Replace("gorm:delete", deleteCallback)
  7. db.DB().SetMaxIdleConns(databaseSetting.MaxIdleConns)
  8. db.DB().SetMaxOpenConns(databaseSetting.MaxOpenConns)
  9. return db, nil
  10. }
  11. func updateTimeStampForCreateCallback(scope *gorm.Scope) {...}
  12. func updateTimeStampForUpdateCallback(scope *gorm.Scope) {...}
  13. func deleteCallback(scope *gorm.Scope) {...}
  14. func addExtraSpaceIfExist(str string) string {...}

在最后我们回到 NewDBEngine 方法中,针对上述写的三个 Callback 方法进行回调注册,才能够让我们的应用程序真正的使用上,至此,我们的公共字段处理就完成了。

2.6.3 新建 dao 方法

我们在项目的 internal/dao 目录下新建 dao.go 文件,写入如下代码:

  1. type Dao struct {
  2. engine *gorm.DB
  3. }
  4. func New(engine *gorm.DB) *Dao {
  5. return &Dao{engine: engine}
  6. }

接下来在同层级下新建 tag.go 文件,用于处理标签模块的 dao 操作,写入如下代码:

  1. func (d *Dao) CountTag(name string, state uint8) (int, error) {
  2. tag := model.Tag{Name: name, State: state}
  3. return tag.Count(d.engine)
  4. }
  5. func (d *Dao) GetTagList(name string, state uint8, page, pageSize int) ([]*model.Tag, error) {
  6. tag := model.Tag{Name: name, State: state}
  7. pageOffset := app.GetPageOffset(page, pageSize)
  8. return tag.List(d.engine, pageOffset, pageSize)
  9. }
  10. func (d *Dao) CreateTag(name string, state uint8, createdBy string) error {
  11. tag := model.Tag{
  12. Name: name,
  13. State: state,
  14. Model: &model.Model{CreatedBy: createdBy},
  15. }
  16. return tag.Create(d.engine)
  17. }
  18. func (d *Dao) UpdateTag(id uint32, name string, state uint8, modifiedBy string) error {
  19. tag := model.Tag{
  20. Name: name,
  21. State: state,
  22. Model: &model.Model{ID: id, ModifiedBy: modifiedBy},
  23. }
  24. return tag.Update(d.engine)
  25. }
  26. func (d *Dao) DeleteTag(id uint32) error {
  27. tag := model.Tag{Model: &model.Model{ID: id}}
  28. return tag.Delete(d.engine)
  29. }

在上述代码中,我们主要是在 dao 层进行了数据访问对象的封装,并针对业务所需的字段进行了处理。

2.6.4 新建 service 方法

我们在项目的 internal/service 目录下新建 service.go 文件,写入如下代码:

  1. type Service struct {
  2. ctx context.Context
  3. dao *dao.Dao
  4. }
  5. func New(ctx context.Context) Service {
  6. svc := Service{ctx: ctx}
  7. svc.dao = dao.New(global.DBEngine)
  8. return svc
  9. }

接下来在同层级下新建 tag.go 文件,用于处理标签模块的业务逻辑,写入如下代码:

  1. type CountTagRequest struct {
  2. Name string `form:"name" binding:"max=100"`
  3. State uint8 `form:"state,default=1" binding:"oneof=0 1"`
  4. }
  5. type TagListRequest struct {
  6. Name string `form:"name" binding:"max=100"`
  7. State uint8 `form:"state,default=1" binding:"oneof=0 1"`
  8. }
  9. type CreateTagRequest struct {
  10. Name string `form:"name" binding:"required,min=2,max=100"`
  11. CreatedBy string `form:"created_by" binding:"required,min=2,max=100"`
  12. State uint8 `form:"state,default=1" binding:"oneof=0 1"`
  13. }
  14. type UpdateTagRequest struct {
  15. ID uint32 `form:"id" binding:"required,gte=1"`
  16. Name string `form:"name" binding:"max=100"`
  17. State uint8 `form:"state" binding:"oneof=0 1"`
  18. ModifiedBy string `form:"modified_by" binding:"required,min=2,max=100"`
  19. }
  20. type DeleteTagRequest struct {
  21. ID uint32 `form:"id" binding:"required,gte=1"`
  22. }
  23. func (svc *Service) CountTag(param *CountTagRequest) (int, error) {
  24. return svc.dao.CountTag(param.Name, param.State)
  25. }
  26. func (svc *Service) GetTagList(param *TagListRequest, pager *app.Pager) ([]*model.Tag, error) {
  27. return svc.dao.GetTagList(param.Name, param.State, pager.Page, pager.PageSize)
  28. }
  29. func (svc *Service) CreateTag(param *CreateTagRequest) error {
  30. return svc.dao.CreateTag(param.Name, param.State, param.CreatedBy)
  31. }
  32. func (svc *Service) UpdateTag(param *UpdateTagRequest) error {
  33. return svc.dao.UpdateTag(param.ID, param.Name, param.State, param.ModifiedBy)
  34. }
  35. func (svc *Service) DeleteTag(param *DeleteTagRequest) error {
  36. return svc.dao.DeleteTag(param.ID)
  37. }

在上述代码中,我们主要是定义了 Request 结构体作为接口入参的基准,而本项目由于并不会太复杂,所以直接放在了 service 层中便于使用,若后续业务不断增长,程序越来越复杂,service 也冗杂了,可以考虑将抽离一层接口校验层,便于解耦逻辑。

另外我们还在 service 进行了一些简单的逻辑封装,在应用分层中,service 层主要是针对业务逻辑的封装,如果有一些业务聚合和处理可以在该层进行编码,同时也能较好的隔离上下两层的逻辑。

2.6.6 新增业务错误码

我们在项目的 pkg/errcode 下新建 module_code.go 文件,针对标签模块,写入如下错误代码:

  1. var (
  2. ErrorGetTagListFail = NewError(20010001, "获取标签列表失败")
  3. ErrorCreateTagFail = NewError(20010002, "创建标签失败")
  4. ErrorUpdateTagFail = NewError(20010003, "更新标签失败")
  5. ErrorDeleteTagFail = NewError(20010004, "删除标签失败")
  6. ErrorCountTagFail = NewError(20010005, "统计标签失败")
  7. )

2.6.7 新增路由方法

我们打开 internal/routers/api/v1 项目目录下的 tag.go 文件,写入如下代码:

  1. func (t Tag) List(c *gin.Context) {
  2. param := service.TagListRequest{}
  3. response := app.NewResponse(c)
  4. valid, errs := app.BindAndValid(c, &param)
  5. if !valid {
  6. global.Logger.Errorf("app.BindAndValid errs: %v", errs)
  7. response.ToErrorResponse(errcode.InvalidParams.WithDetails(errs.Errors()...))
  8. return
  9. }
  10. svc := service.New(c.Request.Context())
  11. pager := app.Pager{Page: app.GetPage(c), PageSize: app.GetPageSize(c)}
  12. totalRows, err := svc.CountTag(&service.CountTagRequest{Name: param.Name, State: param.State})
  13. if err != nil {
  14. global.Logger.Errorf("svc.CountTag err: %v", err)
  15. response.ToErrorResponse(errcode.ErrorCountTagFail)
  16. return
  17. }
  18. tags, err := svc.GetTagList(&param, &pager)
  19. if err != nil {
  20. global.Logger.Errorf("svc.GetTagList err: %v", err)
  21. response.ToErrorResponse(errcode.ErrorGetTagListFail)
  22. return
  23. }
  24. response.ToResponseList(tags, totalRows)
  25. return
  26. }

在上述代码中,我们完成了获取标签列表接口的处理方法,我们在方法中完成了入参校验和绑定、获取标签总数、获取标签列表、 序列化结果集等四大功能板块的逻辑串联和日志、错误处理。

需要注意的是入参校验和绑定的处理代码基本都差不多,因此在后续代码中不再重复,我们继续写入创建标签、更新标签、删除标签的接口处理方法,如下:

  1. func (t Tag) Create(c *gin.Context) {
  2. param := service.CreateTagRequest{}
  3. response := app.NewResponse(c)
  4. valid, errs := app.BindAndValid(c, &param)
  5. if !valid {...}
  6. svc := service.New(c.Request.Context())
  7. err := svc.CreateTag(&param)
  8. if err != nil {
  9. global.Logger.Errorf("svc.CreateTag err: %v", err)
  10. response.ToErrorResponse(errcode.ErrorCreateTagFail)
  11. return
  12. }
  13. response.ToResponse(gin.H{})
  14. return
  15. }
  16. func (t Tag) Update(c *gin.Context) {
  17. param := service.UpdateTagRequest{ID: convert.StrTo(c.Param("id")).MustUInt32()}
  18. response := app.NewResponse(c)
  19. valid, errs := app.BindAndValid(c, &param)
  20. if !valid {...}
  21. svc := service.New(c.Request.Context())
  22. err := svc.UpdateTag(&param)
  23. if err != nil {
  24. global.Logger.Errorf("svc.UpdateTag err: %v", err)
  25. response.ToErrorResponse(errcode.ErrorUpdateTagFail)
  26. return
  27. }
  28. response.ToResponse(gin.H{})
  29. return
  30. }
  31. func (t Tag) Delete(c *gin.Context) {
  32. param := service.DeleteTagRequest{ID: convert.StrTo(c.Param("id")).MustUInt32()}
  33. response := app.NewResponse(c)
  34. valid, errs := app.BindAndValid(c, &param)
  35. if !valid {...}
  36. svc := service.New(c.Request.Context())
  37. err := svc.DeleteTag(&param)
  38. if err != nil {
  39. global.Logger.Errorf("svc.DeleteTag err: %v", err)
  40. response.ToErrorResponse(errcode.ErrorDeleteTagFail)
  41. return
  42. }
  43. response.ToResponse(gin.H{})
  44. return
  45. }

2.6.8 验证接口

我们重新启动服务,也就是再执行 go run main.go,查看启动信息正常后,对标签模块的接口进行验证,请注意,验证示例中的 {id},代指占位符,也就是填写你实际调用中希望处理的标签 ID 即可。

2.6.8.1 新增标签

  1. $ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=Go' -F created_by=eddycjy
  2. {}
  3. $ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=PHP' -F created_by=eddycjy
  4. {}
  5. $ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=Rust' -F created_by=eddycjy
  6. {}

2.6.8.2 获取标签列表

  1. $ curl -X GET 'http://127.0.0.1:8000/api/v1/tags?page=1&page_size=2'
  2. {"list":[{"id":1,"created_by":"eddycjy","modified_by":"","created_on":1574493416,"modified_on":1574493416,"deleted_on":0,"is_del":0,"name":"Go 语言","state":1},{"id":2,"created_by":"eddycjy","modified_by":"","created_on":1574493813,"modified_on":1574493813,"deleted_on":0,"is_del":0,"name":"PHP","state":1}],"pager":{"page":1,"page_size":2,"total_rows":3}}
  3. $ curl -X GET 'http://127.0.0.1:8000/api/v1/tags?page=2&page_size=2'
  4. {"list":[{"id":3,"created_by":"eddycjy","modified_by":"","created_on":1574493817,"modified_on":1574493817,"deleted_on":0,"is_del":0,"name":"Rust","state":1}],"pager":{"page":2,"page_size":2,"total_rows":3}}

2.6.8.3 修改标签

  1. $ curl -X PUT http://127.0.0.1:8000/api/v1/tags/{id} -F state=0 -F modified_by=eddycjy
  2. {}

2.6.8.4 删除标签

  1. $ curl -X DELETE http://127.0.0.1:8000/api/v1/tags/{id}
  2. {}

2.6.9 发现问题

在完成了接口的检验后,我们还要确定一下数据库内的数据变更是否正确。在经过一系列的对比后,我们发现在调用修新标签的接口时,通过接口入参,我们是希望将 id 为 1 的标签状态修改为 0,但是在对比后发现数据库内它的状态值居然还是 1,而且 SQL 语句内也没有出现 state 字段的设置,太神奇了,控制台输出的 SQL 语句如下:

  1. UPDATE `blog_tag` SET `id` = 1, `modified_by` = 'eddycjy', `modified_on` = xxxxx WHERE `blog_tag`.`id` = 1

甚至在我们更进一步其它类似的验证时,发现只要字段是零值的情况下,GORM 就不会对该字段进行变更,这到底是为什么呢?

实际上,这有一个概念上的问题,我们先入为主的认为它一定会变更,其实是不对的,因为在我们程序中使用的是 struct 的方式进行更新操作,而在 GORM 中使用 struct 类型传入进行更新时,GORM 是不会对值为零值的字段进行变更。这又是为什么呢,我们可以猜想,更根本的原因是因为在识别这个结构体中的这个字段值时,很难判定是真的是零值,还是外部传入恰好是该类型的零值,GORM 在这块并没有过多的去做特殊识别。

2.6.10 解决问题

修改项目的 internal/model 目录下的 tag.go 文件里的 Update 方法,如下:

  1. func (t Tag) Update(db *gorm.DB, values interface{}) error {
  2. if err := db.Model(t).Where("id = ? AND is_del = ?", t.ID, 0).Updates(values).Error; err != nil {
  3. return err
  4. }
  5. return nil
  6. }

修改项目的 internal/dao 目录下的 tag.go 文件里的 UpdateTag 方法,如下:

  1. func (d *Dao) UpdateTag(id uint32, name string, state uint8, modifiedBy string) error {
  2. tag := model.Tag{
  3. Model: &model.Model{ID: id},
  4. }
  5. values := map[string]interface{}{
  6. "state": state,
  7. "modified_by": modifiedBy,
  8. }
  9. if name != "" {
  10. values["name"] = name
  11. }
  12. return tag.Update(d.engine, values)
  13. }

重新运行程序,请求修改标签接口,如下:

  1. $ curl -X PUT http://127.0.0.1:8000/api/v1/tags/{id} -F state=0 -F modified_by=eddycjy
  2. {}

检查数据是否正常修改,在正确的情况下,该 id 为 1 的标签,modified_by 为 eddycjy,modified_on 应修改为当前时间戳,state 为 0。

2.6.11 小结

在本章节中,我们针对 “标签管理” 进行了具体的开发,其中涉及到了 model、dao、service、router 的相关方法以及业务错误码的编写和处理。接下来下一步应当是 “文章管理” 的模块开发,我强烈建议读者根据本章的经验,自行构思设计思路,然后亲自思考和实践,这样子对你未来对实际项目进行开发会有明显帮助。而在开发时,或开发后,如果遇到困难可以参考本书的辅导资料,有包含 “文章管理” 的详细模块开发内容说明。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

2.6 模块开发:标签管理 - 图1