Saltar al contenido principal

GraphQL sin servidor con AWS y ent

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

GraphQL es un lenguaje de consulta para APIs HTTP que ofrece una interfaz estáticamente tipada para representar eficientemente las complejas jerarquías de datos actuales. Una forma de usar GraphQL es importar una biblioteca que implemente un servidor GraphQL al que se registran resolvers personalizados que gestionan la interfaz con la base de datos. Una alternativa es utilizar un servicio cloud de GraphQL para implementar el servidor GraphQL y registrar funciones serverless en la nube como resolvers. Entre los muchos beneficios de los servicios cloud, una de las mayores ventajas prácticas es la independencia y capacidad de composición de los resolvers. Por ejemplo, podemos escribir un resolver para una base de datos relacional y otro para una base de datos de búsqueda.

Consideraremos una configuración de este tipo usando Amazon Web Services (AWS) a continuación. En particular, utilizaremos AWS AppSync como servicio cloud de GraphQL y AWS Lambda para ejecutar un resolver de base de datos relacional, que implementaremos usando Go con Ent como framework de entidades. Comparado con Nodejs, el runtime más popular para AWS Lambda, Go ofrece tiempos de arranque más rápidos, mayor rendimiento y, desde mi punto de vista, una mejor experiencia para el desarrollador. Como complemento adicional, Ent presenta un enfoque innovador para el acceso tipado a bases de datos relacionales que, en mi opinión, no tiene igual en el ecosistema de Go. En conclusión, ejecutar Ent con AWS Lambda como resolvers de AWS AppSync es una configuración extremadamente potente para afrontar las exigentes necesidades actuales de las APIs.

En las próximas secciones, configuraremos GraphQL en AWS AppSync y la función AWS Lambda que ejecuta Ent. Posteriormente, propondremos una implementación en Go que integra Ent con el manejador de eventos de AWS Lambda, seguida de una prueba rápida de la función con Ent. Finalmente, la registraremos como fuente de datos en nuestra API de AWS AppSync y configuraremos los resolvers, que definen el mapeo de las peticiones GraphQL a eventos de AWS Lambda. Ten en cuenta que este tutorial requiere una cuenta de AWS y la URL de una base de datos Postgres accesible públicamente, lo cual podría generar costes.

Configuración del esquema de AWS AppSync

Para configurar el esquema GraphQL en AWS AppSync, inicia sesión en tu cuenta de AWS y selecciona el servicio AppSync en la barra de navegación. La página de inicio del servicio AppSync debería mostrarte un botón "Create API", que puedes pulsar para llegar a la página "Getting Started":

Screenshot of getting started with AWS AppSync from scratch

Getting started from scratch with AWS AppSync

En el panel superior que dice "Customize your API or import from Amazon DynamoDB", selecciona la opción "Build from scratch" y haz clic en el botón "Start" de ese panel. Ahora deberías ver un formulario donde puedes introducir el nombre de la API. Para este tutorial, escribimos "Todo", como se muestra en la captura de pantalla inferior, y pulsamos el botón "Create".

Screenshot of creating a new AWS AppSync API resource

Creating a new API resource in AWS AppSync

Tras crear la API de AppSync, deberías ver una página de inicio que muestra un panel para definir el esquema, otro para consultar la API y un tercero sobre cómo integrar AppSync en tu aplicación, como se captura en la siguiente captura de pantalla.

Screenshot of the landing page of the AWS AppSync API

Landing page of the AWS AppSync API

Haz clic en el botón "Edit Schema" del primer panel y reemplaza el esquema anterior con el siguiente esquema GraphQL:

input AddTodoInput {
title: String!
}

type AddTodoOutput {
todo: Todo!
}

type Mutation {
addTodo(input: AddTodoInput!): AddTodoOutput!
removeTodo(input: RemoveTodoInput!): RemoveTodoOutput!
}

type Query {
todos: [Todo!]!
todo(id: ID!): Todo
}

input RemoveTodoInput {
todoId: ID!
}

type RemoveTodoOutput {
todo: Todo!
}

type Todo {
id: ID!
title: String!
}

schema {
query: Query
mutation: Mutation
}

Tras reemplazar el esquema, se ejecuta una breve validación y podrás hacer clic en el botón "Save Schema" en la esquina superior derecha, obteniendo la siguiente vista:

Screenshot AWS AppSync: Final GraphQL schema for AWS AppSync API

Final GraphQL schema of AWS AppSync API

Si enviáramos peticiones GraphQL a nuestra API de AppSync, esta devolvería errores porque no se han adjuntado resolvers al esquema. Configuraremos los resolvers después de desplegar la función de Ent mediante AWS Lambda.

