Saltar al contenido principal

Lo que aprendí al contribuir con mi primera función al plugin gRPC de Ent

· 10 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 →

Llevo años desarrollando software, pero hasta hace poco no sabía qué era un ORM. Aprendí muchas cosas durante mi grado en Ingeniería Informática, pero el Mapeo Objeto-Relacional no fue una de ellas; estaba demasiado centrado en construir cosas con bits y bytes como para preocuparme por algo de tan alto nivel. No debería sorprender entonces que, cuando me encargaron ayudar a construir una aplicación web distribuida, terminara completamente fuera de mi zona de confort.

Una de las dificultades de desarrollar software para otros es que no puedes ver dentro de su cabeza. Los requisitos no siempre están claros y hacer preguntas solo te permite entender una parte de lo que realmente buscan. A veces, simplemente tienes que construir un prototipo y mostrarlo para obtener comentarios útiles.

El problema de este enfoque, claro está, es que desarrollar prototipos lleva tiempo y necesitas pivotar con frecuencia. Si eres como yo y no sabías qué era un ORM, malgastarías mucho tiempo en tareas sencillas pero tediosas:

  1. Redefinir el modelo de datos con los nuevos comentarios del cliente.

  2. Recrear la base de datos de pruebas.

  3. Reescribir las sentencias SQL para interactuar con la base de datos.

  4. Redefinir la interfaz gRPC entre los servicios backend y frontend.

  5. Rediseñar el frontend y la interfaz web.

  6. Mostrárselo al cliente y obtener comentarios

  7. Repetir

Cientos de horas de trabajo solo para descubrir que todo necesita reescribirse. ¡Qué frustración! Imagina mi alivio (y también mi vergüenza) cuando un desarrollador senior me preguntó por qué no usaba un ORM como Ent.

Descubriendo Ent

Solo me llevó un día reimplementar nuestro modelo de datos actual con Ent. ¡No podía creer que hubiera estado haciendo todo este trabajo manualmente cuando existía un framework así! La integración gRPC mediante entproto fue la guinda del pastel: podía realizar operaciones CRUD básicas sobre gRPC simplemente añadiendo unas anotaciones a mi esquema. Esto me permitía saltarme todos los pasos entre la definición del modelo de datos y el rediseño de la interfaz web. Pero había un problema para mi caso de uso: ¿cómo obtener los detalles de las entidades a través de gRPC sin conocer sus IDs de antemano? Veía que Ent podía consultar todo, pero ¿dónde estaba el método GetAll de entproto?

Convirtiéndome en colaborador de código abierto

¡Me sorprendió descubrir que no existía! Podría haber implementado la función en un servicio aparte para mi proyecto, pero parecía un método lo suficientemente genérico como para ser útil en general. Durante años había querido encontrar un proyecto de código abierto al que pudiera contribuir de forma significativa: ¡esta parecía la oportunidad perfecta!

Así que, tras hurgar en el código fuente de entproto hasta altas horas de la madrugada, ¡logré implementar la función! Sintiéndome satisfecho, abrí un pull request y me fui a dormir, sin ser consciente de la experiencia de aprendizaje que acababa de buscar.

Por la mañana, me desperté con la decepción de que Rotem había cerrado mi pull request, pero con una invitación a seguir colaborando para refinar la idea. La razón del cierre era obvia: mi implementación de GetAll era peligrosa. Devolver todos los datos de una tabla solo es viable si la tabla es pequeña. ¡Exponer esta interfaz en tablas grandes podría tener resultados desastrosos!

Generación opcional de métodos de servicio

Mi solución fue hacer que el método GetAll fuera opcional pasando un argumento a entproto.Service(). Esto proporciona control sobre si se expone esta funcionalidad. Decidimos que era una característica deseable, pero que debería ser más genérica. ¿Por qué GetAll debería recibir un trato especial solo por haber sido añadido al final? Sería mejor si todos los métodos pudieran generarse opcionalmente. Algo como:

entproto.Service(entproto.Methods(entproto.Create | entproto.Get))

Sin embargo, para mantener la compatibilidad con versiones anteriores, una anotación entproto.Service() vacía también debería generar todos los métodos. No soy experto en Go, y la única forma que conocía para hacer esto era con una función variádica:

func Service(methods ...Method)

El problema con este enfoque es que solo puedes tener un tipo de argumento de longitud variable. ¿Qué pasa si quisiéramos añadir más opciones a la anotación del servicio más adelante? Aquí fue donde conocí el poderoso patrón de diseño de opciones funcionales:

// ServiceOption configures the entproto.Service annotation.
type ServiceOption func(svc *service)

// Service annotates an ent.Schema to specify that protobuf service generation is required for it.
func Service(opts ...ServiceOption) schema.Annotation {
s := service{
Generate: true,
}
for _, apply := range opts {
apply(&s)
}
// Default to generating all methods
if s.Methods == 0 {
s.Methods = MethodAll
}
return s
}

Este enfoque recibe un número variable de funciones que se llaman para establecer opciones en una estructura, en este caso, nuestra anotación de servicio. Con este método, podemos implementar cualquier cantidad de funciones de opciones además de Methods. ¡Muy interesante!

List: El GetAll Superior

Con la generación opcional de métodos resuelta, pudimos centrarnos nuevamente en añadir GetAll. ¿Cómo podríamos implementar este método de forma segura? Rotem sugirió basar el método en la Propuesta de Mejora de API (AIP) de Google para List, AIP-132. Este enfoque permite a un cliente recuperar todas las entidades, pero divide la recuperación en páginas. Como ventaja adicional, ¡también suena mejor que "GetAll"!

