跳到主要内容

两分钟用 Ent 生成一个正常工作的 Go gRPC 服务端

· 阅读时间 14 分钟

ent + gRPC

引言

随着软件工程组织的规模不断扩大,用一种中心化的、语言中立的格式来定义实体模式有着诸多好处。 在实践中,许多组织把 Protocol Buffers 作为他们的接口描述语言(interface definition language,IDL)。 此外,gRPC,一个基于 Protobuf、仿照 Google 内部使用的 Stubby 的 RPC 框架正因为其效率和代码生成能力而越来越受欢迎。

作为 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 来调用代码生成器初始化 Schema:

go run -mod=mod entgo.io/ent/cmd/ent init 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"
)

// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}

// Fields of the 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 ./...

注意,一些文件依据我们的 Schema 定义生成了出来:

├── ent
│   ├── client.go
│   ├── config.go
// .... many more
│   ├── user
│   ├── user.go
│   ├── user_create.go
│   ├── user_delete.go
│   ├── user_query.go
│   └── user_update.go
├── go.mod
└── go.sum

此时,我们可以打开与数据库的连接。 运行迁移来创建 User 表,并开始读取和写入数据。 在 安装教程 中涵盖了这个主题。 因此,让我们跳过这部分,学习如何根据我们的 Schema 生成 Protobuf 定义和 gRPC 服务器。

使用 entproto 生成Go Protobufs

由于 ent 和 Protobuf 的 Schema 并不完全相同,所以我们必须在我们的 Schema 上提供一些注解,让 entproto 能明白如何生成 Protobuf 定义(在 protobuf 术语中被称为“message”)。

我们要做的第一件事是添加一个 entproto.Message() 注解。 这是我们对是否生成 Protobuf Schema 的选择,我们不一定想将所有的 Schema 实体都生成 proto 消息或 gRPC 服务定义,这条注解让我们能够对此进行控制。 在 ent/schema/user.go 的末尾加上它:

func (User) Annotations() []schema.Annotation {
return []schema.Annotation{
entproto.Message(),
}
}

接下来,我们需要对每个字段进行注解并分配一个字段号。 回忆一下定义 protobuf message 类型时,应给每个字段分配一个唯一的号码。 为此,我们在每个字段上添加一个 entproto.Field 注解。 更新 ent/schema/user.go 中的 Fields

// Fields of the 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 schema 的消息类型定义!

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

A new generate.go file was created with an invocation to protoc, the protobuf code generator instructing it how to generate Go code from our .proto file. For this command to work, we must first install protoc as well as 3 protobuf plugins: protoc-gen-go (which generates Go Protobuf structs), protoc-gen-go-grpc (which generates Go gRPC service interfaces and clients), and protoc-gen-entgrpc (which generates an implementation of the service interface). If you do not have these installed, please follow these directions:

After installing these dependencies, we can re-run code-generation:

go generate ./...

Observe that a new file named ent/proto/entpb/entpb.pb.go was created which contains the generated Go structs for our entities.

Let's write a test that uses it to make sure everything is wired correctly. Create a new file named pb_test.go and write:

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("expected user name to be rotemtam")
}
if user.GetEmailAddress() != "rotemtam@example.com" {
t.Fatal("expected email address to be rotemtam@example.com")
}
}

To run it:

go get -u./... # install deps of the generated package
go test ./...

太棒了! 测试通过。 我们从我们的 Ent Schema 中成功生成了可用的 Go Protobuf 结构体。 接下来,我们来看看如何从我们的 Schema 中自动生成一个可以工作的 CRUD gRPC 服务端

Generating a Fully Working gRPC Server from our Schema

Having Protobuf structs generated from our ent.Schema can be useful, but what we're really interested in is getting an actual server that can create, read, update, and delete entities from an actual database. To do that, we need to update just one line of code! When we annotate a schema with entproto.Service, we tell the entproto code-gen that we are interested in generating a gRPC service definition, from the protoc-gen-entgrpc will read our definition and generate a service implementation. Edit ent/schema/user.go and modify the schema's Annotations:

func (User) Annotations() []schema.Annotation {
return []schema.Annotation{
entproto.Message(),
+ entproto.Service(), // <-- add this
}
}

Now re-run code-generation:

go generate ./...

Observe some interesting changes in ent/proto/entpb:

ent/proto/entpb
├── entpb.pb.go
├── entpb.proto
├── entpb_grpc.pb.go
├── entpb_user_service.go
└── generate.go

First, entproto added a service definition to 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 );
}

In addition, two new files were created. The first, ent_grpc.pb.go, contains the gRPC client stub and the interface definition. If you open the file, you will find in it (among many other things):

