
随着软件工程组织的规模不断扩大,用一种中心化的、语言中立的格式来定义实体模式有着诸多好处。 在实践中,许多组织把 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 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"
)
type User struct {
ent.Schema
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Unique(),
field.String("email_address").
Unique(),
}
}
在这个步骤中,我们向 User
实体添加了两个唯一约束的字段: name
和 email_address
。 ent.Schema
只是结构的定义, 要根据它生成可用的代码,我们需要运行 Ent 的代码生成工具。 运行:
注意,一些文件依据我们的 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
:
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
命令行工具。 它现在看起来像这样:
让我们重新生成代码:
观察一下,一个新的目录 ent/proto
已经创建,它将包含所有与 protobuf 相关的生成代码。 它现在包含以下内容:
ent/proto
└── entpb
├── entpb.proto
└── generate.go
已创建两个文件。 让我们看看它们的内容:
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 的消息类型定义!
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:
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:
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):
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:
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() {
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()
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatalf("failed creating schema resources: %v", err)
}
svc := entpb.NewUserService(client)
server := grpc.NewServer()
entpb.RegisterUserServiceServer(server, svc)
lis, err := net.Listen("tcp", ":5000")
if err != nil {
log.Fatalf("failed listening: %s", err)
}
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())
conn, err := grpc.Dial(":5000", grpc.WithInsecure())
if err != nil {
log.Fatalf("failed connecting to server: %s", err)
}
defer conn.Close()
client := entpb.NewUserServiceClient(conn)
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)
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:
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
仍然是实验阶段,缺少一些基本功能。 例如,许多应用程序的服务中很可能需要 List
或 Find
方法,但目前尚未被支持。 此外,我们计划在不久的将来处理一些其他问题:
- 目前只支持"唯一"的边(O2O, O2M)。
- 生成的“mutating”方法 (Create/Update) 目前设置所有字段,而不考虑零/空值和可空字段。
- 所有字段都从gRPC请求拷贝到ent客户端;计划中会通过添加字段/边的注解,来配置某些字段禁止通过服务进行修改。
接下来
我们相信 ent
+ gRPC 可以是一个在 Go 中构建服务器应用程序的好方式。 例如,为了对我们的应用所管理的实体设置细粒度的访问控制,开发者可以使用隐私策略,它与 gRPC 的集成开箱即用。 想在实体的不同生命周期事件时运行任意的 Go 代码,开发者可以使用自定义钩子。
你想用 ent
构建 gRPC 服务端吗? If you want some help setting up or want the integration to support your use case, please reach out to us via our Discussions Page on GitHub or in the #ent channel on the Gophers Slack or our Discord server.