671 lines
27 KiB
Markdown
671 lines
27 KiB
Markdown
# 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
|
||
<viewer ref="answerViewer" :initialValue="answer.content" />
|
||
|
||
```
|
||
|
||
这里的answer.context,就是我们从后端接口中返回来的回答内容,这个内容是支持带有HTML标签的富文本的。
|
||
|
||
而在需要富文本编辑的回答框中,我们使用editor标签来放置一个编辑器:
|
||
|
||
```javascript
|
||
<editor :options="editorOptions"
|
||
:initialValue = "answerContext"
|
||
initialEditType="markdown"
|
||
ref="toastuiEditor"
|
||
previewStyle="vertical" />
|
||
|
||
```
|
||
|
||
注意一下标签的几个属性。: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
|
||
<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来获取后端数据:
|
||
|
||
```javascript
|
||
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来提交回答数据:
|
||
|
||
```javascript
|
||
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](https://github.com/gohade/bbs/tree/geekbang/34)分支。
|
||
|
||
开发完前端和后端,别忘记使用 `./bbs dev all` 开启前后端联调模式,进行前后端联调。
|
||
|
||
这节课我们开发了问答模块的前端和后端,所有的代码都放在 [geekbang/34](https://github.com/gohade/bbs/tree/geekbang/34)分支,你可以对比查看。
|
||
|
||
![图片](https://static001.geekbang.org/resource/image/5b/51/5bd13d9f475bef425de2b21303265451.jpg?wh=1215x714)
|
||
|
||
## 小结
|
||
|
||
我们开发了问答模块的前端和后端,开发的流程,基本上和用户模块是一致的。只是其中有一些特殊的地方要注意掌握,比如,如何使用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库好的地方有哪些么?
|
||
|
||
欢迎在留言区分享你的学习笔记。感谢你的收听,如果你觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。
|
||
|