本文的主要内容分为两个方面。首先介绍了按照cwgo新手课程,搭建一个简单的用户服务,使用http处理restful接口并调用rpc服务。其次,详细介绍了cwgo生成的源代码结构。
gomall服务搭建
本节会讲解gomall介绍视频的用户服务搭建。
第一步 idl文件编写
IDL(Interface Definition Language,接口定义语言)是一种用于定义系统中不同组件或服务之间接口的语言。它主要用于描述程序间通信的结构和约定,尤其是在分布式系统或跨平台系统中,IDL是实现系统间互操作性的重要工具。cwgo需要通过IDL来确定生成的代码包括什么服务,这样才能生成对应的代码。因此,如果想要搭建服务,就需要首先使用IDL来确定本次需要实现什么样的服务。服务的入参是什么?返回是什么?名字是什么?服务需要的参数内部是什么类型的数据?这些都需要在IDL中表达完整。
本课程提到的IDL有两种,一种是protobuf,一种是thrift。protobuf是现代分布式系统中最流行的IDL,具有高效、紧凑、跨语言的特点,适合微服务和高性能应用,thrift也具有较强的跨语言支持,适用于大规模分布式和大数据系统。这两种IDL的语法不同,但都可以表示接口。在使用过程中可以使用任意一种。课程演示使用的是protobuf,笔者接下来在展示protobuf的内容之后,也会展示thrift的用法。
首先我们来看protobuf的用法。
// common.proto
syntax = "proto3";
package frontend.common;
option go_package = "frontend/common";
message Empty {}
这个protobuf文件首先表明了语法是proto3的规范。package表明了protobuf文件的命名空间。option指定了go语言中生成的代码的包名,如果我们使用了cwgo来生成代码的话,生成的代码会按照使用框架的不同生成在kitex_gen文件夹内或hertz_gen文件夹内。
最后一行,定义了一个名为 Empty 的 message 类型。在 protobuf 中,message 是用于定义数据结构的基本单元。可以看到Empty 消息类型没有任何字段,意味着它是一个空的消息,内部什么类型都没有。这个protobuf文件名为common.proto,为了方便理解,我在开头的注释中表明了它的名字,但这在编码的过程中不是必须的,该文件定义了一个可能会被多个服务共用的类型名为Empty的message,接下来,它会被其它proto文件引用。
// home.proto
syntax = "proto3";
package frontend.home;
import "api.proto";
import "frontend/common.proto";
option go_package = "frontend/home";
service HomeService {
rpc Home(common.Empty) returns(common.Empty) {
option (api.get) = "/";
}
}
这个proto文件的import关键字表明了该proto文件引用了上文的common.proto文件,这样就可以在下文中使用common里面的Empty类型了。除了引用了common文件,还引用了一个叫做api.proto文件。api文件包含了一些基本信息,在创建service时是必须要引用的,并且这并不是一个需要我们自己编写的文件,关于它的内容,我们可以查看cwgo的官方文档,官方文档的第一步就是要创建api.proto文件来注解拓展,也给出了文件的所有内容。我们在开发的时候只需要将所有的内容粘贴过来就好。在使用thrift用作IDL时,不需要引用api.proto。
接下来,在option关键字之后,service关键字定义了一个名为HomeService关键字的服务,客户端可以通过该接口与其通信,内部的代码定义了这个服务的具体接口。rpc表示了具体的名为Home的接口,这个接口的请求参数是common文件里面的Empty类型,该接口也返回一个common文件里的Empty类型。在这个接口内部,api.get表示指定该接口为HTTP请求,且路径为/,这里的api就看出来之前引用api.proto的用途了。
到这里,我们就完成了home服务的IDL文件创建,接下来,我会给出其它服务的proto文件内容。
//auth_page.proto
syntax = "proto3";
package frontend.auth;
import "api.proto";
import "frontend/common.proto";
option go_package = "frontend/auth";
message LoginReq {
string email = 1 [(api.form)="email"];
string password = 2 [(api.form)="password"];
string next = 3 [(api.query)="next"];
}
message RegisterReq {
string email = 1 [(api.form)="email"];
string password = 2 [(api.form)="password"];
string password_confirm = 3 [(api.form)="password_confirm"];
}
service AuthService {
rpc login(LoginReq) returns(common.Empty) {
option (api.post) = "/auth/login";
}
rpc register(RegisterReq) returns(common.Empty) {
option (api.post) = "/auth/register";
}
rpc logout(common.Empty) returns(common.Empty) {
option (api.post) = "/auth/logout";
}
}
//user.proto
syntax = "proto3";
package user;
option go_package = "/user";
message RegisterReq {
string email = 1;
string password = 2;
string password_confirm = 3;
}
message RegisterResp {
int32 user_id = 1;
}
message LoginReq {
string email = 1;
string password = 2;
}
message LoginResp {
int32 user_id = 1;
}
service UserService {
rpc Register (RegisterReq) returns (RegisterResp) {}
rpc Login (LoginReq) returns (LoginResp) {}
}
这份IDL定义了授权和用户服务的相关接口。
接下来,我们会介绍thrift相关的内容。 首先来看common文件的thrift版本
//common.thrift
namespace go hello.example
struct Empty {}
namespace go 表明生成 Go 代码。hello.example 是包的名称,相当于 Go 中的包路径(即生成的 Go 代码会位于 hello/example 目录下,和protobuf的go_package类似。struct 是 Thrift 中用来定义数据结构的关键字。这个定义了一个名为 Empty 的结构体(也可以理解为一个简单的数据类型)。{} 表示 Empty 结构体没有任何字段,也就是说它是一个空结构体。和proto中的message类似。
// user.thrift
namespace go hello.example
include "common.thrift"
struct LoginReq{
1 : string email;
2 : string password;
}
struct RegisterReq {
1 : string email;
2 : string password;
3 : string password_confirm;
}
struct LoginResp {
1 : i32 user_id;
}
struct RegisterResp{
1 : i32 user_id;
}
service UserService {
RegisterResp Register (RegisterReq req);
LoginResp Login (LoginReq req);
}
接下来介绍略显复杂的、定义了用户服务的user.thrift文件。include "common.thrift"引入了另一个 Thrift 文件 common.thrift。这意味着你可以在当前文件中使用 common.thrift 文件中定义的结构体、服务等内容,和proto里面的import类似。对于内部的具体结构体来说,以LoginReq为例子,LoginReq 是一个结构体,表示 登录请求。结构体包含两个字段:email 和 password,它们都是 string 类型。字段前面的 1 和 2 是字段的编号,这些编号用于 Thrift 序列化和反序列化时区分字段。接下来是service关键字,service 定义了一个名为 UserService 的服务,表示提供用户相关操作的服务。Register 方法接收一个 RegisterReq 请求对象,并返回一个 RegisterResp 响应对象。Login 方法接收一个 LoginReq 请求对象,并返回一个 LoginResp 响应对象。
// auth_page.thrift
namespace go hello.example
include "../common.thrift"
struct LoginReq{
1 : string email(api.form="email");
2 : string password(api.form="password");
3 : string next(api.query="next");
}
struct RegisterReq {
1 : string email(api.form="email");
2 : string password(api.form="password");
3 : string password_confirm (api.form="password_confirm");
}
service AuthService {
common.Empty login(1 : LoginReq req)(api.post = "/auth/login");
common.Empty register(1 : RegisterReq req)(api.post = "/auth/register");
common.Empty logout(1 : common.Empty req)(api.post = "/auth/logout");
}
第二步 使用cwgo工具生成对应代码
cwgo是可以生成代码的命令行工具,并整合了多个框架方便开发。我们可以在cwgo的官方文档查看到对应的信息。在完成了对应的idl文件之后,我们可以直接使用cwgo来生成对应的框架代码,我们只需要在服务内部修改我们的服务逻辑即可。 首先我们进入对应的目录,然后执行cwgo命令。
cwgo server -I ../../idl --type HTTP --service frontend --module ${ROOT_MOD}/app/frontend --idl ../../idl/frontend/auth_page.proto
让我们来仔细查看命令中的具体内容。 cwgo server表明需要生成的是服务端,-I则是增加idl的搜索路径,--type指定这次生成的框架代码遵循http协议(默认为RPC),--service指定服务名称,--module指定go module的名字来放入go.mod文件中,--idl来指定具体的idl文件路径。我们可以在官方文档查看到具体的内容。
在这段命令之后,我们可以同样生成需要调用的RPC客户端和服务端。
cwgo server --type RPC --service user --module ${ROOT_MOD}/app/user --pass "-use ${ROOT_MOD}/rpc_gen/kitex_gen" -I ../../idl --idl ../../idl/user.proto
cwgo client --type RPC --service user --module ${ROOT_MOD}/rpc_gen --I ../idl --idl ../idl/user.proto
这段代码生成了RPC对应的客户端和服务端。接下来,最开始生成的http服务就可以调用RPC服务端了。 在框架代码运行时,我们需要在docker或本地中提前运行对应的中间件(redis、MySQL和consul注册中心)
cwgo生成代码结构解析
在官方文档中,我们可以看到已经给出了具体的生成代码结构。
最主要的代码结构分为handler模块、model模块、service模块(官方文档没给出,但是确实生成了)、dal模块(官方文档也没有)、middleware模块(官方文档也没有)。笔者不明白为什么官方文档没有解释这些模块,但在gomall的视频中确实给出了解释。接下来我会结合代码解释每一个部分,大部分为个人理解,如果有错误请在评论区指出。
model模块
model模块一般在biz文件夹之内,当使用hertz框架的时候,会生成对应数据的模型,gomall中model的内容如下所示。
这个方法定义了struct User并可借助此结构体的方法对gorm进行操作。这段代码的方法都是手写的,TableName表示的操作的表名称,Create和GetByEmail表示新建和通过Email找到数据列。
handler模块
handler模块是处理具体内容的地方,在biz文件夹之内。以auth服务的Login接口为例。
我们可以看到,handler里面的Login方法在接收到对应的上下文信息之后,会绑定和验证数据,如果没有出错的话,会在图中黄框调用service。通过service的Run方法来真正的调用具体服务的处理逻辑。这里一般是自动生成的,我们可以不用去管这里的处理逻辑。在main.go中加入handler的register方法即可。
router模块
router模块规定了具体的处理路由,也在biz文件夹之内。
这里规定了具体的接口对应的路由,例如图中黄框所在行就表明了在_auth路由下的/login路径对应的是Login接口,这里的Login对应的实际上是上文提到过的handler的内容。也就是说,router规定了在面对POST来访问/login路径的时候,会调用上文的handler模块的Login代码。而handler内部又会调用真正的服务方法。
dal模块
dal实际上是数据库和Redis初始化的内容,它也在dal文件夹里面。dal文件夹内部的Init函数可以执行对应的初始化流程。
我们以MySQL的初始化过程为例子吧。
这段函数在初始化的时候会调用gorm框架获取gorm.DB类型的变量,这个变量可以对数据库进行直接操作。我们可以在框架运行的时候手动调用dal的init函数,在DB变量被初始化之后,就可以在别的部分使用它来直接操作数据库了。redis也类似。
service模块
service模块表示了具体的服务逻辑,也在biz里面。它被handler调用。
这里service的handler调用了该处的Run方法,我们可以在Run方法里面写明具体的处理逻辑。
结语
在上文中,我们可以看到对应的模块。我们在进行编码的时候,实际上服务的URI可以通过idl文件指定,开发的时候主要修改的是service模块的逻辑。总结一下,router规定了handler的具体路由,handler调用了service的具体内容。