Cómo crear una API GraphQL con Node.js y Apollo Server

Cómo crear una API GraphQL con Node.js y Apollo Server

Para arquitectos de software y líderes de ingeniería modernos, las limitaciones de las APIs REST tradicionales se han vuelto cada vez más evidentes. Problemas como la obtención excesiva de datos (recuperar más datos de los necesarios) y la obtención insuficiente de datos (requerir múltiples llamadas a la API para obtener una vista completa) generan cuellos de botella en el rendimiento y aumentan la complejidad en el lado del cliente.

GraphQL, un lenguaje de consulta para tu API, proporciona una solución definitiva. Permite a los clientes solicitar exactamente los datos que necesitan en una única solicitud, mitigando el problema de las consultas N+1 y permitiendo la tipificación fuerte del cliente al servidor.

Cuando se implementa una API GraphQL en el entorno de ejecución de Node.js, Apollo Server es la solución estándar de la industria, lista para su uso en producción. Proporciona un marco robusto, extensible y de alto rendimiento para construir y gestionar tu grafo de datos.

Este artículo proporciona una guía técnica y paso a paso para ingenieros de software y directores de tecnología, sobre cómo implementar una API GraphQL de alto rendimiento utilizando Node.js y Apollo Server 4, centrándose en las mejores prácticas arquitectónicas, la optimización del rendimiento y los detalles de implementación prácticos.

Servicios de Ingeniería de Productos

Trabaje con nuestros gestores de proyectos, ingenieros de software y probadores de calidad internos para desarrollar su nuevo producto de software personalizado o para apoyar su flujo de trabajo actual, siguiendo las metodologías Agile, DevOps y Lean.

Build with 4Geeks

1. Inicialización del proyecto y configuración de dependencias

Utilizaremos TypeScript para construir nuestro servidor, aprovechando la tipificación estática, lo cual es fundamental para mantener un esquema GraphQL escalable y robusto.TypeScriptpara aprovechar la tipificación estática, lo cual es fundamental para mantener un esquema GraphQL escalable y robusto.

Primero, inicialice un nuevo proyecto de Node.js y configure el compilador de TypeScript.

# 1. Create project directory
mkdir apollo-server-guide
cd apollo-server-guide

# 2. Initialize npm project
npm init -y

# 3. Install core dependencies
npm install @apollo/server graphql

# 4. Install TypeScript and development dependencies
npm install -D typescript ts-node nodemon @types/node

# 5. Create tsconfig.json
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --lib es2020 --module commonjs --target es2020

A continuación, cree un directorio llamado "src" y nuestro archivo principal del servidor, "src/index.ts".

Finalmente, añada unscript de desarrolloa supackage.json

"scripts": {
  "dev": "nodemon src/index.ts"
}

2. Definir el Esquema (El Contrato de la API)

El núcleo de cualquier API GraphQL es su esquema. Definido utilizando el Lenguaje de Definición de Esquemas (SDL), el esquema es un contrato sólido que define todos los tipos de datos y operaciones disponibles (consultas, mutaciones). Este enfoque "primero el esquema" es superior desde el punto de vista arquitectónico, ya que permite que los equipos de frontend y backend trabajen en paralelo en un contrato compartido y auto-documentado.

Definamos un esquema simple para un blog. Crea src/schema.ts:

// src/schema.ts
export const typeDefs = `#graphql
  # A user who writes posts
  type User {
    id: ID!
    username: String!
    email: String!
    posts: [Post!]
  }

  # A blog post
  type Post {
    id: ID!
    title: String!
    content: String!
    author: User! # The user who wrote this post
  }

  # The "Query" type defines all entry points for data fetching
  type Query {
    hello: String
    users: [User!]
    user(id: ID!): User
    posts: [Post!]
    post(id: ID!): Post
  }

  # Input type for creating a new user
  input CreateUserInput {
    username: String!
    email: String!
  }

  # The "Mutation" type defines all entry points for data modification
  type Mutation {
    createUser(input: CreateUserInput!): User
    deleteUser(id: ID!): Boolean
  }
`;

