Saltar al contenido principal

Genera un servidor gRPC en Go completamente funcional en dos minutos con Ent

· 12 min de lectura
[Traducción Beta No Oficial]

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

ent + gRPC

Introducción

Definir esquemas de entidades en un formato centralizado y neutral al lenguaje ofrece muchas ventajas a medida que crece la escala de las organizaciones de desarrollo de software. Para lograrlo, muchas organizaciones utilizan Protocol Buffers como su lenguaje de definición de interfaces (IDL). Además, gRPC, un framework RPC basado en Protobuf inspirado en el sistema interno Stubby de Google, está ganando popularidad debido a su eficiencia y capacidades de generación de código.

Al ser un IDL, gRPC no establece pautas específicas para implementar la capa de acceso a datos, por lo que las implementaciones varían enormemente. Ent es un candidato natural para construir la capa de acceso a datos en cualquier aplicación Go, por lo que existe un gran potencial al integrar ambas tecnologías.

Hoy anunciamos una versión experimental de entproto, un paquete Go y una herramienta de línea de comandos para añadir soporte de Protobuf y gRPC a usuarios de Ent. Con entproto, los desarrolladores pueden configurar un servidor gRPC CRUD completamente funcional en pocos minutos. En este artículo, mostraremos exactamente cómo hacerlo.

Configuración inicial

La versión final de este tutorial está disponible en GitHub. Puedes clonarlo si prefieres seguirla de esa manera.

Empecemos inicializando un nuevo módulo Go para nuestro proyecto:

mkdir ent-grpc-example
cd ent-grpc-example
go mod init ent-grpc-example

A continuación usamos go run para invocar el generador de código de Ent e inicializar un esquema:

go run -mod=mod entgo.io/ent/cmd/ent new User

Nuestro directorio debería verse ahora así:

.
├── ent
│   ├── generate.go
│   └── schema
│   └── user.go
├── go.mod
└── go.sum

Ahora, añadamos el paquete entproto a nuestro proyecto:

go get -u entgo.io/contrib/entproto

A continuación, definiremos el esquema para la entidad User. Abre ent/schema/user.go y edita:

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(),
}
}

En este paso, añadimos dos campos únicos a nuestra entidad User: name y email_address. El ent.Schema es solo la definición del esquema; para generar código utilizable a partir de él necesitamos ejecutar la herramienta de generación de código de Ent. Ejecuta:

go generate ./...

Observa que ahora se han creado varios archivos nuevos a partir de nuestra definición de esquema:

├── 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

En este punto, podemos abrir una conexión a una base de datos, ejecutar una migración para crear la tabla users, y comenzar a leer y escribir datos en ella. Esto se cubre en el Tutorial de Configuración, así que vayamos al grano y aprendamos sobre la generación de definiciones Protobuf y servidores gRPC a partir de nuestro esquema.

Generando Protobufs en Go con entproto

Dado que los esquemas de Ent y Protobuf no son idénticos, debemos añadir algunas anotaciones en nuestro esquema para ayudar a entproto a determinar exactamente cómo generar las definiciones Protobuf (llamadas "Mensajes" en la jerga de protobuf).

Lo primero que necesitamos hacer es añadir una anotación entproto.Message(). Este es nuestro opt-in para la generación del esquema Protobuf; no necesariamente queremos generar mensajes proto o definiciones de servicios gRPC para todas nuestras entidades de esquema, y esta anotación nos da ese control. Para añadirla, agrega en ent/schema/user.go:

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

A continuación, debemos anotar cada campo y asignarle un número de campo. Recuerda que al definir un tipo de mensaje protobuf, cada campo debe tener un número único. Para ello, añadimos una anotación entproto.Field en cada campo. Actualiza los Fields en ent/schema/user.go:

// 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),
),
}
}

Observa que no comenzamos nuestros números de campo desde 1, esto se debe a que ent crea implícitamente el campo ID para la entidad, y a ese campo se le asigna automáticamente el número 1. Ahora podemos generar nuestras definiciones de tipos de mensajes protobuf. Para hacerlo, añadiremos en ent/generate.go una directiva go:generate que invoque la herramienta de línea de comandos entproto. Ahora debería verse así:

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

Vamos a regenerar nuestro código:

go generate ./...

Observa que se ha creado un nuevo directorio que contendrá todo el código generado relacionado con protobuf: ent/proto. Ahora contiene:

ent/proto
└── entpb
├── entpb.proto
└── generate.go

Se crearon dos archivos. Veamos su contenido:

// 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;
}

¡Genial! Se ha creado un nuevo archivo .proto que contiene una definición de tipo de mensaje que se corresponde con nuestro esquema 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

Se ha creado un nuevo archivo generate.go con una invocación a protoc, el generador de código protobuf, que le indica cómo generar código Go desde nuestro archivo .proto. Para que este comando funcione, primero debemos instalar protoc y 3 plugins de protobuf: protoc-gen-go (que genera estructuras Protobuf de Go), protoc-gen-go-grpc (que genera interfaces de servicio gRPC de Go y clientes), y protoc-gen-entgrpc (que genera una implementación de la interfaz de servicio). Si no los tienes instalados, sigue estas instrucciones:

