你好,我是轩脉刃。

上两节课我们开发了一个完整的用户模块的前后端,并且运用了hade框架的不少命令行工具和基础服务。这节课,我们继续开发这个类知乎问答网站的另外一个比较大的业务模块:问答业务模块。

关于问答业务模块的开发,整体的开发流程和基本的使用方式和用户模块其实差不多,说到底这两个模块都是操作数据库中对应的数据表,我们同样使用先分析需求,再实现后端接口,最后是实现前端接口的流程。

问答模块,包含问题表、回答表和之前的用户表,这三个表之间有一些关联关系,在GORM中,如何使用这些关联关系建模,并且封装问答服务,接着对这些问答服务的方法提供足够的测试,是我们今天的解说重点。

页面和接口设计

还是先梳理一下问答模块页面,它包含四个页面:问题创建页、问题列表页、问题详情页、问题更新页。名称都很清晰,在问题更新页中,我们可以对某个问题进行更新修改。不过我们暂时不提供回答的修改功能,只提供回答的创建和删除功能。

问题创建页

在这个页面中,用户可以提出一个问题。提出问题的时候,让用户输入问题的标题和内容。通过点击提交,这个问题就提交进入数据库,并且在列表页面展示了。

图片

问题创建页明显就只会和后端有一个接口的交互,问题创建接口 /question/create。它是POST请求,请求参数包括问题标题 title和问题内容 context。我们用一个结构来表示这个接口的请求内容:

type questionCreateParam struct {
   Title   string `json:"title" binding:"required"`
   Content string `json:"content" binding:"required"`
}

返回值为问题是否创建成功的字符串说明:“操作成功”。

问题列表页

在列表页面中,我们按照创建时间顺序展示问题列表。列表页中的每一项都代表一个问题,展示的时候列出问题的标题、问题的内容(只显示200个字)、问题的创建时间、问题的创建者,以及问题的回答数。

图片

考虑到当问题数比较多的时候,一个页面展示不下,我们为列表页设计一个分页逻辑,当页面下拉到底部的时候,会有“加载中”的字样去后端获取更多的列表信息。

图片

所以问题列表页的接口也比较简单。我们可以把这个页面开始的获取问题列表,和“加载中”功能的接口,设计为同一个:问题列表接口 /question/list。这个接口请求方法为GET,参数需要设计两个,一个参数start表示要从第几个问题开始加载,而另外一个参数size表示请求的问题个数。

对于页面初始化的问题列表,start为0,size为10,表示页面初始化,我们向后端获取10个问题;而对于后面的“加载中”的功能,我们的start为当前页面已经展示的问题数量,size同样为10,表示再加载10个问题,增加到问题列表页中。

然后这个接口最终返回的是一个问题数组,包含问题的标题、问题的内容、问题创建时间、问题创建用户,以及问题的回答数。

问题详情页

到达列表页之后,用户会进入问题详情页查看某个具体的问题,但是这个页面承载的功能远不止查看问题详情这么简单。

首先因为列表页只显示200字,这个页面要能展示问题详情。用户要能回答这个问题,那么这个页面的最下方还要有用户回答框,如果查看人想对某个问题进行回答,可以输入回答内容进行提交。所以也需要展示这个问题的所有回答列表。

有了问题和回答的新增,我们当然要考虑删除。这个页面展示的问题如果是查看人创建的,查看人可以操作将这个问题进行删除。同时,如果回答列表中展示的某个回答是查看人创建的,查看人有权限将这个回答进行删除。

图片

所以问题详情页的接口就比较多了,有4个接口。

查看某个问题详情,并且在这个问题详情中,同时带有这个问题的所有回答,按照回答的创建时间倒序排列。

这个接口为GET请求,它的参数为一个id,表示问题的ID。返回值是问题详情,这个问题详情基本上和问题列表页中的问题是一个模型,但是还要带有一个回答列表信息,把这个问题的所有回答都返回。

这个接口的功能是创建一个回答,它是POST请求,参数有两个:question_id,代表回答对应的问题ID;content,代表回答的具体内容。我们用一个数据结构来代表这个接口的参数:

type answerCreateParam struct {
   QuestionID int64  `json:"question_id" binding:"required"`
   Content    string `json:"content" binding:"required"`
}

接口的返回值是操作成功或者失败的信息。

