Giới thiệu tổng quan

Trong môi trường phát triển hiện đại, API-first đang trở thành tiêu chuẩn cho các dự án đa nền tảng. GraphQL nổi bật với khả năng truy vấn linh hoạt, giảm thiểu over‑fetching và under‑fetching. Khi kết hợp Strapi v5 – một Headless CMS mã nguồn mở – với Apollo Server, chúng ta có thể xây dựng một lớp API GraphQL mạnh mẽ, dễ mở rộng và bảo mật. Bài viết này sẽ đi sâu vào kiến trúc, cách tùy chỉnh resolver, quản lý xác thực JWT, tối ưu hiệu năng và triển khai trên Docker.

1. Kiến trúc tổng quan

Strapi cung cấp sẵn plugin GraphQL, nhưng để khai thác tối đa khả năng tùy biến, chúng ta sẽ:

  1. Khởi tạo một dự án Strapi chuẩn.
  2. Cài đặt và cấu hình plugin strapi-plugin-graphql.
  3. Triển khai một Apollo Server độc lập, kết nối tới Strapi thông qua http hoặc graphql-request.
  4. Áp dụng middleware xác thực JWT trước khi thực thi resolver.
  5. Cache kết quả truy vấn bằng Redis để giảm tải.

Kiến trúc này cho phép chúng ta tách rời phần business logic (Apollo) khỏi phần quản lý nội dung (Strapi), đồng thời dễ dàng mở rộng các micro‑service khác trong tương lai.

2. Khởi tạo dự án Strapi và bật plugin GraphQL

Bước 1: Tạo dự án Strapi

Sử dụng npx để tạo một dự án mới:

$ npx create-strapi-app my-cms --quickstart

Lệnh trên sẽ cài đặt Strapi, khởi động server tại http://localhost:1337 và tạo tài khoản admin.

Bước 2: Cài đặt plugin GraphQL

Trong thư mục dự án, chạy:

$ npm install @strapi/plugin-graphql

Sau khi cài đặt, bật plugin bằng cách chỉnh sửa config/plugins.js:

module.exports = {
  // ...các plugin khác
  graphql: {
    enabled: true,
    config: {
      endpoint: "/graphql",
      shadowCRUD: true,
      playgroundAlways: true,
      depthLimit: 7,
      amountLimit: 100,
    },
  },
};

Khởi động lại Strapi để áp dụng cấu hình.

3. Tạo một API GraphQL tùy chỉnh với Apollo Server

3.1. Thiết lập dự án Apollo

Tạo một thư mục mới apollo-server và khởi tạo dự án Node.js:

$ mkdir apollo-server && cd apollo-server
$ npm init -y
$ npm install apollo-server graphql graphql-request jsonwebtoken redis ioredis

3.2. Định nghĩa schema và resolver

Giả sử Strapi có một collection type article với các trường title, contentauthor. Chúng ta sẽ viết một resolver để lấy danh sách bài viết kèm thông tin tác giả.

const { ApolloServer, gql } = require('apollo-server');
const { request, gql: gqlRequest } = require('graphql-request');
const jwt = require('jsonwebtoken');
const Redis = require('ioredis');

const redis = new Redis({ host: 'redis', port: 6379 });

// GraphQL endpoint của Strapi
const STRAPI_ENDPOINT = 'http://localhost:1337/graphql';

// Định nghĩa schema GraphQL cho Apollo
const typeDefs = gql`
  type Article {
    id: ID!
    title: String!
    content: String!
    author: User!
  }

  type User {
    id: ID!
    username: String!
    email: String!
  }

  type Query {
    articles: [Article!]!
    me: User
  }
`;

// Resolver thực thi truy vấn tới Strapi
const resolvers = {
  Query: {
    articles: async (_, __, { token }) => {
      // Kiểm tra cache Redis
      const cacheKey = 'articles:all';
      const cached = await redis.get(cacheKey);
      if (cached) return JSON.parse(cached);

      // Truy vấn GraphQL của Strapi
      const query = gqlRequest`
        query {
          articles {
            data {
              id
              attributes {
                title
                content
                author {
                  data {
                    id
                    attributes {
                      username
                      email
                    }
                  }
                }
              }
            }
          }
        }
      `;
      const response = await request(STRAPI_ENDPOINT, query, null, {
        Authorization: `Bearer ${token}`,
      });

      // Chuyển đổi dữ liệu sang định dạng GraphQL của Apollo
      const articles = response.articles.data.map(item => ({
        id: item.id,
        title: item.attributes.title,
        content: item.attributes.content,
        author: {
          id: item.attributes.author.data.id,
          username: item.attributes.author.data.attributes.username,
          email: item.attributes.author.data.attributes.email,
        },
      }));

      // Lưu vào cache trong 5 phút
      await redis.setex(cacheKey, 300, JSON.stringify(articles));
      return articles;
    },
    me: async (_, __, { token }) => {
      if (!token) return null;
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      // Giả sử Strapi có endpoint /users/me trả về thông tin người dùng
      const query = gqlRequest`
        query {
          me {
            id
            username
            email
          }
        }
      `;
      const response = await request(STRAPI_ENDPOINT, query, null, {
        Authorization: `Bearer ${token}`,
      });
      return response.me;
    },
  },
};

