作者:王可平
本文为原创,转载请注明作者及出处
在微盟,为了保障海外电商业务(ShopExpress)的外部资源依赖不受制于外部服务的稳定性。我们制作了外部资源管理器,其基本原理如下:
外部资源管理器,从定位上是一个管理外部资源,缓存结果,提升服务健壮度的服务,因此它不会也不应该对返回结果进行处理。如果有类似需要改写,并且缓存返回结果的逻辑,应该在业务层实现。
目前外部资源管理器支持以下域名访问:
*.{{customDomain}}.com 外网 CDN 域名,支持 HTTP、HTTPS,CDN 缓存会根据具体服务进行配置,最小缓存单位时间是一秒钟,最长时间是一年,如字体文件等需要长期缓存的文件一般是一年,比如:https://*.shopexpress.com/gstatic/fonts/s/adobeblank/v20/ieV62YNKKGqPJteRQT8EQmobI8hSxw.woff2
*.internal.shopexpress.com 内网域名,支持 HTTP,无 CDN 缓存(但是部分服务配置有本地缓存优先策略)适合如字体;
字体样式表、列表文件等需要短时间内更新的问题件,比如:http://*.internal.shopexpress.com/googleapis/fonts/css?family=Akronim:Normal
PS. 可以根据返回的文件的 Header 中的globalticket
或beijingtime
来确定,其读取的是否为新的文件。
在外部资源管理中,有三种缓存,分别是CDN 缓存
、本地内存缓存
、还有磁盘缓存
。其中磁盘缓存
又分为普通磁盘缓存
和备份磁盘缓存
两种。之所以设计两种磁盘缓存的原因是,部分缓存策略要求采用“缓存优先”模式来提升加载速度,但是这就导致了源站资源发生变化以后缓存不会更新的情况。因此我们采用了定时控制器
按照具体的资源配置,定期的将“磁盘缓存文件”移入“备份磁盘缓存文件”中,这样,就允许了采用了“缓存优先”策略的资源也可以在一定时间(比如一天)以后依然能够同步到源站的更新。同时,由于“备份磁盘缓存文件”的存在,此时如果源站崩溃了,我们依然可以获取源站崩溃以前的缓存。关于本地内存缓存
和磁盘缓存
的设计,请具体查看下图:
其中值得注意的是,由于成本考虑,应用的内存有限,因此内存缓存将被用户请求激活,并且在一段时间后自然删除,而不是采用将所有资源缓存在内存内的做法。同时,资源文件在当前互联网上实际上采用了多种不同的模式进行压缩编码,包括了 Gzip、Huffman、Brotli 等,一般的浏览器在发送请求时,往往会允许服务处采用多种不同的压缩编码返回二进制文件,但是这就存在我们需要将各种不同的压缩编码的二进制文件分别存储的问题,降低了缓存命中率,并且提供了缓存成本。而如果我们解压二进制文件,再根据用户的请求重新压缩缓存,就增加了我们服务器的开销。因此,经过对比各种压缩编码方案,包括考量兼容性、压缩比率等,我们采用的方案是限制对源站的请求为 Gzip 编码,并且只缓存 Gzip 编码的二进制文件的方案。这是因为虽然 Brotli 压缩比率更为理想,但是其实际解压速度较慢,并且有大量源站和客户端并不支持该压缩方案,故而考量客户端、服务端的兼容性和客户端的性能,我们最终选择了 Gzip 编码。
在磁盘存储部分,我们采用了的是{{domain}}/{{cname}}/{{文件path的md5值}}
的逻辑进行存储的,这是因为 Linux 系统有限制最长文件名。当一些文件 path 特别长的时候(如/fonts?family=Alef%7CAlegreya+SC%7CAlegreya+Sans+SC%7CAleo%7CAlex+Brush%7CAlfa+Slab+One%7CAlice%7CAlike%7CAlike+Angular%7CAllan%7CAllerta%7CAllerta+Stencil%7CAllison%7CAllura%7CAlmarai
)存在系统无法存储文件的问题。而使用了Domain、CNAME进行文件目录区分以后,可以有效降低 MD5 相撞的问题。
最后,单有二进制文件其实是无法支持文件正确的被浏览器解读的,因此我们还需要存储其content-type
。一开始,我们觉得可以采用数据库将对应的二进制文件和其content-type
对应起来,但是通过实际测试,我们发现,单独以源文件名加上.content-type
的命名方式单独存放一个utf-8
编码的content-type
文件,其实际读取速度远高于数据库,并且对磁盘几乎没有任何性能的影响。故而我们设计了磁盘缓存组,由一个二进制文件和一个文件类型组成。
和微盟其它业务的 CDN 有所不同,外部资源管理器的 CDN 是网宿提供的服务,并且是全球加速的,并且其应用部署在北美。因此它在海外,尤其是北美的访问速度非常理想,并且在国内访问速度也可以达到毫秒级别。但是在实践中我们发现网宿的 CDN 的默认配置和我们微盟的通用 NGINX 配置并不能很好的形成合理的CDN 缓存和客户端缓存。
如上左图所示,默认的配置模式下,微盟的 NGINX 会加上如cache-control: no-cache
等诸多返回头。这就导致外部资源管理器配置的客户端缓存控制头被覆盖,使得浏览器获取了错误的缓存配置。同时,微盟的网宿 CDN 的默认配置是,只有以如.js
、.png
等部分文件名结尾的文件才会进行12-24小时的缓存,并且如果请求带上了 Query,如/resources/cdn2/saas/@assets/payssion/0.0.1/alipay_cn.png?foo=bar
那么就不会缓存。这是不理想的,也导致了我们设计的基本方案无法得到实施。所幸,通过运维小伙伴的协助,我们能够改变 NGINX 配置,取消所有返回头添加或改写的逻辑,并且开启了网宿的”定制“缓存功能,使得 CDN 完全根据外部资源管理器返回的 cache-control 头对结果进行缓存。同时,我们加上了特殊情况下按指定文件或源站的逻辑对 CDN 资源进行批量删除的逻辑,解决了配置逻辑变动后CDN 资源没有调整缓存机制的问题。
根据上述描述,我们可以理解,外部资源管理器存在CDN缓存
、本地内存缓存
、还有磁盘缓存
。其中本地内存缓存
和磁盘缓存
又可以被理解为管理器缓存
。对于内网访问(*.internal.shopexpress.com),请求将越过CDN缓存
直接命中管理器缓存
。对于,如luckyorange、googletagmanager这些采用了源站加速策略的资源,这种访问模式实际上是没有缓存的。因此源站加速策略一般适用于外网直接访问。
反过来说,也有一些情况下,需要从客户端发起越过CDN缓存
的外网请求,此时需要对这些请求做一些特殊处理(需要提前报备发起请求的网页)。
对于部分资源,如 iconfont,是存在时间戳参数的。为了降低磁盘缓存的负担,这些资源存在”定制化“缓存策略。就 iconfont 而言,其缓存会采用最新的时间戳来覆盖历史时间戳。因此,如果采用直接命中管理器缓存
的模式,历史时间戳将不再生效!但是如果访问的是CDN缓存
,历史时间戳会在缓存过期之前依然生效。
为了让小伙伴更容易理解外部资源管理器的作用和特性,我们以 ShopExpress 中引入 Google 字体的案例来做一个讲解:
Google 字体是一个世界范围常见的免费字体解决方案。可以在允许商户使用接近上千种美观的字体。但是由于它是免费提供的,因此在世界范围如中国、伊朗、俄罗斯、沙特阿拉伯、也门、利比亚等地区存在服务极端不稳定的情况,同时由于其外部服务的特性,我们难以直接的保障其服务的健壮性。因此我们采用了外部资源管理器来获取其资源。一旦 Google API 或 Gstatic 服务出现故障,我们依然可以利用缓存保障其资源的高度可用。
如上图所示,使用 Google 字体存在三个过程,分别是获取字体配置表、根据字体配置表获取字体文件、以及根据字体文件完成网页的最终渲染。其中字体配置表是如/googleapis/fonts/css?family=Akronim:Normal
这样的文件。其文件的原始内容为:
@font-face {
font-family: 'Akronim';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/akronim/v12/fdN-9sqWtWZZlHRpygd7lA.ttf) format('truetype');
}
可见,如果我们通过外部资源管理器直接获取该字体配置表,则其对应的字体文件资源是来自于fonts.gstatic.com
,这是不理想的 - 我们会希望能够从外部资源管理器获取字体。那么为了实现这个目的,我们就需要对通过外部资源管理器获取该的字体配置表进行修改,将它改为:
@font-face {
font-family: 'Akronim';
font-style: normal;
font-weight: 400;
src: url(https://*.shopexpress.com/gstatic/fonts/s/akronim/v12/fdN-9sqWtWZZlHRpygd7lA.ttf) format('truetype');
}
这样,我们才能顺利从外部资源管理器获取字体文件。可是,外部资源管理器的定位是”确保可控地管理外部资源,并且根据实际需求制定缓存策略“。如果要对获取的文件进行二次加工,并且加入业务逻辑,这是不合适的。因此在外部资源管理器之前,我们加入了c-resouces控制器
,它的目的是对资源内容进行修改。
而进入了第二个环境获取字体文件时,我们不再需要对资源进行二次加工,因此我们可以直接通过外网获取字体资源,享受多层缓存给资源带来的加速和稳定性。
由于商户不一定具有多语言运营能力,我们在基础库内封装了底层能力基于Google 翻译的 SDK,允许商户选择性激活全站机器翻译(简称“机翻”)。该能力允许商户在 B 端后台配置多种可选语种。
在前端项目中,需要引入安装海外电商项目的 C 端基础库(npm包)@aquila/client
(原则上任何项目都可以使用,它提供了包括机器翻译在内的一些通用基础能力)
使用方式如下:
import { aquila } from '@aquila/client';
try {
await aquila.translate.set({
lang: lang,
});
console.log(`目标设置语言为${lang},设置语言为${aquila.translate.get().lang}`);
} catch (e) {
console.log('设置语言失败', e);
}
SDK 会对页面的 DOM 结构和进行监听,对获取可视区域内的文本(白名单模式下,仅白名单元素内文本被翻译)并且通过外部资源管理器调用 Google 翻译的API 实现翻译的功能。开发者在开发过程中只需要引入并且调度简单的 SDK 内的APIs,不再需要关心复杂的 DOM 结构监听和服务器资源的调度问题。