这个接口功能是删除某个回答,它是GET请求,参数为id,表示回答的具体ID。当然在接口的后端逻辑中,我们必须判断这个回答是否是查看人所创建的,如果不是的话,这个接口是不允许进行操作的。接口的返回值就返回操作成功或者失败的信息即可。

这个接口功能是删除某个问题,它是GET请求,参数为id,表示问题的具体ID,和回答的删除接口一个操作。

问题更新页

在这个页面中,用户可以对某个自己提出的问题的内容进行修改。这个页面和问题创建页有类似的页面布局,不同的是进入的时候,问题标题和内容都是有具体内容的。

图片

问题更新页接口就一个,负责完成更新某个问题的功能。更新问题接口 /question/edit,我们允许更新问题的标题和内容,所以这个接口参数有三个:问题ID,表示更新的哪个问题;标题title,表示更新的问题标题;内容content,表示要更新的问题内容。我们定义一个数据结构来表示这个接口的参数:

type questionEditParam struct {
   ID      int64  `json:"id" binding:"required"`
   Title   string `json:"title" binding:"required"`
   Content string `json:"content" binding:"required"`
}

返回值是操作成功或者失败的消息。

好最后我们梳理一下,关于问答模块,一共要开发七个接口。

后端开发

接口定义好,下面就是后端开发了。还记得开发用户模块的时候说过的后端开发四个步骤吗,接口swagger化、定义用户服务协议、开发模块接口、实现用户服务协议,这四个步骤具体负责的内容就不赘述了。今天qa模块的开发,我们仍然沿用这四个步骤。

接口swagger化

首先使用注释将前面定义的七个接口的说明、参数、返回值全部swagger化。

因为问题列表页面和问题详情页面,都会使用到输出“问题”和“回答”这两种结构,还记得第31章我们讨论的模型设计吗,DTO层模型负责前端和后端接口的数据传输,定义了这个DTO层的模型,前端和后端的同学就能依照这个模型来并行开发了。所以我们设计DTO层的模型。

// QuestionDTO 问题列表返回结构
type QuestionDTO struct {
   ID        int64     `json:"id,omitempty"`
   Title     string    `json:"title,omitempty"`
   Context   string    `json:"context,omitempty"` // 在列表页,只显示前200个字符
   AnswerNum int       `json:"answer_num"`
   CreatedAt time.Time `json:"created_at"`
   UpdatedAt time.Time `json:"updated_at"`
   Author  *user.UserDTO `json:"author,omitempty"`  // 作者
   Answers []*AnswerDTO  `json:"answers,omitempty"` // 回答
}
// AnswerDTO 回答返回结构
type AnswerDTO struct {
   ID        int64     `json:"id,omitempty"`
   Content   string    `json:"content,omitempty"`
   CreatedAt time.Time `json:"created_at"`
   UpdatedAt time.Time `json:"updated_at"`
   Author *user.UserDTO `json:"author,omitempty"` // 作者
}

我们可以看到,在DTO层,各个DTO是有关联的,QuestionDTO关联了UserDTO和AnswerDTO,而AnswerDTO 关联了UserDTO。这样关联其实是非常合理的。后续我们输出给前端的数据模型就固定了,比如要输出用户,前端就知道我们一定会输出一个UserDTO的数据模型,能减少前后端的沟通障碍。

然后编写接口方法并注册到路由中:

// RegisterRoutes 注册路由
func RegisterRoutes(r *gin.Engine) error {
   api := &QAApi{}
   if !r.IsBind(qa.QaKey) {
      r.Bind(&qa.QaProvider{})
   }
   questionApi := r.Group("/question", auth.AuthMiddleware())
   {
      // 问题列表
      questionApi.GET("/list", api.QuestionList)
      // 问题详情
      questionApi.GET("/detail", api.QuestionDetail)
      // 创建问题
      questionApi.POST("/create", api.QuestionCreate)
      // 删除问题
      questionApi.POST("/delete", api.QuestionDelete)
      // 更新问题
      questionApi.POST("/edit", api.QuestionEdit)
   }
   answerApi := r.Group("/answer", auth.AuthMiddleware())
   {
      // 创建回答
      answerApi.POST("/create", api.AnswerCreate)
      // 删除回答
      answerApi.POST("/delete", api.AnswerDelete)
   }

   return nil
}