// Context để truyền token từ header
const context = ({ req }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  return { token };
};

const server = new ApolloServer({ typeDefs, resolvers, context });

server.listen({ port: 4000 }).then(({ url }) => {
  console.log(`🚀 Apollo Server ready at ${url}`);
});

Trong đoạn code trên, chúng ta đã:

  • Khởi tạo một ApolloServer độc lập.
  • Sử dụng graphql-request để gọi API GraphQL của Strapi.
  • Áp dụng caching bằng Redis để giảm tải.
  • Triển khai middleware context để truyền JWT từ header.

4. Xác thực và phân quyền JWT

4.1. Cấu hình JWT trong Strapi

Strapi sử dụng JWT để bảo vệ các endpoint. Mở file config/plugins.js và thêm:

module.exports = {
  // ...
  usersPermissions: {
    config: {
      jwtSecret: process.env.JWT_SECRET || 'your-secret-key',
      jwtExpiresIn: '7d',
    },
  },
};

Đảm bảo biến môi trường JWT_SECRET được đặt trong file .env của Strapi.

4.2. Tạo middleware xác thực trong Apollo

Chúng ta sẽ viết một middleware để kiểm tra token trước khi cho phép truy cập các resolver bảo mật.

// authMiddleware.js
const jwt = require('jsonwebtoken');

module.exports = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader) return res.status(401).json({ message: 'Missing Authorization header' });

  const token = authHeader.replace('Bearer ', '');
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload; // gán thông tin người dùng vào request
    next();
  } catch (err) {
    return res.status(401).json({ message: 'Invalid token' });
  }
};

Sau đó, trong server.js (đoạn code Apollo ở trên), chúng ta có thể tích hợp middleware này bằng cách sử dụng express (Apollo Server hỗ trợ express middleware).

const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const authMiddleware = require('./authMiddleware');

const app = express();
app.use(authMiddleware);

const server = new ApolloServer({ typeDefs, resolvers, context });
await server.start();
server.applyMiddleware({ app, path: '/graphql' });

app.listen({ port: 4000 }, () => {
  console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`);
});

Với cách này, mọi request tới Apollo sẽ phải có token hợp lệ, đồng thời chúng ta có thể truy cập thông tin người dùng qua req.user trong resolver.

5. Tối ưu hiệu năng với DataLoader và Redis Cache

5.1. Giới thiệu DataLoader

DataLoader là một công cụ giúp batchcache các truy vấn tới nguồn dữ liệu, giảm thiểu số lần gọi API nội bộ. Khi chúng ta cần lấy thông tin người dùng cho nhiều bài viết, DataLoader sẽ gom các yêu cầu lại thành một lần gọi.

const DataLoader = require('dataloader');

// Loader để lấy thông tin user dựa trên ID
const userLoader = new DataLoader(async ids => {
  const query = gqlRequest`
    query ($ids: [ID!]!) {
      users(filters: { id: { $in: $ids } }) {
        data {
          id
          attributes { username email }
        }
      }
    }
  `;
  const variables = { ids };
  const response = await request(STRAPI_ENDPOINT, query, variables, {
    Authorization: `Bearer ${process.env.SYSTEM_TOKEN}`,
  });
  // Đảm bảo trả về mảng theo thứ tự ids
  const userMap = {};
  response.users.data.forEach(u => {
    userMap[u.id] = {
      id: u.id,
      username: u.attributes.username,
      email: u.attributes.email,
    };
  });
  return ids.map(id => userMap[id] || null);
});

Trong resolver articles, thay vì gọi Strapi cho mỗi tác giả, chúng ta sử dụng userLoader.load(authorId) để batch.

5.2. Kết hợp Redis Cache cho DataLoader

DataLoader chỉ cache trong vòng đời của một request. Để cache lâu dài, chúng ta đồng bộ với Redis:

const cachedUserLoader = async id => {
  const cacheKey = `user:${id}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);
  const user = await userLoader.load(id);
  await redis.setex(cacheKey, 3600, JSON.stringify(user)); // 1 giờ
  return user;
};

