这是我参与「第五届青训营」伴学笔记创作活动的第3天。青训营的第三次课程中讲解了Go语言的框架三件套:Web框架Hertz、RPC框架Kitex、ORM框架Gorm,分析了easy-note项目将三个框架的使用串联起来。本文将讲解easy-note项目的架构及核心组成,以加深对于框架三件套具体使用的知识。
easy-note项目架构
easy-note项目是一个基于Web的笔记服务,支持用户注册、用户登录,以及笔记的增删改查功能。
如上图所示,项目总共拥有三个子模块,其中
demoapi模块使用Kitex框架建立HTTP服务器,并接收前端请求,并通过Kitex将RPC请求发送至其余模块;demouser和demonote则使用Gorm框架,操作Mysql数据库中的用户及笔记数据,并通过Kitex将操作结果通过RPC答复发送回demoapi。在具体的Kitex框架中,各模块通过ETCD进行服务的注册及发现。
demouser模块介绍
Kitex框架:RPC服务端
demouser模块负责系统用户数据的增删改查工作,并通过Kitex作为RPC服务器为demoapi提供操作接口。在这里,demonote模块与其实现方式大致相似,因此只进行demouser的讲解。
func main() {
r, err := etcd.NewEtcdRegistry([]string{constants.EtcdAddress})
if err != nil {
panic(err)
}
addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:8889")
if err != nil {
panic(err)
}
Init()
svr := user.NewServer(new(UserServiceImpl),
server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: constants.UserServiceName}), // server name
server.WithMiddleware(middleware.CommonMiddleware), // middleware
server.WithMiddleware(middleware.ServerMiddleware),
server.WithServiceAddr(addr), // address
server.WithLimit(&limit.Option{MaxConnections: 1000, MaxQPS: 100}), // limit
server.WithMuxTransport(), // Multiplex
server.WithSuite(trace.NewDefaultServerSuite()), // tracer
server.WithBoundHandler(bound.NewCpuLimitHandler()), // BoundHandler
server.WithRegistry(r), // registry
)
err = svr.Run()
if err != nil {
klog.Fatal(err)
}
}
作为RPC服务器,模块使用Kitex框架的server.NewServer函数将自身注册。在NewServer的选项中,通过声明WithRegistry选项,将本模块提供的服务注册在ETCD服务器,其中etcd.NewEtcdRegistry声明了ETCD的服务器地址。
其他Option选项的功能分别为:
WithServerBasicInfo:指定服务器的基本属性,在这里制定了服务名;WithMiddleware:为服务器注册中间件,在这里注册的两个中间件的功能为记录相关服务过程信息;WithServiceAddr:指定模块服务器的具体TCP地址;WithLimit:指定流量限制阈值;WithMuxTransport:指定多路复用相关规则;WithSuite:指定特定配置,在这里使用默认特定配置;WithBoundHandler:自定义IO Bound,在这里设置了一个用于CPU限流的IO Bound。
syntax = "proto3";
package user;
option go_package = "userdemo";
message BaseResp {
int64 status_code = 1;
string status_message = 2;
int64 service_time = 3;
}
message User {
int64 user_id = 1;
string user_name = 2;
string avatar = 3;
}
message CreateUserRequest {
string ser_name = 1;
string password = 2;
}
message CreateUserResponse {
BaseResp base_resp = 1;
}
message MGetUserRequest {
repeated int64 user_ids = 1;
}
...
模块所提供服务的具体请求参数、回复参数需要使用IDL语句声明。在这里,模块的IDL语句使用proto3协议,并存放在user.proto中。在IDL语句编写完成之后,Kitex框架将自动生成所需的服务代码,并提供待填充的RPC接口函数于handle.go中。
type UserServiceImpl struct{}
// CreateUser implements the UserServiceImpl interface.
func (s *UserServiceImpl) CreateUser(ctx context.Context, req *userdemo.CreateUserRequest) (resp *userdemo.CreateUserResponse, err error) {
resp = new(userdemo.CreateUserResponse)
if len(req.UserName) == 0 || len(req.Password) == 0 {
resp.BaseResp = pack.BuildBaseResp(errno.ParamErr)
return resp, nil
}
err = service.NewCreateUserService(ctx).CreateUser(req)
if err != nil {
resp.BaseResp = pack.BuildBaseResp(err)
return resp, nil
}
resp.BaseResp = pack.BuildBaseResp(errno.Success)
return resp, nil
}
...
在本项目中,模块具体的业务逻辑被封装在CreateUserService及其方法中。在RPC处理函数中仅进行输入参数的合理判断,以及业务逻辑的错误处理。
Kitex框架:RPC客户端
func initUserRpc() {
r, err := etcd.NewEtcdResolver([]string{constants.EtcdAddress})
if err != nil {
panic(err)
}
c, err := userservice.NewClient(
constants.UserServiceName,
client.WithMiddleware(middleware.CommonMiddleware),
client.WithInstanceMW(middleware.ClientMiddleware),
client.WithMuxConnection(1), // mux
client.WithRPCTimeout(3*time.Second), // rpc timeout
client.WithConnectTimeout(50*time.Millisecond), // conn timeout
client.WithFailureRetry(retry.NewFailurePolicy()), // retry
client.WithSuite(trace.NewDefaultClientSuite()), // tracer
client.WithResolver(r), // resolver
)
if err != nil {
panic(err)
}
userClient = c
}
在demoapi,在创建RPC客户端时同样需要使用etcd.NewEtcdResolver指定ETCD服务器地址,并在选项中使用WithResolver应用该ETCD服务器进行服务发现。并且,在创建客户端时指定服务端服务名constants.UserServiceName。
其他Option选项的功能分别为:
WithMiddleware:指定中间件,在Service 熔断和超时中间件之后执行;WithInstanceMW:指定中间件,在服务发现和负载均衡之后执行;WithMuxConnection:设置连接多路复用;WithRPCTimeout:设置RPC超时;WithConnectTimeout:设置连接超时;WithSuite:指定特定配置,在这里使用默认特定配置;
Gorm框架:Mysql服务器操作
type CreateUserService struct {
ctx context.Context
}
// NewCreateUserService new CreateUserService
func NewCreateUserService(ctx context.Context) *CreateUserService {
return &CreateUserService{ctx: ctx}
}
// CreateUser create user info.
func (s *CreateUserService) CreateUser(req *userdemo.CreateUserRequest) error {
users, err := db.QueryUser(s.ctx, req.UserName)
if err != nil {
return err
}
if len(users) != 0 {
return errno.UserAlreadyExistErr
}
h := md5.New()
if _, err = io.WriteString(h, req.Password); err != nil {
return err
}
passWord := fmt.Sprintf("%x", h.Sum(nil))
return db.CreateUser(s.ctx, []*db.User{{
UserName: req.UserName,
Password: passWord,
}})
}
如上文所述,实际的业务代码被封装在CreateUserService及其方法中,对于注册用户的接口,使用CreateUser方法进行查询。在方法中,首先通过db.QueryUser查询是否有用户名相同的用户,如有则返回errno.UserAlreadyExistErr错误;如无,则通过db.CreateUser创建新用户,其中用户密码通过md5进行加密。
func Init() {
var err error
DB, err = gorm.Open(mysql.Open(constants.MySQLDefaultDSN),
&gorm.Config{
PrepareStmt: true,
SkipDefaultTransaction: true,
},
)
if err != nil {
panic(err)
}
if err = DB.Use(gormopentracing.New()); err != nil {
panic(err)
}
}
在Init函数中,执行gorm框架的初始化工作,其中选项中PrepareStmt启动了预编译功能、SkipDefaultTransaction禁止了默认事务的生成。通过DB.Use(gormopentracing.New()),启动了数据库日志记录插件。
type User struct {
gorm.Model
UserName string `json:"user_name"`
Password string `json:"password"`
}
func (u *User) TableName() string {
return constants.UserTableName
}
// MGetUsers multiple get list of user info
func MGetUsers(ctx context.Context, userIDs []int64) ([]*User, error) {
res := make([]*User, 0)
if len(userIDs) == 0 {
return res, nil
}
if err := DB.WithContext(ctx).Where("id in ?", userIDs).Find(&res).Error; err != nil {
return nil, err
}
return res, nil
}
// CreateUser create user info
func CreateUser(ctx context.Context, users []*User) error {
return DB.WithContext(ctx).Create(users).Error
}
// QueryUser query list of user info
func QueryUser(ctx context.Context, userName string) ([]*User, error) {
res := make([]*User, 0)
if err := DB.WithContext(ctx).Where("user_name = ?", userName).Find(&res).Error; err != nil {
return nil, err
}
return res, nil
}
在模块中,表的模式通过User结构定义,并通过定义TableName方法更改了表名。模块提供了三个操作数据库的接口:
GetUsers输入一系列的用户ID,并返回对应的行,使用DB.WithContext(ctx).Where("id in ?", userIDs).Find(&res)进行实现,其对应的SQL语句为SELECT * FROM User WHERE id in userIDs;CreateUser创建输入参数指定的一系列行,gorm语句DB.WithContext(ctx).Create(users)的对应SQL语句为INSERT INTO User users;QueryUser返回指定用户名的行,gorm语句B.WithContext(ctx).Where("user_name = ?", userName).Find(&res)所对应的SQL语句为SELECT * FROM User WHERE user_name = userName
demoapi模块介绍
demoapi模块使用Hertz框架搭建HTTP服务器,并在收到请求时通过RPC调用的方法处理请求,及生成HTTP答复;
r := server.New(
server.WithHostPorts("127.0.0.1:8080"),
server.WithHandleMethodNotAllowed(true),
)
其中,使用server.New初始化服务器,并通过WithHostPorts选项指定服务器监听地址及端口;开启WithHandleMethodNotAllowed使得当当前路径匹配不上,但其他方法注册了当前路径的路由时,返回Method Not Allowed。
在项目中,使用authMiddleware, _ := jwt.New(&jwt.HertzJWTMiddleware{...})启动了jwt的Hertz扩展,其为服务器提供了登录功能。在HertzJWTMiddleware的选项中,Authenticator定义了用于认证用户的登录信息方法,其具体方法如下:
Authenticator: func(ctx context.Context, c *app.RequestContext) (interface{}, error) {
var loginVar handlers.UserParam
if err := c.Bind(&loginVar); err != nil {
return "", jwt.ErrMissingLoginValues
}
if len(loginVar.UserName) == 0 || len(loginVar.PassWord) == 0 {
return "", jwt.ErrMissingLoginValues
}
return rpc.CheckUser(context.Background(), &userdemo.CheckUserRequest{UserName: loginVar.UserName, Password: loginVar.PassWord})
},
方法通过Bind参数解析用户名及密码信息,并通过RPC调用返回登录是否成功的结果。
v1 := r.Group("/v1")
user1 := v1.Group("/user")
user1.POST("/login", authMiddleware.LoginHandler)
user1.POST("/register", handlers.Register)
note1 := v1.Group("/note")
note1.Use(authMiddleware.MiddlewareFunc())
note1.GET("/query", handlers.QueryNote)
note1.POST("", handlers.CreateNote)
note1.PUT("/:note_id", handlers.UpdateNote)
note1.DELETE("/:note_id", handlers.DeleteNote)
在这里,模块通过Group将服务分为两个服务组,其功能即为将组前缀与自定义服务的前缀拼接,如/v1/user/login将作为登录函数的前缀。
在用户组中,定义了登录及注册两个接口,其中登录接口使用JWT的authMiddleware.LoginHandler进行服务、注册接口通过RPC调用委托demouser模块处理;在笔记组中,定义了笔记的增删改查接口,并通过RPC调用委托demonote模块处理。