Explicar en detalle el esquema GraphQL actual excede el alcance de este tutorial.
En resumen, implementa:

  • Operación de listar todos con Query.todos
  • Lectura individual con Query.todo
  • Creación con Mutation.createTodo
  • Eliminación con Mutation.deleteTodo

Esta API GraphQL equivale a un diseño REST simple para recurso /todos, usando:

  • GET /todos
  • GET /todos/:id
  • POST /todos
  • DELETE /todos/:id

Para detalles del diseño (argumentos, retornos de los objetos Query y Mutation), sigo prácticas de la API GraphQL de GitHub.

Configurar AWS Lambda

Con la API AppSync lista, pasamos a la función AWS Lambda para ejecutar Ent.
Desde la barra de navegación, accedemos al servicio AWS Lambda que muestra nuestras funciones:

Screenshot of AWS Lambda landing page listing functions

AWS Lambda landing page showing functions.

Hacemos clic en "Crear función" (esquina superior derecha) y seleccionamos "Crear desde cero".
Asignamos nombre "ent", runtime "Go 1.x", y pulsamos "Crear función".
Veremos entonces la página principal de nuestra función "ent":

Screenshot of AWS Lambda landing page listing functions

AWS Lambda function overview of the Ent function.

Antes de subir el binario compilado, ajustamos configuraciones:
Primero, cambiamos el manejador predeterminado de hello a main (nombre del binario Go):

Screenshot of AWS Lambda landing page listing functions

AWS Lambda runtime settings of Ent function.

Segundo, añadimos variable de entorno DATABASE_URL con parámetros de conexión:

Screenshot of AWS Lambda landing page listing functions

AWS Lambda environment variables settings of Ent function.

Para abrir conexión a la base de datos, usamos un DSN, ej: postgres://username:password@hostname/dbname.
AWS Lambda cifra automáticamente las variables de entorno, siendo un mecanismo seguro para credenciales.
Alternativas:

  • AWS Secrets Manager para rotación dinámica de credenciales
  • AWS IAM para autorización de base de datos

Si creaste tu base de datos Postgres en AWS RDS, el nombre de usuario y nombre de base de datos predeterminados son postgres. La contraseña se puede restablecer modificando la instancia de AWS RDS.

Configurar Ent y desplegar en AWS Lambda

Ahora revisamos, compilamos y desplegamos el binario Go en la función "ent".
Código completo en bodokaiser/entgo-aws-appsync.

Primero, creamos un directorio vacío al que cambiamos:

mkdir entgo-aws-appsync
cd entgo-aws-appsync

En segundo lugar, iniciamos un nuevo módulo de Go para contener nuestro proyecto:

go mod init entgo-aws-appsync

Tercero, creamos el esquema Todo incorporando las dependencias de Ent:

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

y añade el campo title:

ent/schema/todo.go
package schema

import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
)

// Todo holds the schema definition for the Todo entity.
type Todo struct {
ent.Schema
}

// Fields of the Todo.
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("title"),
}
}

// Edges of the Todo.
func (Todo) Edges() []ent.Edge {
return nil
}

Finalmente, realizamos la generación de código de Ent:

go generate ./ent

Con Ent, escribimos un conjunto de funciones de resolución que implementan las operaciones de creación, lectura y eliminación en los todos:

internal/handler/resolver.go
package resolver

import (
"context"
"fmt"
"strconv"

"entgo-aws-appsync/ent"
"entgo-aws-appsync/ent/todo"
)

// TodosInput is the input to the Todos query.
type TodosInput struct{}

// Todos queries all todos.
func Todos(ctx context.Context, client *ent.Client, input TodosInput) ([]*ent.Todo, error) {
return client.Todo.
Query().
All(ctx)
}

// TodoByIDInput is the input to the TodoByID query.
type TodoByIDInput struct {
ID string `json:"id"`
}

// TodoByID queries a single todo by its id.
func TodoByID(ctx context.Context, client *ent.Client, input TodoByIDInput) (*ent.Todo, error) {
tid, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("failed parsing todo id: %w", err)
}
return client.Todo.
Query().
Where(todo.ID(tid)).
Only(ctx)
}

// AddTodoInput is the input to the AddTodo mutation.
type AddTodoInput struct {
Title string `json:"title"`
}

// AddTodoOutput is the output to the AddTodo mutation.
type AddTodoOutput struct {
Todo *ent.Todo `json:"todo"`
}

// AddTodo adds a todo and returns it.
func AddTodo(ctx context.Context, client *ent.Client, input AddTodoInput) (*AddTodoOutput, error) {
t, err := client.Todo.
Create().
SetTitle(input.Title).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed creating todo: %w", err)
}
return &AddTodoOutput{Todo: t}, nil
}

// RemoveTodoInput is the input to the RemoveTodo mutation.
type RemoveTodoInput struct {
TodoID string `json:"todoId"`
}

