You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

463 lines
24 KiB
Markdown

2 years ago
# 33业务开发问答业务开发
你好,我是轩脉刃。
上两节课我们开发了一个完整的用户模块的前后端并且运用了hade框架的不少命令行工具和基础服务。这节课我们继续开发这个类知乎问答网站的另外一个比较大的业务模块问答业务模块。
关于问答业务模块的开发,整体的开发流程和基本的使用方式和用户模块其实差不多,说到底这两个模块都是操作数据库中对应的数据表,我们同样使用先分析需求,再实现后端接口,最后是实现前端接口的流程。
问答模块包含问题表、回答表和之前的用户表这三个表之间有一些关联关系在GORM中如何使用这些关联关系建模并且封装问答服务接着对这些问答服务的方法提供足够的测试是我们今天的解说重点。
## 页面和接口设计
还是先梳理一下问答模块页面,它包含四个页面:**问题创建页、问题列表页、问题详情页、问题更新页**。名称都很清晰,在问题更新页中,我们可以对某个问题进行更新修改。不过我们暂时不提供回答的修改功能,只提供回答的创建和删除功能。
### 问题创建页
在这个页面中,用户可以提出一个问题。提出问题的时候,让用户输入问题的标题和内容。通过点击提交,这个问题就提交进入数据库,并且在列表页面展示了。
![图片](https://static001.geekbang.org/resource/image/ee/cc/eefc121a3a73216454cb31b462f2bacc.png?wh=853x780)
问题创建页明显就只会和后端有一个接口的交互,问题创建接口 /question/create。它是POST请求请求参数包括问题标题 title和问题内容 context。我们用一个结构来表示这个接口的请求内容
```go
type questionCreateParam struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
```
返回值为问题是否创建成功的字符串说明:“操作成功”。
### 问题列表页
在列表页面中我们按照创建时间顺序展示问题列表。列表页中的每一项都代表一个问题展示的时候列出问题的标题、问题的内容只显示200个字、问题的创建时间、问题的创建者以及问题的回答数。
![图片](https://static001.geekbang.org/resource/image/1a/59/1a83f05477538yy52yy171aa6b657e59.png?wh=1062x1240)
考虑到当问题数比较多的时候,一个页面展示不下,我们为列表页设计一个分页逻辑,当页面下拉到底部的时候,会有“加载中”的字样去后端获取更多的列表信息。
![图片](https://static001.geekbang.org/resource/image/21/3c/211596ccc4e13b50e9f4128a47fc1d3c.png?wh=889x715)
所以问题列表页的接口也比较简单。我们可以把这个页面开始的获取问题列表,和“加载中”功能的接口,设计为同一个:问题列表接口 /question/list。这个接口请求方法为GET参数需要设计两个一个参数start表示要从第几个问题开始加载而另外一个参数size表示请求的问题个数。
对于页面初始化的问题列表start为0size为10表示页面初始化我们向后端获取10个问题而对于后面的“加载中”的功能我们的start为当前页面已经展示的问题数量size同样为10表示再加载10个问题增加到问题列表页中。
然后这个接口最终返回的是一个问题数组,包含问题的标题、问题的内容、问题创建时间、问题创建用户,以及问题的回答数。
### 问题详情页
到达列表页之后,用户会进入问题详情页查看某个具体的问题,但是这个页面承载的功能远不止查看问题详情这么简单。
首先因为列表页只显示200字这个页面要能展示问题详情。用户要能回答这个问题那么这个页面的最下方还要有用户回答框如果查看人想对某个问题进行回答可以输入回答内容进行提交。所以也需要展示这个问题的所有回答列表。
有了问题和回答的新增,我们当然要考虑删除。这个页面展示的问题如果是查看人创建的,查看人可以操作将这个问题进行删除。同时,如果回答列表中展示的某个回答是查看人创建的,查看人有权限将这个回答进行删除。
![图片](https://static001.geekbang.org/resource/image/33/2a/3374420cc6df2ccd00ee754f5fe30d2a.png?wh=869x919)
所以问题详情页的接口就比较多了有4个接口。
* 问题详情接口 /question/detail
查看某个问题详情,并且在这个问题详情中,同时带有这个问题的所有回答,按照回答的创建时间倒序排列。
这个接口为GET请求它的参数为一个id表示问题的ID。返回值是问题详情这个问题详情基本上和问题列表页中的问题是一个模型但是还要带有一个回答列表信息把这个问题的所有回答都返回。
* 回答创建接口 /answer/create
这个接口的功能是创建一个回答它是POST请求参数有两个question\_id代表回答对应的问题IDcontent代表回答的具体内容。我们用一个数据结构来代表这个接口的参数
```go
type answerCreateParam struct {
QuestionID int64 `json:"question_id" binding:"required"`
Content string `json:"content" binding:"required"`
}
```
接口的返回值是操作成功或者失败的信息。
* 回答删除接口 /answer/delete
这个接口功能是删除某个回答它是GET请求参数为id表示回答的具体ID。当然在接口的后端逻辑中我们必须判断这个回答是否是查看人所创建的如果不是的话这个接口是不允许进行操作的。接口的返回值就返回操作成功或者失败的信息即可。
* 问题删除接口 /question/delete
这个接口功能是删除某个问题它是GET请求参数为id表示问题的具体ID和回答的删除接口一个操作。
### 问题更新页
在这个页面中,用户可以对某个自己提出的问题的内容进行修改。这个页面和问题创建页有类似的页面布局,不同的是进入的时候,问题标题和内容都是有具体内容的。
![图片](https://static001.geekbang.org/resource/image/17/31/17a7595880f3f688e37c22b380209131.png?wh=864x776)
问题更新页接口就一个,负责完成更新某个问题的功能。更新问题接口 /question/edit我们允许更新问题的标题和内容所以这个接口参数有三个问题ID表示更新的哪个问题标题title表示更新的问题标题内容content表示要更新的问题内容。我们定义一个数据结构来表示这个接口的参数
```go
type questionEditParam struct {
ID int64 `json:"id" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
```
返回值是操作成功或者失败的消息。
好最后我们梳理一下,关于问答模块,一共要开发七个接口。
* 问题创建接口 /question/create
* 问题列表接口 /question/list
* 问题详情接口 /question/detail
* 问题删除接口 /question/delete
* 更新问题接口 /question/edit
* 回答创建接口 /answer/create
* 回答删除接口 /answer/delete
## 后端开发
接口定义好下面就是后端开发了。还记得开发用户模块的时候说过的后端开发四个步骤吗接口swagger化、定义用户服务协议、开发模块接口、实现用户服务协议这四个步骤具体负责的内容就不赘述了。今天qa模块的开发我们仍然沿用这四个步骤。
### 接口swagger化
首先使用注释将前面定义的七个接口的说明、参数、返回值全部swagger化。
因为问题列表页面和问题详情页面都会使用到输出“问题”和“回答”这两种结构还记得第31章我们讨论的模型设计吗DTO层模型负责前端和后端接口的数据传输定义了这个DTO层的模型前端和后端的同学就能依照这个模型来并行开发了。所以我们设计DTO层的模型。
```go
// 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的数据模型能减少前后端的沟通障碍。
然后编写接口方法并注册到路由中:
```go
// 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的注释以获取问题详情的接口为例
```go
// 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界面如图
![图片](https://static001.geekbang.org/resource/image/4e/39/4e5743b52483ab31e75481d818b05739.png?wh=1920x638)
## qa服务设计
接口swagger化之后接下来就要设计qa服务了。关于qa服务我们同样先处理模型将DO层模型和PO层模型合并统一使用一个数据模型来定义。
### 问题/回答模型
代表问题的模型Question 和代表回答的模型Answer。
```go
// 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标签使用的好就能节省很多代码量**。这是今天的重点,我们来详细说明一下。
* index
在我们的数据表中除了主键索引之外很有可能需要建立其他某个字段的索引比如回答模型一定少不了根据问题ID查询出所有的回答。那么我们需要针对问题ID在回答表中建立一个索引就可以使用 index 的标签来表示这个索引。
```go
QuestionID int64 `gorm:"column:question_id;index;comment:问题id;not null;default 0"`
```
* not null 和 default
还有一个细节数据库中每个字段默认都是允许为null的但是我们在获取数据的时候并不希望这个数据会为null比如问题表中的回答数字段我们希望它不为空默认为0就可以使用 not null 和 default 两个标签来设置。
```go
AnswerNum int `gorm:"column:answer_num;comment:回答数;not null;default:0"`
```
* time
另外问题表和回答表都有创建时间和更新时间其中创建时间我们希望在使用创建数据的方法Create时自动填充而更新时间也希望能在更新时自动填充。一方面这样服务调用者就能少顾虑到一些“时间”方面的逻辑另一方面这种“时间”的管理我们封闭在服务内部如果调用者逻辑错误也不会导致这两个时间是有问题的。
所以我们使用autoCreateTime、autoUpdateTime、<-:false
```go
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;<-:false;comment:更新时间"`
```
* DeletedAt
问题和回答的数据一定存在需要删除的行为,但是删除时,我们又不希望真正删除数据,**而是希望采用软删除的方式,也就是为数据某个字段打一个标记来标记删除**。
这种软删除的方式在实际业务中是有可能有需求的,比如有的问题和回答是先审批再展示出来的,我们可以先标记为软删除,审批完成之后再放出来;或者用户或者运营同学点击了删除某个问题,但是属于误操作,软删除就为恢复数据提供了可能性。
Gorm提供了 gorm.DeletedAt 的字段类型来表示这个软删除的逻辑所以在问题表和回答表中我们加上这个DeletedAt字段来标记同时由于这个字段用来标记是否删除所以我们在查询的时候一定会经常使用到这个字段进行索引对这个字段使用index的标签来创建一个索引也是非常必要的。
```go
DeletedAt gorm.DeletedAt `gorm:"index"`
```
* foreignKey
最后对于ORM来说问题对象和回答对象其实是一对多的关系它们之间其实是有外键关联的回答对象中的QuestionID和问题对象的ID字段是关联的。
我们可以为回答表创建一个外键:
```go
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](https://gorm.io/zh_CN/docs/belongs_to.html)一个回答属于一个问题所以这里的Question结构它使用了一个外键告诉DBAnswer结构中的QuestionID字段是我的属主的主键根据QuestionID字段去查找Question结构。
同时相对应的,我们为问题表创建一个回答表的数组:
```go
type Question struct {
...
Answers []*Answer `gorm:"foreignKey:QuestionID"`
}
```
相反的Question结构和Answer结构就属于“包含许多”[Has Many](https://gorm.io/zh_CN/docs/has_many.html), 一个问题包含许多个回答它这里的外键tag标记为QuestionID表示我这个问题的回答有很多它们为Answer结构中QuestionID为主键的数据。
BelongsTo、 HasMany是Gorm中的关联逻辑更多的解释和查看用法可以参考官网的[关联](https://gorm.io/zh_CN/docs/belongs_to.html)部分的说明。
**ORM做这个外键约束有什么好处呢它能让Gorm提供的“[预加载](https://gorm.io/zh_CN/docs/preload.html)”功能成为可能**。这个预加载的功能在实际开发过程中是非常好用的。比如现在有多个问题的数组对象questions想要获取每一个问题的所有回答原本我们是需要自己再手写一个ORM的SQL查询来获取。
```go
questionIds := []int64{}
for _, question := range questions {
questionIds := append(questionIds, question.ID)
}
db.Where(map[string]interface{}{"question_id", questionIds}).Find(&answers)
```
但是一旦有了外键约束,我们就可以使用预加载的功能,一行代码直接将这些问题数组对应的回答获取回来了:
```go
db.Preload("Answers").Find(questions)
```
这样在获取的questions中每个问题对象的Answers字段都带有一个回答数组了非常方便。
### 分页模型
除了问题和回答两个模型在问题列表页还会根据分页信息来获取每一页的问题列表。所以我们还需要一个分页模型Pager包含起始位置Start、获取的数据个数Size还有一个Total代表一共有多少数据。
```go
// Pager 代表分页机制
type Pager struct {
Total int64 // 共有多少数据,只有返回值使用
Start int // 起始位置
Size int // 获取的数据个数
}
```
## 协议
模型定义完成下面我们就要来定义服务对外提供的协议接口了。qa服务虽然接口比较多但是它的接口逻辑却并不复杂基本上都围绕问题、回答两个模型的增删改查进行也就是说我们qa服务对外提供的协议基本上也就是围绕这两个对象的增删改查进行的。
首先围绕问题这个模型。
需要创建问题的接口PostQuestion直接把Question模型作为参数即可。创建完问题我们需要获取问题那么就要有GetQuestion接口同时也需要有批量获取Question的接口GetQuestions。创建问题结束我们可能要修改问题那么可以有一个修改问题的接口UpdateQuestion。最后就是删除问题接口DeleteQuestion。
```go
// 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 代表更新问题, 只会对比其中的contexttitle两个字段其他字段不会对比
// ctx必须带操作人
UpdateQuestion(ctx context.Context, question *Question) error
}
```
这里我们关注一下获取问题的两个接口GetQuestion和GetQuestions它们返回的是Question模型和Question模型数组。
但是有一点要注意在前面我们定义的Question模型是带有“外键”属性的比如问题的作者Author、问题的回答Answer。**这些属性,我们希望由上层业务“按需加载”**。
也就是说在服务层,获取问题和获取问题列表默认是没有作者和回答的,如果上层业务需要的话,请重新调用接口来获取。所以这里我们多出了四个接口:单个问题加载作者、多个问题加载作者、单个问题加载回答、多个问题加载回答。
```go
// 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两个方法
```go
// 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](https://github.com/gohade/bbs/tree/geekbang/33) 上了。对应的文档截图也放在这里,欢迎对比查看。
![图片](https://static001.geekbang.org/resource/image/23/8b/23aac201f21227bc8ab1833ae5605e8b.png?wh=734x1414)
## 小结
今天我们主要定义了问答服务的两个协议一个是前端和后端的协议接口将接口的输出、输入以swagger-UI的形式表现另外一个是后端问答服务的协议一共14个接口。
除了让你再熟悉一遍后端开发模块的四步骤之外通过今天的实战希望你能熟练掌握Gorm的模型定义Gorm的tag是个非常强大的存在定义好了这个tag才能真正将之前我们引入ORM的利益最大化这一点在下节课实现qa服务协议的时候也会领略到。
### 思考题
定义好Gorm模型的tag不仅仅能节省我们操作数据库的逻辑还能根据ORM创建数据表这里需要用到Gorm中提供的[Auto Migrations](https://gorm.io/zh_CN/docs/migration.html)功能。实际上我在单元测试的时候往测试数据库中创建表就是使用这个功能你不妨尝试根据这节课定义的Question和Answer往自己的测试数据库中创建两张表questions和answers。
欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课实战继续。