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.

290 lines
20 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 31通用模块用户模块开发
你好,我是轩脉刃。
上一节课分析了一下问答网站的需求,并且搭建起来了前端和后端框架,我们就来填充这个网站的具体内容。今天要做的是用户模块的开发。用户模块基本是所有系统的基础模块,所以如何开发设计用户模块,希望你一定好好掌握。
## 模块设计
简单回顾一下用户模块的需求分析,分为两个部分,用户注册和用户登录。我们先细化一下每个部分并且定义好它们的接口。
### 用户注册
首先是用户注册,它的时序图再放一下:
![](https://static001.geekbang.org/resource/image/4b/12/4b26edc5fa6177ab07113d542cfdda12.png?wh=1362x1216)
其中包含两个页面,第一个页面是预注册过程页面,用户在这个页面中输入用户名、密码、邮箱。这个页面的路径我们设置为/register。
![图片](https://static001.geekbang.org/resource/image/53/b3/53175c922fef8a06f0186b369b54abb3.png?wh=1920x674)
输入用户名密码之后它会发送一个邮件到用户的邮箱在这个邮件中会带着一个确认注册的链接只有通过点击这个链接你才算完成验证。发送邮件的邮箱我使用自己注册的一个126邮箱最终邮件内容效果是这样的
![图片](https://static001.geekbang.org/resource/image/ca/cd/ca9872f2c4dc901337956539bab71dcd.png?wh=1200x324)
用户点击/user/register/verify 链接之后,才算正式注册完毕,接着会引导用户进入登录流程。
所以我们梳理一下,预注册这个过程前端和后端一共进行了两次交互,也就是说需要两个接口。
* /user/register 预注册接口
用户在这个接口中带着用户名、密码和邮箱到后端。一般这种注册类接口我们会选择使用POST方法它的参数为 username、password、email 三个字段。
前端请求这个接口后,后端的行为应该是将用户的信息存储在临时缓存中,并且向用户注册的邮箱发送一封邮件。如果接口请求正确,返回值直接可以是一个文本字符,提示“注册成功,请前往邮箱查看邮件”。
* /user/register/verify 注册验证接口
这个接口是一个在邮件中的链接它的参数为token、请求方法为GET用户点击链接就访问了这个接口。一旦验证成功就引导用户跳转到登录页面所以它返回的状态码为重定向301。
### 用户登录
梳理完用户注册的页面和接口,我们来明确下用户登录的逻辑和接口。
![](https://static001.geekbang.org/resource/image/41/c9/414d876c07f4ef55edeb1047c09e43c9.png?wh=1140x1166)
还是看着用户登录时序图分析,在登录页面,用户输入注册时填写的用户名和密码,登录进入问题列表页。
![图片](https://static001.geekbang.org/resource/image/94/6d/94e7cbfb5313cf7abbe6d91222e15d6d.png?wh=1920x585)
而在问题列表页的头部,右上角有个登出按钮,这个登出按钮能控制用户进行登出操作。
![图片](https://static001.geekbang.org/resource/image/a9/c0/a9fd4cfce64ac76a15b72819e925bbc0.png?wh=1920x674)
所以用户模块的登录部分实际上要提供两个接口,登录接口和登出接口。
* /user/login 登录接口
在这个登录接口参数为用户登录所需要的用户名username、密码password。同注册一样这种对服务端请求的接口我们使用POST方法。
登录接口成功之后服务端会生成一个代表用户登录态的token在缓存中将这个token和用户信息进行关联然后将token返回给前端。所以登录接口的返回值为字符串token。
之后就是上一节课分析过的前端获取到token之后将token种到cookie中后续所有需要登录的请求都要带上这个cookie而服务端对每个请求都会验证这个带有token的cookie。
* /user/logout 登出接口
登出接口并不需要任何的参数,但是它需要用户以登录态来进行这个请求操作。
怎么判断是登录态呢服务端可以从cookie中获取这个用户的token并且根据这个token获取到用户信息验证用户信息正确就说明处于登录中。**所以可以在验证后将缓存中的token信息删除让这个token实际上失效**后续用户必须再次进行登录操作获取一个新的token了这样就完成了登出的效果。
所以这个接口并没有请求参数由于触发它的是一个链接所以选用GET方法返回值就是一个字符串告诉用户登出成功/失败即可。
好,梳理一下关于用户模块的需求和接口。我们一共要实现这么四个接口:/user/register 预注册接口、/user/register/verify 注册验证接口、/user/login 登录接口、/user/logout 登出接口。
## 后端开发
明确了要实现的四个接口,也明确了各个接口的参数和返回值。下面正式开始我们的后端开发之路。后端开发这块,我们按照这样的步骤:
1. 接口swagger化
2. 定义用户服务协议
3. 开发模块接口
4. 实现用户服务协议
这种开发流程每一步都有具体意义。
由于在实际工作中前端和后端一般是并行开发的所以前后端都是通过接口来进行交互的。而前面分析明确的所有接口和参数都是在我们的“脑海”中需要有一个界面将这些接口和参数都展示出来。这个时候就自然想到了hade框架集成的swagger所以第一步我们先将接口swagger化让前后端并行开发成为可行。
其次一切皆服务我们的用户模块也对应一个用户服务。这个用户服务其实不仅仅给用户模块使用后续qa模块也会使用到。所以这个用户服务提供哪些服务协议给上层业务模块呢这个是第二步要思考。
定义好用户服务提供的协议之后我们如何确定这个用户服务协议是能满足要求的呢这就是第三步。如果可以满足我们的模块接口需求那么这个用户服务协议是ok的就可以直接使用这些用户协议来开发具体模块接口了。
最后一步才是最终实现只需要使用各种其他服务比如ORM、cache等来实现我们的用户协议最终就完成了整个后端开发。
### 接口swagger
按照步骤先实现接口的swagger化回忆一下[第23章](https://time.geekbang.org/column/article/435582)讲集成swagger自动生成文件来管理接口我们着手编写可以生成swagger-UI的swagger注释。
首先修改 app/http/swagger.go 文件这个文件以注释形式说明swagger概览包括基础调用地址、接口版本等。所以这个swagger.go文件我们只需要增加关于这些信息的注释即可格式你可以参考swaggo的[官方文档](https://github.com/swaggo/swag#how-to-use-it-with-gin)。
```go
// Package http API.
// @title hadecast
// @version 1.0
// @description hade论坛
// @termsOfService https://github.com/swaggo/swag
// @contact.name jianfengye
// @contact.email jianfengye
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /
...
```
它最终对应的就是swagger-UI的头部
![图片](https://static001.geekbang.org/resource/image/f5/f3/f5bc6d57ee3b8c7f4afba019d9418df3.png?wh=1920x520)
接着我们按照上节课的设计在接口和返回对象的app/module/user目录中接口不全放在一个文件里而是一个接口用一个文件存放。那就要定义四个文件分别存放这四个接口
* app/http/module/user/api\_register.go
* app/http/module/user/api\_verify.go
* app/http/module/user/api\_login.go
* app/http/module/user/api\_logout.go
然后定义好四个接口来实现具体的方法。关键是在方法的头部,要以[swaggo](https://github.com/swaggo/swag)的规范填写对这个接口的请求和返回值要求。这里我们就以参数稍微复杂一点的api\_register为例其他接口的实现大同小异你可以参考GitHub上的代码。
```go
type registerParam struct {
UserName string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,gte=6"`
Email string `json:"email" binding:"required,gte=6"`
}
// Register 注册
// @Summary 用户注册
// @Description 用户注册接口
// @Accept json
// @Produce json
// @Tags user
// @Param registerParam body registerParam true "注册参数"
// @Success 200 {string} Message "注册成功"
// @Router /user/register [post]
func (api *UserApi) Register(c *gin.Context) {
// 验证参数
userService := c.MustMake(provider.UserKey).(provider.Service)
logger := c.MustMake(contract.LogKey).(contract.Log)
param := &registerParam{}
if err := c.ShouldBind(param); err != nil {
c.ISetStatus(404).IText("参数错误"); return
}
...
}
```
按照前面分析的Register接口是POST请求请求参数放在Body体中有三个字段username、password、email。 所以我们定义一个registerParam结构来解析这些请求参数并且使用Gin框架自带的c.ShouldBind 方法把请求Body体中的JSON结构解析为 registerParam结构。
这里的ShouldBind方法我们第一次遇到稍微介绍一下。
ShouldBind方法是Gin框架提供的一个很好用的参数绑定函数**它不仅能将请求体直接绑定到一个数据结构中,还能根据标签,对每个字段进行参数验证**。比如我们希望Password是必须要填写的且字段长度必须大于6个字符。那么在定义结构的时候我可以用tag的形式标记这个规则就像这样
```go
Password string `json:"password" binding:"required,gte=6"`
```
当然绑定tag的写法是要按照Gin的绑定规则来填写的更多的绑定规则你可以参考[官网说明](https://gin-gonic.com/zh-cn/docs/examples/binding-and-validation)。
定义好了参数结构registerParam我们把注意力放到函数的swagger注释中。在Register的注释中使用了这些关键字
* Summary 接口简要说明
* Description 接口详细说明
* Accept 接口接受的请求格式
* Produce 接口返回的response格式
* Tag 接口的标签,便于归类
* Param 接口参数
* Success 接口成功返回的返回值
* Router 接口路由说明
注释的详细写法你需要参考[说明文档](https://github.com/swaggo/swag#declarative-comments-format)。这里还是看下最复杂的Param接口参数说明
```go
// @Param registerParam body registerParam true "注册参数"
```
这句话告诉swag我们的接口参数名称为registerParam它是一个数据结构存储在Body体中参数是必须要填写的参数说明为“注册参数”。
这样register接口的参数定义就完成了根据23章讲过的使用swagger的方法我们通过命令
`./bbs swagger gen` 来生成swagger文件
![图片](https://static001.geekbang.org/resource/image/a2/81/a286446be2ebd484821a722377879581.png?wh=1772x392)
将生成的文件编译进入项目中, `./bbs build self`
![图片](https://static001.geekbang.org/resource/image/10/5a/10dabd4e1560e8bf851ba2f25d47285a.png?wh=1082x106)
然后你可以运行命令 `./bbs app start` 启动App或者选择调试模式 `./bbs dev backend` 来启动swagger-UI这里因为我们还是在调试状态所以选择启动调试模式
![图片](https://static001.geekbang.org/resource/image/02/f3/02f16f255bcec434ddecf764e663acf3.png?wh=1094x344)
打开浏览器 [http://127.0.0.1:8070/swagger/index.html](http://127.0.0.1:8070/swagger/index.htMl)我们就能看到根据接口注释生成的swagger-UI界面了并且后续可以通过这个界面直接调用接口请求来进行调试
![图片](https://static001.geekbang.org/resource/image/74/80/74f8366d6aeba29e8df198488e808c80.png?wh=1920x1030)
### 用户服务设计 - 模型
定义好接口开始定义用户服务了。上节课介绍过了根据一切皆服务的思想我们已经创建好了用户模块服务放在app/provider/user目录中。下面的关键是定义这个用户服务提供哪些服务。
首先,用户服务,要不要有一个用户结构来代表用户?是需要有的,我们在不同服务之间传递“用户”的实例,包含用户名、密码、邮箱等信息,后续也有可能扩展更多信息,所以**不论是为了服务传递的便捷,还是后续扩展的便捷,都有必要将用户信息封装为一个用户结构**。
所以我们在app/provider/user/contract.go中定义一个User结构来表示一个用户这个user结构中首先有ID字段代表用户的唯一标识其次有注册的用户名UserName、密码Password、邮箱Email、创建时间CreatedAt并且由于用户注册和登录的时候会频繁使用token我们再定义一个字符串Token字段来存储注册token或者登录token。
```go
// User 代表一个用户,注意这里的用户信息字段在不同接口和参数可能为空
type User struct {
ID int64 `gorm:"column:id;primaryKey"` // 代表用户id, 只有注册成功之后才有这个id唯一表示一个用户
UserName string `gorm:"column:username"`
Password string `gorm:"column:password"`
Email string `gorm:"column:email"`
CreatedAt time.Time `gorm:"column:created_at"`
Token string `gorm:"-"` // token 可以用作注册token或者登录token
}
```
不知道你注意到了没定义User结构的时候我**在tag标签里也增加了gorm的标签**意思是这个User对象同时也可以作为gorm操作的对象使用的。
不光是这里有点小设计其实我在app/http/module/user中还定义了一个User的相关对象
```go
// UserDTO 表示输出到外部的用户信息
type UserDTO struct {
ID int64
UserName string
CreatedAt time.Time
}
```
如果你没有太理解,因为这里涉及上一讲提到的分层“模型”的设计,我们来仔细思考一下。
首先不管分层逻辑是什么样,我们在实现一个业务模块的时候一般会有三种模型的需求。
**第一种模型是数据库模型**就是说这个这个模型对应数据库中的某个数据这个也就是我们在第25章提到的ORM的概念代码中的数据模型和数据表一一对应这样操作这个模型就相当于能操作数据表。这种模型我们也称之为持久化对象POPersistent Object表示对象与数据库这种持久化层一一映射。
但是数据库模型和数据表关联太紧密了,一旦数据表修改,我们的数据库模型也需要对应修改,所以就会需要**第二种模型领域模型DO**。就是我们在业务中对某个事物的理解和抽象。
DO模型不一定会依赖数据表的字段而是依赖于我们对于要建立的业务的抽象。有了这个模型我们在不同服务、不同模块之间的调用都直接基于这个领域模型就能保持各个模块对同一个事物的理解是一致的。
但是领域模型如果直接输出给用户比如Web前端用户有一些是需要进行加工的比如一些涉及安全的字段需要隐藏、一些字段类型需要转换等。所以输出给前端的是**第三种模型,输出模型**我们经常叫它DTO表示最终在网络上传输的数据对象。
这三种模型从底层到上层依次为PO、DO、DTO。不管我们的业务是如何分层基本上绕不开这三种模型的设计。
![](https://static001.geekbang.org/resource/image/5e/13/5e1f623yyd5f48fb73786e426c966913.jpg?wh=1920x1080)
估计从这段分析中你也能想到这三种模型之间是存在转换关系的它们的转换也是一个比较繁琐的过程比如DO转换为DTO记得吗我们还专门设计了一个映射层就是app/http/module/user/mapper.go中定义的各种映射方法。
那有没有节省代码量,快速开发的方法呢?有的,就是将这三种模型合并。
至于怎么合并,不同的业务有会有不同的选择了。有的人会选择将领域模型和输出模型合并,就是直接将领域模型输出给前端业务;也有的人选择将领域模型和数据库模型合并,在领域模型中增加数据库的操作字段;当然有更极致的设计将这三种模型统一合并,使用一个模型,不仅操作数据库,也操作前端返回值。
不同的合并策略各有好处和劣势。不过好处基本一样,能有效降低某些层的代码量,提升开发效率。**而缺点也差不多,就是合并之后,一旦合并的两层有不同的需求,修改起来可能就会非常复杂了**。比如将领域模型和输出模型合并后如果不断有需求要修改输出模型原先的name字段要修改为username或者原先输出的电话号码要将某些位置替换为\*号,又需要将其分离。
所以三层不同“模型”的优化,实际上需要你对业务有足够的理解,才能精准判断出大致哪几层合并后在“未来”不会有重构的需求。
比如这里我实际上创建了两个模型UserDTO 和 User也就是选择将数据库模型和领域模型合并。因为我觉得我们这个网站的数据库模型比较简单并且基本上和业务领域中的用户逻辑是一致的只有一两个字段会稍微有些不同。所以我有把握后续对“用户”这个业务逻辑的需求都不会影响数据库和业务领域的一致就做了这个选择。
但是提醒一下,如果在你的业务中某个逻辑比较复杂,比如“用户”这个逻辑基本上要由几个数据表才能得出一个完整的用户逻辑,那么这种选择就是错误的了。
另外坚持将UserDTO单独分开这也是我的经验之谈。**越接近前端,需求变化越频繁**,修改/增加/删除某个前端展示字段的需求在实际工作中比比皆是。所以这个输出层模型我一般习惯单独写出来。
好讲到这里相信你就可以理解在“用户服务”中定义模型User会同时带着gorm标签的含义了就是代表我会使用这个模型来操作数据库的。
后端开发剩下的两个步骤,我们下一节课再继续讲解。这节课所有的代码都上传到 [geekbang/31](https://github.com/gohade/bbs/tree/geekbang/31)分支了。你可以对比查看。
## 小结
分析用户模块的注册和登录两个部分后,我们开始开发后端了,但并不是一上手就开始接口逻辑编写,也不是一开始就考虑我应该用数据库还是缓存实现用户存储。而是依照四个步骤:
1. 接口swagger化
2. 定义用户服务协议
3. 开发模块接口
4. 实现用户服务协议
hade框架的整体都是不断在强调“协议优于实现”接口是后端和前端定义的协议用户服务是后端模块与模块之间定义的协议。对于一个业务最重要的是定义好、说明清楚这些协议然后才是实现好这些协议。希望hade框架带给你的不仅仅是工具和功能上的便利更多是开发思维和流程的转变。
### 思考题
看完关于“模型”的讨论,你一定会有很多观点想要交流,比如你的实际项目中,业务架构师是怎么分层的,在每一层是否有定义对应的模型,是否有合并不同的模型?具体业务中的模型设计方式你是否认可?如果你来重新设计一个模型设计,会怎么设计?
欢迎在留言区分享你的思考。感谢你的收听,如果觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。