自行实现基于文件摘要的静态资源缓存方案

前言


关于静态资源的缓存配置在早前的互联网一直是一个十分恶心的话题,这主要是因为缓存的控制权在于客户端,网站开发者无法准确的命令客户端浏览器刷新缓存。PWA 也存在这个问题,并且更加严重(我以后会发文解释)。将不可控的因素交给客户端是十分不明智的,事实上资源缓存也确实造成了不少的麻烦。

所以后来大量的网站都摒弃了采用纯粹的 HTTP 缓存头来控制,改为利用资源文件的摘要信息“版本化”命名资源,这样一旦有变化资源的链接已发生改变,客户端必须重新下载。

现在一部分流行的前端工具或后端框架都可能提供了实现该解决方案的方式,本文主要介绍了现有的方案,并使用 Crystal 语言和任务管理器 sam.cr 入手,自行实现了一个高度可用并能便捷的集成到项目中的工具。

设计


本文将会是对最佳参考 Phoenix 框架的相关功能的模仿,准确的说是对 mix phx.digest 任务的复刻实现。所以有必要先介绍一番 Phoenix 框架是如何提供解决方案的。

在 Phoenix 中,静态文件是从 Webpack 管理的独立前端项目输出而来,但 Phoenix 并不靠 Webpack 来管理资源摘要。毕竟后端模板跟前端已经无关了,靠 Webpack 的相关插件功能并不能做到完美,反而会带来复杂度或并不优雅的改进。

对于 Phoenix 而言,假设有以下静态文件树(原生输出):

priv/static/
├── css
│   ├── admin.css
│   └── user.css
├── favicon.ico
├── fonts
│   └── Material-Icons.woff2
├── images
│   └── avatar.png
├── js
│   ├── admin.js
│   └── user.js
└── robots.txt

当我们发布应用之前会执行 mix phx.digest 任务将这些静态资源以各自摘要命名一份。完成后的文件树:

priv/static/
├── cache_manifest.json
├── css
│   ├── admin-7a72117cf864e55baef2cc1ec2503ea5.css
│   ├── admin-7a72117cf864e55baef2cc1ec2503ea5.css.gz
│   ├── admin.css
│   ├── admin.css.gz
│   ├── user-79f40c0b2293a91eee31eb1b7ee72c66.css
│   ├── user-79f40c0b2293a91eee31eb1b7ee72c66.css.gz
│   ├── user.css
│   └── user.css.gz
├── favicon-1445b52b4a1bbadfba3f68733a7de407.ico
├── favicon.ico
├── fonts
│   ├── Material-Icons-d7e60f9d1433a45ed71817f6d23abeca.woff2
│   └── Material-Icons.woff2
├── images
│   ├── avatar-a4e83108dea53b982bc998cfd076d5d8.png
│   └── avatar.png
├── js
│   ├── admin-c871523bd1e25e683e26d24ff58ca341.js
│   ├── admin-c871523bd1e25e683e26d24ff58ca341.js.gz
│   ├── admin.js
│   ├── admin.js.gz
│   ├── user-7f1d0666e99b9ba2cf60f34455745b44.js
│   ├── user-7f1d0666e99b9ba2cf60f34455745b44.js.gz
│   ├── user.js
│   └── user.js.gz
├── robots-e28dcdd6d785525882a53d92b45bde3e.txt
├── robots-e28dcdd6d785525882a53d92b45bde3e.txt.gz
├── robots.txt
└── robots.txt.gz

可以看到多出了很多后缀某种 hash 字符串的文件副本,这些 hash 实际上就是一直在强调的文件摘要。至于是哪种摘要算法,它并不重要。

这表示我们得重新引用重命名后的资源吗?当然不。如果需要这样做,那只是半个完成度的东西,是为了解决缓存问题反而加重了数倍的繁琐程度的愚蠢做法。至少在逻辑上,我们不需要更改引用路径。

例如我们在模板中会这样引用 /css/user.css 文件:

<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/user.css") %>" />

在引用路径中,我们始终使用原始文件名。模板输出以后的真实路径会自动变化:

<link rel="stylesheet" href="/css/user-79f40c0b2293a91eee31eb1b7ee72c66.css" />

这是因为 Phoenix 为我们的原始路径和摘要文件做了映射关系,所以我们并不需要改写任何引用路径即可享受到以摘要命名资源带来的好处。

原理


按照 Phoenix 的标准流程,因为 phx.digest 任务是在构建应用程序时执行的,并非运行时,所以原始文件和摘要的映射关系并非动态添加的。那么,Phoenix 是如何做到的呢?

秘密就在生成的文件中多出来的那个我故意没有提及的 cache_manifest.json 文件。文件名由 Cache 和 Manifest 两个单词组成,顾名思义就是“缓存清单”,正是这份清单记录了所有静态文件和摘要的映射关系。

我们打开 cache_manifest.json 内容其实可想而知:

{
   "digests":{
      "css/admin-7a72117cf864e55baef2cc1ec2503ea5.css":{
         "digest":"7a72117cf864e55baef2cc1ec2503ea5",
         "logical_path":"css/admin.css",
         "mtime":63740535180,
         "sha512":"CzuItR6Rn2EHRXa+BP6XHw+bgfYUJrxetZAamVzZV/hr+qJHPlHe6pGD9AM4sccPwJxij7wzUS3PutCJa4NRKQ==",
         "size":166717
      },
      "css/user-79f40c0b2293a91eee31eb1b7ee72c66.css":{
         "digest":"79f40c0b2293a91eee31eb1b7ee72c66",
         "logical_path":"css/user.css",
         "mtime":63740535180,
         "sha512":"OsCeLh31wo1t+ggApBy6/vWrmNe+nYB+sjbSefZlSp34tD7HJS+PtCokYf3Lgm3uMUYPXKMu3xkFukFJ/9aK0g==",
         "size":241664
      },
      "favicon-1445b52b4a1bbadfba3f68733a7de407.ico":{
         "digest":"1445b52b4a1bbadfba3f68733a7de407",
         "logical_path":"favicon.ico",
         "mtime":63740535180,
         "sha512":"72VX0yv/ceqTztJeUC7plnnLP0C9by5qbrFSS/8j8cYGAOM/SbIiXX3tIIzmJkFUN1It9XONtuRPC4czRQbl8A==",
         "size":4286
      },
      "fonts/Material-Icons-d7e60f9d1433a45ed71817f6d23abeca.woff2":{
         "digest":"d7e60f9d1433a45ed71817f6d23abeca",
         "logical_path":"fonts/Material-Icons.woff2",
         "mtime":63740535180,
         "sha512":"qQxikQCzqWhctA58FCUoO9zHdqLzPtgc2XnyoEuWBWb1yCVWbnsOo7a38VLcP8X4FhJucWIpZiuA+f4eNugGmg==",
         "size":60832
      },
      "images/avatar-a4e83108dea53b982bc998cfd076d5d8.png":{
         "digest":"a4e83108dea53b982bc998cfd076d5d8",
         "logical_path":"images/avatar.png",
         "mtime":63740535180,
         "sha512":"fsWZnfy/QcWmGGLfvWFT9IoUNBVKHqd0eiGsTLvgyxy2ygE0AtgxtW5Q5bsViGEpyIbeHASA/d2B64kRjBONDg==",
         "size":56172
      },
      "js/admin-c871523bd1e25e683e26d24ff58ca341.js":{
         "digest":"c871523bd1e25e683e26d24ff58ca341",
         "logical_path":"js/admin.js",
         "mtime":63740535180,
         "sha512":"FNU+0C3CR+V2DJQSFqYFlxYxT+nXlSwnKnvhazuodYNdP3laZFaVAkQZY4NfxTQUU3ebhCO0GvFRqfLjZ8p/7w==",
         "size":1973672
      },
      "js/user-7f1d0666e99b9ba2cf60f34455745b44.js":{
         "digest":"7f1d0666e99b9ba2cf60f34455745b44",
         "logical_path":"js/user.js",
         "mtime":63740535180,
         "sha512":"vMIFw3/pxGxvvmTNI+DATg4FN1TwjGRlDG3rcexAmkXlFcEntBUeb+OUJ8XM0Hn6AMLdP0mTrdpY3Sc9z9BFig==",
         "size":367442
      },
      "robots-e28dcdd6d785525882a53d92b45bde3e.txt":{
         "digest":"e28dcdd6d785525882a53d92b45bde3e",
         "logical_path":"robots.txt",
         "mtime":63740535180,
         "sha512":"DrvohhDAHjqRZIpAzXmksFsJGnh93Bb5XaLB+vUi/CJbikTLpzcAQyNHwv3qAp7jFqvg3VxEDqFPO3WON+KuMg==",
         "size":51
      }
   },
   "latest":{
      "css/admin.css":"css/admin-7a72117cf864e55baef2cc1ec2503ea5.css",
      "css/user.css":"css/user-79f40c0b2293a91eee31eb1b7ee72c66.css",
      "favicon.ico":"favicon-1445b52b4a1bbadfba3f68733a7de407.ico",
      "fonts/Material-Icons.woff2":"fonts/Material-Icons-d7e60f9d1433a45ed71817f6d23abeca.woff2",
      "images/avatar.png":"images/avatar-a4e83108dea53b982bc998cfd076d5d8.png",
      "js/admin.js":"js/admin-c871523bd1e25e683e26d24ff58ca341.js",
      "js/user.js":"js/user-7f1d0666e99b9ba2cf60f34455745b44.js",
      "robots.txt":"robots-e28dcdd6d785525882a53d92b45bde3e.txt"
   },
   "version":1
}

