1、系列目录

2、概览

通过本篇文章,读者将会掌握kratos的一般开发流程,涵盖了从接口定义、自定义配置,到业务逻辑实现,再到数据库存储的一整套流程。注册登陆业务是非常高频常见的业务,几乎所有的系统都有注册和登陆的功能。本文展示的注册登陆业务非常基本、非常简单。因此,只需要用户数据库和采用一些主流的JWT认证策略。

2、开干

2.1、定义接口

在api目录下,新建account/v1目录,然后添加account.proto文件。

kratos的接口统一使用protobuf定义的,无论是走grpc协议还是走http协议。把接口的定义和具体的协议隔离出来,并用统一的定义语言定义接口入参和回参以及其他的信息。kratos提供了http协议的protoc插件,在生成grpc的代码时,也会生成http的代码。定义http接口的方式使用的是google的规范。作为入门级的教程,这里就不再详述了。

在这里插入图片描述
然后添加注册和登陆的接口:

syntax = "proto3";

package account.v1;

import "google/api/annotations.proto";

option go_package = "tt-shop/api/account/v1;v1";


service Account {
    rpc Login (LoginRequest) returns (LoginResponse) {
        option (google.api.http) = {
            post: "/account/login"
            body: "*"
        };
    }
    rpc Register (RegisterRequest) returns (RegisterResponse) {
        option (google.api.http) = {
            post: "/account/register"
            body: "*"
        };
    }
}

message LoginRequest {
    string phone = 1;
    string password = 2;
}

message LoginResponse {
    string token = 1;
}

message RegisterRequest {
    string phone = 1;
    string password = 2;
}

message RegisterResponse {
}

最后执行命令(在项目的根目录下):

make api

命令执行完后就能看到目录生成了多个源文件,包括protobuf生成的、grpc生成的和kratos的http插件生成的。
在这里插入图片描述

2.2、建立用户数据库

建立用户表:

create table user
(
    id       int(64) not null auto_increment,
    username varchar(64)  not null,
    password varchar(128) not null,
    phone    varchar(18),
    nickname varchar(20),
    PRIMARY KEY (id)
) engine=innodb default charset=utf8mb4;

2.3、添加认证配置

internal/conf/conf.proto

message Auth {
    string jwt_secret = 1;
    google.protobuf.Duration expire_duration = 2;
}
Bootstrap
message Bootstrap {
    Server server = 1;
    Data data = 2;
    Auth auth = 3;
}
make config
❯ make config
protoc --proto_path=./internal \
               --proto_path=./third_party \
               --go_out=paths=source_relative:./internal \
               internal/conf/conf.proto
configs/config.yaml
server:
  http:
    addr: 0.0.0.0:8000
    timeout: 1s
  grpc:
    addr: 0.0.0.0:9000
    timeout: 1s
data:
  database:
    driver: mysql
    source: root:root@tcp(127.0.0.1:3306)/test?charset=utf8mb4&parseTime=True&loc=Local
  redis:
    addr: 127.0.0.1:6379
    read_timeout: 0.2s
    write_timeout: 0.2s
# 认证配置
auth:
  jwt_secret: "secret"
  expire_duration: 3600s

2.4、实现注册登陆业务

在上一篇教程中介绍过kratos的分层结构,业务在biz层,所以这里先从biz实现(这不是一种规范,读者可以按照自己的习惯来做)。在biz目录新建account.go,存放account业务的源码。

2.4.1、定义用户对象

现阶段使用贫血模型(相对于充血模型而言),因为贫血模型比较直观好理解,适合入门。如果读者比较熟悉充血模型,建议看看番外篇。

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

2.4.2、定义用户数据仓库操作接口

这里定义两个接口,分别执行获取指定id的用户和保存用户信息的操作。这里通过在biz层定义一层接口实现了依赖反转(正常biz需要调data提供的方法来操作用户的数据,依赖反转后,是biz要求data实现哪些接口,biz依赖的是接口,而不是data的实现)。

type UserRepo interface {
	// FetchByUsername 获取指定用户名的用户的信息,如果用户不存在,则返回 ErrUserNotExist。
	FetchByUsername(ctx context.Context, username string) (user *User, err error)
	// Save 保存用户信息并返回用户的id。
	Save(ctx context.Context, user *User) (id int64, err error)
}