// RemoveTodoOutput is the output to the RemoveTodo mutation.
type RemoveTodoOutput struct {
Todo *ent.Todo `json:"todo"`
}

// RemoveTodo removes a todo and returns it.
func RemoveTodo(ctx context.Context, client *ent.Client, input RemoveTodoInput) (*RemoveTodoOutput, error) {
t, err := TodoByID(ctx, client, TodoByIDInput{ID: input.TodoID})
if err != nil {
return nil, fmt.Errorf("failed querying todo with id %q: %w", input.TodoID, err)
}
err = client.Todo.
DeleteOne(t).
Exec(ctx)
if err != nil {
return nil, fmt.Errorf("failed deleting todo with id %q: %w", input.TodoID, err)
}
return &RemoveTodoOutput{Todo: t}, nil
}

El uso de structs de entrada para las funciones resolutoras permite mapear los argumentos de las solicitudes GraphQL. El uso de structs de salida permite devolver múltiples objetos para operaciones más complejas.

Para mapear el evento Lambda a una función de resolución, implementamos un Handler que realiza el mapeo según un campo action en el evento:

internal/handler/handler.go
package handler

import (
"context"
"encoding/json"
"fmt"
"log"

"entgo-aws-appsync/ent"
"entgo-aws-appsync/internal/resolver"
)

// Action specifies the event type.
type Action string

// List of supported event actions.
const (
ActionMigrate Action = "migrate"

ActionTodos = "todos"
ActionTodoByID = "todoById"
ActionAddTodo = "addTodo"
ActionRemoveTodo = "removeTodo"
)

// Event is the argument of the event handler.
type Event struct {
Action Action `json:"action"`
Input json.RawMessage `json:"input"`
}

// Handler handles supported events.
type Handler struct {
client *ent.Client
}

// Returns a new event handler.
func New(c *ent.Client) *Handler {
return &Handler{
client: c,
}
}

// Handle implements the event handling by action.
func (h *Handler) Handle(ctx context.Context, e Event) (interface{}, error) {
log.Printf("action %s with payload %s\n", e.Action, e.Input)

switch e.Action {
case ActionMigrate:
return nil, h.client.Schema.Create(ctx)
case ActionTodos:
var input resolver.TodosInput
return resolver.Todos(ctx, h.client, input)
case ActionTodoByID:
var input resolver.TodoByIDInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionTodoByID, err)
}
return resolver.TodoByID(ctx, h.client, input)
case ActionAddTodo:
var input resolver.AddTodoInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionAddTodo, err)
}
return resolver.AddTodo(ctx, h.client, input)
case ActionRemoveTodo:
var input resolver.RemoveTodoInput
if err := json.Unmarshal(e.Input, &input); err != nil {
return nil, fmt.Errorf("failed parsing %s params: %w", ActionRemoveTodo, err)
}
return resolver.RemoveTodo(ctx, h.client, input)
}

return nil, fmt.Errorf("invalid action %q", e.Action)
}

Además de las acciones del resolutor, también hemos añadido una acción de migración, que ofrece una forma conveniente de exponer las migraciones de la base de datos.

Finalmente, debemos registrar una instancia del tipo Handler en la librería de AWS Lambda.

lambda/main.go
package main

import (
"database/sql"
"log"
"os"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"

"github.com/aws/aws-lambda-go/lambda"
_ "github.com/jackc/pgx/v4/stdlib"

"entgo-aws-appsync/ent"
"entgo-aws-appsync/internal/handler"
)

func main() {
// open the database connection using the pgx driver
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("failed opening database connection: %v", err)
}

// initiate the ent database client for the Postgres database
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
defer client.Close()

// register our event handler to listen on Lambda events
lambda.Start(handler.New(client).Handle)
}

El cuerpo de la función main se ejecuta cuando un AWS Lambda realiza un cold start. Después del cold start, una función Lambda se considera "warm" y solo se ejecuta el código del manejador de eventos, lo que optimiza la eficiencia de las ejecuciones Lambda.

Compilamos y desplegamos el código Go con:

GOOS=linux go build -o main ./lambda
zip function.zip main
aws lambda update-function-code --function-name ent --zip-file fileb://function.zip

El primer comando crea un binario compilado llamado main. El segundo comando comprime el binario en un archivo ZIP, requisito de AWS Lambda. El tercer comando reemplaza el código de la función Lambda llamada ent con el nuevo archivo ZIP. Si trabajas con múltiples cuentas de AWS, usa el parámetro --profile <your aws profile>.

Tras desplegar correctamente la función Lambda, abre la pestaña "Test" de la función "ent" en la consola web e invócala con una acción "migrate":

Screenshot of invoking the Ent Lambda with a migrate action

