Integraciones » Escribir código propio

Escribir código propio


¿Qué es la zona de aplicación personalizada?

Todo lo que nuzur genera por ti — entidades, capa de datos, servidores REST y gRPC — se regenera desde cero en cada ejecución, así que editarlo no sirve de nada: tu cambio desaparece en el siguiente despliegue. La zona de aplicación personalizada es la parte del proyecto generado que no funciona así. Es un directorio (app/ por defecto) cuyos archivos se crean una sola vez, no llevan la cabecera DO NOT EDIT y no se vuelven a tocar. Ahí vive tu código.

Actívala desplegando con --custom:

nuzur-cli deploy --host 203.0.113.10 --project mi-proyecto --custom
Archivo Para qué sirve
app/rest.go Rutas REST propias — sin generación de código
app/grpc.go Sobrescribe o amplía los endpoints gRPC generados
app/idl/proto/custom.proto RPCs de gRPC nuevos — complétalo y ejecuta app/idl/proto/gen.sh
app/worker.go Trabajos en segundo plano — programadores, sondeos, importaciones, consumidores de colas

Cada archivo se escribe solo si no existe todavía, así que una vez creado es tuyo: volver a ejecutar deploy actualiza el código generado a su alrededor y deja el tuyo exactamente como lo escribiste.

Pasa --custom en todos los despliegues de ese proyecto. En versiones antiguas de la CLI, omitir el flag regenera la app sin los puntos de enganche que tus archivos propios usan, y la compilación falla en el servidor. Si mantienes un deploy.json, pon "custom": true en él y olvídate del tema.


Rutas REST propias

Registra tus rutas dentro de ProvideCustomRoutes, en app/rest.go. Devuelve una función que el servidor REST generado invoca con su router de chi, después de montar las rutas CRUD generadas y después de instalar el middleware generado (id de petición, recoverer, logs, CORS y autenticación si la activaste) — así que tus rutas heredan toda la cadena, autenticación incluida, sin hacer nada.

func ProvideCustomRoutes(coreImpl *core.Implementation) restserver.CustomRoutesFn {
	return func(r chi.Router) {
		r.Get("/v1/custom/ping", func(w http.ResponseWriter, req *http.Request) {
			w.WriteHeader(http.StatusOK)
			_, _ = w.Write([]byte(`{"status":"ok"}`))
		})
	}
}

Dos reglas, y las dos las impone chi con un panic al arrancar en lugar de con un error de compilación — así que equivocarse compila, se publica y luego deja el contenedor reiniciándose en bucle:

  • Escribe la ruta completa, incluido el prefijo /v1. r.Get("/custom/ping", …) compila, pero registra la ruta en la raíz; r.Route("/v1", …) provoca un panic, porque las rutas CRUD generadas ya ocupan ese camino.
  • Limita el middleware con r.Group, nunca con r.Use. chi no admite middleware nuevo en un mux que ya tiene rutas.

Registrar una ruta en un camino que ya sirve un handler generado lo eclipsa — una ruta estática gana a un mount — y así puedes sustituir un endpoint generado sin tocar el resto.


Sobrescribir un endpoint gRPC generado

app/grpc.go define un Server que embebe el servicio generado, de modo que todos los RPC generados funcionan sin hacer nada. Define un método con la misma firma para quedarte con uno de ellos y delega en el servidor embebido cuando quieras conservar el comportamiento generado por debajo:

func (s *Server) CreateArticle(ctx context.Context, req *pb.CreateArticleRequest) (*pb.Article, error) {
	// ... tu lógica propia ...
	return s.ArticleServiceServer.CreateArticle(ctx, req) // comportamiento generado
}

Conserva el campo embebido: es lo que hace que todos los endpoints que no sobrescribes sigan funcionando. NewOverride ya está conectado en main.go, así que tu servidor se registra en lugar del generado automáticamente. Devuelve los errores como códigos de grpc/status, igual que hacen los endpoints generados.


RPCs de gRPC nuevos

Sobrescribir cubre la superficie generada. Para un RPC que no existe en tu modelo:

  1. Declara el RPC y sus mensajes en app/idl/proto/custom.proto.
  2. Ejecuta ./gen.sh en ese directorio — necesita protoc en el PATH y escribe los stubs en app/idl/gen.
  3. Implementa el servicio generado junto a grpc.go.
  4. Regístralo en el servidor gRPC con un pequeño invoke de fx.

Registra el servicio nuevo con su propio nombre de servicio. Registrar una segunda implementación del servicio generado provoca un panic del servidor gRPC por registro duplicado, al arrancar.


Trabajos en segundo plano

No todo el trabajo nace de una petición. Refrescar una caché cada cinco minutos, consultar una API externa, vaciar una cola, importar de una fuente con cierta periodicidad — eso vive en app/worker.go:

func RegisterWorkers(lc fx.Lifecycle, coreImpl *core.Implementation, provider config.Provider, logger *zap.Logger)

main.go la invoca a través de fx al arrancar. Recibes el ciclo de vida donde colgar tus hooks, la misma capa de datos que usan los handlers, el proveedor de configuración y el logger. Añadir un trabajo no requiere generar código — edita, compila y ejecuta.

Un trabajo completo, de principio a fin

