GoFrame & Grpc微服务学习 (一)

GoFrame & gRPC微服务学习笔记(一)

前言

由于项目要求,需要使用GoFrame框架和gRPC协议来构建微服务,所以需要学习一下GoFrame和gRPC。

在第一篇的学习中记录,主要是学习和如何使用GoFrame脚手架来快速建立一个微服务框架。

学习目标

  • 了解GoFrame框架的基础架构
  • 掌握gRPC的核心概念
  • 搭建基础的微服务开发环境

Golang中的的微服务和Java中的微服务概念相差不大,微服务就是将一个大的系统拆分成多个小系统,每个小系统都可以独立运行,互不干扰。

同时微服务不直接对外提供服务,而是统一交给一个网关来对外提供服务,网关负责请求的转发和负载均衡。

网关作为一个Web服务,它不直接提供具体的业务功能,而是负责接收请求,转发到各微服务,最后拼接数据返回,以此来完成业务功能。

微服务之间通过grpc协议进行通讯,而网关对外提供http服务,内部则使用grpc协议与各个微服务进行通信。

而微服务的注册中心则使用etcd来实现。

学习内容

1.使用GoFrame脚手架快速建立一个微服务框架

微服务将单体服务拆开,代码也自然分离。它的代码仓库,有两种常见的管理方式:

Multirepo 多仓库模式,每个微服务都有独立的仓库。优点是每个仓库相对较小,易于管理。缺点是需要额外的工具,流程协调各个服务之间的依赖和版本。

Monorepo 单一仓库模式,所有微服务的代码都存放在一个仓库中。优点是可以统一管理版本和依赖,缺点是仓库可能会变得庞大,管理复杂度增加。

GoFrame脚手架支持这两种管理方式,并且提供了脚手架工具来快速创建微服务。

这里使用单一仓库模式。

gRPC是一个由 Google 开发的远程过程调用(RPC)框架,基于HTTP/2。它使用 Protobuf 作为默认的序列化格式。

在安装Go语言插件之前,需要先安装protobuf插件,需要注意的是,protobuf是是用于序列化结构化数据的与语言无关、与平台无关的可扩展机制。所以需要使用apt/dnf/brew等包管理工具来安装。当然也可以选择使用二进制进行安装。

我这里使用的是fedora41,使用dnf安装命令:

1
sudo dnf install protobuf

安装完毕后,可以输入protoc --version来确认版本号。

1
2
$ protoc --version
libprotoc 3.19.6

Go语言通过gRPC-go插件提供gRPC功能。执行命令安装插件:

1
2
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

在上述两者安装完毕后,则初步开发环境已经搭建完成。随机开始建立项目。

由于是一个微服务项目,我们建立总目录时不能使用传统的web结构,而是需要加一个-m参数,表示使用Monorepo模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ gf init --help

USAGE
gf init ARGUMENT [OPTION]

ARGUMENT
NAME name for the project. It will create a folder with NAME in current directory.
The NAME will also be the module name for the project.

OPTION
-m, --mono initialize a mono-repo instead a single-repo
-a, --monoApp initialize a mono-repo-app instead a single-repo
-u, --update update to the latest goframe version
-g, --module custom go module
-h, --help more information about this command

EXAMPLE
gf init my-project
gf init my-mono-repo -m
gf init my-mono-repo -a

$ gf init foo -m

initializing...
initialization done!

在初始化项目目录后,仍然建议升级一下GoFrame版本。

1
2
cd foo
gf up

随后需要安装各类驱动组件,首先是微服务组件grpcx:

1
go get -u github.com/gogf/gf/contrib/rpc/grpcx/v2

其次是数据库驱动

1
go get -u github.com/gogf/gf/contrib/drivers/mysql/v2

附加:数据库驱动组件一览表:

1
2
3
4
5
6
7
8
9
go get -u github.com/gogf/gf/contrib/drivers/mysql/v2
# Easy to copy
go get -u github.com/gogf/gf/contrib/drivers/clickhouse/v2
go get -u github.com/gogf/gf/contrib/drivers/dm/v2
go get -u github.com/gogf/gf/contrib/drivers/mssql/v2
go get -u github.com/gogf/gf/contrib/drivers/oracle/v2
go get -u github.com/gogf/gf/contrib/drivers/pgsql/v2
go get -u github.com/gogf/gf/contrib/drivers/sqlite/v2
go get -u github.com/gogf/gf/contrib/drivers/sqlitecgo/v2

