你好,我是轩脉刃。
上一节课我们设计好用户模块的需求后,开始了后端开发。在后端开发中我们明确了开发流程的四个步骤,先将接口swaggger化,再定义用户服务协议,接着开发模块接口,最后实现用户服务协议。而且上节课已经完成了接口swagger化,以及用户服务协议设计的模型部分。
这节课,我们就继续完成用户服务协议的定义,再开发模块接口和实现用户服务协议。
前面我们设计好了一个模型User了,“接口优于实现”,来设计这个服务的接口,看看要提供哪些能力。
首先用户服务一定要提供的是预注册能力,所以提供了一个Register方法。预注册之后,我们还要提供发送邮件的能力,再提供一个发送邮件的接口SendRegisterMail。当然最后要提供一个确认注册用户的接口VerfityRegister。
在登录这块,用户服务一定要提供登录、登出的接口Login和Logout。同时由于所有业务请求,比如创建问题等逻辑,我们需要使用token来获取用户信息,所以我们也要提供验证登录的接口VerifyLogin。
于是整体的接口设计如下,详细信息都写在注释中了:
// Service 用户相关的服务
type Service interface {
// Register 注册用户,注意这里只是将用户注册, 并没有激活, 需要调用
// 参数:user必填,username,password, 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。
func (api *UserApi) Register(c *gin.Context) {
// 验证参数
userService := c.MustMake(provider.UserKey).(provider.Service)
logger := c.MustMake(contract.LogKey).(contract.Log)
param := ®isterParam{}
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结构中的链式方法:
c.ISetOkStatus().IText("注册成功,请前往邮箱查看邮件");
很明显,这种方法确实比Gin框架自带的Response方法更为优雅轻便了。
实现好Register接口,我们基本确认了之前设计的用户服务中注册部分是满足需求的。再一个个接口 Verify/Login/Logout 都实现一下,基本能确定之前用户服务的设计是可以的。
既然我们已经确定了用户服务设计可行,进入最后一步,实现这些用户服务定义的协议方法。在实现中,你能看到很多之前定义的各种服务的具体使用。用户注册的三个相关协议接口的实现,我们详细说一下,其他登录相关的接口协议,你可以参考GitHub上的代码。
// Register 注册用户,注意这里只是将用户注册, 并没有激活, 需要调用
// 参数:user必填,username,password, email
// 返回值: user 带上token
Register(ctx context.Context, user *User) (*User, error)
预注册协议接口的具体实现要做几个事情:
我们注意到四步操作里前面两步是去数据库的查询操作,所以可以使用hade框架的ORM服务,先从容器中获取ORM服务,使用GetDB获取gorm.DB,接着就可以使用gorm的Where、First 等方法了。由于之前已经定义好了User结构作为数据库模型,所以我们直接使用这个模型:
// 判断邮箱是否已经注册了
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。
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对象。
// 将请求注册进入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中:
// 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服务,所有的这些服务在服务容器中都可以得到。你可以具体感受一下,容器加服务协议在具体的业务代码中带来的便利。
// SendRegisterMail 发送注册的邮件
// 参数:user必填: username, password, email, token
SendRegisterMail(ctx context.Context, user *User) error
发送邮件协议接口要实现的就是一个邮件发送的功能。在Golang中邮件发送功能也是有现成库的,gomail。这个库目前已经有3.4k star了,基于限制比较少的MIT协议。
发送电子邮件的方式其实有很多种,但是我们最好使用SMTP的方式来发送邮件,因为SMTP的服务提供方,基本上都是在互联网上已经认证的服务提供商,比如Gmail、126等。通过这些邮件服务提供商注册的SMTP账号发送邮件,基本上不会进入对方邮箱的“垃圾箱”中。
不过所有邮件服务提供商的SMTP账号都需要单独申请,但是基本都是免费的。这里是我使用126注册的邮箱申请了一个126的SMTP发送账号。
我们把SMTP的账号信息存储在配置文件config/development/app.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 的具体实现:
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方法来记录发送邮件错误信息。
不管配置服务还是日志服务,都是从服务容器中可以获取到。按照上述逻辑,发送邮件的接口就完成了。
注册相关的最后一个协议接口是验证注册。
// VerifyRegister 注册用户,验证注册信息, 返回验证是否成功
VerifyRegister(ctx context.Context, token string) (bool, error)
这个协议接口逻辑会复杂一些了。
它的参数为一个token,我们首先要拿着这个token去缓存中,获取到这个token对应的预注册用户。由于之前已经将User实现了BinaryUnmarshaler接口,这里就使用缓存服务的GetObj方法:
//验证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
}
然后下一步,由于预注册和注册验证过程是异步的,中间数据库是有可能发生变化的,所以我们需要再次验证一下这个用户在数据库中是否已经存在了,他的用户名和邮箱是否是唯一的。
//验证邮箱,用户名的唯一
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 库提供了对密码进行加密的标准方法。还记得这种golang.org/x/ 开头的库么,可以说是Golang标准库的预备库,我们可以直接放心使用。在这个库中,提供了加密密码的方法 GenerateFromPassword 和验证密码的方法 CompareHashAndPassword 。
在这个函数中,我们就使用到加密密码的方法:
// 验证成功将密码存储数据库之前需要加密,不能原文存储进入数据库
hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.MinCost)
if err != nil {
return false, err
}
GenerateFromPassword 的第二个参数cost,是表示加密密码的复杂度,最小必须为MinCost。
最后一步,就是将用户存储到数据库中了。同样使用的是Gorm,其中有个Create方法,能将对象保存进入数据库:
user.Password = string(hash)
// 具体在数据库创建用户
if err := db.Create(user).Error; err != nil {
return false, err
}
return true, nil
以上,就完成了注册实现的具体方法了。
现在,汇总两节课的成果,我们完成了用户服务、用户模块接口以及swagger的搭建。之后就可以很方便地使用swagger来调试用户模块和用户服务了。
记得开启hade特有的调试模式: ./bbs dev backend
打开浏览器 http://localhost:8070/swagger/index.html 看到swagger-UI界面。点击要调试接口的 “try it out” 按钮进入接口调用,填写要调用的接口参数,点击“Execute” 调用接口,并且获取接口返回值。
如果接口调用错误,我们要修改接口,只需要直接在IDE上修改代码,并且直接保存,hade就会检测到文件更新,并且重新编译重启服务,立刻生效。
关于用户接口的前端开发部分,由于并不是我们课程的重点,就简要描述一下关键实现点。
前端一共就两个页面,注册页面和登录页面,所以我们在src/views/中创建两个文件夹,register和login,分别存储这两个页面。这里我主要也描述一下注册页面的具体实现。
在注册页面上,实际上是搭建了一个表单,在element-UI中我们可以使用el-form来方便搭建一个漂亮的表单, 这个表单包含用户名、邮箱、密码等信息,并且这些信息都对应script中的form数据。在表单的按钮,按钮点击行为我们设置成触发submitFrom方法。在src/views/register/index.vue中:
<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>
<script>
export default {
name: "register",
data() {
return {
form: {
username: '', // 用户名
password: '', // 密码
email: '', // 邮箱
repassword: '' // 重复输入密码
},
loading: false,
};
},
对应的submitFrom方法,就调用第30节课介绍的封装了axios库的request.js。我们使用request,并且传递上面输入的form对象数据给后端,如果请求返回成功,就返回返回体中的成功信息。
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
开启前后端同时调试模式:
控制台可以看到前端和后端都已经编译运行了。然后我们通过 http://localhost:8070/#/register 直接看到前端页面:
如果我们发现接口或者页面有需要修改的地方,直接修改前后端的代码即可重新编译,直接调试:
这节课我们实现了用户模块的前后端的开发,代码改动量较大,已经提交到 geekbang/32 分支了。欢迎比对查看。
这节课我们就完完整整做好了用户模块的开发。还是再啰嗦强调一下,后端开发的四个步骤:先将接口swaggger化、再定义用户服务协议、接着开发模块接口、最后实现用户服务协议。服务模块的协议设计不一定能一次性抽象好,可以从服务需要提供哪些对外能力”的角度来思考,从需求出发,遇到新的需求,不断迭代你的设计就可以。
同时关于前端开发,我们重点讲了一下如何使用element-UI来构建页面,以及如何使用axios来向后端发送请求。要掌握前后端都开发完成之后的调试方式,使用dev all 的调试模式来同时调试前后端,这个能让你的开发速度提高不少。
对于用户服务来说,我们定义了一个VerifyLogin的接口,根据token来获取对应的user信息。这个你觉得应该在哪里使用?怎么使用呢?
欢迎在留言区分享你的学习笔记。感谢你的收听,如果觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。下节课我们实战继续。