【kratos入门实战教程】番外篇之充血模型(1)

1,052 阅读6分钟

欢迎 star

仓库地址:github.com/go-kratos/k…

前情回顾

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

开干

登陆业务流程分解

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

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

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

登录参数封装成对象

我们在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
}

引入用户领域对象

参考DDD的设计,我们把User对象改成充血模型,增加校验密码的方法作为User对象的行为,把凭证校验的功能流程交给User对象来做。如下所示:

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
}

整个登录的逻辑缩短成简简单单的19行代码,没有原本的流水账,取而代之的是接近业务流程的步骤。这样的代码一目了然,可读性、可测试、可运维性都能得到保证(登录这个例子比较简单,所以这里的改造看不出有多少收益)。改完用例后,我们还需要改service层,在service层构造LoginRequest对象,在service做参数错误的处理。如下所示:

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)
		})
	}
}

总结

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