Custom Next.js Cache Handler

NextJS có nguyên một mục riêng để nói đến việc Data Fetching: Data Fetching, Caching, and Revalidating Tại sao cần cache cho NextJS Cũng giống như các mã nguồn khác, nếu không có cache thì việc phụ thuộc tốc độ phản hồi của API / Database sẽ là điểm yếu của ứng dụng.Hoặc đôi khi […]

Custom Next.js Cache Handler

NextJS có nguyên một mục riêng để nói đến việc Data Fetching:

Data Fetching, Caching, and Revalidating

Tại sao cần cache cho NextJS

Cũng giống như các mã nguồn khác, nếu không có cache thì việc phụ thuộc tốc độ phản hồi của API / Database sẽ là điểm yếu của ứng dụng.
Hoặc đôi khi bị giới hạn số lần, số lượng request và làm website sập dù tài nguyên còn rất nhiều thì lại càng khó chịu hơn.

Hay như website của mình thì FE và BE nằm hai nơi khác nhau, FE thì khó sập chứ BE nó mà đi thì coi như vứt cả 2 rồi.

Nên là để đảm bảo:

  • Tốc độ phản hồi của ứng dụng, website
  • Tái sử dụng tài nguyên và phản hồi và không gây quá nhiều gánh lên hệ thống BE
  • Lưu trữ dự phòng cho việc không kết nối được BE là ảnh hưởng.

Cache mặc định của NextJS

Đoạn này dịch trong documents của Next:

Cache dữ liệu để không cần phải truy xuất lại từ source trên mỗi yêu cầu.

Mặc định, Next.js tự động lưu trữ các giá trị trả về của fetch trong Bộ nhớ đệm Dữ liệu trên máy chủ. Điều này có nghĩa là dữ liệu có thể được truy xuất tại thời điểm xây dựng hoặc thời điểm yêu cầu, được lưu vào bộ nhớ đệm, và được tái sử dụng trên mỗi yêu cầu dữ liệu.

// 'force-cache' is the default, and can be omitted
fetch('https://...', { cache: 'force-cache' })Code language: TypeScript (typescript)

Tất nhiên sẽ có ngoại lệ, nhưng tự đi mà đọc đi nhé, cho nó quen

Tuỳ chỉnh cache handler cho NextJS

Như đã nói ở trên, tuy rằng NextJS có build-in function rồi, nhưng mà tốt nhất vẫn nên custom nó để phù hợp hơn nhu cầu.

Mình muốn giảm phụ thuộc với backend, cũng như giảm độ trễ cho truy vấn.
Nên quyết định viết lại một chút cho phần handler, để có thể sử dụng Redis.

Đầu tiên là tạo một file custom handler, ở đây mình chọn cái tên cache-handler.mjs. cjs, mjs hay js gì cũng được, miễn là quen thuộc cách viết.

Update next.config.js để nhận custom handler:

module.exports = {
  cacheHandler: require.resolve('./cache-handler.mjs'),
  cacheMaxMemorySize: 0, // disable default in-memory caching
}Code language: TypeScript (typescript)

Nhiều rules thì cứ để nó cuối cùng cũng được, miễn là trong module.exports là mọi thứ sẽ ổn.

Giờ thì hỏi thăm AI một chút để tạo nội dung:

Có vẻ không đúng cho lắm, làm lại nào.

const cache = new Map();

export const getCache = (key) => {
  return cache.get(key);
};

export const setCache = (key, value, ttl = 60) => {
  cache.set(key, value);
  setTimeout(() => {
    cache.delete(key);
  }, ttl * 1000);
};Code language: TypeScript (typescript)

Như thế này có vẻ tốt hơn rồi, nhưng mà vẫn chưa đủ, tiếp tục đổi model

// cache-handler.mjs

class MemoryCacheHandler {
  constructor(options = {}) {
    this.cache = new Map();
    this.maxAge = options.maxAge || 60 * 60 * 24 * 30; // 30 days by default
  }

  async get(key) {
    if (!this.cache.has(key)) {
      return null;
    }

    const { value, timestamp } = this.cache.get(key);
    if (Date.now() - timestamp > this.maxAge * 1000) {
      // Cache expired
      this.cache.delete(key);
      return null;
    }

    return value;
  }

  async set(key, value) {
    this.cache.set(key, {
      value,
      timestamp: Date.now(),
    });
  }

  async delete(key) {
    this.cache.delete(key);
  }

