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标准的错误码响应错误,这样是有好处的,具体的讨论会在系列的文章中说明,敬请期待。