Puntos arquitectónicos clave:

  • ID!: Este tipo escalar representa un identificador único. El ! indica que el campo es No Nulo. Esta rigurosidad es una característica clave de GraphQL, que previene errores de punteros nulos en toda una clase de casos.
  • tipo Query: Este es el punto de entrada principal para todas las operaciones de lectura.
  • tipo Mutation: Este es el punto de entrada principal para todas las operaciones de escritura (CRUD). Utilizar un único input tipo (por ejemplo, CreateUserInput) para las mutaciones es una buena práctica, lo que permite añadir nuevos campos sin romper la compatibilidad con el cliente.
  • Enlace Relacional: Observe el campo author: User!campo enPost tipo. Esto define la relación del grafo. Implementaremos la lógica para esta relación en los resolvers.

3. Implementación de Resolvers (La lógica de ejecución)

Los resolvers son funciones que proporcionan las instrucciones para convertir una operación de GraphQL en datos. Son la "lógica" detrás del "acuerdo" del esquema. Cada campo de su esquema debe tener un resolver correspondiente.

Comencemos con una fuente de datos de prueba e implementemos nuestros resolvers. Crea src/resolvers.ts:

// src/resolvers.ts

// Mock data
const db = {
  users: [
    { id: '1', username: 'alice', email: 'alice@example.com' },
    { id: '2', username: 'bob', email: 'bob@example.com' },
  ],
  posts: [
    { id: '101', title: 'GraphQL is Great', content: '...', authorId: '1' },
    { id: '102', title: 'Apollo Server Deep Dive', content: '...', authorId: '2' },
    { id: '103', title: 'Node.js Performance', content: '...', authorId: '1' },
  ],
};

export const resolvers = {
  Query: {
    hello: () => 'Hello from Apollo Server!',
    
    // Resolver for fetching all users
    users: () => db.users,
    
    // Resolver for fetching a single user by ID
    user: (parent, args: { id: ID }, context, info) => {
      return db.users.find((user) => user.id === args.id);
    },
    
    posts: () => db.posts,
    post: (parent, args: { id: ID }) => {
      return db.posts.find((post) => post.id === args.id);
    },
  },

  Mutation: {
    // Resolver for creating a new user
    createUser: (parent, { input }: { input: { username: string, email: string } }) => {
      const newUser = {
        id: (db.users.length + 1).toString(),
        ...input,
      };
      db.users.push(newUser);
      return newUser;
    },
    
    // Resolver for deleting a user
    deleteUser: (parent, { id }: { id: ID }) => {
      const index = db.users.findIndex((user) => user.id === id);
      if (index === -1) return false;
      db.users.splice(index, 1);
      // Also remove their posts (demonstrating transactional logic)
      db.posts = db.posts.filter((post) => post.authorId !== id);
      return true;
    },
  },

  // --- Relational Resolvers ---
  // These resolvers "connect" the graph.

  User: {
    // This resolver fires when a query asks for a User's `posts` field
    posts: (parentUser) => {
      console.log(`Fetching posts for user: ${parentUser.id}`);
      return db.posts.filter((post) => post.authorId === parentUser.id);
    },
  },

  Post: {
    // This resolver fires when a query asks for a Post's `author` field
    author: (parentPost) => {
      console.log(`Fetching author for post: ${parentPost.id}`);
      return db.users.find((user) => user.id === parentPost.authorId);
    },
  },
};

// Type definitions for resolver arguments
type ID = string;

Resolver Desglose del Argumento:

Una función que se debe resolver recibe cuatro argumentos. Comprender estos es fundamental para la implementación:

  1. padre: El objeto devuelto por el resolver padre. Para Query.users, este es null o undefined. Para Post.author, padre es el objeto Post (parentPost en nuestro ejemplo).
  2. args: Un objeto que contiene los argumentos pasados al campo en la consulta GraphQL (por ejemplo, args.id para la consulta user(id: ID!)).
  3. context: Este es el argumento más importante para los sistemas de producción. Es un objeto compartido entre todos los resolvers para una única solicitud. Se utiliza para pasar información específica de la solicitud, como los datos de autenticación (por ejemplo, el usuario autenticado), los pools de conexión a la base de datos y los loaders de datos.
  4. info: Un objeto que contiene el árbol de sintaxis abstracto (AST) y otra información sobre la consulta que se está ejecutando. Se utiliza principalmente para casos de uso avanzados, como la optimización de consultas.

4. Instanciando y ejecutando el servidor Apollo

Ahora, vamos a configurar nuestros typeDefs y resolvers en src/index.ts para iniciar el servidor.

// src/index.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';

// Define the shape of our context.
// This is empty for now but will be crucial later.
export interface MyContext {
  // Example: token?: string;
}

async function startServer() {
  // The ApolloServer constructor requires two parameters: 
  // your schema definition and your set of resolvers.
  const server = new ApolloServer<MyContext>({
    typeDefs,
    resolvers,
  });

  // startStandaloneServer is a helper function that quickly
  // gets Apollo Server up and running.
  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
  });

  console.log(`🚀 Server ready at: ${url}`);
}

startServer();

Ejecute el servidor connpm run dev. Verá la salida:

🚀 Server ready at: http://localhost:4000/

Navegue a http://localhost:4000/ en su navegador. Le aparecerá el Entorno de prueba de Apollo Server, un IDE interactivo para ejecutar operaciones GraphQL.

Pruebas de Operaciones:

Ejecute la siguientemutación para crear un usuario:

mutation CreateNewUser {
  createUser(input: { username: "charlie", email: "charlie@example.com" }) {
    id
    username
  }
}

Ahora, ejecute estaconsulta para obtener todos los datos y ver las relaciones gráficas en funcionamiento:

query GetAllPostsWithAuthors {
  posts {
    id
    title
    author {
      id
      username
      email
    }
  }
}

Si revisas la consola de tu servidor, podrás ver los registros de nuestros resolvers relacionales:

Obteniendo autor para la publicación: 101

Obteniendo autor para la publicación: 102

Obteniendo autor para la publicación: 103

Esto demuestra un patrón de rendimiento crítico: el problema del "N+1 Query". Obtenimos 3 publicaciones, lo que a su vez desencadenó 3consultas adicionales para obtener la información de los autores. Solucionaremos esto en la siguiente etapa.consultas para los autores. Abordaremos esto en la siguiente etapa.

5. Resolver el problema N+1 utilizando Contexto y DataLoaders

El problema del "N+1" es la principal causa de problemas de rendimiento en GraphQL. La solución es utilizar Agrupación y almacenamiento en caché, que está implementada de forma perfecta por la biblioteca dataloader de Facebook.

Implementaremos nuestro DataLoader dentro de la función correspondiente. Esto garantiza que el procesamiento por lotes se realice por solicitud, lo cual es un patrón arquitectónico fundamental.

1. Instale dataloader:

npm install dataloader

2. Crear DataLoaders:

Crearemos un UserLoader. Su función es recibir un array de identificadores de usuario, obtenerlos todos en una única operación, y luego devolverlos en el orden correcto.

Crea src/loaders.ts:

// src/loaders.ts
import DataLoader from 'dataloader';
import { db } from './db'; // Assume db is exported from a separate file now

// A batch loading function for users
const batchUsers = async (ids: readonly string[]) => {
  console.log(`BATCH: Fetching users for IDs: ${ids}`);
  
  // In a real app, this would be a single SQL query:
  // SELECT * FROM users WHERE id IN (...)
  const users = db.users.filter((user) => ids.includes(user.id));

  // Data must be returned in the same order as the keys.
  // We'll map IDs to users to ensure this.
  const userMap = new Map(users.map((user) => [user.id, user]));
  return ids.map((id) => userMap.get(id) || new Error(`No user found for ID ${id}`));
};