  // Optional: Add a method to clear the entire cache
  async clear() {
    this.cache.clear();
  }

  // Optional: Add a method to get the cache size
  size() {
    return this.cache.size;
  }
}

module.exports = MemoryCacheHandler;Code language: TypeScript (typescript)

Giờ thì tiếp tục yêu cầu sử dụng Redis để handler nữa là xong

// cache-handler.mjs
import Redis from 'ioredis';

class RedisCacheHandler {
  constructor(options = {}) {
    const redisOptions = {
      host: options.host || 'localhost',
      port: options.port || 6379,
      password: options.password,
      db: options.db || 0,
      // Thêm các tùy chọn Redis khác nếu cần
    };

    this.redis = new Redis(redisOptions);
    this.prefix = options.prefix || 'nextjs-cache:';
    this.ttl = options.ttl || 60 * 60 * 24 * 30; // 30 days by default

    // Xử lý sự kiện kết nối
    this.redis.on('connect', () => {
      console.log('Connected to Redis');
    });

    this.redis.on('error', (err) => {
      console.error('Redis connection error:', err);
    });
  }

  async get(key) {
    const value = await this.redis.get(this.prefix + key);
    if (value) {
      return JSON.parse(value);
    }
    return null;
  }

  async set(key, value) {
    await this.redis.set(
      this.prefix + key,
      JSON.stringify(value),
      'EX',
      this.ttl
    );
  }

  async delete(key) {
    await this.redis.del(this.prefix + key);
  }

  async clear() {
    const keys = await this.redis.keys(this.prefix + '*');
    if (keys.length > 0) {
      await this.redis.del(keys);
    }
  }

  async size() {
    const keys = await this.redis.keys(this.prefix + '*');
    return keys.length;
  }
}

// Cấu hình mặc định
const defaultConfig = {
  host: 'localhost',
  port: 6379,
  // password: 'your_redis_password', // Bỏ comment nếu cần password
  db: 0,
  prefix: 'nextjs-cache:',
  ttl: 60 * 60 * 24 * 30, // 30 days
};

// Tạo và xuất instance của RedisCacheHandler
const cacheHandler = new RedisCacheHandler(defaultConfig);

module.exports = cacheHandler;Code language: TypeScript (typescript)

Vậy là xong rồi đó, build lại app thôi nào.

Nhưng nếu chỉ kết thúc tại đây thì mọi thứ đơn giản quá, thay vì tự mình bảo trì code thì mình quyết định ném hết cho thằng khác làm và dùng thôi.

Sử dụng @neshca/cache-handler để làm cache handler

Cách sử dụng thì ở đây, nó có tất cả luôn khỏi cần phải tìm:

https://caching-tools.github.io/next-shared-cache/installation

Mọi chuyện lại bắt đầu từ đây.

import { createClient } from 'redis';
import createRedisHandler from '@neshca/cache-handler/redis-stack';
 
const client = createClient(clientOptions);
await client.connect();
 
const redisHandler = await createRedisHandler({
  client,
  keyPrefix: 'prefix:',
  timeoutMs: 1000,
  revalidateTagQuerySize: 100,
});Code language: TypeScript (typescript)

Redis Stack và sử dụng RedisJSON. Tuy rằng có cách dùng Redis-String import createRedisHandler from '@neshca/cache-handler/redis-strings';

Nhưng với tinh thần low code và chỉ biết copy, nên mình đành viết lại thôi.

redis-stack Handler example

import { CacheHandler } from '@neshca/cache-handler';
import { isImplicitTag } from '@neshca/cache-handler/helpers';
import { createClient, commandOptions } from 'redis';
 
