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