// UserServiceClient is the client API for UserService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to 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)
}

The second file, entpub_user_service.go contains a generated implementation for this interface. For example, an implementation for the Get method:

// Get implements 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)
}
}

Not bad! Next, let's create a gRPC server that can serve requests to our service.

Creating the Server

Create a new file cmd/server/main.go and write:

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"
)

func main() {
// Initialize an ent client.
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatalf("failed opening connection to sqlite: %v", err)
}
defer client.Close()

// Run the migration tool (creating tables, etc).
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}

// Initialize the generated User service.
svc := entpb.NewUserService(client)

// Create a new gRPC server (you can wire multiple services to a single server).
server := grpc.NewServer()

// Register the User service with the server.
entpb.RegisterUserServiceServer(server, svc)

// Open port 5000 for listening to traffic.
lis, err := net.Listen("tcp", ":5000")
if err != nil {
log.Fatalf("failed listening: %s", err)
}

// Listen for traffic indefinitely.
if err := server.Serve(lis); err != nil {
log.Fatalf("server ended: %s", err)
}
}

Notice that we added an import of github.com/mattn/go-sqlite3, so we need to add it to our module:

go get -u github.com/mattn/go-sqlite3

Next, let's run the server, while we write a client that will communicate with it:

go run -mod=mod ./cmd/server

Creating the Client

Let's create a simple client that will make some calls to our server. Create a new file named cmd/client/main.go and write:

package main

import (
"context"
"fmt"
"log"
"math/rand"
"time"

"ent-grpc-example/ent/proto/entpb"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)

func main() {
rand.Seed(time.Now().UnixNano())

// Open a connection to the server.
conn, err := grpc.Dial(":5000", grpc.WithInsecure())
if err != nil {
log.Fatalf("failed connecting to server: %s", err)
}
defer conn.Close()

// Create a User service Client on the connection.
client := entpb.NewUserServiceClient(conn)

// Ask the server to create a random User.
ctx := context.Background()
user := randomUser()
created, err := client.Create(ctx, &entpb.CreateUserRequest{
User: user,
})
if err != nil {
se, _ := status.FromError(err)
log.Fatalf("failed creating user: status=%s message=%s", se.Code(), se.Message())
}
log.Printf("user created with id: %d", created.Id)

// On a separate RPC invocation, retrieve the user we saved previously.
get, err := client.Get(ctx, &entpb.GetUserRequest{
Id: created.Id,
})
if err != nil {
se, _ := status.FromError(err)
log.Fatalf("failed retrieving user: status=%s message=%s", se.Code(), se.Message())
}
log.Printf("retrieved user with id=%d: %v", get.Id, get)
}

func randomUser() *entpb.User {
return &entpb.User{
Name: fmt.Sprintf("user_%d", rand.Int()),
EmailAddress: fmt.Sprintf("user_%d@example.com", rand.Int()),
}
}

Our client creates a connection to port 5000, where our server is listening, then issues a Create request to create a new user, and then issues a second Get request to retrieve it from the database. Let's run our client code:

go run ./cmd/client

Observe the output:

2021/03/18 10:42:58 user created with id: 1
2021/03/18 10:42:58 retrieved user with id=1: id:1 name:"user_730811260095307266" email_address:"user_7338662242574055998@example.com"

Amazing! With a few annotations on our schema, we used the super-powers of code generation to create a working gRPC server in no time!

注意事项和限制

entproto 仍然是实验阶段,缺少一些基本功能。 例如,许多应用程序的服务中很可能需要 ListFind 方法,但目前尚未被支持。 此外,我们计划在不久的将来处理一些其他问题:

  • 目前只支持"唯一"的边(O2O, O2M)。
  • 生成的“mutating”方法 (Create/Update) 目前设置所有字段,而不考虑零/空值和可空字段。
  • 所有字段都从gRPC请求拷贝到ent客户端;计划中会通过添加字段/边的注解,来配置某些字段禁止通过服务进行修改。

接下来

我们相信 ent + gRPC 可以是一个在 Go 中构建服务器应用程序的好方式。 例如,为了对我们的应用所管理的实体设置细粒度的访问控制,开发者可以使用隐私策略,它与 gRPC 的集成开箱即用。 想在实体的不同生命周期事件时运行任意的 Go 代码,开发者可以使用自定义钩子

你想用 ent 构建 gRPC 服务端吗? 如果你需要搭建方面的帮助,或是想要集成支持你的使用案例,请通过我们的 GitHub 讨论页或在 Gophers Slack 的 #ent 频道联系我们。

获取更多 Ent 的新闻与进展: