跳到主要内容

使用 Ent 两分钟生成一个完全可用的 Go gRPC 服务器

· 阅读需 15 分钟

ent + gRPC

简介

将实体模式定义在一个中心、语言无关的格式中,随着软件工程组织规模的增长,这样做有许多好处[链接]。为此,许多组织使用 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 实体添加了两个唯一字段:nameemail_addressent.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.goFields 中做如下更新:

// 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(生成服务实现)。若尚未安装,请按如下步骤:

安装完这些依赖后,再次执行代码生成:

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

首先,entprotoentpb.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 仍处于实验阶段,缺少一些基本功能。例如,许多应用可能希望在服务中加入 ListFind 方法,但目前尚不支持。未来还计划解决以下问题:

  • 目前仅支持“唯一”边(O2O、O2M)。
  • 生成的“变异”方法(Create/Update)目前会设置所有字段,忽略零值/空值和字段可空性。
  • 所有字段均会被复制自 gRPC 请求到 ent 客户端,计划提供通过字段/边注解将某些字段设置为不可在服务中修改的功能。

下一步

我们相信 ent + gRPC 可以成为在 Go 中构建服务器应用的绝佳方法。例如,想为应用管理的实体设置细粒度访问控制,开发者可以使用 Privacy Policies(隐私政策),它们已与 gRPC 集成原生支持。若要在实体的不同生命周期事件上运行任意 Go 代码,开发者可利用自定义 Hooks(钩子)。

你想用 ent 构建 gRPC 服务器吗?如果你需要帮助设置,或希望集成支持你的用例,请通过我们的 GitHub 讨论页面 与我们联系,或者加入 #ent 频道在 Gophers Slack 或我们的 Discord 服务器 进行交流。

更多 Ent 新闻与更新:
  • 订阅我们的简报
  • 关注我们的 Twitter
  • 在 Gophers Slack 的 #ent 频道加入我们
  • 加入我们的 Ent Discord 服务器