Nhờ cách này, các truy vấn lặp lại trong các request tiếp theo sẽ được phục vụ ngay từ Redis, giảm độ trễ đáng kể.

6. Triển khai môi trường Docker Compose

6.1. Dockerfile cho Strapi

# Dockerfile (Strapi)
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
ENV NODE_ENV=production
EXPOSE 1337
CMD ["npm", "run", "start"]

6.2. Dockerfile cho Apollo Server

# Dockerfile (Apollo)
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 4000
CMD ["node", "server.js"]

6.3. docker‑compose.yml

version: "3.8"
services:
  strapi:
    build:
      context: ./strapi
    ports:
      - "1337:1337"
    environment:
      - DATABASE_CLIENT=sqlite
      - DATABASE_FILENAME=./data.db
      - JWT_SECRET=${JWT_SECRET}
    volumes:
      - ./strapi:/app
    depends_on:
      - redis

  apollo:
    build:
      context: ./apollo-server
    ports:
      - "4000:4000"
    environment:
      - STRAPI_ENDPOINT=http://strapi:1337/graphql
      - JWT_SECRET=${JWT_SECRET}
    depends_on:
      - strapi
      - redis
    volumes:
      - ./apollo-server:/app

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: ["redis-server", "--appendonly", "yes"]

Sau khi tạo các file trên, chạy docker-compose up -d --build để khởi động ba service: Strapi, Apollo và Redis. Các service sẽ giao tiếp qua mạng nội bộ Docker, giúp môi trường phát triển nhất quán.

7. Kiểm thử và giám sát

7.1. Kiểm thử tích hợp với Jest

Viết test cho resolver articles bằng jestsupertest:

const request = require('supertest');
const { createServer } = require('../server'); // hàm tạo Apollo server

describe('GraphQL Articles Query', () => {
  let server;
  beforeAll(async () => {
    server = await createServer();
  });

  it('should return list of articles with author', async () => {
    const query = `
      query {
        articles {
          id
          title
          author { id username }
        }
      }
    `;
    const response = await request(server)
      .post('/graphql')
      .send({ query })
      .set('Authorization', `Bearer ${process.env.SYSTEM_TOKEN}`);
    expect(response.status).toBe(200);
    expect(response.body.data.articles).toBeInstanceOf(Array);
    expect(response.body.data.articles[0]).toHaveProperty('author');
  });
});

7.2. Giám sát với Prometheus & Grafana

Thêm exporter cho Node.js (prom-client) vào Apollo Server:

const client = require('prom-client');
const collectDefaultMetrics = client.collectDefaultMetrics;
collectDefaultMetrics({ timeout: 5000 });

app.get('/metrics', async (req, res) => {
  res.set('Content-Type', client.register.contentType);
  res.end(await client.register.metrics());
});

Prometheus sẽ scrape endpoint /metrics, và Grafana hiển thị biểu đồ thời gian phản hồi, số lượng request, cache hit ratio, v.v.

8. Các lưu ý bảo mật và best practices

  • Không để JWT secret trong mã nguồn. Dùng .env và công cụ quản lý secret (Vault, AWS Secrets Manager).
  • Giới hạn độ sâu truy vấn GraphQL. Cấu hình depthLimit trong plugin Strapi để tránh query quá phức tạp.
  • Thực thi rate‑limiting. Sử dụng middleware express-rate-limit cho Apollo để bảo vệ trước tấn công DoS.
  • Kiểm tra input. Dùng Joi hoặc zod để validate biến truyền vào GraphQL.
  • Áp dụng principle of least privilege. Tạo role trong Strapi chỉ cho phép đọc article và viết comment nếu cần.

Kết luận

Việc kết hợp Strapi v5 với Apollo Server cho phép xây dựng một API GraphQL mạnh mẽ, linh hoạt và bảo mật. Nhờ các kỹ thuật như DataLoader, Redis cache, JWT middleware và Docker Compose, chúng ta có thể tối ưu hiệu năng, giảm chi phí vận hành và dễ dàng mở rộng trong môi trường micro‑service. Đối với những nhà phát triển muốn nhanh chóng triển khai giải pháp CMS + GraphQL cho các dự án web hoặc mobile, việc nắm vững kiến trúc trên là nền tảng vững chắc.

Để nâng cao kỹ năng thực tiễn, bạn có thể Tham khảo khóa học "Xây dựng Back-End Nodejs bằng Strapi CMS" tại đây.