sensai2.0

334
15

序. workbox是google推荐的基于service-worker的离线缓存框架。

正好前阵子在搞service-worker离线缓存,顺便仔细阅读了一下workbox v2.1的源码,故在此记录之。

源码路径: https://github.com/GoogleChrome/workbox/tree/v2.1.2

预备知识: 

* ServiceWorker+CacheStorage: https://www.w3.org/TR/service-workers-1/

* IndexedDB: ​https://www.w3.org/TR/IndexedDB/

* FetchAPI: https://fetch.spec.whatwg.org/


1. 功能简介

workbox文档如下: https://developers.google.com/web/tools/workbox/reference-docs/latest/module-workbox-sw.WorkboxSW

workbox主要提供两种cache的方法——precache和runtime-cache

顾名思义precache是指在service-worker启动后到activate之前对资源作预缓存,而runtime-cache则是在service-worker激活后的运行时根据业务方的配置对特定的请求做缓存,并根据策略决定请求的处理方式(直接返回缓存/优先返回缓存/仅断网时返回缓存等)。

service-worker.js中引入workbox后会直接在global scope添加WorkboxSW对象,内部包含公开的方法/属性如下:

                                                            +----------------------------+
                                                            |                            |
       +-----------------+         +--------------------+   |    Strategies              |
       | SWRoutingRouter |         |                    |   |                            |
       +-----------------+         |     WorkboxSW      |   |  + cacheFirst()            |
               ^*inherit           |                    |   |                            |
+------------------------------+   |                    |   |  + cacheOnly()             |
|          Router              |   |   + precache()     |   |                            |
|                              |   |                    |   |  + networkFirst()          |
| + registerRouter()           |   |   + get strategies +--->                            |
|                              |   |                    |   |  + networkOnly()           |
| + registerNavigationRouter() <---+   + get router     |   |                            |
|                              |   |                    |   |  + staleWhileRevalidate()  |
+------------------------------+   +--------------------+   |                            |
                                                            +----------------------------+

precache()方法用来指定预缓存的文件列表;

router属性是SWRoutingRouter类的实例,提供registerRouter()和registerNavigationRouter()两个公共方法,用来配置运行时缓存的url路由以及运行时缓存的方法

而strategies类似一个helper,包含了若干个常用的运行时缓存策略方法,作为registerRouter的参数传入;

基本使用方法如下:

importScripts("/path/to/workbox.js");

const workbox = new self.WorkboxSW({
  skipWaiting: true   // workbox 基本配置
});

// runtime cache
workbox.router.registerRoute(
  '/action/*',  // 匹配需要缓存的路由
  workbox.strategies.networkFirst({ // 缓存策略
    // 配置缓存策略的参数
    cacheExpiration: {
      maxAgeSeconds: 604800
    },
    networkTimeoutSeconds: 5
  })
);

// precache
workbox.precache([
    '/a.css',
    '/b.jpg'
]);


2. 结构层级