Tras instalar estas dependencias, podemos volver a ejecutar la generación de código:

go generate ./...

Observa que se ha creado un nuevo archivo llamado ent/proto/entpb/entpb.pb.go que contiene las estructuras Go generadas para nuestras entidades.

Observa que se ha creado un nuevo archivo llamado pb_test.go que contiene las estructuras Go generadas para nuestras entidades.

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

Para ejecutarlo:

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

¡Bravo! La prueba ha pasado. Hemos generado con éxito estructuras Protobuf de Go funcionales a partir de nuestro esquema Ent. A continuación, veamos cómo generar automáticamente un servidor gRPC CRUD funcional a partir de nuestro esquema.

¡Genial! La prueba pasa. Hemos generado con éxito estructuras Go Protobuf funcionales a partir de nuestro esquema Ent. A continuación, veamos cómo generar automáticamente un servidor gRPC CRUD funcional a partir de nuestro esquema.

Tener estructuras Protobuf generadas desde nuestro ent.Schema puede ser útil, pero lo que realmente nos interesa es obtener un servidor real que pueda crear, leer, actualizar y eliminar entidades de una base de datos real. ¡Para hacerlo, solo necesitamos actualizar una línea de código! Cuando anotamos un esquema con entproto.Service, le indicamos al code-gen de entproto que queremos generar una definición de servicio gRPC, que protoc-gen-entgrpc leerá para generar una implementación del servicio. Edita ent/schema/user.go y modifica las Annotations del esquema:

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

Ahora vuelve a ejecutar la generación de código:

go generate ./...

Observa algunos cambios interesantes en ent/proto/entpb:

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

Primero, entproto añadió una definición de servicio a 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 );
}

Además, se crearon dos archivos nuevos. El primero, ent_grpc.pb.go, contiene el stub del cliente gRPC y la definición de la interfaz. Si abres el archivo, encontrarás en él (entre muchas otras cosas):

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

El segundo archivo, entpub_user_service.go, contiene una implementación generada para esta interfaz. Por ejemplo, una implementación para el método Get:

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

¡Nada mal! A continuación, creemos un servidor gRPC que pueda atender las solicitudes de nuestro servicio.

Creación del servidor

Crea un nuevo archivo cmd/server/main.go y escribe:

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

Fíjate que hemos añadido una importación de github.com/mattn/go-sqlite3, por lo que debemos añadirla a nuestro módulo:

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

A continuación, vamos a ejecutar el servidor mientras escribimos un cliente que se comunicará con él:

go run -mod=mod ./cmd/server

Creación del cliente

Vamos a crear un cliente simple que realizará algunas llamadas a nuestro servidor. Crea un nuevo archivo llamado cmd/client/main.go y escribe:

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()),
}
}

Nuestro cliente crea una conexión al puerto 5000, donde nuestro servidor está escuchando, luego emite una solicitud Create para crear un nuevo usuario, y luego emite una segunda solicitud Get para recuperarlo de la base de datos. Ejecutemos nuestro código de cliente:

go run ./cmd/client

Ejecutemos nuestro código de cliente:

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"

¡Increíble! Con unas pocas anotaciones en nuestro esquema, usamos los superpoderes de la generación de código para crear un servidor gRPC funcional en muy poco tiempo.

¡Increíble! Con solo unas anotaciones en nuestro esquema, hemos aprovechado los superpoderes de la generación de código para crear un servidor gRPC funcional en un instante.

entproto aún se encuentra en etapa experimental y carece de cierta funcionalidad básica. Por ejemplo, muchas aplicaciones probablemente querrán un método List o Find en su servicio, pero estos aún no están soportados. Además, hay otros problemas que planeamos abordar en un futuro próximo:

  • Actualmente solo se admiten aristas "únicas" (O2O, O2M).

  • Actualmente solo se admiten relaciones "únicas" (O2O, O2M).

  • Los métodos de "mutación" generados (Create/Update) actualmente establecen todos los campos, sin considerar valores cero/nulos ni la nulabilidad de los campos.

Próximos pasos

Creemos que ent + gRPC puede ser una excelente manera de construir aplicaciones de servidor en Go. Por ejemplo, para establecer controles de acceso granulares a las entidades gestionadas por nuestra aplicación, los desarrolladores ya pueden usar Políticas de Privacidad que funcionan sin configuración adicional con la integración gRPC. Para ejecutar código Go arbitrario en los diferentes eventos del ciclo de vida de las entidades, los desarrolladores pueden utilizar Hooks personalizados.

¿Quieres construir servidores gRPC con ent? Si necesitas ayuda para configurarlo o quieres que la integración admita tu caso de uso, contáctanos a través de nuestra Página de Discusiones en GitHub, en el canal #ent de Gophers Slack o en nuestro servidor de Discord.

[Para más noticias y actualizaciones de Ent:]