CacheHandler.onCreation(async () => {
  // Always create a Redis client inside the `onCreation` callback.
  const client = createClient({
    url: 'redis://localhost:6379',
  });
 
  // Redis won't work without error handling. https://github.com/redis/node-redis?tab=readme-ov-file#events
  client.on('error', (error) => {
    if (typeof process.env.NEXT_PRIVATE_DEBUG_CACHE !== 'undefined') {
      // Use logging with caution in production. Redis will flood your logs. Hide it behind a flag.
      console.error('Redis client error:', error);
    }
  });
 
  await client.connect();
 
  // Define a timeout for Redis operations.
  const timeoutMs = 1000;
 
  // Define a key prefix for the cache.
  // It is useful to avoid key collisions with other data in Redis,
  // or to delete all cache keys at once by using a pattern.
  const keyPrefix = 'my-app-cache:';
 
  // Define a key for shared tags.
  // You'll see how to use it later in the `revalidateTag` method
  const sharedTagsKey = '_sharedTags_';
 
  // Create an assert function to ensure that the client is ready before using it.
  // When you throw an error in any Handler method,
  // the CacheHandler will use the next available Handler listed in the `handlers` array.
  function assertClientIsReady() {
    if (!client.isReady) {
      throw new Error('Redis client is not ready yet or connection is lost.');
    }
  }
 
  const revalidatedTagsKey = `${keyPrefix}__revalidated_tags__`;
 
  // Create a custom Redis Handler
  const customRedisHandler = {
    // Give the handler a name.
    // It is useful for logging in debug mode.
    name: 'redis-strings-custom',
    // We do not use try/catch blocks in the Handler methods.
    // CacheHandler will handle errors and use the next available Handler.
    async get(key, { implicitTags }) {
      // Ensure that the client is ready before using it.
      // If the client is not ready, the CacheHandler will use the next available Handler.
      assertClientIsReady();
 
      // Create a new AbortSignal with a timeout for the Redis operation.
      // By default, redis client operations will wait indefinitely.
      const options = commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
 
      // Get the value from Redis.
      // We use the key prefix to avoid key collisions with other data in Redis.
      const result = await client.get(options, keyPrefix + key);
 
      // If the key does not exist, return null.
      if (!result) {
        return null;
      }
 
      // Redis stores strings, so we need to parse the JSON.
      const cacheValue = JSON.parse(result);
 
      // If the cache value has no tags, return it early.
      if (!cacheValue) {
        return null;
      }
 
      // Get the set of explicit and implicit tags.
      // implicitTags are available only on the `get` method.
      const combinedTags = new Set([...cacheValue.tags, ...implicitTags]);
 
      // If there are no tags, return the cache value early.
      if (combinedTags.size === 0) {
        return cacheValue;
      }
 
      // Get the revalidation times for the tags.
      const revalidationTimes = await client.hmGet(
        commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
        revalidatedTagsKey,
        Array.from(combinedTags),
      );
 
      // Iterate over all revalidation times.
      for (const timeString of revalidationTimes) {
        // If the revalidation time is greater than the last modified time of the cache value,
        if (timeString && Number.parseInt(timeString, 10) > cacheValue.lastModified) {
          // Delete the key from Redis.
          await client.unlink(commandOptions({ signal: AbortSignal.timeout(timeoutMs) }), keyPrefix + key);
 
          // Return null to indicate cache miss.
          return null;
        }
      }
 
      // Return the cache value.
      return cacheValue;
    },
    async set(key, cacheHandlerValue) {
      // Ensure that the client is ready before using it.
      assertClientIsReady();
 
      // Create a new AbortSignal with a timeout for the Redis operation.
      const options = commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
 
      // Redis stores strings, so we need to stringify the JSON.
      const setOperation = client.set(options, keyPrefix + key, JSON.stringify(cacheHandlerValue));
 
      // If the cacheHandlerValue has a lifespan, set the automatic expiration.
      // cacheHandlerValue.lifespan can be null if the value is the page from the Pages Router without getStaticPaths or with `fallback: false`
      // so, we need to check if it exists before using it
      const expireOperation = cacheHandlerValue.lifespan
        ? client.expireAt(options, keyPrefix + key, cacheHandlerValue.lifespan.expireAt)
        : undefined;
 
      // If the cache handler value has tags, set the tags.
      // We store them separately to save time to retrieve them in the `revalidateTag` method.
      const setTagsOperation = cacheHandlerValue.tags.length
        ? client.hSet(options, keyPrefix + sharedTagsKey, key, JSON.stringify(cacheHandlerValue.tags))
        : undefined;
 
      // Wait for all operations to complete.
      await Promise.all([setOperation, expireOperation, setTagsOperation]);
    },
    async revalidateTag(tag) {
      // Ensure that the client is ready before using it.
      assertClientIsReady();
 
      // Check if the tag is implicit.
      // Implicit tags are not stored in the cached values.
      if (isImplicitTag(tag)) {
        // Mark the tag as revalidated at the current time.
        await client.hSet(
          commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
          revalidatedTagsKey,
          tag,
          Date.now(),
        );
      }
 
      // Create a map to store the tags for each key.
      const tagsMap = new Map();
 
      // Cursor for the hScan operation.
      let cursor = 0;
 
      // Define a query size for the hScan operation.
      const hScanOptions = { COUNT: 100 };
 
      // Iterate over all keys in the shared tags.
      do {
        const remoteTagsPortion = await client.hScan(
          commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
          keyPrefix + sharedTagsKey,
          cursor,
          hScanOptions,
        );
 
        // Iterate over all keys in the portion.
        for (const { field, value } of remoteTagsPortion.tuples) {
          // Parse the tags from the value.
          tagsMap.set(field, JSON.parse(value));
        }
 
        // Update the cursor for the next iteration.
        cursor = remoteTagsPortion.cursor;
 
        // If the cursor is 0, we have reached the end.
      } while (cursor !== 0);
 
      // Create an array of keys to delete.
      const keysToDelete = [];
 
      // Create an array of tags to delete from the hash map.
      const tagsToDelete = [];
 
      // Iterate over all keys and tags.
      for (const [key, tags] of tagsMap) {
        // If the tags include the specified tag, add the key to the delete list.
        if (tags.includes(tag)) {
          // Key must be prefixed because we use the key prefix in the set method.
          keysToDelete.push(keyPrefix + key);
          // Set an empty string as the value for the revalidated tag.
          tagsToDelete.push(key);
        }
      }
 
      // If there are no keys to delete, return early.
      if (keysToDelete.length === 0) {
        return;
      }
 
      // Delete the keys from Redis.
      const deleteKeysOperation = client.unlink(
        commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
        keysToDelete,
      );
 
      // Update the tags in Redis by deleting the revalidated tags.
      const updateTagsOperation = client.hDel(
        // Use the isolated option to prevent the command from being executed on the main connection.
        { isolated: true, ...commandOptions({ signal: AbortSignal.timeout(timeoutMs) }) },
        keyPrefix + sharedTagsKey,
        tagsToDelete,
      );
 
      // Wait for all operations to complete.
      await Promise.all([deleteKeysOperation, updateTagsOperation]);
    },
  };
 
  return {
    // The order of the handlers is important.
    // The CacheHandler will run get methods in the order of the handlers array.
    // Other methods will be run in parallel.
    handlers: [customRedisHandler],
  };
});
 