export const createLoaders = () => ({
  user: new DataLoader(batchUsers),
});

(Nota: Para que esto funcione, refactor tu "mock" de db desde resolvers.ts en un archivo propio, por ejemplo, src/db.ts, e importalo tanto en resolvers.ts como en loaders.ts.)

Servicios de Ingeniería de Productos

Colabore con nuestros gerentes de proyecto, ingenieros de software y testers de calidad para desarrollar su nuevo producto de software personalizado o para apoyar su flujo de trabajo actual, siguiendo metodologías Agile, DevOps y Lean.

Build with 4Geeks

3. Actualizar el contexto e inicialización del servidor:

Ahora, modificamos src/index.ts para crear nuevas instancias de carga para cada solicitud y las pasamos a través del contexto.

// src/index.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createLoaders } from './loaders'; // Import loaders
import { db } from './db'; // Import db

// Define the shape of our context
export interface MyContext {
  db: typeof db;
  loaders: ReturnType<typeof createLoaders>;
  // Example: You would also pass auth info here
  // userId?: string;
}

async function startServer() {
  const server = new ApolloServer<MyContext>({
    typeDefs,
    resolvers,
  });

  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
    
    // This context function runs on every request
    context: async ({ req }) => {
      // Example: Authenticate user from `req.headers.authorization`
      // const userId = getUserIdFromToken(req.headers.authorization);

      return {
        // We create new DataLoaders for each request
        loaders: createLoaders(),
        // We can also pass our DB (or connection pool)
        db,
        // userId,
      };
    },
  });

  console.log(`🚀 Server ready at: ${url}`);
}

startServer();

4. Refactor los Resolvers para utilizar DataLoaders:

Finalmente, actualice src/resolvers.ts para utilizar los cargadores del contexto en lugar de realizar búsquedas directas.

// src/resolvers.ts (Partial)
import { MyContext } from './index'; // Import the context type

export const resolvers = {
  Query: {
    // ... other query resolvers ...
    // Pass context.db to resolvers that need it
    users: (parent, args, context: MyContext) => context.db.users,
    user: (parent, args: { id: ID }, context: MyContext) => {
      return context.db.users.find((user) => user.id === args.id);
    },
    // ...
  },
  
  Mutation: {
    // ... mutation resolvers ...
    // Use context.db for modifications
    createUser: (parent, { input }, context: MyContext) => {
      // ... logic using context.db.users ...
    },
    // ...
  },

  User: {
    posts: (parentUser, args, context: MyContext) => {
      // This is still an N+1, but for posts.
      // You would create a PostLoader to solve this.
      console.log(`Fetching posts for user: ${parentUser.id}`);
      return context.db.posts.filter((post) => post.authorId === parentUser.id);
    },
  },

  Post: {
    // REFACTORED: This resolver now uses the DataLoader
    author: (parentPost, args, context: MyContext) => {
      // Instead of a direct lookup...
      // return context.db.users.find((user) => user.id === parentPost.authorId);
      
      // We "load" the ID. DataLoader will batch and cache this.
      console.log(`SCHEDULING: Author load for post: ${parentPost.id}`);
      return context.loaders.user.load(parentPost.authorId);
    },
  },
};

Ahora, vuelva a ejecutar la consulta GetAllPostsWithAuthors de antes. Consulte la consola de su servidor:

SCHEDULING: Author load for post: 101
SCHEDULING: Author load for post: 102
SCHEDULING: Author load for post: 103
BATCH: Fetching users for IDs: 1,2

El problema del N+1 se ha resuelto.DataLoader ha recopilado todos los authorId necesarios (1, 2, 1), los ha duplicado (1, 2), y ha enviado una solicitud única. Esto representa una mejora significativa en el rendimiento y es un patrón imprescindible para GraphQL de producción.

6. Consideraciones de producción