最后按照swaggo的方式来编写swagger的注释,以获取问题详情的接口为例:

// QuestionDetail 获取问题详情
// @Summary 获取问题详细
// @Description 获取问题详情,包括问题的所有回答
// @Accept  json
// @Produce  json
// @Tags qa
// @Param id query int true "问题id"
// @Success 200 QuestionDTO question "问题详情,带回答和作者"
// @Router /question/detail [get]
func (api *QAApi) QuestionDetail(c *gin.Context) {
    ...
}

最后我们使用 ./bbs swagger gen 生成swagger文件,并且编译 ./bbs build self ,编译进入 bbs 文件,最后再使用 ./bbs dev backend 展示swagger-UI界面如图:
图片

qa服务设计

接口swagger化之后,接下来就要设计qa服务了。关于qa服务,我们同样先处理模型,将DO层模型和PO层模型合并,统一使用一个数据模型来定义。

问题/回答模型

代表问题的模型Question 和代表回答的模型Answer。

// Question 代表问题
type Question struct {
   ID        int64          `gorm:"column:id;primaryKey"`
   Title     string         `gorm:"column:title;comment:标题"`
   Context   string         `gorm:"column:context;comment:内容"`
   AuthorID  int64          `gorm:"column:author_id;comment:作者id;not null;default:0"`
   AnswerNum int            `gorm:"column:answer_num;comment:回答数;not null;default:0"`
   CreatedAt time.Time      `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
   UpdatedAt time.Time      `gorm:"column:updated_at;autoUpdateTime;<-:false;comment:更新时间"`
   DeletedAt gorm.DeletedAt `gorm:"index"`
   Author    *user.User     `gorm:"foreignKey:AuthorID"`
   Answers   []*Answer      `gorm:"foreignKey:QuestionID"`
}

// Answer 代表一个回答
type Answer struct {
   ID         int64          `gorm:"column:id;primaryKey"`
   QuestionID int64          `gorm:"column:question_id;index;comment:问题id;not null;default 0"`
   Content    string         `gorm:"column:context;comment:内容"`
   AuthorID   int64          `gorm:"column:author_id;comment:作者id;not null;default:0"`
   CreatedAt  time.Time      `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
   UpdatedAt  time.Time      `gorm:"column:updated_at;autoUpdateTime;<-:false;comment:更新时间"`
   DeletedAt  gorm.DeletedAt `gorm:"index"`
   Author     *user.User     `gorm:"foreignKey:AuthorID"`
   Question   *Question      `gorm:"foreignKey:QuestionID"`
}

你可以看到,我们使用了非常丰富的Gorm的tag标签。在Gorm的使用中,一个必须要掌握的就是tag标签的运用,你的tag标签使用的好,就能节省很多代码量。这是今天的重点,我们来详细说明一下。

在我们的数据表中,除了主键索引之外,很有可能需要建立其他某个字段的索引,比如回答模型一定少不了根据问题ID查询出所有的回答。那么我们需要针对问题ID,在回答表中建立一个索引,就可以使用 index 的标签来表示这个索引。

   QuestionID int64          `gorm:"column:question_id;index;comment:问题id;not null;default 0"`

还有一个细节,数据库中每个字段默认都是允许为null的,但是我们在获取数据的时候,并不希望这个数据会为null,比如问题表中的回答数字段,我们希望它不为空,默认为0,就可以使用 not null 和 default 两个标签来设置。

   AnswerNum int            `gorm:"column:answer_num;comment:回答数;not null;default:0"`

另外,问题表和回答表都有创建时间和更新时间,其中,创建时间我们希望在使用创建数据的方法Create时自动填充,而更新时间也希望能在更新时自动填充。一方面,这样服务调用者就能少顾虑到一些“时间”方面的逻辑,另一方面,这种“时间”的管理,我们封闭在服务内部,如果调用者逻辑错误,也不会导致这两个时间是有问题的。

所以我们使用autoCreateTime、autoUpdateTime、<-:false 分别表示创建数据自动更新时间、更新数据自动更新时间、禁止写入。

