如何突破@eggjs/static缓存策略局限性?我有多种方式

49 阅读5分钟

需求背景与痛点分析

在Egg.js项目中,我们常使用官方自带的static 中间件来托管静态资源。该插件默认在生产环境为所有静态资源设置Cache-Control: public, max-age=31536000(1年缓存),而开发环境则是max-age=0禁用强缓存)。这种"一刀切"的策略在大部分场景是可以接受的:

  • 前端资源在每次打包后文件名会带上自己的hash值,因此内容变更会让文件名变动,让缓存失效。
  • 而入口文件html则是走模板引擎去渲染,不会走static中间件,不受缓存策略影响。

那剩下的少数场景怎么办呢?

说说我的场景:

最近接到个跨团队协作的需求,使用到模块联邦来保持各业务的独立CICD。

而模块联邦的入口文件remoteEntry.js,我是托管在egg-static上的,会受缓存策略的影响。

每次更新部署后,都需要用户“强制刷新”清除缓存才会更新内容,这是不可接受的。

我看不少人在issue中提出路径级缓存策略定制的需求/疑惑,看起来这是Egg.js开发者普遍面临的痛点。而官方人员给出的解决方案是:

  1. egg-static 只建议在开发环境或对内的小系统使用,正常的业务最好是用 cdn 托管静态文件
  2. 实在要用,你可以把 images 放到另一个目录,不在 public 下,然后自己 router 对应的路径,手动实例化和挂载这个 static middeware 到 router middleware 上

就让我们来看看如何突破@eggjs/static缓存策略局限性?

方案1:中间件支持配置不同的缓存策略

先不考虑官方人员给出的建议,通过Readme可以看到@eggjs/static是基于@eggjs/koa-static-cache实现,并且说明支持其所有配置。 其中的files 字段可以让我们配置具体的文件路径的maxAge等配置。

// @eggjs/koa-static-cache 给出的配置方法
const files = {};

app.use(staticCache('/public', {
  maxAge: 60 * 60 * 24 * 365
}, files));

files['/package.json'].maxAge = 60 * 60 * 24 * 30;

但该方案不推荐使用,通过查看源码egg-statickoa-static-cache的原配置上封装了一层。 当不传files 字段时,会默认使用LRU 缓存算法,而LRU 是用来解决“启用动态模式时可能出现OOM 内存溢出”。

static 生产环境配置默认开启动态模式和Buffer模式,开启后"访问过的静态文件,会被缓存在内存中,方便下次访问",所以需要LRU 算法来避免缓存爆炸的问题。

当然你也可以把动态模式给关闭,再使用该files配置。

image.png

方案1.1:中间件支持配置多个dir,应用不同的缓存策略

Readme提及dir 字段支持数组的类型入参,虽然官方文档未明确说明此配置和缓存策略有关系(从字段名也看不出有关系)。 但过阅读@eggjs/static源码,发现其核心配置项dir是支持多目录动态挂载的特性,并且不同目录的缓存策略是可以独立配置的。

源码:遍历dirs 数组配置,每次遍历static 中间件会单独一次挂载,不同的dir 配置互不干扰。

image.png

这意味着可以通过分目录挂载+独立配置实现差异化缓存策略,下面分享一下具体的配置。

访问/mf路由则会返回maxAge: 0的响应头,而/plublic 路径还是缓存1年。

// config.prod.js
const config = {};
const distPath = path_1.default.resolve(appInfo.baseDir, '..', 'dist');
const mfDistPath = path_1.default.resolve(appInfo.baseDir, '..', 'mf');
config.static = {
    dir: [
        distPath,
        {
            prefix: '/mf',
            dir: mfDistPath,
            maxAge: 0,
            // dirObj类型传参:需要把 egg-static 的默认配置手动引进来
            dynamic: true,
            preload: false,
            buffer: true,
            maxFiles: 1000,
        },
    ],
};

方案2:修改static 中间件返回的响应头

方案1.1中我们实现了对特定目录应用不同的缓存策略,那能不能对特定文件也应用不同缓存策略呢?

我们都知道Egg.js 是基于Koa架构的,也就是中间件执行顺序会按照“洋葱圈模型”。理论上我们只在egg-static 后面加一个中间件,并针对请求路径改写响应头的“Cache-Control”字段就能满足我们的需求。让我们来实践一下。

// middleware/cache.ts
export default (options, app) => {
    const cache = async (ctx: Context, next: () => Promise<void>) => {
        await next();
        if (ctx.path.endsWith("remoteEntry.js")) {
            ctx.set('Cache-Control', 'no-cache');
        }
    };
    return cache;
};
// app.ts
export default class App implements IBoot {
    // https://eggjs.github.io/zh/guide/lifecycle.html
    readonly app: Application;
    constructor(app: Application) {
        this.app = app;
    }
    
    async configDidLoad() {
        const { config } = this.app;
        const index = config.coreMiddleware.indexOf('static');
        if (index > -1) {
            // 插入cache 中间件到static 后面
            config.coreMiddleware.splice(index, 0, 'cache');
        }
    }
}

很遗憾,但该方案其实是不行的!经过排查static 插件在执行完后所有逻辑后,没有再执行next()。所以请求在static 中执行完就结束了,在他后面的中间件统统不会接收到该请求,请求头也无法再修改。

方案2.1:通过fork static 中间件来修改返回的响应头

官网人员提及到“手动实例化 static 中间件”给了我灵感。既然static 原插件不行,那我搞个forkStatic 来替换掉static 不就OK了。废话不多说,上代码~

// middleware/forkStatic.ts
import { Context } from '@nexus/egg';
import staticEgg from 'egg-static/app/middleware/static';

export default (_options, app) => {
    const noCachePaths = ['/mf/mf-manifest.json', '/mf/remoteEntry.js'];
    const staticMid = staticEgg(app.config['static'], app); // 复用static配置

    const forkStatic = async (ctx: Context, next: () => Promise<void>) => {
        await staticMid(ctx, next);

        if (noCachePaths.some((path) => ctx.path.endsWith(path))) {
            // ctx.set('Cache-Control', 'no-store'); // 关闭强缓存&协商缓存
            ctx.set('Cache-Control', 'no-cache'); // 只关闭强缓存
        }
    };

    return forkStatic;
};
// app.ts
export default class App implements IBoot {
    // https://eggjs.github.io/zh/guide/lifecycle.html
    readonly app: Application;
    constructor(app: Application) {
        this.app = app;
    }
    
    async configDidLoad() {
        const { config } = this.app;
        const index = config.coreMiddleware.indexOf('static');
        if (index > -1) {
            config.coreMiddleware[index] = 'forkStatic'; // 替代原来的static 中间件
        }
    }
}

结语

上面介绍了两种有效的方案来“突破@eggjs/static缓存策略局限性”,希望这个问题不再困扰你们。