2.6 模块开发:标签管理
在初步完成了业务接口的入参校验的逻辑处理后,接下来我们正式的进入业务模块的业务逻辑开发,在本章节将完成标签模块的接口代码编写,涉及的接口如下:
功能 | HTTP 方法 | 路径 |
---|---|---|
新增标签 | POST | /tags |
删除指定标签 | DELETE | /tags/:id |
更新指定标签 | PUT | /tags/:id |
获取标签列表 | GET | /tags |
2.6.1 新建 model 方法
首先我们需要针对标签表进行处理,并在项目的 internal/model
目录下新建 tag.go 文件,针对标签模块的模型操作进行封装,并且只与实体产生关系,代码如下:
func (t Tag) Count(db *gorm.DB) (int, error) {
var count int
if t.Name != "" {
db = db.Where("name = ?", t.Name)
}
db = db.Where("state = ?", t.State)
if err := db.Model(&t).Where("is_del = ?", 0).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (t Tag) List(db *gorm.DB, pageOffset, pageSize int) ([]*Tag, error) {
var tags []*Tag
var err error
if pageOffset >= 0 && pageSize > 0 {
db = db.Offset(pageOffset).Limit(pageSize)
}
if t.Name != "" {
db = db.Where("name = ?", t.Name)
}
db = db.Where("state = ?", t.State)
if err = db.Where("is_del = ?", 0).Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}
func (t Tag) Create(db *gorm.DB) error {
return db.Create(&t).Error
}
func (t Tag) Update(db *gorm.DB) error {
return db.Model(&Tag{}).Where("id = ? AND is_del = ?", t.ID, 0).Update(t).Error
}
func (t Tag) Delete(db *gorm.DB) error {
return db.Where("id = ? AND is_del = ?", t.Model.ID, 0).Delete(&t).Error
}
- 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 方法后。
func NewDBEngine(databaseSetting *setting.DatabaseSettingS) (*gorm.DB, error) {}
func updateTimeStampForCreateCallback(scope *gorm.Scope) {}
func updateTimeStampForUpdateCallback(scope *gorm.Scope) {}
func deleteCallback(scope *gorm.Scope) {}
func addExtraSpaceIfExist(str string) string {}
2.6.2.1 新增行为的回调
func updateTimeStampForCreateCallback(scope *gorm.Scope) {
if !scope.HasError() {
nowTime := time.Now().Unix()
if createTimeField, ok := scope.FieldByName("CreatedOn"); ok {
if createTimeField.IsBlank {
_ = createTimeField.Set(nowTime)
}
}
if modifyTimeField, ok := scope.FieldByName("ModifiedOn"); ok {
if modifyTimeField.IsBlank {
_ = modifyTimeField.Set(nowTime)
}
}
}
}
- 通过调用
scope.FieldByName
方法,获取当前是否包含所需的字段。 - 通过判断
Field.IsBlank
的值,可以得知该字段的值是否为空。 - 若为空,则会调用
Field.Set
方法给该字段设置值,入参类型为 interface{},内部也就是通过反射进行一系列操作赋值。
2.6.2.2 更新行为的回调
func updateTimeStampForUpdateCallback(scope *gorm.Scope) {
if _, ok := scope.Get("gorm:update_column"); !ok {
_ = scope.SetColumn("ModifiedOn", time.Now().Unix())
}
}
- 通过调用
scope.Get("gorm:update_column")
去获取当前设置了标识gorm:update_column
的字段属性。 - 若不存在,也就是没有自定义设置
update_column
,那么将会在更新回调内设置默认字段 ModifiedOn 的值为当前的时间戳。
2.6.2.3 删除行为的回调
func deleteCallback(scope *gorm.Scope) {
if !scope.HasError() {
var extraOption string
if str, ok := scope.Get("gorm:delete_option"); ok {
extraOption = fmt.Sprint(str)
}
deletedOnField, hasDeletedOnField := scope.FieldByName("DeletedOn")
isDelField, hasIsDelField := scope.FieldByName("IsDel")
if !scope.Search.Unscoped && hasDeletedOnField && hasIsDelField {
now := time.Now().Unix()
scope.Raw(fmt.Sprintf(
"UPDATE %v SET %v=%v,%v=%v%v%v",
scope.QuotedTableName(),
scope.Quote(deletedOnField.DBName),
scope.AddToVars(now),
scope.Quote(isDelField.DBName),
scope.AddToVars(1),
addExtraSpaceIfExist(scope.CombinedConditionSql()),
addExtraSpaceIfExist(extraOption),
)).Exec()
} else {
scope.Raw(fmt.Sprintf(
"DELETE FROM %v%v%v",
scope.QuotedTableName(),
addExtraSpaceIfExist(scope.CombinedConditionSql()),
addExtraSpaceIfExist(extraOption),
)).Exec()
}
}
}
func addExtraSpaceIfExist(str string) string {
if str != "" {
return " " + str
}
return ""
}
- 通过调用
scope.Get("gorm:delete_option")
去获取当前设置了标识gorm:delete_option
的字段属性。 - 判断是否存在
DeletedOn
和IsDel
字段,若存在则调整为执行 UPDATE 操作进行软删除(修改 DeletedOn 和 IsDel 的值),否则执行 DELETE 进行硬删除。 - 调用
scope.QuotedTableName
方法获取当前所引用的表名,并调用一系列方法针对 SQL 语句的组成部分进行处理和转移,最后在完成一些所需参数设置后调用scope.CombinedConditionSql
方法完成 SQL 语句的组装。
2.6.2.4 注册回调行为
func NewDBEngine(databaseSetting *setting.DatabaseSettingS) (*gorm.DB, error) {
...
db.SingularTable(true)
db.Callback().Create().Replace("gorm:update_time_stamp", updateTimeStampForCreateCallback)
db.Callback().Update().Replace("gorm:update_time_stamp", updateTimeStampForUpdateCallback)
db.Callback().Delete().Replace("gorm:delete", deleteCallback)
db.DB().SetMaxIdleConns(databaseSetting.MaxIdleConns)
db.DB().SetMaxOpenConns(databaseSetting.MaxOpenConns)
return db, nil
}
func updateTimeStampForCreateCallback(scope *gorm.Scope) {...}
func updateTimeStampForUpdateCallback(scope *gorm.Scope) {...}
func deleteCallback(scope *gorm.Scope) {...}
func addExtraSpaceIfExist(str string) string {...}
在最后我们回到 NewDBEngine 方法中,针对上述写的三个 Callback 方法进行回调注册,才能够让我们的应用程序真正的使用上,至此,我们的公共字段处理就完成了。
2.6.3 新建 dao 方法
我们在项目的 internal/dao
目录下新建 dao.go 文件,写入如下代码:
type Dao struct {
engine *gorm.DB
}
func New(engine *gorm.DB) *Dao {
return &Dao{engine: engine}
}
接下来在同层级下新建 tag.go 文件,用于处理标签模块的 dao 操作,写入如下代码:
func (d *Dao) CountTag(name string, state uint8) (int, error) {
tag := model.Tag{Name: name, State: state}
return tag.Count(d.engine)
}
func (d *Dao) GetTagList(name string, state uint8, page, pageSize int) ([]*model.Tag, error) {
tag := model.Tag{Name: name, State: state}
pageOffset := app.GetPageOffset(page, pageSize)
return tag.List(d.engine, pageOffset, pageSize)
}
func (d *Dao) CreateTag(name string, state uint8, createdBy string) error {
tag := model.Tag{
Name: name,
State: state,
Model: &model.Model{CreatedBy: createdBy},
}
return tag.Create(d.engine)
}
func (d *Dao) UpdateTag(id uint32, name string, state uint8, modifiedBy string) error {
tag := model.Tag{
Name: name,
State: state,
Model: &model.Model{ID: id, ModifiedBy: modifiedBy},
}
return tag.Update(d.engine)
}
func (d *Dao) DeleteTag(id uint32) error {
tag := model.Tag{Model: &model.Model{ID: id}}
return tag.Delete(d.engine)
}
在上述代码中,我们主要是在 dao 层进行了数据访问对象的封装,并针对业务所需的字段进行了处理。
2.6.4 新建 service 方法
我们在项目的 internal/service
目录下新建 service.go 文件,写入如下代码:
type Service struct {
ctx context.Context
dao *dao.Dao
}
func New(ctx context.Context) Service {
svc := Service{ctx: ctx}
svc.dao = dao.New(global.DBEngine)
return svc
}
接下来在同层级下新建 tag.go 文件,用于处理标签模块的业务逻辑,写入如下代码:
type CountTagRequest struct {
Name string `form:"name" binding:"max=100"`
State uint8 `form:"state,default=1" binding:"oneof=0 1"`
}
type TagListRequest struct {
Name string `form:"name" binding:"max=100"`
State uint8 `form:"state,default=1" binding:"oneof=0 1"`
}
type CreateTagRequest struct {
Name string `form:"name" binding:"required,min=2,max=100"`
CreatedBy string `form:"created_by" binding:"required,min=2,max=100"`
State uint8 `form:"state,default=1" binding:"oneof=0 1"`
}
type UpdateTagRequest struct {
ID uint32 `form:"id" binding:"required,gte=1"`
Name string `form:"name" binding:"max=100"`
State uint8 `form:"state" binding:"oneof=0 1"`
ModifiedBy string `form:"modified_by" binding:"required,min=2,max=100"`
}
type DeleteTagRequest struct {
ID uint32 `form:"id" binding:"required,gte=1"`
}
func (svc *Service) CountTag(param *CountTagRequest) (int, error) {
return svc.dao.CountTag(param.Name, param.State)
}
func (svc *Service) GetTagList(param *TagListRequest, pager *app.Pager) ([]*model.Tag, error) {
return svc.dao.GetTagList(param.Name, param.State, pager.Page, pager.PageSize)
}
func (svc *Service) CreateTag(param *CreateTagRequest) error {
return svc.dao.CreateTag(param.Name, param.State, param.CreatedBy)
}
func (svc *Service) UpdateTag(param *UpdateTagRequest) error {
return svc.dao.UpdateTag(param.ID, param.Name, param.State, param.ModifiedBy)
}
func (svc *Service) DeleteTag(param *DeleteTagRequest) error {
return svc.dao.DeleteTag(param.ID)
}
在上述代码中,我们主要是定义了 Request 结构体作为接口入参的基准,而本项目由于并不会太复杂,所以直接放在了 service 层中便于使用,若后续业务不断增长,程序越来越复杂,service 也冗杂了,可以考虑将抽离一层接口校验层,便于解耦逻辑。
另外我们还在 service 进行了一些简单的逻辑封装,在应用分层中,service 层主要是针对业务逻辑的封装,如果有一些业务聚合和处理可以在该层进行编码,同时也能较好的隔离上下两层的逻辑。
2.6.6 新增业务错误码
我们在项目的 pkg/errcode
下新建 module_code.go 文件,针对标签模块,写入如下错误代码:
var (
ErrorGetTagListFail = NewError(20010001, "获取标签列表失败")
ErrorCreateTagFail = NewError(20010002, "创建标签失败")
ErrorUpdateTagFail = NewError(20010003, "更新标签失败")
ErrorDeleteTagFail = NewError(20010004, "删除标签失败")
ErrorCountTagFail = NewError(20010005, "统计标签失败")
)
2.6.7 新增路由方法
我们打开 internal/routers/api/v1
项目目录下的 tag.go 文件,写入如下代码:
func (t Tag) List(c *gin.Context) {
param := service.TagListRequest{}
response := app.NewResponse(c)
valid, errs := app.BindAndValid(c, ¶m)
if !valid {
global.Logger.Errorf("app.BindAndValid errs: %v", errs)
response.ToErrorResponse(errcode.InvalidParams.WithDetails(errs.Errors()...))
return
}
svc := service.New(c.Request.Context())
pager := app.Pager{Page: app.GetPage(c), PageSize: app.GetPageSize(c)}
totalRows, err := svc.CountTag(&service.CountTagRequest{Name: param.Name, State: param.State})
if err != nil {
global.Logger.Errorf("svc.CountTag err: %v", err)
response.ToErrorResponse(errcode.ErrorCountTagFail)
return
}
tags, err := svc.GetTagList(¶m, &pager)
if err != nil {
global.Logger.Errorf("svc.GetTagList err: %v", err)
response.ToErrorResponse(errcode.ErrorGetTagListFail)
return
}
response.ToResponseList(tags, totalRows)
return
}
在上述代码中,我们完成了获取标签列表接口的处理方法,我们在方法中完成了入参校验和绑定、获取标签总数、获取标签列表、 序列化结果集等四大功能板块的逻辑串联和日志、错误处理。
需要注意的是入参校验和绑定的处理代码基本都差不多,因此在后续代码中不再重复,我们继续写入创建标签、更新标签、删除标签的接口处理方法,如下:
func (t Tag) Create(c *gin.Context) {
param := service.CreateTagRequest{}
response := app.NewResponse(c)
valid, errs := app.BindAndValid(c, ¶m)
if !valid {...}
svc := service.New(c.Request.Context())
err := svc.CreateTag(¶m)
if err != nil {
global.Logger.Errorf("svc.CreateTag err: %v", err)
response.ToErrorResponse(errcode.ErrorCreateTagFail)
return
}
response.ToResponse(gin.H{})
return
}
func (t Tag) Update(c *gin.Context) {
param := service.UpdateTagRequest{ID: convert.StrTo(c.Param("id")).MustUInt32()}
response := app.NewResponse(c)
valid, errs := app.BindAndValid(c, ¶m)
if !valid {...}
svc := service.New(c.Request.Context())
err := svc.UpdateTag(¶m)
if err != nil {
global.Logger.Errorf("svc.UpdateTag err: %v", err)
response.ToErrorResponse(errcode.ErrorUpdateTagFail)
return
}
response.ToResponse(gin.H{})
return
}
func (t Tag) Delete(c *gin.Context) {
param := service.DeleteTagRequest{ID: convert.StrTo(c.Param("id")).MustUInt32()}
response := app.NewResponse(c)
valid, errs := app.BindAndValid(c, ¶m)
if !valid {...}
svc := service.New(c.Request.Context())
err := svc.DeleteTag(¶m)
if err != nil {
global.Logger.Errorf("svc.DeleteTag err: %v", err)
response.ToErrorResponse(errcode.ErrorDeleteTagFail)
return
}
response.ToResponse(gin.H{})
return
}
2.6.8 验证接口
我们重新启动服务,也就是再执行 go run main.go
,查看启动信息正常后,对标签模块的接口进行验证,请注意,验证示例中的 {id}
,代指占位符,也就是填写你实际调用中希望处理的标签 ID 即可。
2.6.8.1 新增标签
$ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=Go' -F created_by=eddycjy
{}
$ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=PHP' -F created_by=eddycjy
{}
$ curl -X POST http://127.0.0.1:8000/api/v1/tags -F 'name=Rust' -F created_by=eddycjy
{}
2.6.8.2 获取标签列表
$ curl -X GET 'http://127.0.0.1:8000/api/v1/tags?page=1&page_size=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}}
$ curl -X GET 'http://127.0.0.1:8000/api/v1/tags?page=2&page_size=2'
{"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 修改标签
$ curl -X PUT http://127.0.0.1:8000/api/v1/tags/{id} -F state=0 -F modified_by=eddycjy
{}
2.6.8.4 删除标签
$ curl -X DELETE http://127.0.0.1:8000/api/v1/tags/{id}
{}
2.6.9 发现问题
在完成了接口的检验后,我们还要确定一下数据库内的数据变更是否正确。在经过一系列的对比后,我们发现在调用修新标签的接口时,通过接口入参,我们是希望将 id 为 1 的标签状态修改为 0,但是在对比后发现数据库内它的状态值居然还是 1,而且 SQL 语句内也没有出现 state 字段的设置,太神奇了,控制台输出的 SQL 语句如下:
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 方法,如下:
func (t Tag) Update(db *gorm.DB, values interface{}) error {
if err := db.Model(t).Where("id = ? AND is_del = ?", t.ID, 0).Updates(values).Error; err != nil {
return err
}
return nil
}
修改项目的 internal/dao
目录下的 tag.go 文件里的 UpdateTag 方法,如下:
func (d *Dao) UpdateTag(id uint32, name string, state uint8, modifiedBy string) error {
tag := model.Tag{
Model: &model.Model{ID: id},
}
values := map[string]interface{}{
"state": state,
"modified_by": modifiedBy,
}
if name != "" {
values["name"] = name
}
return tag.Update(d.engine, values)
}
重新运行程序,请求修改标签接口,如下:
$ curl -X PUT http://127.0.0.1:8000/api/v1/tags/{id} -F state=0 -F modified_by=eddycjy
{}
检查数据是否正常修改,在正确的情况下,该 id 为 1 的标签,modified_by 为 eddycjy,modified_on 应修改为当前时间戳,state 为 0。
2.6.11 小结
在本章节中,我们针对 “标签管理” 进行了具体的开发,其中涉及到了 model、dao、service、router 的相关方法以及业务错误码的编写和处理。接下来下一步应当是 “文章管理” 的模块开发,我强烈建议读者根据本章的经验,自行构思设计思路,然后亲自思考和实践,这样子对你未来对实际项目进行开发会有明显帮助。而在开发时,或开发后,如果遇到困难可以参考本书的辅导资料,有包含 “文章管理” 的详细模块开发内容说明。