最后是etcd组件:

1
go get -u github.com/gogf/gf/contrib/registry/etcd/v2

至此,微服务框架建立完毕。

2.微服务编写整体思路

首先,一般一个微服务负责一块业务,如用户账号信息。一个微服务对应一个数据库。在编写业务代码之前,先要设计数据库表格。

在设计完数据库表格之后,可以使用gf init app/user -a初始化一个新的微服务仓库。

hack/config.yaml中配置好数据库连接,执行gf gen dao即可通过goframe一键生成对应的dao、do和entity实体类。

dao:通过对象方式访问底层数据源,底层基于ORM组件实现。
do:数据转换模型,用于业务模型到数据模型的转换,由工具维护,用户不能修改。工具每次生成代码文件将会覆盖该目录。
entity:数据模型,由工具维护,用户不能修改。工具每次生成代码文件将会覆盖该目录。

由于采用了grpc,需要生成pbentity模型。需要在hack/config.yaml中增加以下字段:

1
2
pbentity:  
- link: "mysql:root:12345678@tcp(srv.com:3306)/user"

随后执行gf gen pbentity即可完成proto文件的生成。

gen pbentity与gen dao的差别

  1. gen dao 生成的数据是go文件,主要在微服务内部使用,例如ORM操作;
  2. gen pbentity生成的数据是proto文件,主要用作gRPC微服务之间的通讯。

随后为编写业务逻辑代码,业务逻辑代码存放在*/internal/logic

编写完毕之后,就要开始编写协议文件。

协议文件指的是*.proto文件,proto是gRPC协议通讯的标准,可以类比为json和HTTP。但是千万不要理解proto就是json,他们还是有一定区别的:proto同时定义“接口”信息和响应参数,请求参数,而json就单纯的保存数据。

proto文件统一存放在manifest/protobuf下,和普通的HTTP服务一样,使用目录层级来管理接口版本。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
syntax = "proto3";  

package account.v1;

option go_package = "proxima/app/user/api/account/v1";

service Account{
rpc UserRegister(UserRegisterReq) returns (UserRegisterRes) {}
}

message UserRegisterReq {
string username = 1; // v:required|min-length:2
string password = 2; // v:required|min-length:6
string email = 3; // v:required|email
}

message UserRegisterRes {
int32 id = 1;
}

简单的介绍一下proto语法:

  • syntax 规定本文件语法版本;
  • package 定义的是服务命名空间,可以理解为包名;
  • option 设定编译选项,go_package指定生成的Go代码所属的包名。GoFrame中固定格式是项目名 + app + 微服务名称 + api + 模块名 + v1
  • service 定义远程调用方法,一般是RPC,规定其请求和响应参数;
  • message 定义数据结构,string是数据类型,username是字段名,赋值号后面的递增数字是字段编号。最后面的注释是框架提供的参数校检,使用方式普通的HTTP接口一致

我们的文件定义了以下内容:

  • 使用proto3语法版本的定义;
  • 定义了包名为account.v1
  • 设置了Go代码生成的包路径选项go_packageproxima/app/user/api/account/v1
  • 定义了一个Account服务,包含一个RPC方法UserRegister,它接受UserRegisterReq消息并返回UserRegisterRes消息;
  • 定义了一个消息类型UserRegisterReq,包含三个字段:
    • username (字符串类型,编号为1)
    • password (字符串类型,编号为2)
    • email (字符串类型,编号为3)
  • 定义了一个消息类型UserRegisterRes,包含一个字段:
    • id (整型,编号为1)
  • import "pbentity/users.proto";,代表引入了其他proto文件。引入的这个文件是gf gen pbentity生成的;
  • pbentity.Users user调用引入的数据模型,和go的结构体几乎一致。

随后编写控制器,微服务也有对应的控制器,相当于是请求过来了之后该如何处理,和MVC三层模型中的controller基本一致,只不过和web不同点在于,其不会对外暴露,只对内暴露,并且通过grpc交互。

在这里补充一下,MVC三层模型并不是JAVA的“专利”,任何语言都可以完成MVC三层模型。而且也是对自己提个醒,模型等抽象概念,在任何语言中都可以适用,不要被刻板印象所限制!