func RegisterWorkers(lc fx.Lifecycle, coreImpl *core.Implementation, provider config.Provider, logger *zap.Logger) {
	interval := 15 * time.Minute
	if d := provider.Get("workers.sync_interval").String(); d != "" {
		if parsed, err := time.ParseDuration(d); err == nil {
			interval = parsed
		}
	}

	// NO uses el contexto de OnStart: ese se cancela en cuanto termina el arranque.
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup

	lc.Append(fx.Hook{
		OnStart: func(context.Context) error {
			wg.Add(1)
			go func() {
				defer wg.Done()
				ticker := time.NewTicker(interval)
				defer ticker.Stop()
				for {
					select {
					case <-ctx.Done():
						return
					case <-ticker.C:
						syncTick(ctx, coreImpl, logger)
					}
				}
			}()
			return nil // nunca bloquees el arranque
		},
		OnStop: func(stopCtx context.Context) error {
			cancel()
			done := make(chan struct{})
			go func() { wg.Wait(); close(done) }()
			select {
			case <-done:
				return nil
			case <-stopCtx.Done(): // se agotó el plazo de apagado
				return stopCtx.Err()
			}
		},
	})
}

// syncTick es una unidad de trabajo, y se recupera por su cuenta.
func syncTick(ctx context.Context, coreImpl *core.Implementation, logger *zap.Logger) {
	defer func() {
		if r := recover(); r != nil {
			logger.Error("el worker de sincronización entró en panic", zap.Any("panic", r))
		}
	}()

	// Solo una réplica debería ejecutar este ciclo. GET_LOCK es por conexión, así
	// que toma una conexión dedicada y ciérrala para liberar el bloqueo.
	conn, err := coreImpl.DB().Conn(ctx)
	if err != nil {
		logger.Error("worker de sincronización: sin conexión", zap.Error(err))
		return
	}
	defer conn.Close()

	var acquired int
	if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 0)", "miapp:sync").Scan(&acquired); err != nil || acquired != 1 {
		return // otra réplica lo tiene — sáltate este ciclo
	}

	// ... lee de la fuente externa y escribe a través de coreImpl ...
}

Lo que te va a morder

  • OnStart no puede bloquear. fx ejecuta los hooks uno detrás de otro y no atiende ni una petición hasta que el tuyo retorna. Lanza una goroutine y devuelve nil.
  • El contexto de OnStart es el plazo de arranque, no la vida de la app. Se cancela en cuanto termina el arranque, así que una goroutine que lo herede muere de inmediato, y normalmente en silencio. Deriva de context.Background() y cancela en OnStop.
  • OnStop debería cancelar y esperar. Si no, un despliegue progresivo corta el trabajo en curso a mitad de una escritura.
  • Cada réplica ejecuta el trabajo. Este es el proceso de la API: escala a tres pods y tu tarea "cada cinco minutos" se ejecuta tres veces cada cinco minutos, en paralelo. Nada lo coordina por ti — usa un lease (una fila con propietario y caducidad, o el bloqueo consultivo de MySQL del ejemplo) o mantén el despliegue en una sola réplica.
  • Un panic sin recuperar tumba todo el proceso. Los recoverers de HTTP y gRPC no ven un panic de tu goroutine: se lleva la API por delante. Recupera por unidad de trabajo, registra el error y mantén el bucle vivo.
  • Las escrituras vacían las cachés compartidas. Las cachés por entidad que tu trabajo escribe son las mismas que sirven las lecturas de la API, así que escribir fila a fila en un bucle apretado deja a la API leyendo en frío. Agrupa en lotes (más abajo).
  • Lee los ajustes del proveedor de configuración, no de constantes: intervalos, tamaños de lote, endpoints e interruptores de funcionalidad son ajustes del despliegue. Ten en cuenta que config/base.yaml se regenera en cada despliegue — pon los valores reales en la configuración del servidor.

Llegar a la base de datos desde tu código

Tu código — rutas, sobrescrituras y trabajos por igual — recibe *core.Implementation, la misma capa de datos que usan los handlers generados. Nunca abras tu propia conexión ni escribas SQL a mano contra tus tablas; perderías la validación, la caché y los eventos que te da la capa generada.

Cada entidad independiente expone un módulo con Fetch… (uno por índice), List, Insert, Update, Upsert y Delete:

// articletypes es core/module/article/types — cada módulo tiene el suyo.
res, err := coreImpl.Article().List(ctx, articletypes.ListRequest{PageSize: 50})

List y las búsquedas por índice se paginan en SQL. Una petición con PageSize a cero devuelve cero filas — indícalo siempre.

Todos aceptan opciones: WithSkipCache() evita la caché en memoria y WithSQLTransaction(tx) se une a una transacción existente. No hay ningún ayudante de transacciones en core.Implementation: la abres sobre el *sql.DB y la pasas a cada llamada que deba compartirla, usando la opción propia de cada módulo:

tx, err := coreImpl.DB().Begin()
if err != nil {
	return err
}
defer tx.Rollback() // no hace nada una vez el Commit ha ido bien

for _, a := range articles {
	req := articletypes.UpsertRequest{Article: a}
	if _, err := coreImpl.Article().Upsert(ctx, req, article.WithSQLTransaction(tx)); err != nil {
		return err
	}
}

return tx.Commit()

Eso es el agrupado que le conviene a un trabajo en segundo plano: una transacción por unidad lógica de trabajo, en lugar de una por fila.


Publicarlo

No hay compilación aparte, ni un segundo servicio, ni un comando extra. Edita tus archivos en el directorio de la app y vuelve a ejecutar el mismo despliegue:

nuzur-cli deploy --host 203.0.113.10 --project mi-proyecto --custom

Deploy regenera el código generado que rodea a tus archivos, recompila la imagen en el servidor y reinicia el contenedor — así que tus rutas, tus sobrescrituras y tus trabajos se publican con el resto de la app. Mantén el directorio en git y cada redespliegue será un diff revisable entre lo que actualizó el generador y lo que escribiste tú.


Siguientes pasos