你好,我是轩脉刃。

上节课我们已经完成了问答业务的一部分开发,主要是两部分,前后端接口设计,把接口的输出、输入以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服务最多的业务接口了,我们单独拿出来梳理一下逻辑,分为六个步骤:

  1. 解析参数
  2. 获取问题详情
  3. 加载问题作者
  4. 加载问题答案
  5. 问题答案加载答案作者
  6. 返回数据

不过步骤多一点,实现倒不复杂,第二步到第五步,分别使用了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开发

前端的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库好的地方有哪些么?

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