随后是编写cmd,一般叫做internal/cmd/cmd.go,也叫做控制器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// package cmd 包含命令行相关的功能实现
package cmd

import (
// context 包提供了跨 API 边界和进程的请求范围数据、取消信号和截止时间
"context"
// grpcx 是 GoFrame 框架提供的 gRPC 扩展包
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
// gcmd 提供命令行功能支持
"github.com/gogf/gf/v2/os/gcmd"
// grpc 包含 gRPC 核心功能
"google.golang.org/grpc"
// words 包含词语服务相关的控制器
"proxima/app/word/internal/controller/words"
)

var (
// Main 定义主命令行入口
Main = gcmd.Command{
// 命令名称
Name: "main",
// 命令用法说明
Usage: "main",
// 命令简要说明
Brief: "word grpc service",
// Func 定义命令执行的具体逻辑
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
// 创建新的 gRPC 服务器配置
c := grpcx.Server.NewConfig()

// 添加服务器选项,配置拦截器
c.Options = append(c.Options, []grpc.ServerOption{
// ChainUnary 用于链接多个一元拦截器
// UnaryValidate 是请求验证拦截器,用于验证每个 RPC 请求
grpcx.Server.ChainUnary(
grpcx.Server.UnaryValidate,
)}..., // 使用...运算符展开切片
)

// 使用配置创建新的 gRPC 服务器实例
s := grpcx.Server.New(c)

// 注册 words 服务到 gRPC 服务器
words.Register(s)

// 启动 gRPC 服务器
// Run 方法会阻塞直到服务器停止运行
s.Run()

// 返回 nil 表示命令执行成功
return nil
},
}
)

随后,再配置一下主入口文件:main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main  

import (
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"

"github.com/gogf/gf/contrib/registry/etcd/v2"
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"

"proxima/app/user/internal/cmd"
)

func main() {
var ctx = gctx.New()
conf, err := g.Cfg("etcd").Get(ctx, "etcd.address")
if err != nil {
panic(err)
}

var address = conf.String()
grpcx.Resolver.Register(etcd.New(address)) //核心代码在这一条,根据地址注册入内

cmd.Main.Run(ctx)
}

配置文件:

manifest/config/etcd.yaml

1
2
3
# 用来告诉程序往哪里注册
etcd:
address: "srv.com:2379"

manifest/config/config.yaml

1
2
3
4
5
6
7
8
grpc:  
name: "user"
address: ":32001"

database:
default:
link: "mysql:root:12345678@tcp(srv.com:3306)/user"
debug: true

gprc字段定义了两个字段,微服务名称和监听端口。微服务名称会用作服务注册,监听端口不必多言。

3.网关编写整体思路

网关的初始化和前面微服务的初始化无误。但是网关的意义和微服务的意义完全不同。

网关的核心作用:

  • 协议转换:将 HTTP/REST 请求转换为 gRPC 调用,实现协议适配
  • 路由管理:统一管理和转发客户端请求到相应的微服务
  • 流量控制:实现负载均衡、限流、熔断等流量治理功能
  • 统一入口:为所有微服务提供统一的访问入口

故第一步应该先将微服务的各个请求接口写入到网关内,而由于goframe脚手架的存在,在api目录下,只需要写出请求参数和响应参数,通过gf gen ctrl命令即可生成控制器。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package v1  

import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)

type CreateReq struct {
g.Meta `path:"words" method:"post" sm:"创建" tags:"单词"`
Word string `json:"word" v:"required|length:1,100" dc:"单词"`
Definition string `json:"definition" v:"required|length:1,300" dc:"单词定义"`
}

type CreateRes struct {
}

type DetailReq struct {
g.Meta `path:"words/{id}" method:"get" sm:"详情" tags:"单词"`
Id uint `json:"id" v:"required"`
}