export default CacheHandler;Code language: TypeScript (typescript)

Nếu so sánh giữa redis string mặc định thì nó cũng chẳng khác gì mình viết ở trên, nên tập trung một chút vào:

  • Chỉ cache các URL, nếu không thì đám 404 cũng được cache, xui xui gì post một bài ngay cái URL bị cache là 404 thì đợi đến khi cache hết hạn thì bài nó hiện nhé – cái này bot scan cũng mệt lắm
  • Như trên, cache càng ít càng nhẹ cho Redis, query cũng nhanh hơn
  • Phải revalidatedTagsKey và có timeout
  • Fallback về localHandler chứ không block App khởi chạy khi kết nối Redis, không làm thì giải quyết nhiều thứ lắm.

Và quan trọng hơn là Redis stack khá là phiền toái, nên mình viết luôn thành ioredis và dùng Redis String thay vì Redis Json, khỏi cài plugin gì.

Custom Cache Handler của Đan Lê

import { CacheHandler } from '@neshca/cache-handler';
import { isImplicitTag } from '@neshca/cache-handler/helpers';
import createLocalHandler from '@neshca/cache-handler/local-lru';
import Redis from 'ioredis';

CacheHandler.onCreation(async () => {

    const redisConfig = {
        dropBufferSupport: true,
        lazyConnect: true,
        showFriendlyErrorStack: true,
        maxRetriesPerRequest: 0,
      };

    const client = new Redis(process.env.REDIS_URL || 'redis://localhost:6379/0', redisConfig);

    client.on('error', () => { });

    const timeoutMs = 1000;
    const keyPrefix = 'nextRedis:';
    const sharedTagsKey = '_sharedTags_';

    function assertClientIsReady() {
        if (client.status !== 'ready') {
            // throw new Error('Redis client is not ready yet or connection is lost.');
            console.error('Error - Redis not ready yet or connection lost. Fallback to lru-cache');
        }
    }

    const revalidatedTagsKey = `${keyPrefix}__revalidated_tags__`;

    const customRedisHandler = {
        name: 'nextjs_cache_handler',
        async get(key, { implicitTags }) {
            assertClientIsReady();
            // Skip caching for assets
            if (key.includes('/image/') || key.includes('/images/') || key.includes('/static/') || key.includes('/statics/') || key.endsWith('.js') || key.endsWith('.css') || key.endsWith('.jpg') || key.endsWith('.jpeg') || key.endsWith('.png') || key.endsWith('.webp') ) {
                return null;
            }

            const result = await client.get(keyPrefix + key);

            if (!result) {
                return null;
            }

            const cacheValue = JSON.parse(result);

            if (!cacheValue) {
                return null;
            }

            const combinedTags = new Set([...cacheValue.tags, ...implicitTags]);

            if (combinedTags.size === 0) {
                return cacheValue;
            }

            const revalidationTimes = await client.hmget(revalidatedTagsKey, Array.from(combinedTags));

            for (const timeString of revalidationTimes) {
                if (timeString && Number.parseInt(timeString, 10) > cacheValue.lastModified) {
                    await client.unlink(keyPrefix + key);
                    return null;
                }
            }
            // console.log('customRedisHandler.get', key); //debug only
            return cacheValue;
        },
        async set(key, cacheHandlerValue) {
            assertClientIsReady();
            // Skip caching for assets
            if (key.includes('/_next/') || key.endsWith('.js') || key.endsWith('.css') || key.endsWith('.jpg') || key.endsWith('.jpeg') || key.endsWith('.png') || key.endsWith('.webp') ) {
                return;
            }

            const pipeline = client.pipeline();

            pipeline.set(keyPrefix + key, JSON.stringify(cacheHandlerValue));

            if (cacheHandlerValue.lifespan) {
                pipeline.expireat(keyPrefix + key, cacheHandlerValue.lifespan.expireAt);
            }

            if (cacheHandlerValue.tags.length) {
                pipeline.hset(keyPrefix + sharedTagsKey, key, JSON.stringify(cacheHandlerValue.tags));
            }
            // console.log('customRedisHandler.set', key, cacheHandlerValue); // debug only
            await pipeline.exec();
        },
        async revalidateTag(tag) {
            assertClientIsReady();

            if (isImplicitTag(tag)) {
                await client.hset(revalidatedTagsKey, tag, Date.now());
            }

            const tagsMap = new Map();
            let cursor = '0';

            do {
                const [newCursor, results] = await client.hscan(keyPrefix + sharedTagsKey, cursor, 'COUNT', 25);
                cursor = newCursor;

                for (let i = 0; i < results.length; i += 2) {
                    tagsMap.set(results[i], JSON.parse(results[i + 1]));
                }
            } while (cursor !== '0');

            const keysToDelete = [];
            const tagsToDelete = [];

            for (const [key, tags] of tagsMap) {
                if (tags.includes(tag)) {
                    keysToDelete.push(keyPrefix + key);
                    tagsToDelete.push(key);
                }
            }

            if (keysToDelete.length === 0) {
                return;
            }

            const pipeline = client.pipeline();
            pipeline.unlink(...keysToDelete);
            pipeline.hdel(keyPrefix + sharedTagsKey, ...tagsToDelete);
            await pipeline.exec();
        },
    };

    const localHandler = createLocalHandler({
        maxItemsNumber: 10000,
        maxItemSizeBytes: 1024 * 1024 * 500,
    });

    return {
        handlers: [customRedisHandler, localHandler],
    };
});

export default CacheHandler;Code language: TypeScript (typescript)

Tại đây để linh hoạt thì chuyển về ENV: REDIS_URL

if (key.includes('/image/') || key.includes('/images/') || key.includes('/static/') || key.includes('/statics/') || key.endsWith('.js') || key.endsWith('.css') || key.endsWith('.jpg') || key.endsWith('.jpeg') || key.endsWith('.png') || key.endsWith('.webp') ) {
                return null;
            }Code language: TypeScript (typescript)

Xử lý static assets.

Và có một fallback

return {
        handlers: [customRedisHandler, localHandler],
    };Code language: TypeScript (typescript)

Code thì ít mà copy AI là nhiều đấy, nhưng hoạt động khá tốt khá ổn định.

Các bạn thấy nó sai chỗ nào góp ý mình sửa nhé. Hoặc là cứ thả thế dùng luôn cũng ổn.

Update: Đã sửa nguyên cái file cho nó đúng hơn, và cũng như check Redis khi build để bỏ qua, nhưng nhác update nên kệ thế đã

A
WRITTEN BY

ArcLight

Responses (0 )