# 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为0,size为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,代表回答对应的问题ID;content,代表回答的具体内容。我们用一个数据结构来代表这个接口的参数: ```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结构,它使用了一个外键,告诉DB,Answer结构中的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 代表更新问题, 只会对比其中的context,title两个字段,其他字段不会对比 // 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。 欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课实战继续。