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.

27 KiB

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系列方法能方便解析参数的时候并且验证参数

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 代表更新问题, 只会对比其中的contexttitle两个字段其他字段不会对比
   // 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这个接口的逻辑会比其他逻辑复杂一些。

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

再看一下对多个回答加载回答作者的方法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。

但是这两种方式都有一些弊端第一种需要单独搭建一个测试MySQLMySQL搭建在哪里、使用什么镜像又有很多需要讨论的点第二种则需要单独使用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库好的地方有哪些么

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