type DetailRes struct {
Id uint `json:"id"`
Word string `json:"word"`
Definition string `json:"definition"`
ExampleSentence string `json:"exampleSentence"`
ChineseTranslation string `json:"chineseTranslation"`
Pronunciation string `json:"pronunciation"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
1
2
3
4
5
6
7
8
9
10
11
$ gf gen ctrl
generated: D:\project\proxima\app\gateway\api\user\user.go
generated: D:\project\proxima\app\gateway\internal\controller\user\user.go
generated: D:\project\proxima\app\gateway\internal\controller\user\user_new.go
generated: D:\project\proxima\app\gateway\internal\controller\user\user_v1_login.go
generated: D:\project\proxima\app\gateway\api\words\words.go
generated: D:\project\proxima\app\gateway\internal\controller\words\words.go
generated: D:\project\proxima\app\gateway\internal\controller\words\words_new.go
generated: D:\project\proxima\app\gateway\internal\controller\words\words_v1_create.go
generated: D:\project\proxima\app\gateway\internal\controller\words\words_v1_detail.go
done!

随后编写配置文件,将网关正确加入到etcd内。在这里提一句,网关的角色应该为客户端(Client),而微服务的角色应该为服务端(Server)。

在编写好配置文件后,则需要编写cmd文件,在cmd文件内,需要注册其作为路由,以实现统一入口功能。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package cmd  

import (
"context"

"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcmd"
"proxima/app/gateway/internal/controller/user"
"proxima/app/gateway/internal/controller/words"
)

var (
Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start http gateway server",
Func: func(ctx context.Context, parser *gcmd.Parser) (err error) {
s := g.Server()
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(ghttp.MiddlewareHandlerResponse)
group.Group("/v1", func(group *ghttp.RouterGroup) {
group.Group("/", func(group *ghttp.RouterGroup) {
group.Bind(user.NewV1())
group.Bind(words.NewV1())
})
})
})
s.Run()
return nil
},
}
)

其次也需要添加一个超时拦截器,以保证不会无限制阻塞导致卡死。

拦截器等工具的具体实现则放在utility/内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package utility  

import (
"context"
"time"
"google.golang.org/grpc"
)

func GrpcClientTimeout(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption,
) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

err := invoker(ctx, method, req, reply, cc, opts...)
return err
}

在有路由之后,则需要封装路由内的逻辑,也就是如何调度微服务。

我们将在控制器属性里,定义gRPC client,供后续使用。

定义client由grpcx.Client.MustNewGrpcClientConn(service, opts...)完成。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package user  

import (
"github.com/gogf/gf/contrib/rpc/grpcx/v2"
"proxima/app/gateway/api/user"
"proxima/app/gateway/utility"
v1 "proxima/app/user/api/account/v1"
)

type ControllerV1 struct {
AccountClient v1.AccountClient
}

func NewV1() user.IUserV1 {
var conn = grpcx.Client.MustNewGrpcClientConn("user", grpcx.Client.ChainUnary(
utility.GrpcClientTimeout, //使用我们刚刚所写的超时拦截器
))

return &ControllerV1{
AccountClient: v1.NewAccountClient(conn),
}
}

接下来,我们要在控制器里调用微服务,完成具体的业务逻辑。

实际开发中,复杂业务逻辑需要封装到logic中,像Web单体服务一样,由控制器调用。

controller/user/user_v1_login.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package user  

import (
"context"

account "proxima/app/user/api/account/v1"

"proxima/app/gateway/api/user/v1"
)

func (c *ControllerV1) Login(ctx context.Context, req *v1.LoginReq) (res *v1.LoginRes, err error) {
user, err := c.AccountClient.UserLogin(ctx, &account.UserLoginReq{
Username: req.Username,
Password: req.Password,
})

if err != nil {
return nil, err
}

return &v1.LoginRes{
Token: user.GetToken(),
}, nil
}

至此,初步网关已经完成。

总结

微服务开发流程

  1. 设计数据库表结构
  2. 初始化微服务仓库: gf init app/user -a
  3. 生成数据访问对象:
    • 配置hack/config.yaml
    • 执行gf gen dao生成dao/do/entity
    • 执行gf gen pbentity生成proto实体
  4. 编写业务逻辑代码 (internal/logic)
  5. 编写proto协议文件 (manifest/protobuf)
  6. 编写控制器
  7. 编写cmd入口文件
  8. 配置文件编写:
    • etcd配置
    • 数据库配置
    • grpc服务配置

网关开发流程

  1. 初始化网关项目
  2. 定义API接口参数
  3. 使用gf gen ctrl生成控制器
  4. 编写配置文件
  5. 编写cmd文件(注册路由)
  6. 实现拦截器等工具类
  7. 封装微服务调用逻辑:
    • 定义gRPC client
    • 实现具体的业务调用

GoFrame & Grpc微服务学习 (一)
https://thankseveryone.top/37176a41392d/
作者
CuiChangHe
发布于
2025年1月6日
许可协议