每一个原始路径对应一个摘要命名后的路径,每一个摘要文件对应一份详细信息。要实现原始路径和真实路径的映射,在应用启动时读取并缓存这份文件中 latest 内容即可。所以 Routes.static_path/2 函数实际上只是将原始路径作为参数并返回最新的摘要文件路径。

这便是 Phoenix 的 Digest 解决方案原理。

实现


基于以上原理和清单文件格式,我自行实现了一个名为 digests.cr 的项目,它同时也是一个运行时库。

在 Crystal 项目中,集成并使用它非常容易。首先在 shards.yml 中添加相关依赖:

dependencies:
  digests:
    github: Hentioe/digests.cr
  # Also need sam as a task execution tool
  sam:
    github: imdrasil/sam.cr

创建 sam.cr 并载入 digests.cr 提供的任务:

require "sam"
load_dependencies "digests"

# your custom tasks here

Sam.help

生成摘要文件(等同于 Phoenix 的 phx.digest 任务):

crystal sam.cr -- digests:make

上面说过它同时还是运行时库,并非纯粹的生成工具。所以我们还要在项目代码中使用 Digests 模块的相关 API。

初始化:

Digests.init

在模板中使用:

<script src="<%= Digests.logical_path("/js/app.js") %>"></script>

如上,相对于 Phoenix 提供的名为 static_path 的函数,我将它命名做 logical_path。因为原始文件路径实际上也是一个“逻辑路径”,它永远不变的指向着不断变化的摘要文件。

与 Phoenix 相同,Digests 也会生成 cache_manifest.json 文件,并且它们的结构大抵相同。我的所有 Crystal 项目都在使用 digests.cr,因为感受过 Phoenix 的美好,总想着在其它生态中实现同样的东西。

结束语


本文介绍了一种由构建时生成,运行时读取并映射的缓存资源摘要命名化后的解决方案。它的原理很简单,实现也并不复杂,但能带来巨大的作用,彻底规避浏览器缓存问题。

因为大多数人应该都没听过 Crystal,所以我没有一步一步的写实现过程。不过我想大多数人都应该能用自己的技术栈复刻出来:)