Si bien lo anterior constituye una base sólida, un director de tecnología (CTO) debe considerar lo siguiente para un despliegue en producción:

  • Manejo de Errores: Por defecto, cualquier excepción lanzada en un resolver devuelve un error genérico "Internal Server Error". Use la clase GraphQLError de Apollo para proporcionar códigos y mensajes de error específicos al cliente (por ejemplo, FORBIDDEN, BAD_USER_INPUT).
  • Autenticación y Autorización: La autenticación debe gestionarse en la función context validando un token de los encabezados de la solicitud y adjuntando el ID del usuario al contexto. Luego, la lógica de autorización se puede colocar en la parte superior de los resolvers (por ejemplo, if (context.userId !== post.authorId) throw new GraphQLError(...)) o gestionarse de forma declarativa utilizando directivas de esquema.
  • Organización del Esquema: Para aplicaciones grandes, los archivos schema.ts y resolvers.ts son difíciles de mantener. La mejor práctica es ubicar los tipos, los resolvers y los modelos de datos por característica o dominio (por ejemplo, en un /features/User directorio) y luego fusionarlos de forma programática.
  • Capa de Datos Persistente: Reemplace el mock db con una conexión real a la base de datos (por ejemplo, una instancia de PrismaClient o una conexión de knex) y pásela a través del context objeto, tal como lo hicimos con los loaders.
  • Monitoreo: Una API GraphQL es un único punto final (/graphql), lo que hace que la monitorización tradicional basada en rutas de REST sea obsoleta. Utilice una herramienta como Apollo Studio para obtener métricas de rendimiento a nivel de campo, rastrear la ejecución de consultas y gestionar su registro de esquema.

Conclusión

Ha implementado con éxito una API GraphQL lista para producción utilizando Node.js y Apollo Server. Ha definido un esquema sólido, implementado la lógica de resolución y, lo más importante, ha diseñado una capa de obtención de datos eficiente utilizando la API y DataLoader para resolver el problema de las consultas N+1.

Esta plataforma ofrece una experiencia de desarrollo sin igual y una capa de comunicación altamente eficiente para aplicaciones modernas. Al basarse en estos patrones, su organización de ingeniería puede gestionar eficazmente complejas gráficas de datos, desacoplar el desarrollo del cliente y del servidor, y entregar aplicaciones altamente receptivas.

Preguntas frecuentes

¿Qué es GraphQL y cómo mejora en comparación con las APIs REST?

GraphQL es un lenguaje de consulta para tu API que proporciona una solución a las limitaciones comunes encontradas en las APIs REST. A diferencia de REST, que a menudo resulta en sobrecarga (recuperación de más datos de lo necesario) o en falta de datos (requerir múltiples llamadas a la API para obtener datos completos), GraphQL permite a los clientes solicitar exactamente los datos que necesitan en una sola solicitud. Esto mejora el rendimiento y reduce la complejidad en el lado del cliente.

¿Cuál es la diferencia entre un esquema GraphQL y los resolvers?

El esquema es el núcleo de una API GraphQL; es un contrato sólido, definido utilizando el Lenguaje de Definición de Esquemas (SDL), que describe todos los tipos de datos y operaciones disponibles (como Query para leer datos y Mutation para modificar datos). Resolvers son las funciones que proporcionan la lógica de ejecución para el esquema. Para cada campo definido en el esquema, existe una función de resolver correspondiente, responsable de obtener y devolver los datos reales para ese campo.

¿Cuál es el problema del "N+1" en GraphQL y cómo se puede solucionar?

El problema del "N+1" es una importante dificultad de rendimiento donde una consulta inicial para una lista de "N" elementos (la consulta "+1") desencadena una nueva solicitud de base de datos para cada elemento individual de la lista para obtener datos relacionados (las "N" consultas). Esto se soluciona utilizando el agrupamiento y la caché, implementado de forma más eficaz con la biblioteca DataLoader. DataLoader recopila todos los IDs individuales de las ejecuciones paralelas del resolvedor, los elimina de la lista y ejecuta una única consulta de base de datos para obtener todos los datos necesarios de una sola vez.