欢迎 star

前情回顾

在前篇文章中提到了充血模型和贫血模型的概念,本篇文章将会讨论充血模型在注册/登陆业务中的应用,分析充血模型是如何分离业务和策略的。如果读者对充血模型、贫血模型等概念不熟悉,建议先翻阅相关的资料,对相关概念有大致的轮廓会对读者更有帮助。

登陆业务流程分解

业务流程只是一个需求实现的大致流程,到了具体实现的时候还会有其他的操作流程的。一般的登陆认证流程大致能抽象成如图所示的流程: 在这里插入图片描述 但是回顾我们在biz层实现的时候,还做了参数校验、获取用户信息、加密密码等操作。这种就属于是流水账式的编程,在业务简单的时候可以这样一把梭。但是业务复杂的时候,一个方法就会很庞大,代码就会很臃肿,维护起来也会很困难。

让实现流程更接近业务流程

目前的贫血模型实现的流程如下: 在这里插入图片描述 从图中可以看出,其实很接近业务的流程了。接下来开始改造代码,我们实现充血模型代替目前的贫血模型。如下所示: 在这里插入图片描述

登录参数封装成对象

LoginRequest
type LoginRequest struct {
 username string
 password string
}

func NewLoginRequest(username, password string) (*LoginRequest, error) {
 // 校验参数
 if username == "" {
  return nil, fmt.Errorf("用户名不能为空")
 }
 if password == "" {
  return nil, fmt.Errorf("密码不能为空")
 }
 return &LoginRequest{
  username: username,
  password: password,
 }, nil
}

引入用户领域对象

UserUserUser
type User struct {
 ID       int64  // 用户ID
 Username string // 用户名
 Password string // 密码
 Nickname string // 昵称
 Avatar   string // 头像
}

func (u *User) CheckAuth(ctx context.Context, password string, encryptService EncryptService) error {
 // 校验参数
 if username == "" {
  return nil, ErrMissingUsername
 }
 if password == "" {
  return nil, ErrMissingPassword
 }
 return &LoginRequest{
  username: username,
  password: password,
 }, nil
}

EncryptService改造成领域服务

EncryptService
type EncryptService interface {
 Encrypt(ctx context.Context, target []byte) (result []byte, err error)
 // Token 签发token
 Token(ctx context.Context, user *User) (string, error)
}

type encryptServiceImpl struct {
 authConfig *conf.Auth
}

func NewEncryptService(authConfig *conf.Auth) EncryptService {
 return &encryptServiceImpl{
  authConfig: authConfig,
 }
}

func (e *encryptServiceImpl) Encrypt(ctx context.Context, target []byte) (result []byte, err error) {
 encodeToString := base64.StdEncoding.EncodeToString(target)
 return []byte(encodeToString), nil
}

func (e *encryptServiceImpl) Token(ctx context.Context, user *User) (string, error) {
 claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
  ExpiresAt: jwt.NewNumericDate(time.Now().Add(e.authConfig.GetExpireDuration().AsDuration())), // 设置token的过期时间
 })
 return claims.SignedString([]byte(e.authConfig.GetJwtSecret()))
}

最后改造登录用例

在上述步骤都完成后,我们就可以改造登录的用例的,如下所示:

//Login 登录,认证成功返回token,认证失败返回错误
func (a *AccountUseCase) Login(ctx context.Context, loginReq *LoginRequest) (token string, err error) {
 // 获取用户信息
 user, err := a.userRepo.FetchByUsername(ctx, loginReq.username)
 if err != nil {
  return "", fmt.Errorf("登录失败:%w", err)
 }
 // 校验密码
 err = user.CheckAuth(ctx, loginReq.password, a.encryptService)
 if err != nil {
  return "", fmt.Errorf("登录失败:%w", err)
 }
 // 生成token
 token, err = a.encryptService.Token(ctx, user)
 if err != nil {
  a.logger.Errorf("登录失败,生成token失败:%v", err)
  return "", fmt.Errorf("登录失败")
 }
 return token, nil
}
LoginRequest
func (a *accountService) Login(ctx context.Context, request *v1.LoginRequest) (*v1.LoginResponse, error) {
 loginRequest, err := biz.NewLoginRequest(request.GetPhone(), request.GetPassword())
 if err != nil {
  return nil, errors.New(500, "登录失败", err.Error())
 }
 token, err := a.auc.Login(ctx, loginRequest)
 if err != nil {
  return nil, errors.New(500, "登录失败", err.Error())
 }
 return &v1.LoginResponse{
  Token: token,
 }, nil
}

单元测试

下面我们来看看改造后的代码怎么写单元测试。

参数校验

