你好,我是轩脉刃。

上一节课我们设计好用户模块的需求后,开始了后端开发。在后端开发中我们明确了开发流程的四个步骤,先将接口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的。所以这个接口的设计是有“需要”的。

服务协议的设计从需求出发,当遇到新的需求,不断迭代就可以了。

用户模块接口实现

设计了用户服务的协议,下一步我们也不是急于实现它,需要先验证下这些服务协议是否能满足我们的“需求”。如何验证呢?可以直接开发用户接口,确认是否有未满足的需求。

上一节课梳理了,要实现四个接口:

我们还是拿其中比较复杂的注册接口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 := &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结构中的链式方法:

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)

预注册协议接口的具体实现要做几个事情:

  1. 去数据库判断邮箱是否已经注册用户了,如果邮箱已经注册,那么这个预注册操作是不能执行的;
  2. 去数据库判断用户名是否已经被注册了,如果用户名已经被注册了,那么预注册操作也是不能执行的;
  3. 生成预注册的验证token;
  4. 将要注册的用户存储在缓存中,存储1天,待用户注册验证。

我们注意到四步操作里前面两步是去数据库的查询操作,所以可以使用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信息。这个你觉得应该在哪里使用?怎么使用呢?

欢迎在留言区分享你的学习笔记。感谢你的收听,如果觉得今天的内容对你有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。下节课我们实战继续。