Tags: #Golang
参考自 https://www.cloudwego.io/zh/docs/kitex/getting-started/pre-knowledge/
RPC (Remote Procedure Call) ,即远程过程调用。通俗来讲,就是调用远端服务的某个方法,并获取到对应的响应。RPC 本质上定义了一种通信的流程,而具体的实现技术没有约束,核心需要解决的问题为序列化与网络通信。如可以通过 gob/json/pb/thrift 来序列化和反序列化消息内容,通过 socket/http 来进行网络通信。只要客户端与服务端在这两方面达成共识,能够做到消息正确的解析接口即可。
一般来说,RPC 框架包括了代码生成、序列化、网络通讯等,主流的微服务框架也会提供服务治理相关的能力,比如服务发现、负载均衡、熔断等等。
一次 rpc 调用包括以下基本流程,分为客户端和服务端两个部分:
其中步骤 2 中包含的流程称为「服务治理」,通常包括并不限于服务发现、负载均衡、ACL、熔断、限流等等功能。这些功能是由其他组件提供的,并不是 Thrift 框架所具有的功能。
例如基于 Thrift 的 RPC 服务开发,通常包括如下过程:
Go 语言的环境准备参考 Golang 安装
完成安装后打开终端并输入 go version ,正确输出 Go 版本以及系统架构信息代表安装成功。例如
$ go version// outputgo version go1.24.1 linux/amd64
首先确保 GOPATH 环境变量已经被正确地定义(例如 export GOPATH=~/go)并且将$GOPATH/bin 添加到 PATH 环境变量之中(例如 export PATH=$GOPATH/bin:$PATH)
安装 kitex
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
安装成功后,执行 kitex --version 可以看到具体版本号的输出:
$ kitex --versionv0.12.3
本章节中,将会模拟一个简单的电商场景,包括商品服务、库存服务与 API 服务,商品服用调用库存服务查询库存,API 服务调用商品服务查询商品信息,对前端或用户暴露 HTTP 接口供查询商品信息。
mkdir idlcd idl
base.thrift
namespace go example.shop.basestruct BaseResp {1: string code2: string msg}
item.thrift
namespace go example.shop.iteminclude "base.thrift"struct Item {1: i64 id2: string title3: string description4: i64 stock}struct GetItemReq {1: required i64 id}struct GetItemResp {1: Item item255: base.BaseResp baseResp}service ItemService{GetItemResp GetItem(1: GetItemReq req)}
stock.thrift
namespace go example.shop.stockinclude "base.thrift"struct GetItemStockReq {1: required i64 item_id}struct GetItemStockResp {1: i64 stock255: base.BaseResp BaseResp}service StockService {GetItemStockResp GetItemStock(1:GetItemStockReq req)}
在本章节中,将会模拟一个简单的电商场景,包括商品服务、库存服务与 API 服务,商品服用调用库存服务查询库存,API 服务调用商品服务查询商品信息,对前端或用户暴露 HTTP 接口供查询商品信息。
在本项目中,借助远程 Git 仓库,可以让 IDL、kitex_gen、API 服务、多个 RPC 服务的代码,分散多个仓库,不局限于本地同目录。
先创建一个项目目录:
mkdir example_shopcd example_shop
按照开发流程,首先需要编写 IDL,这里以 thrift IDL 为例子。
创建 idl 目录用于存放项目 idl 文件
mkdir idlcd idl
一般不同的服务都会使用不同的 IDL,这里创建 item.thrift 与 stock.thrift 分别定义商品服务与库存服务的接口,同时创建 base.thrift 定义公共数据结构。
base.thrift
namespace go example.shop.basestruct BaseResp {1: string code2: string msg}
item.thrift
namespace go example.shop.iteminclude "base.thrift"struct Item {1: i64 id2: string title3: string description4: i64 stock}struct GetItemReq {1: required i64 id}struct GetItemResp {1: Item item255: base.BaseResp baseResp}service ItemService{GetItemResp GetItem(1: GetItemReq req)}
stock.thrift
namespace go example.shop.stockinclude "base.thrift"struct GetItemStockReq {1: required i64 item_id}struct GetItemStockResp {1: i64 stock255: base.BaseResp BaseResp}service StockService {GetItemStockResp GetItemStock(1:GetItemStockReq req)}
最后将 idl 目录上传到远程 Git 服务,此处假设为 https://code.example.com/example_shop/idl
有了 IDL 以后,便可以通过 kitex 工具生成项目代码了。因为有两个 IDL 定义了服务,所以执行两次 kitex 命令:
# 此处的 ${module} 参数用 code.example.com/example_shop(示例)替代kitex -module ${module} idl/item.thriftkitex -module ${module} idl/stock.thrift
-module 参数很重要。kitex_gen 同级目录下生成 go.mod 文件,并以 module ${module} 开头。如果不想受限于单目录下开发,而是多仓库协同的话,此处生成的 go.mod 可以手动删掉。kitex_gen 所属项目的前缀,并编码在 kitex_gen 的代码中。定义错误的话,其他仓库引用 kitex_gen 时,将会无法正确拉取其依赖。idl/item.thrift 和 idl/stock.thrift 则是对应 thrift 文件所在路径,这个路径不会被编码在代码之中生成的代码分两部分,一部分是结构体的编解码序列化代码,由 IDL 编译器生成;另一部分由 kitex 工具在前者产物上叠加,生成用于创建和发起 RPC 调用的桩代码。它们默认都在 kitex_gen 目录下。
最后将 kitex_gen 目录上传到远程 Git 服务,此处同样假设为 https://code.example.com/example_shop/kitex_gen
前面生成的
kitex_gen代码并不能直接运行,需要自己完成NewClient和NewServer的构建。kitex 命令行工具提供了-service参数能直接生成带有脚手架的代码,接下来让我们为商品服务和库存服务分别生成脚手架。
首先为两个 RPC 服务分别单独创建目录
mkdir -p rpc/item rpc/stock
再分别进入各自的目录中,执行如下命令生成代码:
# 此处的 ${module} 参数用 code.example.com/example_shop(示例)替代// item 目录下执行kitex -module item -service example.shop.item -use ${module}/kitex_gen ../../idl/item.thrift// stock 目录下执行kitex -module stock -service example.shop.stock -use ${module}/kitex_gen ../../idl/stock.thrift
kitex 默认会将代码生成到执行命令的目录下,kitex 的命令中:
-module 参数表明生成代码的 go mod 中的 module name,在本例中为 item 和 stock。也可以命名为 shop_item 和 shop_stock,分仓库开发的话,没什么所谓。-service 参数表明要生成脚手架代码,后面紧跟的 example.shop.item 或 example.shop.stock 为该服务的名字。-use 参数表示让 kitex 不生成 kitex_gen 目录,而使用该选项给出的 import path。在本例中因为第一次已经生成 kitex_gen 目录了,后面都可以复用。-use 后面接的参数值,会被编码在脚手架里 import kitex_gen 时的 URL 前缀在 item 和 stock 各自的目录下执行 go mod tidy 拉取依赖。
最后,项目结构如下(可以划分为 4 个 Git 仓库):
.├── idl // 示例 idl 存放的目录│ ├── base.thrift│ ├── item.thrift│ └── stock.thrift├── kitex_gen│ └── example│ └── shop│ ├── base│ │ ├── base.go // 根据 IDL 生成的编解码文件,由 IDL 编译器生成│ │ ├── k-base.go // kitex 专用的一些拓展内容│ │ └── k-consts.go│ ├── item│ │ ├── item.go // 根据 IDL 生成的编解码文件,由 IDL 编译器生成│ │ ├── itemservice // kitex 封装代码主要在这里│ │ │ ├── client.go│ │ │ ├── itemservice.go│ │ │ └── server.go│ │ ├── k-consts.go│ │ └── k-item.go // kitex 专用的一些拓展内容│ └── stock│ ├── k-consts.go│ ├── k-stock.go // kitex 专用的一些拓展内容│ ├── stock.go // 根据 IDL 生成的编解码文件,由 IDL 编译器生成│ └── stockservice // kitex 封装代码主要在这里│ ├── client.go│ ├── server.go│ └── stockservice.go└── rpc├── item│ ├── build.sh // 用来编译的脚本,一般情况下不需要更改| ├── go.mod // go module 文件| ├── go.sum│ ├── handler.go // 服务端的业务逻辑都放在这里,这也是需要更改和编写的文件│ ├── kitex_info.yaml│ ├── main.go│ └── script│ └── bootstrap.sh└── stock├── build.sh // 用来编译项目的脚本,一般情况下不需要更改├── go.mod // go module 文件├── go.sum├── handler.go // 服务端的业务逻辑都放在这里,这也是需要更改和编写的文件├── kitex_info.yaml├── main.go // 服务启动函数,一般在这里做一些资源初始化的工作,可以更改└── script└── bootstrap.sh
最终状态调用关系:API 服务(HTTP 协议) -> 商品服务(rpc/item,RPC 协议)-> 库存服务(rpc/stock,RPC 协议)
需要编写的服务端逻辑都在 handler.go 这个文件中,库存服务的业务逻辑在 rpc/stock/handler.go 中修改,此处不作赘述,直接运行服务。
直接看看 rpc/stock/main.go:main.go 中的代码很简单,使用 kitex 生成的代码创建一个 server 服务端,并调用其 Run 方法开始运行。通常使用 main.go 进行一些项目初始化,如加载配置等。
package mainimport ("log"stock "code.example.com/example_shop/kitex_gen/example/shop/stock/stockservice")func main() {svr := stock.NewServer(new(StockServiceImpl))err := svr.Run()if err != nil {log.Println(err.Error())}}
如果直接运行该服务的话,会默认监听 8888 端口。可以在创建 server 时传入 option 配置将监听的端口修改为 8890
package mainimport ("log""net"stock "code.example.com/example_shop/kitex_gen/example/shop/stock/stockservice""github.com/cloudwego/kitex/server")func main() {addr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:8890")svr := stock.NewServer(new(StockServiceImpl), server.WithServiceAddr(addr))err := svr.Run()if err != nil {log.Println(err.Error())}}
有两种运行方式:
go run . 直接运行服务下面细讲第一种方式:
kitex 为我们生成了编译脚本,即 build.sh。在 build.sh 主要做了以下事情:
RUN_NAME,用于指定生成的可执行文件的名称,值为我们在 IDL 中指定的 namespace。本例中为 example.shop.stockoutput 目录,此后的编译出的二进制文件放在 output/bin 下。同时将 script 目录下的项目启动脚本复制进去IS_SYSTEM_TEST_ENV 的值判断生成普通可执行文件或测试可执行文件。值为 1 则代表使用 go test -c 生成测试文件,否则正常使用 go build 命令编译。直接执行 sh build.sh 即可编译项目。
编译成功后,生成 output 目录:
output├── bin // 存放二进制可执行文件│ └── example.shop.stock└── bootstrap.sh // 运行文件的脚本
执行 sh output/bootstrap.sh 即可启动编译后的二进制文件。输出类似以下命令,代表运行成功:
2025/03/16 08:35:11.654937 server.go:79: [Info] KITEX: server listen at addr=[::]:8890
已经成功运行了库存服务,接下来补充商品服务,实现对库存服务的调用。
为了实现 client 的复用,在 ItemServiceImpl 中补充字段 stockservice.Client ,在 rpc/item/handler.go 中补充以下方法:
package mainimport ("context""log"item "code.example.com/example_shop/kitex_gen/example/shop/item""code.example.com/example_shop/kitex_gen/example/shop/stock""code.example.com/example_shop/kitex_gen/example/shop/stock/stockservice""github.com/cloudwego/kitex/client")// ItemServiceImpl implements the last service interface defined in the IDL.type ItemServiceImpl struct {stockCli stockservice.Client}func NewStockClient(addr string) (stockservice.Client, error) {return stockservice.NewClient("example.shop.stock", client.WithHostPorts(addr))}// GetItem implements the ItemServiceImpl interface.func (s *ItemServiceImpl) GetItem(ctx context.Context, req *item.GetItemReq) (resp *item.GetItemResp, err error) {resp = item.NewGetItemResp()resp.Item = item.NewItem()resp.Item.Id = req.GetId()resp.Item.Title = "Kitex"resp.Item.Description = "Kitex is an excellent framework!"stockReq := stock.NewGetItemStockReq()stockReq.ItemId = req.GetId()stockResp, err := s.stockCli.GetItemStock(context.Background(), stockReq)if err != nil {log.Println(err)stockResp.Stock = 0}resp.Item.Stock = stockResp.GetStock()return}
为 ItemServiceImpl 补充了库存服务的客户端,需要初始化后才能使用,在 rpc/item/main.go 中完成初始化操作:
package mainimport ("log"item "code.example.com/example_shop/kitex_gen/example/shop/item/itemservice")func main() {itemServiceImpl := new(ItemServiceImpl)stockCli, err := NewStockClient("0.0.0.0:8890")if err != nil {log.Fatal(err)}itemServiceImpl.stockCli = stockClisvr := item.NewServer(itemServiceImpl)err = svr.Run()if err != nil {log.Println(err.Error())}}
由于库存服务跑在 8890 端口,所以指定 8890 端口创建客户端。
至此,商品服务代码编写完整,参照上文重新编译启动商品服务,看到如下输出代表运行成功:
2025/03/16 08:52:09.836315 server.go:79: [Info] KITEX: server listen at addr=[::]:8888
有了商品服务和库存服务后,接下来需要编写 API 服务,用于调用这些 RPC 服务,并对外暴露 HTTP 接口。
此处使用 Gin 做一个简单演示,有关 Gin 框架的用法参见 Gin 文档
创建 API 目录,并新建 main.go,代码如下:
package mainimport ("context""log""time""github.com/cloudwego/kitex/client""github.com/cloudwego/kitex/client/callopt""github.com/gin-gonic/gin""code.example.com/example_shop/kitex_gen/example/shop/item""code.example.com/example_shop/kitex_gen/example/shop/item/itemservice")var (itemCli itemservice.Client)func main() {// 创建 clientc, err := itemservice.NewClient("example.shop.item", client.WithHostPorts("0.0.0.0:8888"))if err != nil {log.Fatal(err)}itemCli = c// 启动 Gin 服务router := gin.Default()router.GET("/api/item", Handler)router.Run() // 监听并在 0.0.0.0:8080 上启动服务}func Handler(c *gin.Context) {req := item.NewGetItemReq()req.Id = 1024resp, err := itemCli.GetItem(context.Background(), req, callopt.WithRPCTimeout(3*time.Second))if err != nil {log.Fatal(err)}c.JSON(200, gin.H{"code": 0, "value": resp.String()})}
执行 go run main.go 命令即可启动 API 服务,监听 8080 端口,请求 localhost:8080/api/item 即可发起 RPC 调用商品服务提供的 GetItem 接口,并获取到响应结果。
为了更贴近真实环境,接下来为以上服务接入注册中心,在本例中选择了 etcd 作为注册中心,etcd 的安装与使用可参考 etcd.io 或使用下述 docker compose 文件,接下来默认你已经安装并启动 etcd 服务实例。
推荐使用官方镜像,支持版本控制:
docker pull quay.io/coreos/etcd:v3.5.12
防止容器重启后数据丢失:
mkdir -p /opt/etcd/{data,config} # 数据目录和配置目录
docker pull quay.io/coreos/etcd:v3.5.12
有两种方式:
单节点部署的核心命令
docker run -d \--name etcd-single \-p 2379:2379 \ # 客户端访问端口-p 2380:2380 \ # 节点间通信端口(单节点可忽略)-v /opt/etcd/data:/var/lib/etcd \ # 数据持久化-v /opt/etcd/config:/etc/etcd \ # 配置文件目录quay.io/coreos/etcd:v3.5.12 \etcd \--name single-node \--data-dir /var/lib/etcd \--advertise-client-urls http://0.0.0.0:2379 \--listen-client-urls http://0.0.0.0:2379 \--initial-cluster-state new
或者,使用配置文件启动(推荐)
创建配置文件 /opt/etcd/config/etcd.conf.yml
name: etcd-singledata-dir: /var/lib/etcdlisten-client-urls: http://0.0.0.0:2379advertise-client-urls: http://0.0.0.0:2379initial-cluster-token: etcd-single-clusterinitial-cluster-state: new
启动时加载配置文件
docker run -d \--name etcd-single \-p 2379:2379 \-p 2380:2380 \-v /opt/etcd/data:/var/lib/etcd \-v /opt/etcd/config:/etc/etcd \-v /opt/etcd/config/etcd.conf.yml:/etc/etcd/etcd.conf.yml \quay.io/coreos/etcd:v3.5.12 \etcd --config-file /etc/etcd/etcd.conf.yml
docker exec -it etcd-single etcdctl endpoint health# 输出示例:http://0.0.0.0:2379 is healthy
在 rpc/stock/main.go 中补充以下逻辑:
package mainimport ("log""net"stock "code.example.com/example_shop/kitex_gen/example/shop/stock/stockservice""github.com/cloudwego/kitex/pkg/rpcinfo""github.com/cloudwego/kitex/server"etcd "github.com/kitex-contrib/registry-etcd")func main() {// 使用时请传入真实 etcd 的服务地址,本例中为 127.0.0.1:2379r, err := etcd.NewEtcdRegistry([]string{"127.0.0.1:2379"})if err != nil {log.Fatal(err)}addr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:8890")svr := stock.NewServer(new(StockServiceImpl),server.WithServiceAddr(addr),// 指定 Registry 与服务基本信息server.WithRegistry(r),server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: "example.shop.stock",},),)err = svr.Run()if err != nil {log.Println(err.Error())}}
如果库存服务和其他服务不在同一个网络环境,其他服务将无法通过 0.0.0.0:8890 访问库存服务。
解决方法:(详情参见 服务注册扩展)
当 Client 端无法访问 Server 端的 Listen Address,而需要使用公共的 IP 地址访问 Server 端时,可以通过指定 RegistryInfo 中的
Addr和SkipListenAddr进行设置。
func main() {// 使用时请传入真实 etcd 的服务地址,本例中为 127.0.0.1:2379r, err := etcd.NewEtcdRegistry([]string{"127.0.0.1:2379"})if err != nil {log.Fatal(err)}// 服务端本地监听 8890 端口addr, _ := net.ResolveTCPAddr("tcp", "0.0.0.0:8890")// 服务注册时,传入可访问的公网 IP + 端口remoteAddr, _ := net.ResolveTCPAddr("tcp", os.Getenv("HOST_IP")+":8890")registryInfo := ®istry.Info{Addr: remoteAddr,SkipListenAddr: true,}svr := stock.NewServer(new(StockServiceImpl),server.WithServiceAddr(addr),// 指定 Registry 与服务基本信息server.WithRegistry(r),server.WithRegistryInfo(registryInfo),server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: "example.shop.stock",},),)err = svr.Run()if err != nil {log.Println(err.Error())})
以同样的方式实现服务注册,在 rpc/item/main.go 中补充以下逻辑:
package mainimport ("log"item "code.example.com/example_shop/kitex_gen/example/shop/item/itemservice""github.com/cloudwego/kitex/pkg/rpcinfo""github.com/cloudwego/kitex/server"etcd "github.com/kitex-contrib/registry-etcd")func main() {// 使用时请传入真实 etcd 的服务地址,本例中为 127.0.0.1:2379r, err := etcd.NewEtcdRegistry([]string{"0.0.0.0:2379"})if err != nil {log.Fatal(err)}itemServiceImpl := new(ItemServiceImpl)stockCli, err := NewStockClient("0.0.0.0:8890")if err != nil {log.Fatal(err)}itemServiceImpl.stockCli = stockClisvr := item.NewServer(itemServiceImpl,// 指定 Registry 与服务基本信息server.WithRegistry(r),server.WithServerBasicInfo(&rpcinfo.EndpointBasicInfo{ServiceName: "example.shop.item",}),)err = svr.Run()if err != nil {log.Println(err.Error())}}
补充完成代码后,分别启动这两个服务,两个服务的终端都会出现类似输出:
2025/03/16 17:51:47.507073 etcd_registry.go:299: [Info] start keepalive lease 38f7959f4b9c4334 for etcd registry
再使用 etcdctl 确认是否注册成功,执行 docker exec -it etcd-single etcdctl get --prefix "kitex" 会有类似如下输出:
kitex/registry-etcd/example.shop.item/192.168.196.240:8888{"network":"tcp","address":"192.168.196.240:8888","weight":10,"tags":null}kitex/registry-etcd/example.shop.stock/127.0.0.1:8890{"network":"tcp","address":"127.0.0.1:8890","weight":10,"tags":null}
如果都能正确输出,代表服务注册成功。
在前面的代码中,商品服务调用库存服务的逻辑放在了 rpc/item/handler.go 中,故在此文件的 NewStockClient 中添加逻辑:
func NewStockClient() (stockservice.Client, error) {// 使用时请传入真实 etcd 的服务地址,本例中为 127.0.0.1:2379r, err := etcd.NewEtcdResolver([]string{"127.0.0.1:2379"})if err != nil {log.Fatal(err)}return stockservice.NewClient("example.shop.stock", client.WithResolver(r)) // 指定 Resolver}
记得把 rpc/item/main.go 中调用 NewStockClient() 的参数去掉。
修改 API 目录下的 main.go 中的 main 函数
var (itemCli itemservice.Client)func main() {// 使用时请传入真实 etcd 的服务地址,本例中为 127.0.0.1:2379resolver, err := etcd.NewEtcdResolver([]string{"127.0.0.1:2379"})if err != nil {log.Fatal(err)}// 创建 clientc, err := itemservice.NewClient("example.shop.item", client.WithResolver(resolver))if err != nil {log.Fatal(err)}itemCli = c// 启动 Gin 服务router := gin.Default()router.GET("/api/item", Handler)router.Run() // 监听并在 0.0.0.0:8080 上启动服务}