Invoking Lambda with a "migrate" action

Si tiene éxito, verás una caja de confirmación verde y podrás probar el resultado de una acción "todos":

Screenshot of invoking the Ent Lambda with a todos action

Invoking Lambda with a "todos" action

Si las pruebas fallan, lo más probable es que tengas un problema con la conexión a tu base de datos.

Configuración de resolvers en AWS AppSync

Con la función "ent" desplegada correctamente, nos queda registrarla como fuente de datos en nuestra API de AppSync y configurar los resolvers del esquema para mapear las peticiones de AppSync a eventos Lambda. Primero, abre nuestra API de AWS AppSync en la consola web y dirígete a "Data Sources" en el panel de navegación izquierdo.

Screenshot of the list of data sources registered to the AWS AppSync API

List of data sources registered to the AWS AppSync API

Haz clic en el botón "Create data source" en la esquina superior derecha para comenzar a registrar la función "ent" como fuente de datos:

Screenshot registering the ent Lambda as data source to the AWS AppSync API

Registering the ent Lambda as data source to the AWS AppSync API

Ahora, abre el esquema GraphQL de la API de AppSync y busca el tipo Query en la barra lateral derecha. Haz clic en el botón "Attach" junto al tipo Query.Todos:

Screenshot attaching a resolver to Query type in the AWS AppSync API

Attaching a resolver for the todos Query in the AWS AppSync API

En la vista del resolver para Query.todos, selecciona la función Lambda como fuente de datos, activa la opción de plantilla de mapeo de solicitud,

Screenshot configuring the resolver mapping for the todos Query in the AWS AppSync API

Configuring the resolver mapping for the todos Query in the AWS AppSync API

y copia la siguiente plantilla:

Query.todos
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "todos"
}
}

Repite el mismo procedimiento para los tipos Query y Mutation restantes:

Query.todo
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "todo",
"input": $util.toJson($context.args.input)
}
}
Mutation.addTodo
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "addTodo",
"input": $util.toJson($context.args.input)
}
}
Mutation.removeTodo
{
"version" : "2017-02-28",
"operation": "Invoke",
"payload": {
"action": "removeTodo",
"input": $util.toJson($context.args.input)
}
}

Las plantillas de mapeo de solicitud nos permiten construir los objetos de evento con los que invocamos las funciones Lambda. A través del objeto $context, tenemos acceso a la solicitud GraphQL y la sesión de autenticación. Además, es posible organizar múltiples resolvers secuencialmente y referenciar sus salidas mediante el objeto $context. En principio, también es posible definir plantillas de mapeo de respuesta. Sin embargo, en la mayoría de los casos es suficiente devolver el objeto de respuesta "tal cual".

Probando AppSync con el explorador de consultas

La forma más sencilla de probar la API es usando el Query Explorer en AWS AppSync. Alternativamente, se puede registrar una API key en la configuración de AppSync y usar cualquier cliente GraphQL estándar.

Primero creemos un todo con el título foo:

mutation MyMutation {
addTodo(input: {title: "foo"}) {
todo {
id
title
}
}
}
Screenshot of an executed addTodo Mutation using the AppSync Query Explorer

"addTodo" Mutation using the AppSync Query Explorer

Al solicitar una lista de todos debería devolver un solo todo con título foo:

query MyQuery {
todos {
title
id
}
}
Screenshot of an executed addTodo Mutation using the AppSync Query Explorer

"addTodo" Mutation using the AppSync Query Explorer

Solicitar el todo foo por su ID también debería funcionar:

query MyQuery {
todo(id: "1") {
title
id
}
}
Screenshot of an executed addTodo Mutation using the AppSync Query Explorer

"addTodo" Mutation using the AppSync Query Explorer

Conclusión

Hemos desplegado con éxito una API GraphQL serverless para gestionar tareas simples usando AWS AppSync, AWS Lambda y Ent. En particular, proporcionamos instrucciones paso a paso para configurar AppSync y Lambda a través de la consola web. Además, discutimos una propuesta sobre cómo estructurar nuestro código Go.

No cubrimos pruebas ni configuración de infraestructura de bases de datos en AWS. Estos aspectos son más desafiantes en el paradigma serverless que en el tradicional. Por ejemplo, cuando muchas funciones Lambda se inician en frío simultáneamente, agotamos rápidamente el pool de conexiones de la base de datos y necesitamos un proxy de base de datos. Además, debemos repensar las pruebas ya que solo tenemos acceso a pruebas locales y de extremo a extremo, pues no podemos ejecutar servicios cloud fácilmente de forma aislada.

Sin embargo, el servidor GraphQL propuesto escala bien para las demandas complejas de aplicaciones reales, beneficiándose de la infraestructura serverless y la agradable experiencia de desarrollo con Ent.

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

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