你好,我是轩脉刃。
上节课我们已经完成了问答业务的一部分开发,主要是两部分,前后端接口设计,把接口的输出、输入以swagger-UI的形式表现;以及后端问答服务的接口设计,一共14个接口。这节课我们就继续完成问答的业务开发。
还是先划一下今天的重点,我们先使用前面定义的问答服务协议接口,来完成业务模块的接口开发,验证问答服务的协议接口是否满足需求,然后再实现我们的问答服务协议。不过因为这次问答服务实现的接口比较多,0 bug有一定难度,所以会为问答服务写一下单元测试,希望你重点掌握。
最后,我们实现前端的Vue页面,同样,由于前端页面的编写不是课程重点,还是挑重点的实现难点解说一下。
下面开始我们今天的实战吧。
上一节课定义好了问答服务的14个接口,可以使用这14个接口来实现业务模块了。我们的业务模块接口有七个接口需要开发:
这里就挑选关于问题的两个复杂一点的接口来具体说明一下,问题创建接口/question/create、问题详情接口 /question/detail 。
问题创建接口,为post方法,上一节课我们已经定义了它的参数结构questionCreateParam。那么要实现这个接口的剩余步骤就是:
解析参数我们在用户模块说过了,使用Gorm的Bind系列方法,能方便解析参数的时候并且验证参数:
param := &questionCreateParam{}
if err := c.ShouldBind(param); err != nil {
c.ISetStatus(400).IText(err.Error())
return
}
获取登录用户信息,也使用在用户模块实现的中间件。
这个中间件,我们在第32章的课后问题中布置给你作为课后作业,这里也说一下答案。对于需要用户登录才能操作的接口,我们都让这些接口通过一个中间件Auth。在Auth中,验证前端传递的cookie,从cookie中获取到token,然后通过token去缓存中获取用户信息。
// 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中间件中了,能保证“同类”逻辑封装在一个模块或者一个文件中,不管是从代码优雅角度,还是查找追查问题角度都很方便,这个算是一个编程经验吧。
// 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。
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
}
最后,使用链式调用方法返回操作成功:
c.ISetOkStatus().IText("操作成功")
问题详情接口,可能是所有接口里面使用qa服务最多的业务接口了,我们单独拿出来梳理一下逻辑,分为六个步骤:
不过步骤多一点,实现倒不复杂,第二步到第五步,分别使用了qaService的 GetQuestion、QuestionLoadAuthor、QuestionLoadAnswers、AnswersLoadAuthor,代码如下:
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 分支。
简单回顾一下上节课定义的qa服务的14个后端服务协议:
// 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,这个接口的逻辑会比其他逻辑复杂一些。
func (q *QaService) PostAnswer(ctx context.Context, answer *Answer) error
可以看到,它的参数就是一个answer结构,但是我们仔细想想,并不是简单将answer表创建一条新数据就行的,还需要做一个事情,将这个回答所归属问题的回答数+1。所以这里有一个查询问题。
我们可以把在answer表创建一条新数据的逻辑,和增加问题回答数的逻辑放在一起。这里会有多次去数据库的操作,需要将它们封成一个事务来做,否则的话,就会出现比如创建回答了,但是问题回答数没有+1;或者两个事务都对一个问题回答数操作,出现脏数据。
所以需要使用Gorm的事务函数Transaction,将这个函数内的所有数据库操作封装起来。
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:
func (q *QaService) AnswersLoadAuthor(ctx context.Context, answers *[]*Answer) error
参数中传递了带有回答ID的answers结构,所以我们需要将ID全部查询出来。
也就是说需要在一个指针数组中,将某个字段查询出来,并且做成数组,怎么做?这里我使用的是第三方库 collection,我之前开源的一个作品,目前有560+的star,基于 Apache 协议开源。这个库最大的特点就是对“数组”进行一些特殊操作。
比如这里的,将一个数组指针的ID字段获取出来,组装成一个int64数组,就可以这么用:
answerColl := collection.NewObjPointCollection(*answers)
ids, err := answerColl.Pluck("ID").ToInt64s()
先New一个指针数组,然后使用Pluck,将某个字段获取出来,再使用 toInt64s 将获取出来的数组转位 []int64 数组。
回到Answer结构,ID已经获取了,怎么查找对应作者呢?
还记得吗,上一节课我们已经在Answer结构中,设置了Answer和User结构的Belongs To关系,所以这里可以直接使用Preload方法,加载Answers中的Author字段:
q.ormDB.WithContext(ctx).Preload("Author").Order("created_at desc").Find(answers, ids)
直接使用Preload,是不是比每个answer foreach来的更为方便了?
了解了这两个方法中难点的事务使用方式和Preload的使用方式,其他的12个协议接口的实现,基本上,逻辑就和这两个方法差不多了,你可以去GitHub上的 geekbang/34 上的代码比对查看。
问答服务有14个协议接口这么多,我们编写好这些协议接口之后,是很有可能有错误的,这个时候,我们会非常希望写一下单元测试来验证一下这些服务。那么对于hade框架的服务,怎么编写单元测试呢?我们一起做一下。
首先,要知道hade框架最核心的就是一个服务容器container。所以我们需要创建一个单元测试使用的container。框架的test/env.go中,我写好了初始化服务容器的函数InitBaseContainer:
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中,我们将如下的服务绑定到这个容器中:
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,将所有的数据库操作都进行mock。
但是这两种方式都有一些弊端,第一种需要单独搭建一个测试MySQL,MySQL搭建在哪里、使用什么镜像,又有很多需要讨论的点;第二种,则需要单独使用sqlmock来写一些mock的代码,增加了代码量。
hade的模拟数据库操作,我们使用一个更为巧妙的方式:使用SQLite驱动,并且将SQLite数据在内存中进行操作,来模拟MySQL的操作。
因为SQLite数据库和MySQL数据库的操作基本上是一样的,我们对SQLite的操作能等同于对MySQL进行操作,而且SQLite还有一个非常好的功能,只要将DSN设置为"file::memory:?cache=shared",就能将SQLite的数据库保存在内存中。当然保存在内存中的代价就是,进程结束,这个内存也就消失了。但是这个对于跑一次的单元测试来说并没有什么影响。
那么具体怎么操作呢?
我们先把config/testing/database.yaml 配置一下:
driver: sqlite # 连接驱动
dsn: "file::memory:?cache=shared"
driver代表使用SQLite驱动,dsn代表在内存中创建一个数据库来提供给ORM进行操作。
然后在测试用例中,我们正常使用hade封装的Gorm就可以了。
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,这个第三方库是SmartyStreets 公司开源的,目前有6.8k个star,使用的是自有开源协议,协议说明是允许用户使用下载的。
goconvey是非常好用的一个单元测试的库,我现在的项目基本都是使用这个库来做单元测试的。它的好处有几个:
一是提供丰富的断言。比如:
So(err, ShouldBeNil)
So(question1.CreatedAt, ShouldNotBeNil)
So(q.Title, ShouldEqual, question1.Title)
这种So系列,带上一个语义化的函数语法ShouldNotBeNil / ShouldBeNil / ShouldEqual,能让整个断言的判断可读性更高。
其次这个库有丰富的Web界面。看它提供的一个可视化的工具界面,我们可以使用这个工具快速启动一个小的、简单的测试用例结果库:
在这个页面中,还有一个编写测试逻辑自动生成测试代码的功能:
上图,我们在左侧使用中文输入测试逻辑,在右侧就能生成我们的测试代码。
当然这个测试代码只有框架,没有具体的逻辑,后续只需要将这个测试代码直接拷贝进入我们的单元测试app/provider/qa/service_test.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控制台是实时更新的,我们在编写测试用例的时候,每次保存结束之后,测试结果页面就会同步刷新:
还可以通过控制台直接查看我们的代码覆盖率:
总而言之goconvey是一个非常好用的第三方库,强烈推荐你在后续的项目中使用这个第三方库进行单元测试。
好我们理解了如何mock数据库,如何使用goconvey,那么问答服务14个接口的单元测试编写逻辑就不是什么难事了,剩下的都是工作量,具体的代码就不在这里展开,可以参考GitHub上的这个service_test.go 代码。
编写好单元测试之后,我们的后端问答服务具体实现就完成了。讲到这里,后端的的四个开发步骤也就都完成了。下面我们来看下前端Vue开发。
前端的Vue开发我们要实现的业务在上一节课也梳理过了,一共4个页面:
我们拿比较复杂的“问题详情页”来描述一下。
这里其实用了富文本编辑器的两个形式,一个是富文本编辑器的编辑形式,就是最下方“我来回答”的这个输入框;另外一个是富文本编辑器的查看形式,就是上方问题和所有回答的内容部分。
Vue的组件开发中最复杂的就是这个富文本编辑器了。
富文本编辑器,我们使用第三方组件 toast-ui-editor,这个组件库提供的viewer模式和editor模式能满足我们编辑和展示的需求。
首先需要在package.json中引入这个组件库:
"@toast-ui/vue-editor": "^3.1.1",
然后在需要的页面组件,就是这里详情页的组件中import引入组件库:
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标签来展示内容:
<viewer ref="answerViewer" :initialValue="answer.content" />
这里的answer.context,就是我们从后端接口中返回来的回答内容,这个内容是支持带有HTML标签的富文本的。
而在需要富文本编辑的回答框中,我们使用editor标签来放置一个编辑器:
<editor :options="editorOptions"
:initialValue = "answerContext"
initialEditType="markdown"
ref="toastuiEditor"
previewStyle="vertical" />
注意一下标签的几个属性。:options属性代表这个编辑器的所有属性,它的属性值editorOptions,我们在组件的data数据中进行设置,比如编辑器高度、编辑器头部的编辑功能有哪些等等:
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'],
]
}
这些设置项都可以在官网查到说明。
掌握了如何使用toast-ui-editor作为富文本编辑器之后,前端页面的开发逻辑就并不复杂了。先使用element-UI搭建页面框架:
<template>
<el-row type="flex" justify="center" align="middle">
<el-col :span="8">
<el-card v-if="question" class="box-card" shadow="never">
...
<div>
<viewer ref="questionViewer" :options="questionViewerOptions" :initialValue="question.context" />
</div>
</el-card>
...
在页面加载的时候,调用/question/detail来获取后端数据:
methods: {
getDetail: function (id) {
const that = this
this.id = id
// 调用后端接口
request({
url: "/question/detail",
method: 'GET',
params: {
"id": id
}
}).then(function (response) {
that.question = response.data;
})
},
在提交回答的时候,触发/answer/create来提交回答数据:
postAnswer: function () {
// 获取富文本编辑器内容
let html = this.$refs.toastuiEditor.$data.editor.getHTML()
this.answerContext = html
const that = this
// 调用后端接口
request({
method: 'POST',
url: "/answer/create",
data: {
"question_id": that.id,
"context": that.answerContext,
},
}).then(function () {
that.$router.go(0)
})
},
更多代码可以参考 GitHub 上的geekbang/34分支。
开发完前端和后端,别忘记使用 ./bbs dev all
开启前后端联调模式,进行前后端联调。
这节课我们开发了问答模块的前端和后端,所有的代码都放在 geekbang/34分支,你可以对比查看。
我们开发了问答模块的前端和后端,开发的流程,基本上和用户模块是一致的。只是其中有一些特殊的地方要注意掌握,比如,如何使用Gorm的预加载功能、如何在单元测试里面用SQLite mock SQL操作、如何使用容器做hade的单元测试、如何使用goconvey做测试框架、如何使用toast-ui-editor做富文本编辑器。这些都是我们问答模块实现的重点和难点。
到这里课程主体就要结束了。因为是Web框架的搭建课,所以这个课程除了专栏的文字之外,还有很大一部分在GitHub上,也就是代码。我们一共完成了两个项目,一个是hade框架项目,一个是类知乎问答网站 bbs,都是开源项目,每个项目也都保留着按章节的演进步骤和代码。
当然,专栏的文字不能穷尽所有知识点,有一些代码的实现细节,需要你自己在动手写的时候才能发现,如果有疑惑,不妨去GitHub上对比代码进行查看。非常欢迎你把这两个项目作为自己学习Golang的第一个开源项目,提交并合并你的修改。
看GitHub上的代码,bbs项目中的error,我使用的并不是官方的error库,而是github.com/pkg/errors 库。这个库,使用方式和标准的error库是一样的,但是它有很多额外的好处,你可以研究一下这个第三方error库,并且描述下它比官方error库好的地方有哪些么?
欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。