2.4.3、实现注册功能

接下来,我们定义一个account的用例,通过用例对象提供注册的功能。account用例对象依赖了用户数据仓库接口UserRepo,需要使用UserRepo来操作用户数据,例如查询。同时,用户的密码加密也抽取到一独立的加密服务中。因为使用了依赖注入工具,所以这些使用到的依赖都不需要自己去构建初始化,只要作为参数传入构造函数即可(事实上,go并有所谓的构造函数,这里为了方便描述)。
然后,account用例对象提供一个注册的方法,实现基本的注册逻辑。这里只是简单的注册需求,有些公司会有特别的安全需求,例如密码长度和包含的符号类型,读者可自行拓展。

注意了!生成环境严禁数据库存储密码明文!!!!!

以下是具体的代码:

type AccountUseCase struct {
	authConfig     *conf.Auth
	encryptService EncryptService
	userRepo       UserRepo
	logger         *log.Helper
}

//NewAccountUseCase 创建一个AccountUseCase,依赖作为参数传入
func NewAccountUseCase(logger log.Logger, authConfig *conf.Bootstrap, userRepo UserRepo, encryptService EncryptService) *AccountUseCase {
	return &AccountUseCase{
		encryptService: encryptService,
		userRepo:       userRepo,
		logger:         log.NewHelper(logger),
		authConfig:     authConfig.Auth,
	}
}
//Register 注册
func (a *AccountUseCase) Register(ctx context.Context, username, pwd string) (err error) {
	// 校验参数
	if username == "" || pwd == "" {
		return fmt.Errorf("注册失败:%w", ErrRegisterParamEmpty)
	}
	// 判断用户是否已经注册一次了
	user, err := a.userRepo.FetchByUsername(ctx, username)
	if err != nil && !errors.Is(err, ErrUserNotExist) {
		log.Errorf("注册失败,参数[username: %s,pwd:%s],err:%v", username, pwd, err)
		return fmt.Errorf("注册失败")
	}
	if user != nil {
		return fmt.Errorf("用户已经存在")
	}
	// 加密密码
	encrypt, err := a.encryptService.Encrypt(ctx, []byte(pwd))
	if err != nil {
		log.Errorf("注册失败,参数[username: %s,pwd:%s],err:%v", username, pwd, err)
		return fmt.Errorf("注册失败")
	}
	_, err = a.userRepo.Save(ctx, &User{
		Username: username,
		Password: string(encrypt),
	})
	if err != nil {
		return fmt.Errorf("注册失败:%w", err)
	}
	return nil
}

2.4.5、实现登陆功能

实现了注册业务后,我们来实现登陆的业务。登陆本质上就是一个获取/发放访问凭证行为。这里我们使用的是JWT,所以接口只需返回token就ok了。(篇幅问题,不设计太多的业务需求,有兴趣的读者可以私信作者一起探讨)

给account用例对象添加处理登陆的方法:

//Login 登录,认证成功返回token,认证失败返回错误
func (a *AccountUseCase) Login(ctx context.Context, username, password string) (token string, err error) {
	// 校验参数
	if username == "" || password == "" {
		return "", fmt.Errorf("登录失败:%w", ErrRegisterParamEmpty)
	}
	// 获取用户信息
	user, err := a.userRepo.FetchByUsername(ctx, username)
	if err != nil {
		return "", fmt.Errorf("登录失败:%w", err)
	}
	// 校验密码
	encrypt, err := a.encryptService.Encrypt(ctx, []byte(password))
	if err != nil {
		return "", fmt.Errorf("登录失败:%w", err)
	}
	if user.Password != string(encrypt) {
		return "", fmt.Errorf("登录失败:%w", ErrPasswordWrong)
	}
	// 生成token
	claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
		ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.authConfig.GetExpireDuration().AsDuration())), // 设置token的过期时间
	})
	token, err = claims.SignedString([]byte(a.authConfig.GetJwtSecret()))
	if err != nil {
		a.logger.Errorf("登录失败,生成token失败:%v", err)
		return "", fmt.Errorf("登录失败")
	}
	return token, nil
}

