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.

496 lines
22 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.

# 32通用模块用户模块开发
你好,我是轩脉刃。
上一节课我们设计好用户模块的需求后开始了后端开发。在后端开发中我们明确了开发流程的四个步骤先将接口swaggger化再定义用户服务协议接着开发模块接口最后实现用户服务协议。而且上节课已经完成了接口swagger化以及用户服务协议设计的模型部分。
这节课,我们就继续完成用户服务协议的定义,再开发模块接口和实现用户服务协议。
## 用户服务协议
前面我们设计好了一个模型User了“接口优于实现”来设计这个服务的接口看看要提供哪些能力。
首先用户服务一定要提供的是预注册能力所以提供了一个Register方法。预注册之后我们还要提供发送邮件的能力再提供一个发送邮件的接口SendRegisterMail。当然最后要提供一个确认注册用户的接口VerfityRegister。
在登录这块用户服务一定要提供登录、登出的接口Login和Logout。同时由于所有业务请求比如创建问题等逻辑我们需要使用token来获取用户信息所以我们也要提供验证登录的接口VerifyLogin。
于是整体的接口设计如下,详细信息都写在注释中了:
```go
// Service 用户相关的服务
type Service interface {
// Register 注册用户,注意这里只是将用户注册, 并没有激活, 需要调用
// 参数user必填usernamepassword, email
// 返回值: user 带上token
Register(ctx context.Context, user *User) (*User, error)
// SendRegisterMail 发送注册的邮件
// 参数user必填 username, password, email, token
SendRegisterMail(ctx context.Context, user *User) error
// VerifyRegister 注册用户,验证注册信息, 返回验证是否成功
VerifyRegister(ctx context.Context, token string) (bool, error)
// Login 登录相关使用用户名密码登录获取完成User信息
Login(ctx context.Context, user *User) (*User, error)
// Logout 登出
Logout(ctx context.Context, user *User) error
// VerifyLogin 登录验证
VerifyLogin(ctx context.Context, token string) (*User, error)
}
```
这里也说明一下,要抽象设计出一个服务模块的协议确实不是一件很简单的事情,也不一定能一次性设计好。
我们说过,**从“服务需要提供哪些对外能力”的角度来思考,会比较完善**。比如这里为什么要设计一个VerfityLogin能力呢系统对外提供的接口并没有这个服务但是在内部我们每次验证token的时候会需要用token来验证和换取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
我们还是拿其中比较复杂的注册接口api\_register.go做一下说明其他接口的实现没什么难点你可以参考GitHub上的代码。
注册接口我们要做几个事情?**首先验证接口参数,其次要进行预注册,然后发送预注册的验证邮件,最后返回成功状态**。
验证接口参数之前讲过,使用定义好的 registerParam结构和Gin带有的binding逻辑就可以做参数的获取和验证了。预注册的逻辑既然已经定义好了用户服务的预注册接口这里可以直接调用这个接口Register。同样发送验证邮件的接口我们也已经在用户服务中定义好了直接调用SendRegisterMail即可。最终返回成功状态我们使用hade框架对Gin扩展的IStatusOk。
```go
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(400).IText("参数错误"); return
}
// 注册对象
model := &provider.User{
UserName: param.UserName,
Password: param.Password,
Email: param.Email,
CreatedAt: time.Now(),
}
// 注册
userWithToken, err := userService.Register(c, model)
if err != nil {
logger.Error(c, err.Error(), map[string]interface{}{
"stack": fmt.Sprintf("%+v", err),
})
c.ISetStatus(500).IText(err.Error()); return
}
if userWithToken == nil {
c.ISetStatus(500).IText("注册失败"); return
}
if err := userService.SendRegisterMail(c, userWithToken); err != nil {
c.ISetStatus(500).IText("发送电子邮件失败"); return
}
c.ISetOkStatus().IText("注册成功,请前往邮箱查看邮件"); return
}
```
这里使用了之前我们对Gin框架扩展定义的Response结构中的链式方法
```go
c.ISetOkStatus().IText("注册成功,请前往邮箱查看邮件");
```
很明显这种方法确实比Gin框架自带的Response方法更为优雅轻便了。
实现好Register接口我们基本确认了之前设计的用户服务中注册部分是满足需求的。再一个个接口 Verify/Login/Logout 都实现一下,基本能确定之前用户服务的设计是可以的。
## 开发模块接口
既然我们已经确定了用户服务设计可行进入最后一步实现这些用户服务定义的协议方法。在实现中你能看到很多之前定义的各种服务的具体使用。用户注册的三个相关协议接口的实现我们详细说一下其他登录相关的接口协议你可以参考GitHub上的代码。
### 预注册协议接口
```go
// Register 注册用户,注意这里只是将用户注册, 并没有激活, 需要调用
// 参数user必填usernamepassword, email
// 返回值: user 带上token
Register(ctx context.Context, user *User) (*User, error)
```
预注册协议接口的具体实现要做几个事情:
1. 去数据库判断邮箱是否已经注册用户了,如果邮箱已经注册,那么这个预注册操作是不能执行的;
2. 去数据库判断用户名是否已经被注册了,如果用户名已经被注册了,那么预注册操作也是不能执行的;
3. 生成预注册的验证token
4. 将要注册的用户存储在缓存中存储1天待用户注册验证。
我们注意到四步操作里**前面两步是去数据库的查询操作所以可以使用hade框架的ORM服务**先从容器中获取ORM服务使用GetDB获取gorm.DB接着就可以使用gorm的Where、First 等方法了。由于之前已经定义好了User结构作为数据库模型所以我们直接使用这个模型
```go
// 判断邮箱是否已经注册了
ormService := u.container.MustMake(contract.ORMKey).(contract.ORMService)
db, err := ormService.GetDB()
if err != nil {
return nil, err
}
userDB := &User{}
if db.Where(&User{Email: user.Email}).First(userDB).Error != gorm.ErrRecordNotFound {
return nil, errors.New("邮箱已注册用户,不能重复注册")
}
if db.Where(&User{UserName: user.UserName}).First(userDB).Error != gorm.ErrRecordNotFound {
return nil, errors.New("用户名已经被注册,请换一个用户名")
}
```
而第三步生成token就使用一个简单的随机生成token的算法直接去一排字符串中随机获取下标来生成token。
```go
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
func genToken(n int) string {
b := make([]byte, n)
for i := range b {
// 这里是随机获取的
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return string(b)
}
```
最后一步需要将User对象存储到缓存中。我们使用key为"user:register:\[token\]"来存储User对象。
```go
// 将请求注册进入redis保存一天
cacheService := u.container.MustMake(contract.CacheKey).(contract.CacheService)
key := fmt.Sprintf("user:register:%v", user.Token)
if err := cacheService.SetObj(ctx, key, user, 24*time.Hour); err != nil {
return nil, err
}
return user, nil
```
但是你还记得吗,**在hade的Cache服务中如果直接使用SetObj和GetObj操作对象那么这个对象必须实现BinaryMarshaler和BinaryUnMarshaler**。所以我们再给User对象实现这两个接口。在app/provider/user/service.go中
```go
// MarshalBinary 实现BinaryMarshaler 接口
func (b *User) MarshalBinary() ([]byte, error) {
return json.Marshal(b)
}
// UnmarshalBinary 实现 BinaryUnMarshaler 接口
func (b *User) UnmarshalBinary(bt []byte) error {
return json.Unmarshal(bt, b)
}
```
于是用户服务的Register方法实现就写好了。在这个小小的方法中我们已经演示了之前定义的ORM服务、Cache服务所有的这些服务在服务容器中都可以得到。你可以具体感受一下容器加服务协议在具体的业务代码中带来的便利。
### 发送邮件协议接口
```go
// SendRegisterMail 发送注册的邮件
// 参数user必填 username, password, email, token
SendRegisterMail(ctx context.Context, user *User) error
```
发送邮件协议接口要实现的就是一个邮件发送的功能。在Golang中邮件发送功能也是有现成库的[gomail](https://github.com/go-gomail/gomail)。这个库目前已经有3.4k star了基于限制比较少的MIT协议。
发送电子邮件的方式其实有很多种但是我们最好使用SMTP的方式来发送邮件因为SMTP的服务提供方基本上都是在互联网上已经认证的服务提供商比如Gmail、126等。通过这些邮件服务提供商注册的SMTP账号发送邮件基本上不会进入对方邮箱的“垃圾箱”中。
不过所有邮件服务提供商的SMTP账号都需要单独申请但是基本都是免费的。这里是我使用126注册的邮箱申请了一个126的SMTP发送账号。
我们把SMTP的账号信息存储在配置文件config/development/app.yaml中
```yaml
domain: "http://hadecast.funaio.cn"
smtp:
host: "smtp.126.com"
port: 25
from: "jianfengye110@126.com"
username: "jianfengye110"
password: "123456"
```
下面来演示如何使用gomail来通过SMTP账号发送邮件我们直接看app/provider/user/service.go 的具体实现:
```go
func (u *UserService) SendRegisterMail(ctx context.Context, user *User) error {
logger := u.container.MustMake(contract.LogKey).(contract.Log)
configer := u.container.MustMake(contract.ConfigKey).(contract.Config)
// 配置服务中获取发送邮件需要的参数
host := configer.GetString("app.smtp.host")
port := configer.GetInt("app.smtp.port")
username := configer.GetString("app.smtp.username")
password := configer.GetString("app.smtp.password")
from := configer.GetString("app.smtp.from")
domain := configer.GetString("app.domain")
// 实例化gomail
d := gomail.NewDialer(host, port, username, password)
// 组装message
m := gomail.NewMessage()
m.SetHeader("From", from)
m.SetAddressHeader("To", user.Email, user.UserName)
m.SetHeader("Subject", "感谢您注册我们的hadecast")
link := fmt.Sprintf("%v/user/register/verify?token=%v", domain, user.Token)
m.SetBody("text/html", fmt.Sprintf("请点击下面的链接完成注册:%s", link))
// 发送电子邮件
if err := d.DialAndSend(m); err != nil {
logger.Error(ctx, "send email error", map[string]interface{}{
"err": err,
"message": m,
})
return err
}
return nil
}
```
首先通过hade的配置服务来获取SMTP的所有配置使用这些配置实例化一个gomail.Dialer对象然后创建邮件的内容内容的From和To分别代表发送方和接收方接收方自然就是我们的预注册用户填写的邮箱。将链接放在邮件的Body里面。组装好邮件内容之后我们使用DailAndSend 就可以直接发送一个邮件到预注册的用户的邮箱了。
在这个过程中,**我们会希望如果发送邮箱失败的话,使用日志记录一下发送失败的原因和内容**。这个是很有必要的因为后续如果希望有一些脚本能补发邮件这个日志就很有帮助了。所以使用hade定义的日志服务这里使用日志服务的Error方法来记录发送邮件错误信息。
不管配置服务还是日志服务,都是从服务容器中可以获取到。按照上述逻辑,发送邮件的接口就完成了。
### 注册验证协议接口
注册相关的最后一个协议接口是验证注册。
```go
// VerifyRegister 注册用户,验证注册信息, 返回验证是否成功
VerifyRegister(ctx context.Context, token string) (bool, error)
```
这个协议接口逻辑会复杂一些了。
它的参数为一个token我们首先要拿着这个token去缓存中获取到这个token对应的预注册用户。由于之前已经将User实现了BinaryUnmarshaler接口这里就使用缓存服务的GetObj方法
```go
//验证token
cacheService := u.container.MustMake(contract.CacheKey).(contract.CacheService)
key := fmt.Sprintf("user:register:%v", token)
user := &User{}
if err := cacheService.GetObj(ctx, key, user); err != nil {
return false, err
}
if user.Token != token {
return false, nil
}
```
然后下一步,**由于预注册和注册验证过程是异步的,中间数据库是有可能发生变化的**,所以我们需要再次验证一下这个用户在数据库中是否已经存在了,他的用户名和邮箱是否是唯一的。
```go
//验证邮箱,用户名的唯一
ormService := u.container.MustMake(contract.ORMKey).(contract.ORMService)
db, err := ormService.GetDB()
if err != nil {
return false, err
}
userDB := &User{}
if db.Where(&User{Email: user.Email}).First(userDB).Error != gorm.ErrRecordNotFound {
return false, errors.New("邮箱已注册用户,不能重复注册")
}
if db.Where(&User{UserName: user.UserName}).First(userDB).Error != gorm.ErrRecordNotFound {
return false, errors.New("用户名已经被注册,请换一个用户名")
}
```
最后准备将这个缓存中的用户存储进入数据库users表。这里我们知道缓存中预注册用户的密码是用户填写的真实密码。但是将真实密码直接存储进入数据库是一个非常不安全的做法。如果我们的数据库被黑客攻击拖库了这对我们的网站用户是个非常大的影响。所以这里我们**有必要对用户的密码做一次加密操作**。
Golang的 [golang.org/x/crypto/bcrypt](https://pkg.go.dev/golang.org/x/crypto/bcrypt) 库提供了对密码进行加密的标准方法。还记得这种golang.org/x/ 开头的库么可以说是Golang标准库的预备库我们可以直接放心使用。在这个库中提供了加密密码的方法 GenerateFromPassword 和验证密码的方法 CompareHashAndPassword 。
在这个函数中,我们就使用到加密密码的方法:
```go
// 验证成功将密码存储数据库之前需要加密,不能原文存储进入数据库
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.MinCost)
if err != nil {
return false, err
}
```
GenerateFromPassword 的第二个参数cost是表示加密密码的复杂度最小必须为MinCost。
最后一步就是将用户存储到数据库中了。同样使用的是Gorm其中有个Create方法能将对象保存进入数据库
```go
user.Password = string(hash)
// 具体在数据库创建用户
if err := db.Create(user).Error; err != nil {
return false, err
}
return true, nil
```
以上,就完成了注册实现的具体方法了。
## 后端调试
现在汇总两节课的成果我们完成了用户服务、用户模块接口以及swagger的搭建。之后就可以很方便地使用swagger来调试用户模块和用户服务了。
记得开启hade特有的调试模式 `./bbs dev backend`
![图片](https://static001.geekbang.org/resource/image/85/c6/8560931d5c87e56860fa87ca1e1b28c6.png?wh=1084x336)
打开浏览器 [http://localhost:8070/swagger/index.html](http://localhost:8070/swagger/index.html) 看到swagger-UI界面。点击要调试接口的 “try it out” 按钮进入接口调用填写要调用的接口参数点击“Execute” 调用接口,并且获取接口返回值。
![图片](https://static001.geekbang.org/resource/image/76/aa/765346a7f820dbfc8fa61408faffceaa.png?wh=1920x1111)
![图片](https://static001.geekbang.org/resource/image/2c/25/2cf2bb0168135e98f02fb84016590a25.png?wh=1920x1068)
如果接口调用错误我们要修改接口只需要直接在IDE上修改代码并且直接保存hade就会检测到文件更新并且重新编译重启服务立刻生效。
![图片](https://static001.geekbang.org/resource/image/9f/b1/9fa0d12a5548781869fd52041e080ab1.png?wh=1078x674)
## 前端开发
关于用户接口的前端开发部分,由于并不是我们课程的重点,就简要描述一下关键实现点。
前端一共就两个页面注册页面和登录页面所以我们在src/views/中创建两个文件夹register和login分别存储这两个页面。这里我主要也描述一下注册页面的具体实现。
![图片](https://static001.geekbang.org/resource/image/26/08/2669c8cdbbfd66df207e8b97f06ba508.png?wh=788x848)
在注册页面上实际上是搭建了一个表单在element-UI中我们可以使用el-form来方便搭建一个漂亮的表单 这个表单包含用户名、邮箱、密码等信息并且这些信息都对应script中的form数据。在表单的按钮按钮点击行为我们设置成触发submitFrom方法。在src/views/register/index.vue中
```plain
<el-form v-model="form" class="register-form">
<el-form-item >
<el-input v-model="form.username" placeholder="用户名" ></el-input>
</el-form-item>
<el-form-item >
<el-input v-model="form.email" placeholder="邮箱"></el-input>
</el-form-item>
<el-form-item >
<el-input
placeholder="密码"
type="password"
v-model="form.password"
></el-input>
</el-form-item>
<el-form-item >
<el-input
placeholder="确认密码"
type="password"
v-model="form.repassword"
></el-input>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
class="login-button"
type="primary"
native-type="submit"
@click="submitForm"
block
>注册</el-button>
</el-form-item>
</el-form>
```
```plain
<script>
export default {
name: "register",
data() {
return {
form: {
username: '', // 用户名
password: '', // 密码
email: '', // 邮箱
repassword: '' // 重复输入密码
},
loading: false,
};
},
```
对应的submitFrom方法就调用第30节课介绍的封装了axios库的request.js。我们使用request并且传递上面输入的form对象数据给后端如果请求返回成功就返回返回体中的成功信息。
```plain
methods: {
submitForm: function(e) {
if (this.form.repassword !== this.form.password) {
this.$message.error("两次输入密码不一致");
return;
}
const that = this;
request({
url: '/user/register',
method: 'post',
data: this.form
}).then(function (response) {
const msg = response.data
that.$message.success(msg);
})
}
}
```
注册界面的开发就完成了虽然逻辑比较简单但也是使用了前面介绍的几个前端组件vue、element-Ui、axios所以如果你看源码对这几个组件的使用有一些疑惑的话还是要研究一下每一个前端组件。
写完前端之后别忘记我们的hade模块的强大调试功能之一可以前后端同时调试。
使用命令 `./hade dev all` 开启前后端同时调试模式:
![图片](https://static001.geekbang.org/resource/image/94/08/9422ea8fc5a9fc0f33d2348cfc340308.png?wh=1015x733)
控制台可以看到前端和后端都已经编译运行了。然后我们通过 [http://localhost:8070/#/register](http://localhost:8070/#/register) 直接看到前端页面:
![图片](https://static001.geekbang.org/resource/image/c1/b4/c1d21d6f0eb943f48a062d294820e4b4.png?wh=536x484)
如果我们发现接口或者页面有需要修改的地方,直接修改前后端的代码即可重新编译,直接调试:
![图片](https://static001.geekbang.org/resource/image/bd/6a/bd0148ac02be4e707c9086fb6313036a.png?wh=471x244)
这节课我们实现了用户模块的前后端的开发,代码改动量较大,已经提交到 [geekbang/32](https://github.com/gohade/bbs/tree/geekbang/32) 分支了。欢迎比对查看。
## 小结
这节课我们就完完整整做好了用户模块的开发。还是再啰嗦强调一下后端开发的四个步骤先将接口swaggger化、再定义用户服务协议、接着开发模块接口、最后实现用户服务协议。**服务模块的协议设计不一定能一次性抽象好,可以从服务需要提供哪些对外能力”的角度来思考,从需求出发,遇到新的需求,不断迭代你的设计就可以**。
同时关于前端开发我们重点讲了一下如何使用element-UI来构建页面以及如何使用axios来向后端发送请求。要掌握前后端都开发完成之后的调试方式使用dev all 的调试模式来同时调试前后端,这个能让你的开发速度提高不少。
### 思考题
对于用户服务来说我们定义了一个VerifyLogin的接口根据token来获取对应的user信息。这个你觉得应该在哪里使用怎么使用呢
欢迎在留言区分享你的学习笔记。感谢你的收听,如果觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。下节课我们实战继续。