Solicitud List

Con este diseño, un mensaje de solicitud se vería así:

message ListUserRequest {
int32 page_size = 1;

string page_token = 2;

View view = 3;

enum View {
VIEW_UNSPECIFIED = 0;

BASIC = 1;

WITH_EDGE_IDS = 2;
}
}

Tamaño de página

El campo page_size permite al cliente especificar el número máximo de entradas que desea recibir en el mensaje de respuesta, con un tamaño máximo de página de 1000. Esto elimina el problema de devolver más resultados de los que el cliente puede manejar en la implementación inicial de GetAll. Además, el tamaño máximo de página se implementó para evitar que un cliente sobrecargue el servidor.

Token de página

El campo page_token es una cadena codificada en base64 que utiliza el servidor para determinar dónde comienza la siguiente página. Un token vacío significa que queremos la primera página.

Vista

El campo view se utiliza para especificar si la respuesta debe devolver los IDs de los bordes asociados con las entidades.

Respuesta List

El mensaje de respuesta se vería así:

message ListUserResponse {
repeated User user_list = 1;

string next_page_token = 2;
}

Lista

El campo user_list contiene las entidades de la página.

Token de página siguiente

El campo next_page_token es una cadena codificada en base64 que puede utilizarse en otra solicitud List para recuperar la siguiente página de entidades. Un token vacío significa que esta respuesta contiene la última página de entidades.

Paginación

Con la interfaz gRPC definida, comenzó el desafío de implementarla. Una de las decisiones de diseño más críticas fue cómo implementar la paginación. El enfoque ingenuo sería usar paginación LIMIT/OFFSET para saltar las entradas que ya hemos visto. Sin embargo, este enfoque tiene importantes desventajas; la más problemática es que la base de datos tiene que recuperar todas las filas que está omitiendo para obtener las filas que queremos.

Paginación por Conjunto de Claves

Rotem propuso un enfoque mucho mejor: la paginación por conjunto de claves. Este método es ligeramente más complejo porque requiere usar una columna única (o combinación de columnas) para ordenar las filas. Pero a cambio obtenemos una mejora significativa de rendimiento. Esto se debe a que podemos aprovechar las filas ordenadas para seleccionar solo las entradas con valores en las columnas únicas que sean mayores (orden ascendente) o menores (orden descendente) que o iguales a los valores del token de página proporcionado por el cliente. Así, la base de datos no necesita recuperar las filas que queremos omitir, ¡acelerando considerablemente las consultas en tablas grandes!

Con la paginación por conjunto de claves seleccionada, el siguiente paso fue determinar cómo ordenar las entidades. El enfoque más directo para Ent era usar el campo id; todos los esquemas lo tienen y está garantizado que es único. Esta fue la estrategia elegida para la implementación inicial. Además, necesitábamos decidir si usar orden ascendente o descendente. Para la primera versión se eligió el orden descendente.

Uso

Veamos cómo utilizar realmente la nueva función List:

package main

import (
"context"
"log"

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

func main() {
// 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)
ctx := context.Background()
// Initialize token for first page.
pageToken := ""
// Retrieve all pages of users.
for {
// Ask the server for the next page of users, limiting entries to 100.
users, err := client.List(ctx, &entpb.ListUserRequest{
PageSize: 100,
PageToken: pageToken,
})
if err != nil {
se, _ := status.FromError(err)
log.Fatalf("failed retrieving user list: status=%s message=%s", se.Code(), se.Message())
}
// Check if we've reached the last page of users.
if users.NextPageToken == "" {
break
}
// Update token for next request.
pageToken = users.NextPageToken
log.Printf("users retrieved: %v", users)
}
}

Perspectivas futuras

La implementación actual de List tiene algunas limitaciones que podrían abordarse en futuras revisiones. Primero, la ordenación está limitada a la columna id. Esto hace que List sea compatible con cualquier esquema, pero no es muy flexible. Idealmente, el cliente debería poder especificar qué columnas usar para ordenar. Alternativamente, las columnas de ordenación podrían definirse en el esquema. Además, List está restringida al orden descendente. En el futuro, esto podría ser una opción especificada en la solicitud. Finalmente, List actualmente solo funciona con esquemas que usan campos id de tipo int32, uuid o string. Esto se debe a que debe definirse un método de conversión separado al token de página para cada tipo que Ent admite en la plantilla de generación de código (¡solo soy una persona!).

Conclusión

Estaba bastante nervioso cuando comencé mi misión de contribuir con esta funcionalidad a entproto; como nuevo colaborador de código abierto, no sabía qué esperar. ¡Me complace compartir que trabajar en el proyecto Ent fue muy divertido! Pude colaborar con personas increíbles y conocedoras mientras ayudaba a la comunidad de código abierto. Desde opciones funcionales y paginación por conjunto de claves hasta pequeñas ideas obtenidas mediante revisiones de PR, aprendí mucho sobre Go (y desarrollo de software en general) en el proceso. ¡Animo encarecidamente a cualquiera que piense en contribuir a que dé el salto! Te sorprenderá lo mucho que ganas con la experiencia.

¿Tienes preguntas? ¿Necesitas ayuda para empezar? Únete a nuestro servidor de Discord o canal de Slack.

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