2.4.6、总结

到这里,我们就完成了注册登陆的业务了。读者可能会好奇数据库都没有,怎么算是完成了?因为就业务本身而言,和底层的存储、中间件或者其他服务并没有关系。在kratos的工程结构下,业务是收敛在biz层,并不会和其他层耦合。数据库的部分会在本文的后续内容中展示,读者如果想直接看单元测试,可以直接看本系列的单元测试的文章。这里为了先出效果,让读者有亲身感受,就跳过单元测试了,但是在现实生产环境中,请遵守相应的开发规范进行单元测试。

2.5、实现用户数据仓库

在本小节,我们将会在data层实现前面在biz层定义的用户的数据仓库的接口。

这里再说一下,biz层是最核心的一层,对外层是没有依赖的,但是biz层需要访问数据,直观上需要依赖data层,但实际上并不需要。因为使用了接口,实现了依赖反转,在biz层定义了接口,让data层依赖biz层去实现接口。

user.godata.go
package data

import (
	"github.com/go-kratos/kratos/v2/log"
	"github.com/google/wire"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"yy-shop/internal/conf"
)

// ProviderSet is data providers.
var ProviderSet = wire.NewSet(NewData, NewUserRepo)

// Data .
type Data struct {
	db *gorm.DB
}

// NewData .
func NewData(c *conf.Data, logger log.Logger) (*Data, func(), error) {
	db, err := gorm.Open(mysql.Open(c.GetDatabase().GetSource()), &gorm.Config{})
	if err != nil {
		return nil, nil, err
	}
	cleanup := func() {
		log.NewHelper(logger).Info("closing the data resources")
	}
	return &Data{
		db: db,
	}, cleanup, nil
}

user.go:

package data

import (
	"context"
	"github.com/go-kratos/kratos/v2/log"
	"gorm.io/gorm"
	"yy-shop/internal/biz"
)

type userRepo struct {
	data  *Data
	log   *log.Helper
	table *gorm.DB
}

func NewUserRepo(data *Data, logger log.Logger) biz.UserRepo {
	return &userRepo{
		data:  data,
		log:   log.NewHelper(logger),
		table: data.db.Table("user"),
	}
}

func (u *userRepo) FetchByUsername(ctx context.Context, username string) (user *biz.User, err error) {
	user = &biz.User{}
	u.table.WithContext(ctx).First(user, "username = ?", username)
	if user.ID == 0 {
		return nil, biz.ErrUserNotExist
	}
	return user, nil
}

func (u *userRepo) Save(ctx context.Context, user *biz.User) (id int64, err error) {
	result := u.table.WithContext(ctx).Create(user)
	if result.Error != nil {
		return 0, result.Error
	}
	return user.ID, nil
}

注意:被操作的实体是定义在biz层的,但是按照kratos的设计,存储到数据库的实体应该是data层自己定义的,data层应该要做一次ACl,即防腐层处理。不过有时候最优的未必是最好的,这里业务本身并不复杂,编码就没必要搞复杂,这里就一把梭了。

2.6、实现注册登陆接口

kratos默认就支持了HTTP和GRPC两种协议的接口,作为教程,两种接口我们都会实现。接口和协议分别对应的是service和server,这样也比较好理解,接口是一种服务,而具体的协议是一种 “服务器”。server依赖service提供具体协议的服务,这样底层就能复用而无需做两套了。

2.6.1、实现Service

account.goaccountServiceAccount
package service

import (
	"context"
	"github.com/go-kratos/kratos/v2/errors"
	"github.com/go-kratos/kratos/v2/log"
	v1 "yy-shop/api/account/v1"
	"yy-shop/internal/biz"
)

type accountService struct {
	v1.UnimplementedAccountServer
	log *log.Helper
	auc *biz.AccountUseCase
}

func NewAccountService(logger log.Logger, auc *biz.AccountUseCase) v1.AccountServer {
	return &accountService{
		log: log.NewHelper(logger),
		auc: auc,
	}
}

func (a *accountService) Login(ctx context.Context, request *v1.LoginRequest) (*v1.LoginResponse, error) {
	token, err := a.auc.Login(ctx, request.GetPhone(), request.GetPassword())
	if err != nil {
		return nil, errors.New(500, "登录失败", err.Error())
	}
	return &v1.LoginResponse{
		Token: token,
	}, nil
}

