Go语言框架三件套(Web/RPC/ORM) | 青训营笔记

184 阅读6分钟

这是我参与「第五届青训营」伴学笔记创作活动的第3天。青训营的第三次课程中讲解了Go语言的框架三件套:Web框架Hertz、RPC框架Kitex、ORM框架Gorm,分析了easy-note项目将三个框架的使用串联起来。本文将讲解easy-note项目的架构及核心组成,以加深对于框架三件套具体使用的知识。

easy-note项目架构

easy-note项目是一个基于Web的笔记服务,支持用户注册、用户登录,以及笔记的增删改查功能。 image.png 如上图所示,项目总共拥有三个子模块,其中demoapi模块使用Kitex框架建立HTTP服务器,并接收前端请求,并通过Kitex将RPC请求发送至其余模块;demouserdemonote则使用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模块处理。