
简介
将实体模式定义在一个中心、语言无关的格式中,随着软件工程组织规模的增长,这样做有许多好处[链接]。为此,许多组织使用 Protocol Buffers 作为它们的接口定义语言 (IDL)。另外,gRPC 是一个基于 Protobuf 的 RPC 框架,模仿了 Google 内部的 Stubby,由于其效率和代码生成能力而变得越来越流行。
作为 IDL,gRPC 并不规定实现数据访问层的具体准则,因此实现方式差异很大。Ent 是在任何 Go 应用中构建数据访问层的天然候选者,二者集成具有很大潜力。
今天我们宣布 entproto 的实验版本,一个 Go 包和命令行工具,为 Ent 用户添加 Protobuf 与 gRPC 支持。使用 entproto,开发者可以在几分钟内搭建一个完全可运行的 CRUD gRPC 服务器。本文将详细展示实现步骤。
设置项目
最终的完整教程可在 GitHub 查看,你也可以直接克隆它。
首先为项目初始化一个新的 Go 模块:
mkdir ent-grpc-example
cd ent-grpc-example
go mod init ent-grpc-example
接下来使用 go run 调用 Ent 代码生成器,初始化一个 schema:
go run -mod=mod entgo.io/ent/cmd/ent new User
此时目录结构应为:
.
├── ent
│ ├── generate.go
│ └── schema
│ └── user.go
├── go.mod
└── go.sum
然后将 entproto 包加入项目:
go get -u entgo.io/contrib/entproto
接下来,我们为 User 实体定义 schema。打开 ent/schema/user.go 并编辑:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema"
"entgo.io/ent/schema/field"
)
// User 持有 User 实体的模式定义。
type User struct {
ent.Schema
}
// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Unique(),
field.String("email_address").
Unique(),
}
}
在此步骤中,我们为 User 实体添加了两个唯一字段:name 与 email_address。ent.Schema 仅是模式定义;若要生成可用的生产代码,需要对其运行 Ent 的代码生成工具:
go generate ./...
生成后将出现一大堆新的文件:
├── ent
│ ├── client.go
│ ├── config.go
│ └── ...
│ ├── user
│ ├── user.go
│ ├── user_create.go
│ ├── user_delete.go
│ ├── user_query.go
│ └── user_update.go
├── go.mod
└── go.sum
此时我们已经可以打开数据库连接、运行迁移创建 users 表,并对其进行读写。详细步骤请参阅Setup Tutorial。接下来让我们学习如何从模式生成 Protobuf 定义与 gRPC 服务器。
使用 entproto 生成 Go Protobuf
由于 Ent 与 Protobuf 的模式并不相同,我们需要给模式加上注解,帮助 entproto 确定如何生成 Protobuf 定义(在 Protobuf 术语中称为 Messages)。
第一步,我们要给 User 添加 entproto.Message() 注解。该注解是我们对 Protobuf 模式生成的选择性开启;我们并不一定想从所有模式实体生成 proto 消息或 gRPC 服务定义,注解给予我们该控制。为此,在 ent/schema/user.go 中添加:
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{
entproto.Message(),
}
}
下一步,需要为每个字段加上 entproto.Field 注解并指定字段编号。回忆一下,在定义 protobuf 消息类型时,每个字段都需被唯一编号。对此,在 ent/schema/user.go 的 Fields 中做如下更新:
// User 的字段。
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Unique().
Annotations(
entproto.Field(2),
),
field.String("email_address").
Unique().
Annotations(
entproto.Field(3),
),
}
}
请注意,我们没有从 1 开始编号,因为 Ent 会隐式创建 ID 字段,并自动分配编号 1。接下来,我们可以生成 protobuf 消息类型定义。为此,我们将在 ent/generate.go 中添加 go:generate 指令,调用 entproto 的命令行工具。文件变为:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
//go:generate go run -mod=mod entgo.io/contrib/entproto/cmd/entproto -path ./schema
重新生成代码:
go generate ./...
现在会出现一个新的目录 ent/proto,其中包含所有 protobuf 相关生成代码:
ent/proto
└── entpb
├── entpb.proto
└── generate.go
两个文件已生成。先来看它们的内容:
// Code generated by entproto. DO NOT EDIT.
syntax = "proto3";
package entpb;
option go_package = "ent-grpc-example/ent/proto/entpb";
message User {
int32 id = 1;
string user_name = 2;
string email_address = 3;
}
太好了!已经生成了一个 .proto 文件,其中包含与我们的 User 模式对应的消息类型定义。
package entpb
//go:generate protoc -I=.. --go_out=.. --go-grpc_out=.. --go_opt=paths=source_relative --go-grpc_opt=paths=source_relative --entgrpc_out=.. --entgrpc_opt=paths=source_relative,schema_path=../../schema entpb/entpb.proto
generate.go 中的 protoc 调用会把 .proto 文件编译为 Go 代码。要使该命令正常工作,需先安装 protoc 以及 3 个 Protobuf 插件:protoc-gen-go(生成 Go Protobuf 结构体)、protoc-gen-go-grpc(生成 Go gRPC 服务接口和客户端),以及 protoc-gen-entgrpc(生成服务实现)。若尚未安装,请按如下步骤:
- protoc 安装
- protoc-gen-go + protoc-gen-go-grpc 安装
- 运行
go get -u entgo.io/contrib/entproto/cmd/protoc-gen-entgrpc安装protoc-gen-entgrpc
安装完这些依赖后,再次执行代码生成:
go generate ./...
这时会生成一个新文件 ent/proto/entpb/entpb.pb.go,其中包含为我们实体生成的 Go 结构体。
接下来编写一个测试,使用它确保一切正确连接。新建文件 pb_test.go 并写入:
package main
import (
"testing"
"ent-grpc-example/ent/proto/entpb"
)
func TestUserProto(t *testing.T) {
user := entpb.User{
Name: "rotemtam",
EmailAddress: "rotemtam@example.com",
}
if user.GetName() != "rotemtam" {
t.Fatal("预期用户名称为 rotemtam")
}
if user.GetEmailAddress() != "rotemtam@example.com" {
t.Fatal("预期邮箱地址为 rotemtam@example.com")
}
}
运行测试:
go get -u ./... # 安装生成包的依赖
go test ./...
如预期,测试通过。我们已经成功从 Ent 模式生成了可用的 Go Protobuf 结构体。接下来,让我们看看如何自动生成一个完整的 CRUD gRPC 服务器。
从模式生成完整可用的 gRPC 服务器
生成 Protobuf 结构体固然实用,但我们真正想要的是能够对实际数据库中的实体执行创建、读取、更新、删除的服务器。为此,只需更新一行代码!当我们给模式加上 entproto.Service 注解时,我们告诉 entproto 代码生成器我们想生成一个 gRPC 服务定义,随后 protoc-gen-entgrpc 将读取我们的定义并生成服务实现。编辑 ent/schema/user.go 并修改 Annotations:
func (User) Annotations() []schema.Annotation {
return []schema.Annotation{
entproto.Message(),
+ entproto.Service(), // <-- 添加此行
}
}
重新运行代码生成:
go generate ./...
现在在 ent/proto/entpb 中会出现一些变化:
ent/proto/entpb
├── entpb.pb.go
├── entpb.proto
├── entpb_grpc.pb.go
├── entpb_user_service.go
└── generate.go
首先,entproto 在 entpb.proto 中添加了服务定义:
service UserService {
rpc Create ( CreateUserRequest ) returns ( User );
rpc Get ( GetUserRequest ) returns ( User );
rpc Update ( UpdateUserRequest ) returns ( User );
rpc Delete ( DeleteUserRequest ) returns ( google.protobuf.Empty );
}
另外,新增了两份文件。第一个 ent_grpc.pb.go 包含了 gRPC 客户端存根和接口定义。若打开文件,可见以下片段:
// UserServiceClient 是 UserService 服务的客户端 API。
//
// 关于 ctx 使用语义与关闭/结束流式 RPC,请参阅 https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream。
type UserServiceClient interface {
Create(ctx context.Context, in *CreateUserRequest, opts ...grpc.CallOption) (*User, error)
Get(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
Update(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*User, error)
Delete(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
第二份 entpub_user_service.go 则包含该接口的实现。例如,Get 方法的实现:
// Get 实现 UserServiceServer.Get
func (svc *UserService) Get(ctx context.Context, req *GetUserRequest) (*User, error) {
get, err := svc.client.User.Get(ctx, int(req.GetId()))
switch {
case err == nil:
return toProtoUser(get), nil
case ent.IsNotFound(err):
return nil, status.Errorf(codes.NotFound, "not found: %s", err)
default:
return nil, status.Errorf(codes.Internal, "internal error: %s", err)
}
}
不错!接下来让我们创建一个可以服务请求的 gRPC 服务器。
创建服务器
新建文件 cmd/server/main.go 并写入:
package main
import (
"context"
"log"
"net"
_ "github.com/mattn/go-sqlite3"
"ent-grpc-example/ent"
"ent-grpc-example/ent/proto/entpb"
"google.golang.org/grpc"
)
// main 启动服务器。
func main() {
// 初始化 ent 客户端。
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatalf("打开 sqlite 连接失败: %v", err)
}
defer client.Close()
// 运行迁移工具(创建表等)。
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("创建 schema 资源失败: %v", err)
}
// 初始化生成的 User 服务。
svc := entpb.NewUserService(client)
// 创建一个新的 gRPC 服务器(你可以在同一服务器上挂载多个服务)。
server := grpc.NewServer()
// 在服务器上注册 User 服务。
entpb.RegisterUserServiceServer(server, svc)
// 打开 5000 端口监听流量。
lis, err := net.Listen("tcp", ":5000")
if err != nil {
log.Fatalf("监听失败: %s", err)
}
// 持续监听流量。
if err := server.Serve(lis); err != nil {
log.Fatalf("服务器遇到错误: %s", err)
}
}
请注意我们已添加了 github.com/mattn/go-sqlite3 的引用,因此需要将其加入模块:
go get -u github.com/mattn/go-sqlite3
接下来,启动服务器,随后再写一个客户端与其通信:
go run -mod=mod ./cmd/server
创建客户端
让我们编写一个简单的客户端来调用我们的服务器。新建文件 cmd/client/main.go 并写入:
package main
import (
"context"
"fmt"
"log"
"math/rand"
"time"
"ent-grpc-example/ent/proto/entpb"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
// main 启动客户端。
func main() {
rand.Seed(time.Now().UnixNano())
// 连接到服务器。
conn, err := grpc.Dial(":5000", grpc.WithInsecure())
if err != nil {
log.Fatalf("连接服务器失败: %s", err)
}
defer conn.Close()
// 在连接上创建 User 服务客户端。
client := entpb.NewUserServiceClient(conn)
// 请求服务器创建一个随机 User。
ctx := context.Background()
user := randomUser()
created, err := client.Create(ctx, &entpb.CreateUserRequest{
User: user,
})
if err != nil {
se, _ := status.FromError(err)
log.Fatalf("创建用户失败: status=%s message=%s", se.Code(), se.Message())
}
log.Printf("已创建用户,ID 为: %d", created.Id)
// 在单独一次 RPC 调用中,检索之前保存的用户。
get, err := client.Get(ctx, &entpb.GetUserRequest{
Id: created.Id,
})
if err != nil {
se, _ := status.FromError(err)
log.Fatalf("检索用户失败: status=%s message=%s", se.Code(), se.Message())
}
log.Printf("检索到用户,ID=%d: %v", get.Id, get)
}
// randomUser 生成一个随机 User。
func randomUser() *entpb.User {
return &entpb.User{
Name: fmt.Sprintf("user_%d", rand.Int()),
EmailAddress: fmt.Sprintf("user_%d@example.com", rand.Int()),
}
}
我们的客户端连接到 5000 端口(服务器正在监听),然后执行 Create 请求创建新用户,随后执行第二次 Get 请求检索刚存入数据库中的用户。运行客户端:
go run ./cmd/client
输出示例:
2021/03/18 10:42:58 已创建用户,ID 为: 1
2021/03/18 10:42:58 检索到用户,ID=1: id:1 name:"user_730811260095307266" email_address:"user_7338662242574055998@example.com"
太棒了!只需在模式上添加几个注解,即可利用强大的代码生成能力快速创建一个可用的 gRPC 服务器。
注意事项与限制
entproto 仍处于实验阶段,缺少一些基本功能。例如,许多应用可能希望在服务中加入 List 或 Find 方法,但目前尚不支持。未来还计划解决以下问题:
- 目前仅支持“唯一”边(O2O、O2M)。
- 生成的“变异”方法(Create/Update)目前会设置所有字段,忽略零值/空值和字段可空性。
- 所有字段均会被复制自 gRPC 请求到 ent 客户端,计划提供通过字段/边注解将某些字段设置为不可在服务中修改的功能。
下一步
我们相信 ent + gRPC 可以成为在 Go 中构建服务器应用的绝佳方法。例如,想为应用管理的实体设置细粒度访问控制,开发者可以使用 Privacy Policies(隐私政策),它们已与 gRPC 集成原生支持。若要在实体的不同生命周期事件上运行任意 Go 代码,开发者可利用自定义 Hooks(钩子)。
你想用 ent 构建 gRPC 服务器吗?如果你需要帮助设置,或希望集成支持你的用例,请通过我们的 GitHub 讨论页面 与我们联系,或者加入 #ent 频道在 Gophers Slack 或我们的 Discord 服务器 进行交流。
- 订阅我们的简报
- 关注我们的 Twitter
- 在 Gophers Slack 的 #ent 频道加入我们
- 加入我们的 Ent Discord 服务器