func (a *accountService) Register(ctx context.Context, request *v1.RegisterRequest) (*v1.RegisterResponse, error) {
	err := a.auc.Register(ctx, request.GetPhone(), request.GetPassword())
	if err != nil {
		return nil, errors.New(500, "注册失败", err.Error())
	}
	return &v1.RegisterResponse{}, nil
}

2.6.2、实现Server

servergrpchttpgrpc.gohttp.go

grpc.go

package server

import (
	"github.com/go-kratos/kratos/v2/log"
	"github.com/go-kratos/kratos/v2/middleware/recovery"
	"github.com/go-kratos/kratos/v2/transport/grpc"
	account_v1 "yy-shop/api/account/v1"
	"yy-shop/internal/conf"
)

// NewGRPCServer new a gRPC server.
func NewGRPCServer(c *conf.Server, logger log.Logger,
	as account_v1.AccountServer,
) *grpc.Server {
	var opts = []grpc.ServerOption{
		grpc.Middleware(
			recovery.Recovery(),
		),
	}
	if c.Grpc.Network != "" {
		opts = append(opts, grpc.Network(c.Grpc.Network))
	}
	if c.Grpc.Addr != "" {
		opts = append(opts, grpc.Address(c.Grpc.Addr))
	}
	if c.Grpc.Timeout != nil {
		opts = append(opts, grpc.Timeout(c.Grpc.Timeout.AsDuration()))
	}
	srv := grpc.NewServer(opts...)
	account_v1.RegisterAccountServer(srv, as)
	return srv
}

http.go

package server

import (
	"github.com/go-kratos/kratos/v2/log"
	"github.com/go-kratos/kratos/v2/middleware/recovery"
	"github.com/go-kratos/kratos/v2/transport/http"
	account_v1 "yy-shop/api/account/v1"
	"yy-shop/internal/conf"
)

// NewHTTPServer new a HTTP server.
func NewHTTPServer(c *conf.Server, logger log.Logger,
	as account_v1.AccountServer,
) *http.Server {
	var opts = []http.ServerOption{
		http.Middleware(
			recovery.Recovery(),
		),
	}
	if c.Http.Network != "" {
		opts = append(opts, http.Network(c.Http.Network))
	}
	if c.Http.Addr != "" {
		opts = append(opts, http.Address(c.Http.Addr))
	}
	if c.Http.Timeout != nil {
		opts = append(opts, http.Timeout(c.Http.Timeout.AsDuration()))
	}
	srv := http.NewServer(opts...)
	account_v1.RegisterAccountHTTPServer(srv, as)
	return srv
}

2.7、依赖注入

data.govar ProviderSet = wire.NewSet(NewData, NewUserRepo)
  • biz.go
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(NewAccountUseCase, NewEncryptService)
  • data.go
// ProviderSet is data providers.
var ProviderSet = wire.NewSet(NewData, NewUserRepo)
  • service.go
// ProviderSet is service providers.
var ProviderSet = wire.NewSet(NewAccountService)
  • server.go
// ProviderSet is server providers.
var ProviderSet = wire.NewSet(NewGRPCServer, NewHTTPServer)
cmd/yy-shop/wire.go
// wireApp init kratos application.
func wireApp(*conf.Server, *conf.Data, *conf.Bootstrap, log.Logger) (*kratos.App, func(), error) {
	panic(wire.Build(server.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp))
}
wire ./...
3、测试
run configurationwork directory

注意:本地的开发环境在上一篇的环境搭建已经启动过了

然后,我们打开postman,请求注册接口,返回的是200状态码即请求成功,如下图所示:
在这里插入图片描述
如果注册失败,返回的就不是200状态码了,并且会有具体的错误原因,如下图所示:
在这里插入图片描述
上面成功注册了一个用户后,我们就可以调试登录接口了,如下图所示:
在这里插入图片描述

注意:kratos提倡的错误处理可能和读者熟悉的不一样,kratos提倡使用http标准的错误码响应错误,这样是有好处的,具体的讨论会在系列的文章中说明,敬请期待。