account_test.go
func TestNewLoginRequest(t *testing.T) {
 data := []struct {
  name     string
  username string
  password string
  wantErr  error
  wantData *LoginRequest
 }{
  {
   name:     "缺少用户名",
   password: "123456",
   wantErr:  ErrMissingUsername,
  },
  {
   name:     "缺少密码",
   username: "admin",
   wantErr:  ErrMissingPassword,
  },
  {
   name:     "正常",
   username: "admin",
   password: "123456",
   wantData: &LoginRequest{
    username: "admin",
    password: "123456",
   },
   wantErr: nil,
  },
 }
 for _, item := range data {
  t.Run(item.name, func(t *testing.T) {
   got, err := NewLoginRequest(item.username, item.password)
   assert.Equal(t, item.wantErr, err)
   if item.wantErr == nil {
    assert.Equal(t, item.wantData.username, got.username)
    assert.Equal(t, item.wantData.password, got.password)
   }
  })
 }
}

登陆业务

登陆业务的单元测试比较复杂,因为需要一些外部的依赖,所以需要使用gomock,把依赖mock出来再自定义依赖的行为(gomock的东西不在本文的范围内)。按照登陆的业务流程,具体测试了用户不存在、密码错误和token生成失败的情况(篇幅限制)。代码如下:

func TestAccountUseCase_Login(t *testing.T) {

 controller := gomock.NewController(t)
 repo := NewMockUserRepo(controller)
 encryptService := NewMockEncryptService(controller)
 accountUseCase := NewAccountUseCase(log.DefaultLogger, &conf.Bootstrap{
  Auth: &conf.Auth{
   JwtSecret: "123",
  },
 }, repo, encryptService)

 data := []struct {
  name      string
  mockFunc  func()
  wantErr   assert.ErrorAssertionFunc
  wantToken string
  ctx       context.Context
  req       *LoginRequest
 }{
  {
   name: "正常登陆",
   mockFunc: func() {
    encryptService.EXPECT().Encrypt(gomock.Any(), gomock.Any()).Return([]byte("123"), nil).Times(1)
    repo.EXPECT().FetchByUsername(gomock.Any(), gomock.Any()).Return(&User{
     Password: "123",
    }, nil).Times(1)
    encryptService.EXPECT().Token(gomock.Any(), gomock.Any()).Return("123", nil).Times(1)
   },
   wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
    assert.NoError(t, err)
    return true
   },
   wantToken: "123",
   ctx:       context.Background(),
   req: &LoginRequest{
    username: "123",
    password: "123",
   },
  },
  {
   name: "用户不存在",
   mockFunc: func() {
    repo.EXPECT().FetchByUsername(gomock.Any(), gomock.Any()).Return(nil, ErrUserNotExist).Times(1)
   },
   wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
    assert.ErrorAs(t, err, &ErrUserNotExist)
    return false
   },
   wantToken: "123",
   ctx:       context.Background(),
   req: &LoginRequest{
    username: "123",
    password: "123",
   },
  },
  {
   name: "密码校验不过",
   mockFunc: func() {
    encryptService.EXPECT().Encrypt(gomock.Any(), gomock.Any()).Return([]byte("1233"), nil).Times(1)
    repo.EXPECT().FetchByUsername(gomock.Any(), gomock.Any()).Return(&User{
     Password: "123",
    }, nil).Times(1)
   },
   wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
    assert.ErrorAs(t, err, &ErrPasswordWrong)
    return false
   },
   wantToken: "123",
   ctx:       context.Background(),
   req: &LoginRequest{
    username: "123",
    password: "123",
   },
  },
  {
   name: "token生成失败",
   mockFunc: func() {
    encryptService.EXPECT().Encrypt(gomock.Any(), gomock.Any()).Return([]byte("123"), nil).Times(1)
    encryptService.EXPECT().Token(gomock.Any(), gomock.Any()).Return("", errors.New("123")).Times(1)
    repo.EXPECT().FetchByUsername(gomock.Any(), gomock.Any()).Return(&User{
     Password: "123",
    }, nil).Times(1)
   },
   wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
    assert.ErrorAs(t, err, &ErrLoginFail)
    return false
   },
   wantToken: "123",
   ctx:       context.Background(),
   req: &LoginRequest{
    username: "123",
    password: "123",
   },
  },
 }
 for _, item := range data {
  t.Run(item.name, func(t *testing.T) {
   item.mockFunc()
   got, err := accountUseCase.Login(item.ctx, item.req)
   if !item.wantErr(t, err) {
    return
   }
   assert.Equal(t, item.wantToken, got)
  })
 }
}

总结

登陆的例子复杂度不够,不是很好例子。改成充血模型后,业务逻辑拆分更细,业务流程更加清晰。把不变的业务流程抽取出来,形成主干(校验凭证-发放令牌),把具体的业务策略封装到具体实现中(校验凭证<-检验密码实现,也可能是校验密码和验证码、邮箱之类的)。