- 【kratos入门实战教程】0-商城项目介绍
- 【kratos入门实战教程】1-kratos项目搭建和开发环境配置
- 【kratos入门实战教程】2-实现注册登陆业务
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插件生成的。
建立用户表:
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、添加认证配置
项目使用的是JWT认证方式,加密JWT token需要一些秘钥、过期时间等等。这些额外的信息不会直接硬编码到代码中,而是写到配置文件中,做成可配置的。在这一小细节,读者将会掌握如何在kratos项目中添加配置。
kratos的配置声明是放在internal/conf/conf.proto,在proto文件中添加如下配置:
message Auth { string jwt_secret = 1; google.protobuf.Duration expire_duration = 2; }
然后Bootstrap的message增加认证配置项:
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
此时,在定义配置的目录下就能看到新编译出来的pb文件。
然后在configs/config.yaml增加新增的认证配置auth:
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: 3600s2.4、实现注册登陆业务
2.4.1、定义用户对象在上一篇教程中介绍过kratos的分层结构,业务在biz层,所以这里先从biz实现(这不是一种规范,读者可以按照自己的习惯来做)。在biz目录新建account.go,存放account业务的源码。
现阶段使用贫血模型(相对于充血模型而言),因为贫血模型比较直观好理解,适合入门。如果读者比较熟悉充血模型,建议看看番外篇。
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层去实现接口。
在orm框架的选型上,我们使用的是Gorm。对于Gorm不做过多的介绍和入门讲解了,网上已经有很多很好的教程了。
废话就说到了这了,接下来让我们把视线切换到在data目录下,我们新建一个user.go的文件,用来存放用户数据仓库的实现逻辑。在data.go中,我们实现接入mysql的逻辑。
data.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 }
2.6、实现注册登陆接口注意:被操作的实体是定义在biz层的,但是按照kratos的设计,存储到数据库的实体应该是data层自己定义的,data层应该要做一次ACl,即防腐层处理。不过有时候最优的未必是最好的,这里业务本身并不复杂,编码就没必要搞复杂,这里就一把梭了。
kratos默认就支持了HTTP和GRPC两种协议的接口,作为教程,两种接口我们都会实现。接口和协议分别对应的是service和server,这样也比较好理解,接口是一种服务,而具体的协议是一种 “服务器”。server依赖service提供具体协议的服务,这样底层就能复用而无需做两套了。
2.6.1、实现Service在service目录下新建account.go文件,定义一个账号服务accountService,实现上面使用protobuf定义的Account service。逻辑非常简单,这一层主要是做一个错误的转换。
文件内容如下:
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
在server目录下,我们能看到分别代表grpc和http的接入点的文件:grpc.go 和 http.go。在使用上,kratos的http接入点和grpc的在使用上是没有太大的区别的,代码结构几乎是相同的,只是调用的包不一样而已。如下所示:
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、依赖注入
读到这里,业务的编码就算是完成了,但是还要把每一层串起来,整个项目才能正常跑起来。细心的读者可能已经发现了,在前面处理连接mysql的逻辑的时候,在data.go文件中出现了一个var ProviderSet = wire.NewSet(NewData, NewUserRepo)的语句。没错,kratos是结合了wire做依赖注入的,在每个目录下都有一个和目录名称一致的源文件,该文件的其中一个作用就是声明wire的provider。
- 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)
最终,这些声明的provider会在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、测试当读者读到这里了,我们的应用就可以启动并通过postman(或者其他读者熟悉的工具)进行接口测试了。在测试前,我们先来启动服务,使用goland的读者,需要配置run configuration。因为服务启动的时候需要读取配置文件,而Goland默认的启动路径是在项目的根目录下,读者需要配置work directory到cmd目录下,如下图所示:
配置后启动,可以看到启动日志就说明启动成功了:
注意:本地的开发环境在上一篇的环境搭建已经启动过了
然后,我们打开postman,请求注册接口,返回的是200状态码即请求成功,如下图所示:
如果注册失败,返回的就不是200状态码了,并且会有具体的错误原因,如下图所示:
上面成功注册了一个用户后,我们就可以调试登录接口了,如下图所示:
注意:kratos提倡的错误处理可能和读者熟悉的不一样,kratos提倡使用http标准的错误码响应错误,这样是有好处的,具体的讨论会在系列的文章中说明,敬请期待。