# 32|通用模块(下):用户模块开发 你好,我是轩脉刃。 上一节课我们设计好用户模块的需求后,开始了后端开发。在后端开发中我们明确了开发流程的四个步骤,先将接口swaggger化,再定义用户服务协议,接着开发模块接口,最后实现用户服务协议。而且上节课已经完成了接口swagger化,以及用户服务协议设计的模型部分。 这节课,我们就继续完成用户服务协议的定义,再开发模块接口和实现用户服务协议。 ## 用户服务协议 前面我们设计好了一个模型User了,“接口优于实现”,来设计这个服务的接口,看看要提供哪些能力。 首先用户服务一定要提供的是预注册能力,所以提供了一个Register方法。预注册之后,我们还要提供发送邮件的能力,再提供一个发送邮件的接口SendRegisterMail。当然最后要提供一个确认注册用户的接口VerfityRegister。 在登录这块,用户服务一定要提供登录、登出的接口Login和Logout。同时由于所有业务请求,比如创建问题等逻辑,我们需要使用token来获取用户信息,所以我们也要提供验证登录的接口VerifyLogin。 于是整体的接口设计如下,详细信息都写在注释中了: ```go // 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。 ```go 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结构中的链式方法: ```go c.ISetOkStatus().IText("注册成功,请前往邮箱查看邮件"); ``` 很明显,这种方法确实比Gin框架自带的Response方法更为优雅轻便了。 实现好Register接口,我们基本确认了之前设计的用户服务中注册部分是满足需求的。再一个个接口 Verify/Login/Logout 都实现一下,基本能确定之前用户服务的设计是可以的。 ## 开发模块接口 既然我们已经确定了用户服务设计可行,进入最后一步,实现这些用户服务定义的协议方法。在实现中,你能看到很多之前定义的各种服务的具体使用。用户注册的三个相关协议接口的实现,我们详细说一下,其他登录相关的接口协议,你可以参考GitHub上的代码。 ### 预注册协议接口 ```go // Register 注册用户,注意这里只是将用户注册, 并没有激活, 需要调用 // 参数:user必填,username,password, 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 ``` ```plain