# 34|业务开发(下):问答业务开发 你好,我是轩脉刃。 上节课我们已经完成了问答业务的一部分开发,主要是两部分,前后端接口设计,把接口的输出、输入以swagger-UI的形式表现;以及后端问答服务的接口设计,一共14个接口。这节课我们就继续完成问答的业务开发。 还是先划一下今天的重点,我们先使用前面定义的问答服务协议接口,来完成业务模块的接口开发,验证问答服务的协议接口是否满足需求,然后再实现我们的问答服务协议。不过因为这次问答服务实现的接口比较多,0 bug有一定难度,所以会为问答服务写一下单元测试,希望你重点掌握。 最后,我们实现前端的Vue页面,同样,由于前端页面的编写不是课程重点,还是挑重点的实现难点解说一下。 下面开始我们今天的实战吧。 ## 开发模块接口 上一节课定义好了问答服务的14个接口,可以使用这14个接口来实现业务模块了。我们的业务模块接口有七个接口需要开发: * 问题创建接口 /question/create * 问题列表接口 /question/list * 问题详情接口 /question/detail * 问题删除接口 /question/delete * 更新问题接口 /question/edit * 回答创建接口 /answer/create * 回答删除接口 /answer/delete 这里就挑选关于问题的两个复杂一点的接口来具体说明一下,问题创建接口/question/create、问题详情接口 /question/detail 。 ### 问题创建接口 问题创建接口,为post方法,上一节课我们已经定义了它的参数结构questionCreateParam。那么要实现这个接口的剩余步骤就是: * 解析参数 * 获取登录用户信息 * 使用登录用户创建一个问题 * 返回“操作成功” 解析参数我们在用户模块说过了,使用Gorm的Bind系列方法,能方便解析参数的时候并且验证参数: ```go param := &questionCreateParam{} if err := c.ShouldBind(param); err != nil { c.ISetStatus(400).IText(err.Error()) return } ``` 获取登录用户信息,也使用在用户模块实现的中间件。 这个中间件,我们在第32章的课后问题中布置给你作为课后作业,这里也说一下答案。**对于需要用户登录才能操作的接口,我们都让这些接口通过一个中间件Auth**。在Auth中,验证前端传递的cookie,从cookie中获取到token,然后通过token去缓存中获取用户信息。 ```go // AuthMiddleware 登录中间件 func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { envService := c.MustMake(contract.EnvKey).(contract.Env) userService := c.MustMake(user.UserKey).(user.Service) // 如果在调试模式下,根据参数的user_id 获取信息 if envService.AppEnv() == contract.EnvDevelopment { userID, exist := c.DefaultQueryInt64("user_id", 0) if exist { authUser, _ := userService.GetUser(c, userID) if authUser != nil { c.Set("auth_user", authUser) c.Next() return } } } token, err := c.Cookie("hade_bbs") if err != nil || token == "" { c.ISetStatus(401).IText("请登录后操作") return } authUser, err := userService.VerifyLogin(c, token) if err != nil || authUser == nil { c.ISetStatus(401).IText("请登录后操作") return } c.Set("auth_user", authUser) c.Next() } } ``` 在这个中间件中,我们获取到用户信息,将用户信息存储到Gin的context中,key名字为auth\_user。同时为这个中间件创建一个获取这个用户信息的方法,GetAuthUser。这个方法比较简单,直接从gin.Context中获取认证用户信息。 为什么获取用户信息的方法,也定义在Auth中间件中呢?因为这样设计之后,**认证用户的所有逻辑,都放在这个Auth中间件中了,能保证“同类”逻辑封装在一个模块或者一个文件中**,不管是从代码优雅角度,还是查找追查问题角度都很方便,这个算是一个编程经验吧。 ```go // GetAuthUser 获取已经验证的用户 func GetAuthUser(c *gin.Context) *user.User { t, exist := c.Get("auth_user") if !exist { return nil } return t.(*user.User) } ``` 回到我们的问题创建接口,在第二步,调用一下Auth中间件的GetAuthUser方法,就能获取到当前的登录用户了。 第三步,就是最核心的创建问题接口,我们调用用户服务的PostQuestion就能完成这个逻辑了。注意的是,要创建问题的AuthorID字段,我们使用的是上一步的登录用户ID。 ```go question := &provider.Question{ Title: param.Title, Context: param.Content, AuthorID: user.ID, // 这里的user是我们的登录用户 } // 创建问题 if err := qaService.PostQuestion(c, question); err != nil { c.ISetStatus(500).IText(err.Error()) return } ``` 最后,使用链式调用方法返回操作成功: ```go c.ISetOkStatus().IText("操作成功") ``` ### 问题详情接口 问题详情接口,可能是所有接口里面使用qa服务最多的业务接口了,我们单独拿出来梳理一下逻辑,分为六个步骤: 1. 解析参数 2. 获取问题详情 3. 加载问题作者 4. 加载问题答案 5. 问题答案加载答案作者 6. 返回数据 不过步骤多一点,实现倒不复杂,第二步到第五步,分别使用了qaService的 GetQuestion、QuestionLoadAuthor、QuestionLoadAnswers、AnswersLoadAuthor,代码如下: ```go func (api *QAApi) QuestionDetail(c *gin.Context) { qaService := c.MustMake(provider.QaKey).(provider.Service) id, exist := c.DefaultQueryInt64("id", 0) if !exist { c.ISetStatus(400).IText("参数错误") return } // 获取问题详情 question, err := qaService.GetQuestion(c, id) if err != nil { c.ISetStatus(500).IText(err.Error()) return } // 加载问题作者 if err := qaService.QuestionLoadAuthor(c, question); err != nil { c.ISetStatus(500).IText(err.Error()) return } // 加载所有答案 if err := qaService.QuestionLoadAnswers(c, question); err != nil { c.ISetStatus(500).IText(err.Error()) return } // 加载答案的所有作者 if err := qaService.AnswersLoadAuthor(c, &question.Answers); err != nil { c.ISetStatus(500).IText(err.Error()) return } // 输出转换 questionDTO := ConvertQuestionToDTO(question, nil) c.ISetOkStatus().IJson(questionDTO) } ``` 这里就实现了两个接口,问题创建接口和问题详情接口,其他接口的具体实现可以参考GitHub上的 [geekbang/34](https://github.com/gohade/bbs/tree/geekbang/34) 分支。 ## 实现用户服务协议 简单回顾一下上节课定义的qa服务的14个后端服务协议: ```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 // 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 // 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 } ``` ### 服务实现 下面我们就来实现这14个协议接口,有两个需要注意的函数重点说明一下,PostAnswer、AnswersLoadAuthor。 * PostAnswer 增加回答的服务PostAnswer,这个接口的逻辑会比其他逻辑复杂一些。 ```go func (q *QaService) PostAnswer(ctx context.Context, answer *Answer) error ``` 可以看到,它的参数就是一个answer结构,但是我们仔细想想,**并不是简单将answer表创建一条新数据就行的,还需要做一个事情,将这个回答所归属问题的回答数+1**。所以这里有一个查询问题。 我们可以把在answer表创建一条新数据的逻辑,和增加问题回答数的逻辑放在一起。这里会有多次去数据库的操作,需要将它们封成一个事务来做,否则的话,就会出现比如创建回答了,但是问题回答数没有+1;或者两个事务都对一个问题回答数操作,出现脏数据。 所以需要使用Gorm的事务函数Transaction,将这个函数内的所有数据库操作封装起来。 ```go func (q *QaService) PostAnswer(ctx context.Context, answer *Answer) error { if answer.QuestionID == 0 { return errors.New("问题不存在") } // 必须使用事务 err := q.ormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { question := &Question{ID: answer.QuestionID} // 获取问题 if err := tx.First(question).Error; err != nil { return err } // 增加回答 if err := tx.Create(answer).Error; err != nil { return err } // 问题回答数量+1 question.AnswerNum = question.AnswerNum + 1 if err := tx.Save(question).Error; err != nil { return err } return nil }) if err != nil { return err } return nil } ``` 从上面代码可以看到,我们将查询问题、插入回答、增加问题回答数封装在一个事务中,这样一旦其中有数据库操作失败,那么整个操作都会失败,并且回滚,保证几个数据表的数据一致性。 * AnswersLoadAuthor 再看一下对多个回答加载回答作者的方法,AnswersLoadAuthor: ```go func (q *QaService) AnswersLoadAuthor(ctx context.Context, answers *[]*Answer) error ``` 参数中传递了带有回答ID的answers结构,所以我们需要将ID全部查询出来。 也就是说需要在一个指针数组中,将某个字段查询出来,并且做成数组,怎么做?这里我使用的是第三方库 [collection](https://github.com/jianfengye/collection),我之前开源的一个作品,目前有560+的star,基于 Apache 协议开源。这个库最大的特点就是对“数组”进行一些特殊操作。 比如这里的,将一个数组指针的ID字段获取出来,组装成一个int64数组,就可以这么用: ```go answerColl := collection.NewObjPointCollection(*answers) ids, err := answerColl.Pluck("ID").ToInt64s() ``` 先New一个指针数组,然后使用Pluck,将某个字段获取出来,再使用 toInt64s 将获取出来的数组转位 \[\]int64 数组。 回到Answer结构,ID已经获取了,怎么查找对应作者呢? 还记得吗,上一节课我们已经**在Answer结构中,设置了Answer和User结构的Belongs To关系**,所以这里可以直接使用Preload方法,加载Answers中的Author字段: ```go q.ormDB.WithContext(ctx).Preload("Author").Order("created_at desc").Find(answers, ids) ``` 直接使用Preload,是不是比每个answer foreach来的更为方便了? 了解了这两个方法中难点的事务使用方式和Preload的使用方式,其他的12个协议接口的实现,基本上,逻辑就和这两个方法差不多了,你可以去GitHub上的 [geekbang/34](https://github.com/gohade/bbs/tree/geekbang/34) 上的代码比对查看。 ## 单元测试 问答服务有14个协议接口这么多,我们编写好这些协议接口之后,是很有可能有错误的,这个时候,我们会非常希望写一下单元测试来验证一下这些服务。那么对于hade框架的服务,怎么编写单元测试呢?我们一起做一下。 首先,要知道**hade框架最核心的就是一个服务容器container**。所以我们需要创建一个单元测试使用的container。框架的test/env.go中,我写好了初始化服务容器的函数InitBaseContainer: ```go package test import ( "github.com/gohade/hade/framework" "github.com/gohade/hade/framework/provider/app" "github.com/gohade/hade/framework/provider/env" ) const ( BasePath = "/Users/yejianfeng/Documents/workspace/gohade/bbs" ) func InitBaseContainer() framework.Container { // 初始化服务容器 container := framework.NewHadeContainer() // 绑定App服务提供者 container.Bind(&app.HadeAppProvider{BaseFolder: BasePath}) // 后续初始化需要绑定的服务提供者... container.Bind(&env.HadeTestingEnvProvider{}) return container } ``` 这个函数中,我们初始化了一个服务容器,当然这里的BasePath表示框架的初始化路径,在具体环境里,请替换你的项目所在的路径。 然后这个初始化服务容器先是绑定了AppProvider,再绑定了HadeTestingEnvProvider,其他的服务容器,就需要你在单元测试中自己绑定了。具体在单元测试文件app/provider/qa/service\_test.go中,我们将如下的服务绑定到这个容器中: ```go func Test_QA(t *testing.T) { container := test.InitBaseContainer() container.Bind(&config.HadeConfigProvider{}) container.Bind(&log.HadeLogServiceProvider{}) container.Bind(&orm.GormProvider{}) container.Bind(&redis.RedisProvider{}) container.Bind(&cache.HadeCacheProvider{}) container.Bind(&user.UserProvider{}) ``` 包含config、gorm、redis、cache、user等服务。这里注意下,由于在InitBaseContainer中设置的env服务为HadeTestingEnvProvider,这个服务会将env设置为testing,所以这里config服务的所有配置,都会去config/testing/目录下进行寻找。 我们是为qa服务写单元测试,qa服务最本质使用的是数据库操作,所以如何模拟数据库操作来模拟单元测试就是绕不开的问题了。 ### 模拟数据库操作 我们模拟数据库操作其实有多种方式,有的人直接创建一个测试的MySQL数据库,将所有操作在MySQL数据库中进行;也有的人使用一些第三方库,比如 [go-sqlmock](https://github.com/DATA-DOG/go-sqlmock),将所有的数据库操作都进行mock。 但是这两种方式都有一些弊端,第一种需要单独搭建一个测试MySQL,MySQL搭建在哪里、使用什么镜像,又有很多需要讨论的点;第二种,则需要单独使用sqlmock来写一些mock的代码,增加了代码量。 hade的模拟数据库操作,我们使用一个更为巧妙的方式:**使用SQLite驱动,并且将SQLite数据在内存中进行操作,来模拟MySQL的操作**。 因为SQLite数据库和MySQL数据库的操作基本上是一样的,我们对SQLite的操作能等同于对MySQL进行操作,而且SQLite还有一个非常好的功能,只要将DSN设置为"file::memory:?cache=shared",就能将SQLite的数据库保存在内存中。当然保存在内存中的代价就是,进程结束,这个内存也就消失了。但是这个对于跑一次的单元测试来说并没有什么影响。 那么具体怎么操作呢? 我们先把config/testing/database.yaml 配置一下: ```go driver: sqlite # 连接驱动 dsn: "file::memory:?cache=shared" ``` driver代表使用SQLite驱动,dsn代表在内存中创建一个数据库来提供给ORM进行操作。 然后在测试用例中,我们正常使用hade封装的Gorm就可以了。 ```go ormService := container.MustMake(contract.ORMKey).(contract.ORMService) db, err := ormService.GetDB(orm.WithGormConfig(func(options *contract.DBConfig) { options.DisableForeignKeyConstraintWhenMigrating = true })) // 创建问题1 { question1.AuthorID = user1.ID err := qaService.PostQuestion(ctx, question1) So(err, ShouldBeNil) question1, err = qaService.GetQuestion(ctx, question1.ID) So(err, ShouldBeNil) So(question1.CreatedAt, ShouldNotBeNil) } ``` 这样不仅省去了创建测试数据库的操作,也省去了写一些mock方法的代码量。 关于单元测试的断言,我们就使用一个第三方库[goconvey](https://github.com/smartystreets/goconvey),这个第三方库是SmartyStreets 公司开源的,目前有6.8k个star,使用的是自有[开源协议](https://github.com/smartystreets/goconvey/blob/master/LICENSE.md),协议说明是允许用户使用下载的。 goconvey是非常好用的一个单元测试的库,我现在的项目基本都是使用这个库来做单元测试的。它的好处有几个: 一是提供丰富的断言。比如: ```go So(err, ShouldBeNil) So(question1.CreatedAt, ShouldNotBeNil) So(q.Title, ShouldEqual, question1.Title) ``` 这种So系列,带上一个语义化的函数语法ShouldNotBeNil / ShouldBeNil / ShouldEqual,能让整个断言的判断可读性更高。 其次这个库有丰富的Web界面。看它提供的一个可视化的工具界面,我们可以使用这个工具快速启动一个小的、简单的测试用例结果库: ![图片](https://static001.geekbang.org/resource/image/c4/cf/c4fb6f284c1514d3121dfd0a9d11dacf.png?wh=1920x972) 在这个页面中,还有一个编写测试逻辑自动生成测试代码的功能: ![图片](https://static001.geekbang.org/resource/image/37/63/375d2d9fde205eyy74614d0216b8fb63.png?wh=1920x777) 上图,我们在左侧使用中文输入测试逻辑,在右侧就能生成我们的测试代码。 当然这个测试代码只有框架,没有具体的逻辑,后续只需要将这个测试代码直接拷贝进入我们的单元测试app/provider/qa/service\_test.go中,然后再一个个填充其中的测试方法,就可以了: ```go Convey("创建问题1", func() { question1 = &Question{ Title: "question1", Context: "this is context", AnswerNum: 0, } question1.AuthorID = user1.ID err := qaService.PostQuestion(ctx, question1) So(err, ShouldBeNil) question1, err = qaService.GetQuestion(ctx, question1.ID) So(err, ShouldBeNil) So(question1.CreatedAt, ShouldNotBeNil) // 创建问题2 Convey("创建问题2", func() { question2 = &Question{ Title: "question2", Context: "this is context", AnswerNum: 0, } question2.AuthorID = user2.ID err := qaService.PostQuestion(ctx, question2) So(err, ShouldBeNil) question2, err = qaService.GetQuestion(ctx, question2.ID) So(err, ShouldBeNil) Convey("获取问题1", func() { q, err := qaService.GetQuestion(ctx, question1.ID) So(err, ShouldBeNil) So(q.Title, ShouldEqual, question1.Title) }) ``` 同时这个Web控制台是实时更新的,我们在编写测试用例的时候,每次保存结束之后,测试结果页面就会同步刷新: ![图片](https://static001.geekbang.org/resource/image/17/3d/179afff268f318f65d59dd7178a9223d.png?wh=1920x859) 还可以通过控制台直接查看我们的代码覆盖率: ![图片](https://static001.geekbang.org/resource/image/46/5d/462914581ed0552c40f579c2be05205d.png?wh=1920x1388) 总而言之goconvey是一个非常好用的第三方库,强烈推荐你在后续的项目中使用这个第三方库进行单元测试。 好我们理解了如何mock数据库,如何使用goconvey,那么问答服务14个接口的单元测试编写逻辑就不是什么难事了,剩下的都是工作量,具体的代码就不在这里展开,可以参考GitHub上的这个[service\_test.go](https://github.com/gohade/bbs/blob/geekbang/34/app/provider/qa/service_test.go) 代码。 编写好单元测试之后,我们的后端问答服务具体实现就完成了。讲到这里,后端的的四个开发步骤也就都完成了。下面我们来看下前端Vue开发。 ### 前端Vue开发 前端的Vue开发我们要实现的业务在上一节课也梳理过了,一共4个页面: * 问题创建页 * 问题列表页 * 问题详情页 * 问题更新页 我们拿比较复杂的“问题详情页”来描述一下。 ![图片](https://static001.geekbang.org/resource/image/21/74/213ea5ed1c71574090e472ff90bb3374.png?wh=1222x1844) **这里其实用了富文本编辑器的两个形式**,一个是富文本编辑器的编辑形式,就是最下方“我来回答”的这个输入框;另外一个是富文本编辑器的查看形式,就是上方问题和所有回答的内容部分。 Vue的组件开发中最复杂的就是这个富文本编辑器了。 富文本编辑器,我们使用第三方组件 [toast-ui-editor](https://github.com/nhn/tui.editor),这个组件库提供的viewer模式和editor模式能满足我们编辑和展示的需求。 首先需要在package.json中引入这个组件库: ```go "@toast-ui/vue-editor": "^3.1.1", ``` 然后在需要的页面组件,就是这里详情页的组件中import引入组件库: ```javascript import { Viewer} from '@toast-ui/vue-editor'; import { Editor } from '@toast-ui/vue-editor'; export default { components: { viewer: Viewer, editor: Editor, }, ``` 在需要使用toast-ui-editor viewer模式的地方,直接使用viewer标签来展示内容: ```javascript ``` 这里的answer.context,就是我们从后端接口中返回来的回答内容,这个内容是支持带有HTML标签的富文本的。 而在需要富文本编辑的回答框中,我们使用editor标签来放置一个编辑器: ```javascript ``` 注意一下标签的几个属性。:options属性代表这个编辑器的所有属性,它的属性值editorOptions,我们在组件的data数据中进行设置,比如编辑器高度、编辑器头部的编辑功能有哪些等等: ```javascript editorOptions: { minHeight: '200px', language: 'en-US', useCommandShortcut: true, usageStatistics: true, hideModeSwitch: true, toolbarItems: [ ['heading', 'bold', 'italic', 'strike'], ['hr', 'quote'], ['ul', 'ol', 'task', 'indent', 'outdent'], ['table', 'link'], ['code', 'codeblock'], ['scrollSync'], ] } ``` 这些设置项都可以在[官网](https://www.npmjs.com/package/@toast-ui/editor)查到说明。 掌握了如何使用toast-ui-editor作为富文本编辑器之后,前端页面的开发逻辑就并不复杂了。先使用element-UI搭建页面框架: ```plain