CreatedAt  time.Time      `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt  time.Time      `gorm:"column:updated_at;autoUpdateTime;<-:false;comment:更新时间"`

问题和回答的数据一定存在需要删除的行为,但是删除时,我们又不希望真正删除数据,而是希望采用软删除的方式,也就是为数据某个字段打一个标记来标记删除

这种软删除的方式在实际业务中是有可能有需求的,比如有的问题和回答是先审批再展示出来的,我们可以先标记为软删除,审批完成之后再放出来;或者用户或者运营同学点击了删除某个问题,但是属于误操作,软删除就为恢复数据提供了可能性。

Gorm提供了 gorm.DeletedAt 的字段类型来表示这个软删除的逻辑,所以在问题表和回答表中我们加上这个DeletedAt字段来标记;同时由于这个字段用来标记是否删除,所以我们在查询的时候一定会经常使用到这个字段进行索引,对这个字段使用index的标签来创建一个索引也是非常必要的。

DeletedAt  gorm.DeletedAt `gorm:"index"`

最后,对于ORM来说,问题对象和回答对象其实是一对多的关系,它们之间其实是有外键关联的,回答对象中的QuestionID和问题对象的ID字段是关联的。

我们可以为回答表创建一个外键:

type Answer struct {
  ...
  QuestionID int64          `gorm:"column:question_id;index;comment:问题id;not null;default 0"`
  Question   *Question      `gorm:"foreignKey:QuestionID"`
}

Answer结构和Question结构是“属于关系”(Belongs To),一个回答属于一个问题,所以这里的Question结构,它使用了一个外键,告诉DB,Answer结构中的QuestionID字段,是我的属主的主键,根据QuestionID字段去查找Question结构。

同时相对应的,我们为问题表创建一个回答表的数组:

type Question struct {
   ...
   Answers   []*Answer      `gorm:"foreignKey:QuestionID"`
}

相反的,Question结构和Answer结构就属于“包含许多”(Has Many), 一个问题包含许多个回答,它这里的外键tag标记为QuestionID,表示,我这个问题的回答有很多,它们为Answer结构中QuestionID为主键的数据。

BelongsTo、 HasMany,是Gorm中的关联逻辑,更多的解释和查看用法可以参考官网的关联部分的说明。

ORM做这个外键约束有什么好处呢?它能让Gorm提供的“预加载”功能成为可能。这个预加载的功能在实际开发过程中是非常好用的。比如现在有多个问题的数组对象questions,想要获取每一个问题的所有回答,原本我们是需要自己再手写一个ORM的SQL查询来获取。

questionIds := []int64{}
for _, question := range questions {
  questionIds := append(questionIds, question.ID)
}

db.Where(map[string]interface{}{"question_id", questionIds}).Find(&answers)

但是一旦有了外键约束,我们就可以使用预加载的功能,一行代码直接将这些问题数组对应的回答获取回来了:

db.Preload("Answers").Find(questions)

这样在获取的questions中,每个问题对象的Answers字段都带有一个回答数组了,非常方便。

分页模型

除了问题和回答两个模型,在问题列表页还会根据分页信息来获取每一页的问题列表。所以我们还需要一个分页模型Pager,包含起始位置Start、获取的数据个数Size,还有一个Total代表一共有多少数据。

// Pager 代表分页机制
type Pager struct {
   Total int64 // 共有多少数据,只有返回值使用
   Start int   // 起始位置
   Size  int   // 获取的数据个数
}

协议

模型定义完成,下面我们就要来定义服务对外提供的协议接口了。qa服务虽然接口比较多,但是它的接口逻辑却并不复杂,基本上都围绕问题、回答两个模型的增删改查进行,也就是说,我们qa服务对外提供的协议,基本上也就是围绕这两个对象的增删改查进行的。

首先围绕问题这个模型。

需要创建问题的接口PostQuestion,直接把Question模型作为参数即可。创建完问题,我们需要获取问题,那么就要有GetQuestion接口,同时也需要有批量获取Question的接口GetQuestions。创建问题结束,我们可能要修改问题,那么可以有一个修改问题的接口UpdateQuestion。最后就是删除问题接口DeleteQuestion。

// Service 代表qa的服务
type Service interface {

   // GetQuestions 获取问题列表,question简化结构
   GetQuestions(ctx context.Context, pager *Pager) ([]*Question, error)
   
   // GetQuestion 获取某个问题详情,question简化结构
   GetQuestion(ctx context.Context, questionID int64) (*Question, error)
   
   // PostQuestion 上传某个问题
   // ctx必须带操作人id
   PostQuestion(ctx context.Context, question *Question) error

   // DeleteQuestion 删除问题,同时删除对应的回答
   // ctx必须带操作人信息
   DeleteQuestion(ctx context.Context, questionID int64) error

   // UpdateQuestion 代表更新问题, 只会对比其中的context,title两个字段,其他字段不会对比
   // ctx必须带操作人
   UpdateQuestion(ctx context.Context, question *Question) error
}

这里我们关注一下获取问题的两个接口,GetQuestion和GetQuestions,它们返回的是Question模型和Question模型数组。

但是有一点要注意,在前面,我们定义的Question模型是带有“外键”属性的,比如问题的作者Author、问题的回答Answer。这些属性,我们希望由上层业务“按需加载”

也就是说在服务层,获取问题和获取问题列表默认是没有作者和回答的,如果上层业务需要的话,请重新调用接口来获取。所以这里我们多出了四个接口:单个问题加载作者、多个问题加载作者、单个问题加载回答、多个问题加载回答。

// Service 代表qa的服务
type Service interface {

   // QuestionLoadAuthor 问题加载Author字段
   QuestionLoadAuthor(ctx context.Context, question *Question) error
   
   // QuestionsLoadAuthor 批量加载Author字段
   QuestionsLoadAuthor(ctx context.Context, questions *[]*Question) error

   // QuestionLoadAnswers 单个问题加载Answers
   QuestionLoadAnswers(ctx context.Context, question *Question) error
   
   // QuestionsLoadAnswers 批量问题加载Answers
   QuestionsLoadAnswers(ctx context.Context, questions *[]*Question) error

}

在使用的时候注意一下,多个问题加载的方法中,第二个参数传递的是指向slice的指针 *[]*Question。因为我们在调用接口的时候,会重新修改这个指针指向的slice。修改的时候是有可能变更原先slice地址的,所以这里使用了“指向slice的指针”。

再看围绕“回答”这个模型。

我们一样需要有创建回答接口PostAnswer、删除回答接口DeleteAnswer、获取回答接口GetAnswer。由于产品设计上并不允许对回答进行修改,所以这里暂时不需要更新回答的接口。

同样我们也提供“回答”作者信息的按需加载,也就是单个回答的按需加载AnswerLoadAuthor和多个回答的按需加载AnswersLoadAuthor两个方法:

// Service 代表qa的服务
type Service interface {

   // PostAnswer 上传某个回答
   // ctx必须带操作人信息
   PostAnswer(ctx context.Context, answer *Answer) error
   
   // GetAnswer 获取回答
   GetAnswer(ctx context.Context, answerID int64) (*Answer, error)

   // AnswerLoadAuthor 问题加载Author字段
   AnswerLoadAuthor(ctx context.Context, question *Answer) error
   // AnswersLoadAuthor 批量加载Author字段
   AnswersLoadAuthor(ctx context.Context, questions *[]*Answer) error

   // DeleteAnswer 删除某个回答
   // ctx必须带操作人信息
   DeleteAnswer(ctx context.Context, answerID int64) error
}

好了,qa服务的后端服务协议我们就定义完成了,一共有14个协议接口,代表qa服务对外提供的14种能力。所有代码都存放到 GitHub上的geekbang/33 上了。对应的文档截图也放在这里,欢迎对比查看。

图片

小结

今天我们主要定义了问答服务的两个协议,一个是前端和后端的协议接口,将接口的输出、输入以swagger-UI的形式表现,另外一个是后端问答服务的协议,一共14个接口。

除了让你再熟悉一遍后端开发模块的四步骤之外,通过今天的实战,希望你能熟练掌握Gorm的模型定义,Gorm的tag是个非常强大的存在,定义好了这个tag,才能真正将之前我们引入ORM的利益最大化,这一点在下节课实现qa服务协议的时候也会领略到。

思考题

定义好Gorm模型的tag,不仅仅能节省我们操作数据库的逻辑,还能根据ORM创建数据表,这里需要用到Gorm中提供的Auto Migrations功能。实际上,我在单元测试的时候,往测试数据库中创建表就是使用这个功能,你不妨尝试根据这节课定义的Question和Answer,往自己的测试数据库中创建两张表questions和answers。

欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课实战继续。