Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Una de las preguntas más frecuentes que recibimos de la comunidad de Ent es cómo sincronizar objetos o referencias entre la base de datos que respalda una aplicación de Ent (como MySQL o PostgreSQL) con servicios externos. Por ejemplo, los usuarios quieren crear o eliminar registros en su CRM cuando se crea o elimina un usuario en Ent, publicar mensajes en un sistema Pub/Sub cuando se actualiza una entidad, o verificar referencias a blobs en almacenamiento de objetos como AWS S3 o Google Cloud Storage.
Garantizar la consistencia entre dos sistemas de datos separados no es tarea sencilla. Cuando queremos propagar, por ejemplo, la eliminación de un registro de un sistema a otro, no hay forma obvia de garantizar que ambos sistemas terminen en un estado sincronizado, ya que uno de ellos podría fallar o el enlace de red podría ser lento o estar caído. Dicho esto, y especialmente con el auge de las arquitecturas de microservicios, estos problemas se han vuelto más comunes, y los investigadores de sistemas distribuidos han desarrollado patrones para resolverlos, como el Patrón Saga.
La aplicación de estos patrones suele ser compleja y difícil, por lo que en muchos casos los arquitectos no persiguen un diseño "perfecto", sino que optan por soluciones más simples que implican aceptar cierta inconsistencia entre los sistemas o implementar procedimientos de reconciliación en segundo plano.
En esta publicación, no discutiremos cómo resolver transacciones distribuidas ni implementar el patrón Saga con Ent. En su lugar, limitaremos nuestro alcance a estudiar cómo interceptar mutaciones de Ent antes y después de que ocurran, y ejecutar allí nuestra lógica personalizada.
Propagar mutaciones a sistemas externos
En nuestro ejemplo, crearemos un esquema simple de User con 2 campos de texto inmutables: "name" y "avatar_url".
Ejecutemos el comando ent init para crear un esqueleto de esquema para nuestro User:
go run entgo.io/ent/cmd/ent new User
Luego, añadimos los campos name y avatar_url y ejecutamos go generate para generar los recursos.
type User struct {
ent.Schema
}
func (User) Fields() []ent.Field {
return []ent.Field{
field.String("name").
Immutable(),
field.String("avatar_url").
Immutable(),
}
}
go generate ./ent
El problema
El campo avatar_url define una URL a una imagen en un bucket de nuestro almacenamiento de objetos (por ejemplo, AWS S3). Para los fines de
esta discusión, queremos asegurarnos de que:
Cuando se crea un usuario, exista una imagen con la URL almacenada en
"avatar_url"en nuestro bucket.Se eliminen las imágenes huérfanas del bucket. Esto significa que cuando un usuario se elimina de nuestro sistema, su imagen de avatar también se borra.
Para interactuar con blobs, usaremos el paquete gocloud.dev/blob. Este paquete
proporciona abstracciones para leer, escribir, eliminar y listar blobs en un bucket. Similar al paquete database/sql,
permite interactuar con diversos almacenamientos de objetos con la misma API configurando su URL de controlador.
Por ejemplo:
// Open an in-memory bucket.
if bucket, err := blob.OpenBucket(ctx, "mem://photos/"); err != nil {
log.Fatal("failed opening in-memory bucket:", err)
}
// Open an S3 bucket named photos.
if bucket, err := blob.OpenBucket(ctx, "s3://photos"); err != nil {
log.Fatal("failed opening s3 bucket:", err)
}
// Open a bucket named photos in Google Cloud Storage.
if bucket, err := blob.OpenBucket(ctx, "gs://my-bucket"); err != nil {
log.Fatal("failed opening gs bucket:", err)
}
Hooks de esquema
Los hooks son una característica potente de Ent que permite añadir lógica personalizada antes y después de operaciones que mutan el grafo.
Los hooks pueden definirse dinámicamente usando client.Use (llamados "Runtime Hooks"), o explícitamente en el esquema
(llamados "Schema Hooks") de la siguiente manera:
// Hooks of the User.
func (User) Hooks() []ent.Hook {
return []ent.Hook{
EnsureImageExists(),
DeleteOrphans(),
}
}
Como puedes imaginar, el hook EnsureImageExists se encargará de garantizar que cuando se cree un usuario, su URL
de avatar exista en el bucket, y DeleteOrphans asegurará que se eliminen las imágenes huérfanas. Comencemos a
escribirlos.
func EnsureImageExists() ent.Hook {
hk := func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
avatarURL, exists := m.AvatarURL()
if !exists {
return nil, errors.New("avatar field is missing")
}
// TODO:
// 1. Verify that "avatarURL" points to a real object in the bucket.
// 2. Otherwise, fail.
return next.Mutate(ctx, m)
})
}
// Limit the hook only to "Create" operations.
return hook.On(hk, ent.OpCreate)
}
func DeleteOrphans() ent.Hook {
hk := func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
id, exists := m.ID()
if !exists {
return nil, errors.New("id field is missing")
}
// TODO:
// 1. Get the AvatarURL field of the deleted user.
// 2. Cascade the deletion to object storage.
return next.Mutate(ctx, m)
})
}
// Limit the hook only to "DeleteOne" operations.
return hook.On(hk, ent.OpDeleteOne)
}
Ahora, quizás te estés preguntando: ¿cómo accedemos al cliente de blobs desde los hooks de mutación? Lo descubrirás en la siguiente sección.
Inyección de Dependencias
La opción entc.Dependency permite extender los builders generados con dependencias externas como campos de estructura, y proporciona opciones para inyectarlas durante la inicialización del cliente.
Para inyectar un blob.Bucket que esté disponible dentro de nuestros hooks, podemos seguir el tutorial sobre dependencias externas en el sitio web, y definir gocloud.dev/blob.Bucket como dependencia.
func main() {
opts := []entc.Option{
entc.Dependency(
entc.DependencyName("Bucket"),
entc.DependencyType(&blob.Bucket{}),
),
}
if err := entc.Generate("./schema", &gen.Config{}, opts...); err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}
A continuación, vuelve a ejecutar la generación de código:
go generate ./ent
Ahora podemos acceder a la API de Bucket desde todos los builders generados. Terminemos las implementaciones de los hooks mencionados.
// EnsureImageExists ensures the avatar_url points
// to a real object in the bucket.
func EnsureImageExists() ent.Hook {
hk := func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
avatarURL, exists := m.AvatarURL()
if !exists {
return nil, errors.New("avatar field is missing")
}
switch exists, err := m.Bucket.Exists(ctx, avatarURL); {
case err != nil:
return nil, fmt.Errorf("check key existence: %w", err)
case !exists:
return nil, fmt.Errorf("key %q does not exist in the bucket", avatarURL)
default:
return next.Mutate(ctx, m)
}
})
}
return hook.On(hk, ent.OpCreate)
}
// DeleteOrphans cascades the user deletion to the bucket.
// Hence, when a user is deleted, its avatar image is deleted
// as well.
func DeleteOrphans() ent.Hook {
hk := func(next ent.Mutator) ent.Mutator {
return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
id, exists := m.ID()
if !exists {
return nil, errors.New("id field is missing")
}
u, err := m.Client().User.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("getting deleted user: %w", err)
}
if err := m.Bucket.Delete(ctx, u.AvatarURL); err != nil {
return nil, fmt.Errorf("deleting user avatar from bucket: %w", err)
}
return next.Mutate(ctx, m)
})
}
return hook.On(hk, ent.OpDeleteOne)
}
¡Llegó el momento de probar nuestros hooks! Escribamos un ejemplo verificable que confirme que nuestros 2 hooks funcionan como se espera.
package main
import (
"context"
"fmt"
"log"
"github.com/a8m/ent-sync-example/ent"
_ "github.com/a8m/ent-sync-example/ent/runtime"
"entgo.io/ent/dialect"
_ "github.com/mattn/go-sqlite3"
"gocloud.dev/blob"
_ "gocloud.dev/blob/memblob"
)
func Example_SyncCreate() {
ctx := context.Background()
// Open an in-memory bucket.
bucket, err := blob.OpenBucket(ctx, "mem://photos/")
if err != nil {
log.Fatal("failed opening bucket:", err)
}
client, err := ent.Open(
dialect.SQLite,
"file:ent?mode=memory&cache=shared&_fk=1",
// Inject the blob.Bucket on client initialization.
ent.Bucket(bucket),
)
if err != nil {
log.Fatal("failed opening connection to sqlite:", err)
}
defer client.Close()
if err := client.Schema.Create(ctx); err != nil {
log.Fatal("failed creating schema resources:", err)
}
if err := client.User.Create().SetName("a8m").SetAvatarURL("a8m.png").Exec(ctx); err == nil {
log.Fatal("expect user creation to fail because the image does not exist in the bucket")
}
if err := bucket.WriteAll(ctx, "a8m.png", []byte{255, 255, 255}, nil); err != nil {
log.Fatalf("failed uploading image to the bucket: %v", err)
}
fmt.Printf("%q\n", keys(ctx, bucket))
// User creation should pass as image was uploaded to the bucket.
u := client.User.Create().SetName("a8m").SetAvatarURL("a8m.png").SaveX(ctx)
// Deleting a user, should delete also its image from the bucket.
client.User.DeleteOne(u).ExecX(ctx)
fmt.Printf("%q\n", keys(ctx, bucket))
// Output:
// ["a8m.png"]
// []
}
Conclusión
¡Genial! Hemos configurado Ent para extender nuestro código generado e inyectar el blob.Bucket como dependencia externa. Luego definimos dos hooks de mutación y usamos la API de blob.Bucket para garantizar que se cumplan nuestras restricciones de producto.
El código de este ejemplo está disponible en github.com/a8m/ent-sync-example.
- Suscríbete a nuestro Newsletter
- Síguenos en Twitter
- Únete a #ent en Gophers Slack
- Únete al Ent Discord Server