阅读源码之前首先要简单过一遍官方的文档(https://developers.google.com/web/tools/workbox/reference-docs/latest/​),大致了解各个模块的组成和功能;然后以对外接口为入口,根据import的路径层层深入地遍历对应的模块的接口实现,从而了解各个模块之间的引用关系。

workbox v2.1项目的代码结构如下:


WorkboxSW类为整个项目的“main函数”,其实现全部位于workbox-sw这个目录下;

而WorkboxSW类中引入了router对象实例,其实现位于workbox-routing目录下​;

实现precache预缓存功能,一定需要依赖workbox-precaching模块实现预缓存资源的存储和版本更新,当然由于需要判断预缓存资源的路径,同样也需要依赖workbox-routing模块;

而strategies属性类似是一个工厂,返回了一些常用的缓存策略,其实现位于workbox-runtime-caching下。同时各个strategies拥有不少自定义配置的参数(如cache-expiration, broadcase-cache-update, networkTimeoutSeconds​等等),workbox代码以"plugin"的形式动态地注入对应的模块来实现这些功能,其实现位于(workbox-background-sync/workbox-broadcast-cache-update/workbox-cache-expiration​/workbox-cacheable-response/workbox-range-requests等等)

这里值得一提的是,precache实际上也是一种特殊的cache first strategies,只是由于其cache的时序在service-worker激活之前,并且缓存更新/失效的机制与其他runtime-cache不一样,所以单列了出来。

运行时的主要代码即用到上面的几个模块,其依赖关系如下图:

                 +-------------------------------------------------------+
                 |                       workbox-sw                      |
                 |                                                       +---+
                 |                                                       |   |
                 |  strategies             router          precache()    |   |
                 +----+-----------------------+--------------+-----------+   |
                      |                       |              |               |
                      |                       |    +---------+               |
                      |                       |    |         |               |
+---------------------v---+            +------v----v-----+   |               |
|                         |            |                 |   |               |
| workbox-runtime-caching | <----------+ workbox-routing +------------+------+
|                         |            |                 |   |        |
+-------------------------+     传 参   +-----------------+   |        |
               plugins|                                      |        |
                      |            +--------------------+    |        |
+---------------------v---------+  |                    <----+        |
|                               |  | workbox-precaching |             |
|workbox-background-sync        |  |                    |             |
|                               |  +----------+---------+  +----------v-------+
|workbox-broadcast-cache-update |             |            |                  |
|                               |  +----------v---------+  |  service-worker  |
|workbox-cache-expiration​       |  |                    |  |                  |
|                               |  |    IndexedDB       |  |  oninstall       |
|workbox-cacheable-response     +-->    fetch           |  |  onactivate      |
|                               |  |    CacheStorage    |  |  onfetch         |
|workbox-range-requests         |  |                    |  |  ......          |
+-------------------------------+  +--------------------+  +------------------+

workbox中还有workbox-build/workbox-cli模块,其作用是作为打包插件用于自动生成/更新serviceworker.js,​以及workbox-google-analytics用做google统计的帮助模块,这里不多赘述。

除此之外,workbox中还有一个通用的lib目录,里面存放了一些公共的帮助类和公共方法(idb-helper用于简化IndexedDB的操作、log-helper用于封装打log等等)

最后需要指出的是,workbox内部主要操作3种浏览器api,分别为CacheStorage——用来存请求缓存,IndexedDB——用来记录缓存的时间/版本号以判断缓存是否已过期,以及ServiceWorker——用来拦截请求并做处理。


3. WorkboxSW概览

WorkboxSW类相当于workbox的“main”函数,其constructor方法中定义了service-worker在启动的各个生命周期下需要做的事情,同时内部引入了其他的模块来实现各个功能(或作为工厂方法返回其他对象)。

WorkboxSW类的constructor核心部分如下,可以看出:

* this._strategies返回缓存策略工厂类

* this._runtimeCacheName用作缓存runtime cache的CacheStorage对象名

* this._router即router属性的readonly实例对象——用来配置runtime cache策略

* this._revisionedCacheManager和this._precacheRouter相结合在内部实现precache的功能


接下来看Router.prototype.addFetchListener、​_registerInstallActivateEvents和_registerDefaultRoutes三个方法

首先是addFetchListener,可以猜到Router类的这个方法封装了service-worker在触发onfetch时做的事情——拦截并处理请求。

然后是_registerInstallActivateEvents,故名思议这个方法封装了service-worker在oninstall和onactivate时应该做的事情:初始化precache、claim clients、skip waiting等等...当然初始化precache的工作由this._revisionedCacheManager来做


最后是_registerDefaultRoutes,这个方法其实叫_registerPrecacheRoutes更贴切一些,这个方法获取了this._revisionedCacheManager下的precache urls,并设置this._precacheRouter匹配这些url时采取CacheFirst缓存策略,从而实现precache预缓存的读取。


如果把workbox比作一个mvc结构的web应用,那么WorkboxSW类就是最顶层统筹全局的Application类,constructor就是start servlet的启动代码,对onactivate/oninstall等事件的绑定可以看做是注册一系列条件触发的后台任务,Router即Application中配置的路由表,Router中对路由的处理方法Handler可以比作Controller下的Action,Strategies中的几个工厂方法(cacheFirst/networkFirst等等)即框架提供的默认Action,而RevisionedCacheManager及其操作的原生api(IndexedDB、CacheStorage)可以看做是Model.

                      +----------------------------+
                      | WorkboxSW   (Application)  |
                      +-------------+--------------+
                                    |                   +---------------+
                      +-------------v--------------+    |ServiceWorker  |
                      | constructor (start servlet)+---->EventHandlers  |
                      +-------------+--------------+    |   (Tasks)     |
                                    |                   +-+---+---------+
+---------+           +-------------v--------------+      |   ^
| onfetch +----------->           Router           |      |   |
+----+----+           +-------------+--------------+      |   | trigger
     ^     FetchEvent               |                     |   |
     |                +-------------v---------------+     |   |
     |                | Handler (Controller/Action) |     | +-+---------+
     |                +------+----------------^-----+     | | oninstall |
     |                       |                |           | |           |
     |                       |                |           | | onactivate|
     |           +-----------v-----+  +-------v--------+  | |           |
     |           | responsePromise |  | fetch          |  | | ...       |
     |           |                 |  | IndexedDB      |  | |           |
     +-----------+                 |  | CacheStorage   <--- +-----------+
event.respondWith| (View)          |  | (Model)        |CRUD
                 +-----------------+  +----------------+


4. Router

WorkboxSW下的router对象继承了workbox-routing模块的router,并对外提供了两个注册路由的方法:registerRoute
​和
registerNavigationRoute

路由主要的实现的逻辑在workbox-routing.Router类中,下图展示了workbox-routing.Router基本结构:

+----------------------+    +------------------+
|        Router        |    |      Route       |    +-----------+
|                      |    |                  |    |           |
|  - _route            +---->    + handler     +--->+  Handler  |
|  Map<string,[Route]> | 1-n|                  |    |           |
|                      |    |    + method      |    +-----------+
|  + registerRoutes()  |    |                  |
|                      |    |    + match()     |
|  + unregisterRoutes()|    |     (virtual)    |
|                      |    |                  |
|  + handleRequest()   |    +---------+--------+
|                      |              ^
|  + addFetchListener()|              |
|                      |              | inherit
+----------------------+              |
                 +-------------------------------------------+
                 |                    |                      |
           +-----+------+      +------+------+      +--------+--------+
           |            |      |             |      |                 |
           |ExpressRoute|      | RegexpRoute |      | NavigationRoute |
           |            |      |             |      |                 |
           +------------+      +-------------+      +-----------------+
Router的内部通过Map<string, Array<Route>>结构维护了若干个Route对象

Route对象主要有3个字段:

* match字段类型为function,返回url匹配的结果;

* handler字段类型为Handler对象或一个function类型,用于对匹配成功url的处理;

* method字段即请求的method(GET,POST),也作为Map对象的key,用于在匹配url前先做一层过滤;

ExpressRoute、RegexpRoute以及NavigationRoute都派生于Route对象并且复写了match方法,用于提供不同种类的url匹配表达式,业务方也可以直接传入function来自行匹配

Router完整匹配流程如下:


其中_findHandlerAndParams中通过循环找到最终匹配当前URL的Route对象,并返回Route中的匹配结果(路由中的参数params)和handler方法:


Router匹配到当前的Route后便执行Route中的Handler方法了。

上面提到的对外公开的WorkboxSW.router类继承于Router类并提供了registerRouteregisterNavigationRoute​两个公共方法,不难想到这两个方法的作用是进一步简化了注册路由的步骤。


以registerRoute为例,方法内通过判断capture的类型来自动选择用于匹配的Route类,对外接口进一步收口。


5. Handler

Handler即缓存策略,用于处理已匹配的路由url;WorkboxSW.strategies下有若干现成的常用缓存策略,其实现位于workbox-runtime-caching模块下,Handler也是workbox中最核心的部分


其基本结构图如下:

+----------------------+       Map<string, Array<object>>
|    RequestWrapper    |
|                      |      +------------------------------+
|  + plugins           +----->|                              |
|                      |      | + cacheDidUpdate()           |
|  + getCache()        |      |                              |
|                      |      | + cachedResponseWillBeUsed() |
|  + match()           |      |                              |
|  (match cache)       |      | + cacheWillUpdate()          |
|                      |      |                              |
|  + fetch()           |      | + fetchDidFail()             |
|                      |      |                              |
|  + fetchAndCache()   |      | + requestWillFetch()         |
|                      |      |                              |
+----------^-----------+      +------------------------------+
           |                        +------------+
           |                 +------+ CacheOnly  |
+----------+--------+        |      +------------+
|                   | inherit|      +------------+
|     Handler       <--------+------+ CacheFirst |
|                   |        |      +------------+
|  + handle()       |        |      +--------------+
|   (virtual)       |        +------+ NetworkFirst |
|                   |        |      +--------------+
|  + waitOnCache    |        |      +-------------+
|                   |        +------+ NetworkOnly |
|  + requestWrapper |        |      +-------------+
|                   |        |      +----------------------+
+-------------------+        +------+ StaleWhileRevalidate |
                                    +----------------------+
Handler基类定义了handle方法,同时Handler内部聚合了RequestWrapper​对象实例

RequestWrapper对象内部定义了一些基本的处理请求的方法:

* match() 实现了从本地CacheStorage中取Response

* fetch() 实现了请求服务器并返回Response

* fetchAndCache() 实现了请求服务器并将Response存入CacheStorage

除此之外,RequestWrapper内维护了一个Map<string, Array<Object>>的属性plugins,并支持通过注入plugin方法来定义在fetch/match/fetchAndCache过程中特定生命周期下的处理行为。

RequestWrapper中支持注入的plugin方法有以下几种:


* cacheWillUpdate和cacheDidUpdate在fetchAndCache方法内,fetch返回成功时触发cacheWillUpdate来判断cache是否可更新,更新完成后触发cacheDidUpdate方法

* cachedResponseWillBeUsed直接作用于match方法,返回结果替换当前match方法的结果

* requestWillFetch在fetch前触发

* fetchDidFail在fetch失败时触发

plugins参数在RequestWrapper的constructor中传入,plugins数组的元素需要为object类型,并且object中需要包含方法名为cacheWillUpdate、cacheDidUpdate、cachedResponseWillBeUsed​、requestWillFetch、fetchDidFail中​任意一个的成员方法。

plugins初始化处理方式如下:


需要注意的是cacheWillUpdate和cachedResponseWillBeUsed只能定义一次方法,其他plugins可以定义多次,并在生命周期内依次触发。

有了RequestWrapper这个高级工具提供的方法,Handler的各个派生类的实现就非常简单了,基本上通过fetch/fetchAndCache/match这三个方法的组合加上注入特定实现的plugin方法就可以实现;

NetworkFirst、NetworkOnly、CacheFirst、CacheOnly从字面意思就可以理解含义了。

StaleWhileRevalidate比较特殊,该策略先从缓存中取出Response返回,并同时调用fetch取到最新Response并更新缓存,既保证了新鲜度,又利用了缓存,其实现如下:



6. 基于plugins实现的功能

workbox中有5个模块,通过注入RequestWrapper中的plugins​来实现相应的功能;


* workbox-background-sync定义了fetchDidFail方法,支持fetch失败时本地buffer并尝试重传

* workbox-broadcast-cache-update定义了cacheDidUpdate方法,实现了基于Broadcase Channel API通知当前service-worker的浏览器其他clients页面资源已更新的方法

* workbox-cache-expiration同时定义了cachedResponseWillBeUsed和cacheDidUpdate方法,基于IndexedDB记录缓存的Expiration信息,并实现cache超时失效的判断与失效后的删除

* workbox-cacheable-response定义了cacheWillUpdate方法,基于CacheStorage实现判断当前Response是否可缓存

* workbox-range-requests定义了cachedResponseWillBeUsed方法,实现了​通过判断Request Header中的Range字段自动返回缓存Response报文的一部分

workbox-cache-expiration和workbox-cacheable-response两个模块​是RequestWrapper中使用的比较多的,所以这边着重写一下


6.1 workbox-cacheable-response

workbox-cacheable-response模块提供了CacheableResponsePlugin类,并通过复写cacheWillUpdate方法来判断Response是否Cacheable


基类实现如下:


在RequestWrapper内定义的默认的CacheableResponsePlugin对象:


可以看到默认的缓存条件是请求返回码为200.


6.2 workbox-cache-expiration

​workbox-cache-expiration提供了CacheExpirationPlugin类,其中通过复写了​cachedResponseWillBeUsed和cacheDidUpdate两个方法实现了缓存有效期与超时失效的判断

CacheExpirationPlugin类继承与内部的CacheExpiration类,其定义如下:


cachedResponseWillBeUsed方法通过判断缓存是否新鲜来决定是否使用缓存

cacheDidUpdate在fetchAndCache方法内缓存存入完成后调用,通过IndexedDB记录请求缓存的请求时间,并触发旧缓存的检测与删除

updateTimestamp、expireEntries和isResponseFresh是CacheExpiration类的三个重要方法

CacheExpiration的constructor定义如下:


业务方需要传入maxAgeSeconds和maxEntries两个参数,分别代表缓存有效期和最大缓存数目。

首先看updateTimestamp​方法,这个方法很简单——往indexeddb里面写入当前url和当前请求返回的时间戳:


写入结果示例如下:

然后是expireEntries​方法,其功能是删除旧的已经失效的缓存Cache:


实现逻辑也很明朗——根据用户传入的maxAgeSeconds和maxEntries参数配合时间戳now来判断哪些缓存已过期,并删除之。

虽然js是单线程的,但是由于CacheStorage和IndexedDB这两个api都是async的,可能会出现几乎同时多个cacheDidUpdate过程进入这个方法并同时操作CacheStorage和IndexedDB,使之失去了原子性,并可能会造成删除失败的问题。所以这边加了一个类似线程锁的实现,保证同时只有一个expireEntries在执行。并且在遇到并发时记录后续并发的now参数,并在本次expireEntries执行完成后手动执行下一次,强行转为串行执行。

最后是isResponseFresh方法,用于在获取Cache匹配时判断Cache是否已过期。


以上实现了缓存有效期的检测与过期缓存的删除.


7. Strategies

WorkboxSW.strategies在之前几节已经提到过,它可以看做是一个对业务方友好的工厂,支持通过传参的方式配置内置的几种Handler及plugin

以strategies.networkOnly(options)方法为例,其内部调用了通用的私有方法_getCachingMechanism如下:


内部通用方法_getCachingMechanism方法定义如下:


那么对业务方而言,如果希望实现一个策略如下:

* 优先使用本地cache

* cache最多存10条,过期时间7天

* cache更新时通过channel通知所有clients

* 请求返回状态吗为0、200、404(fetch在mode=no-cors时请求跨域资源时status code为0,这类请求一般是img、script标签发出并由service-worker fetch代理,其Response内容无法被看到,但是依然能够通过cache.put方法存入CacheStorage并且存在header "Example-Header: Header-Value"时才本地缓存

那么可以配置如下:

const cacheFirstStrategy = workboxSW.strategies.cacheFirst({
  cacheExpiration: {                                        
    maxEntries: 10,
    maxAgeSeconds: 7 * 24 * 60 * 60                         
  },
  broadcastCacheUpdate: {
    channelName: 'example-channel-name'
  },
  cacheableResponse: {                                      
    statuses: [0, 200, 404],                                
    headers: {
      'Example-Header': 'Header-Value'    
    }                                                       
  }
});


8. precache的具体实现

第三章节已经简单提到过WorkboxSW类中通过操作_revisionedCacheManager字段实现precache缓存与事件处理的流程,这边详细介绍一下RevisionedCacheManager这个类及相关的模块实现


其类图结构如下:

                                            +------------------------------+
                                            |                              |
                                            |      BaseCacheManager        |
                                            |                              |            +----------------+
                            1-n             | Map<string, BaseCacheEntry>  |            |                |
                        +-------------------+   - entriesToCache           +------------> RequestWrapper |
                        |                   |                              |            |                |
                +-------v--------+          +--------------------+---------+            +----------------+
                |                |                               ^
                | BaseCacheEntry |                               |inherit
                |                |                               |
                +-------^--------+                 +-------------+--------------+
                        |                          |                            |
                        | inherit                  |   RevisionedCacheManager   |      +----------------------+
         +--------------+----------+               |                            |      |                      |
         |                         |               |  - _revisionDetailsModel   +----->+ RevisionDetailsModel |
+--------+------------+   +--------+---------+     |                            |      |                      |
|                     |   |                  |     |  + addToCacheList()        |      +----------+-----------+
| ObjectPrecacheEntry |   | StringCacheEntry |     |                            |                 |
|                     |   |                  |     |  + install()               |      +----------v-----------+
+--------^------------+   +--------^---------+     |                            |      |                      |
         |                         |               |  + cleanup()               |      |       IDBHelper      |
         |                         |               |                            |      |                      |
         +-------------------------+---------------+  - parseEntry() [override] |      +----------------------+
                           new                     |                            |
                                                   +----------------------------+

主要有三个部分

* BaseCacheEntry及其派生类ObjectPrecacheEntry、StringCacheEntry主要用于解析并存储precache传入​的url参数

* BaseCacheManager及RevisionedCacheManager主要提供了一系列预缓存的操作方法,fetchAndCache的操作主要在BaseCacheManager中实现

* RevisionDetailsModel主要封装了IndexedDB用于存储precache文件的版本号,以便在precache文件更新后能够实时更新缓存

业务方添加缓存首先调用WorkboxSW对象的precache方法,如下

// Cache a set of revisioned URLs
const workboxSW = new WorkboxSW();
workboxSW.precache([
    '/images/logo.59a325f32baad11bd47a8c515ec44ae5.jpg'
]);

// ...precache() can also take objects to cache
// non-revisioned URLs.
// Please use workbox-build or the workbox CLI to generate the manifest for
// you.
workboxSW.precache([
    {
      url: '/index.html',
      revision: '613e6c7332dd83e848a8b00c403827ed'
    }
]);
然后precache内部调用addToCacheList方法:


addToCacheList方法调用基类BaseCacheManager::_addEntries方法,_addEntries方法中又调用了派生类即RevisionedCacheManager::_parseEntry方法;


RevisionedCacheManager::_parseEntry方法即根据入参的类型来选择ObjectPrecacheEntry或者StringCacheEntry来初始化“CacheEntry”


precache方法最终将业务方传入数据规整后放进Map<string, BaseCacheEntry>_entriesToCache下;
​BaseCacheEntry内部维护了四个字段:


entryID表示缓存的唯一标识,一般就是请求的url;

revision表示传入的版本号;

request即Request对象,表示precache的请求体;

cacheBust用于表示缓存是否已新鲜,一般是根据revision来判断的;

​StringCacheEntry由于传入的是字符串,所以没有失效和版本的概念,构造方法也比较简单:


而ObjectCacheEntry传入的对象包含了版本号也url,构造方法复杂一些:


BaseCacheEntry中提供了通用的获取Request对象的方法,对于cacheBust为true的BaseCacheEntry对象,需要重新设置Request对象的属性以避免使用到缓存:


接下来在service-worker在oninstall状态时执行this._revisionedCacheManager.install(),以及在onactivate状态时执行

this._revisionedCacheManager.cleanup()

install()方法内部取出预缓存的BaseCacheEntry列表,根据版本号和缓存url匹配当前预缓存是否在本地已存在,最终留下所有需要请求服务端的预缓存并缓存之。

其中使用到了IndexedDB来存储缓存revision和url,其中key是url


cleanup()方法则是清除CacheStorage中不用的多余的缓存——如果CacheStorage中存在但BaseCacheEntry列表中不存在,则删除之。

最终WorkboxSW调用router匹配所有BaseCacheEntry列表中的url,并针对这些url request采取Cache-First策略,最终实现了预缓存的策略。(见第三节)


9. 总结

这篇​笔记记录了阅读workbox源码的心路历程,可以概括为自顶向下到自底向上:先从入口看对外接口,再看各个模块之间依赖关系,然后深入各个模块的实现关系,最后回到入口类看如果统筹组织各个模块使之成为整体。

从代码的角度看,workbox的模块组织和模块间内聚耦合关系的实现还是值得学习的。


回复

对话列表

×