200 行程式碼實現一個高效快取庫

pingan8787發表於2021-10-31


這兩天用到 cacheables 快取庫,覺得挺不錯的,和大家分享一下我看完原始碼的總結。

一、介紹

「cacheables」正如它名字一樣,是用來做記憶體快取使用,其程式碼僅僅 200 行左右(不含註釋),官方的介紹如下:

一個簡單的記憶體快取,支援不同的快取策略,使用 TypeScript 編寫優雅的語法。

它的特點:

  • 優雅的語法,包裝現有 API 呼叫,節省 API 呼叫;
  • 完全輸入的結果。不需要型別轉換。
  • 支援不同的快取策略。
  • 整合日誌:檢查 API 呼叫的時間。
  • 使用輔助函式來構建快取 key。
  • 適用於瀏覽器和 Node.js。
  • 沒有依賴。
  • 進行大範圍測試。
  • 體積小,gzip 之後 1.43kb。

當我們業務中需要對請求等非同步任務做快取,避免重複請求時,完全可以使用上「cacheables」。

二、上手體驗

上手 cacheables很簡單,看看下面使用對比:

// 沒有使用快取
fetch("https://some-url.com/api");

// 有使用快取
cache.cacheable(() => fetch("https://some-url.com/api"), "key");

接下來看下官網提供的快取請求的使用示例:

1. 安裝依賴

npm install cacheables
// 或者
pnpm add cacheables

2. 使用示例

import { Cacheables } from "cacheables";
const apiUrl = "http://localhost:3000/";

// 建立一個新的快取例項  ①
const cache = new Cacheables({
  logTiming: true,
  log: true,
});

// 模擬非同步任務
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// 包裝一個現有 API 呼叫 fetch(apiUrl),並分配一個 key 為 weather
// 下面例子使用 'max-age' 快取策略,它會在一段時間後快取失效
// 該方法返回一個完整 Promise,就像' fetch(apiUrl) '一樣,可以快取結果。
const getWeatherData = () =>
  // ②
  cache.cacheable(() => fetch(apiUrl), "weather", {
    cachePolicy: "max-age",
    maxAge: 5000,
  });

const start = async () => {
  // 獲取新資料,並新增到快取中
  const weatherData = await getWeatherData();

  // 3秒之後再執行
  await wait(3000);

  // 快取新資料,maxAge設定5秒,此時還未過期
  const cachedWeatherData = await getWeatherData();

  // 3秒之後再執行
  await wait(3000);

  // 快取超過5秒,此時已過期,此時請求的資料將會再快取起來
  const freshWeatherData = await getWeatherData();
};

start();

上面示例程式碼我們就實現一個請求快取的業務,在 maxAge為 5 秒內的重複請求,不會重新傳送請求,而是從快取讀取其結果進行返回。

3. API 介紹

官方文件中介紹了很多 API,具體可以從文件中獲取,比較常用的如 cache.cacheable(),用來包裝一個方法進行快取。
所有 API 如下:

  • new Cacheables(options?): Cacheables
  • cache.cacheable(resource, key, options?): Promise<T>
  • cache.delete(key: string): void
  • cache.clear(): void
  • cache.keys(): string[]
  • cache.isCached(key: string): boolean
  • Cacheables.key(...args: (string | number)[]): string

可以通過下圖加深理解:

三、原始碼分析

克隆 cacheables 專案下來後,可以看到主要邏輯都在 index.ts中,去掉換行和註釋,程式碼量 200 行左右,閱讀起來比較簡單。
接下來我們按照官方提供的示例,作為主線來閱讀原始碼。

1. 建立快取例項

示例中第 ① 步中,先通過 new Cacheables()建立一個快取例項,在原始碼中Cacheables類的定義如下,這邊先刪掉多餘程式碼,看下類提供的方法和作用:

export class Cacheables {
  constructor(options?: CacheOptions) {
    this.enabled = options?.enabled ?? true;
    this.log = options?.log ?? false;
    this.logTiming = options?.logTiming ?? false;
  }
  // 使用提供的引數建立一個 key
  static key(): string {}

  // 刪除一筆快取
  delete(): void {}

  // 清除所有快取
  clear(): void {}

  // 返回指定 key 的快取物件是否存在,並且有效(即是否超時)
  isCached(key: string): boolean {}

  // 返回所有的快取 key
  keys(): string[] {}

  // 用來包裝方法呼叫,做快取
  async cacheable<T>(): Promise<T> {}
}

這樣就很直觀清楚 cacheables 例項的作用和支援的方法,其 UML 類圖如下:

在第 ① 步例項化時,Cacheables 內部建構函式會將入參儲存起來,介面定義如下:

const cache = new Cacheables({
  logTiming: true,
  log: true,
});

export type CacheOptions = {
  // 快取開關
  enabled?: boolean;
  // 啟用/禁用快取命中日誌
  log?: boolean;
  // 啟用/禁用計時
  logTiming?: boolean;
};

根據引數可以看出,此時我們 Cacheables 例項支援快取日誌和計時功能。

2. 包裝快取方法

第 ② 步中,我們將請求方法包裝在 cache.cacheable方法中,實現使用 max-age作為快取策略,並且有效期 5000 毫秒的快取:

const getWeatherData = () =>
  cache.cacheable(() => fetch(apiUrl), "weather", {
    cachePolicy: "max-age",
    maxAge: 5000,
  });

其中,cacheable 方法是 Cacheables類上的成員方法,定義如下(移除日誌相關程式碼):

// 執行快取設定
async cacheable<T>(
  resource: () => Promise<T>,  // 一個返回Promise的函式
  key: string,  // 快取的 key
  options?: CacheableOptions, // 快取策略
): Promise<T> {
  const shouldCache = this.enabled
  // 沒有啟用快取,則直接呼叫傳入的函式,並返回撥用結果
  if (!shouldCache) {
    return resource()
  }
    // ... 省略日誌程式碼
  const result = await this.#cacheable(resource, key, options) // 核心
    // ... 省略日誌程式碼
  return result
}

其中cacheable 方法接收三個引數:

  • resource:需要包裝的函式,是一個返回 Promise 的函式,如 () => fetch()
  • key:用來做快取的 key
  • options:快取策略的配置選項;

返回 this.#cacheable私有方法執行的結果,this.#cacheable私有方法實現如下:

// 處理快取,如儲存快取物件等
async #cacheable<T>(
  resource: () => Promise<T>,
  key: string,
  options?: CacheableOptions,
): Promise<T> {
  // 先通過 key 獲取快取物件
  let cacheable = this.#cacheables[key] as Cacheable<T> | undefined
    // 如果不存在該 key 下的快取物件,則通過 Cacheable 例項化一個新的快取物件
  // 並儲存在該 key 下
  if (!cacheable) {
    cacheable = new Cacheable()
    this.#cacheables[key] = cacheable
  }
    // 呼叫對應快取策略
  return await cacheable.touch(resource, options)
}

this.#cacheable私有方法接收的引數與 cacheable方法一樣,返回的是 cacheable.touch方法呼叫的結果。
如果 key 的快取物件不存在,則通過 Cacheable類建立一個,其 UML 類圖如下:

3. 處理快取策略

上一步中,會通過呼叫 cacheable.touch方法,來執行對應快取策略,該方法定義如下:

// 執行快取策略的方法
async touch(
  resource: () => Promise<T>,
  options?: CacheableOptions,
): Promise<T> {
  if (!this.#initialized) {
    return this.#handlePreInit(resource, options)
  }
  if (!options) {
    return this.#handleCacheOnly()
  }
    // 通過例項化 Cacheables 時候配置的 options 的 cachePolicy 選擇對應策略進行處理
  switch (options.cachePolicy) {
    case 'cache-only':
      return this.#handleCacheOnly()
    case 'network-only':
      return this.#handleNetworkOnly(resource)
    case 'stale-while-revalidate':
      return this.#handleSwr(resource)
    case 'max-age': // 本案例使用的型別
      return this.#handleMaxAge(resource, options.maxAge)
    case 'network-only-non-concurrent':
      return this.#handleNetworkOnlyNonConcurrent(resource)
  }
}

touch方法接收兩個引數,來自 #cacheable私有方法引數的 resourceoptions
本案例使用的是 max-age快取策略,所以我們看看對應的 #handleMaxAge私有方法定義(其他的類似):

// maxAge 快取策略的處理方法
#handleMaxAge(resource: () => Promise<T>, maxAge: number) {
    // #lastFetch 最後傳送時間,在 fetch 時會記錄當前時間
    // 如果當前時間大於 #lastFetch + maxAge 時,會非併發呼叫傳入的方法
  if (!this.#lastFetch || Date.now() > this.#lastFetch + maxAge) {
    return this.#fetchNonConcurrent(resource)
  }
  return this.#value // 如果是快取期間,則直接返回前面快取的結果
}

當我們第二次執行 getWeatherData() 已經是 6 秒後,已經超過 maxAge設定的 5 秒,所有之後就會快取失效,重新發請求。

再看下 #fetchNonConcurrent私有方法定義,該方法用來傳送非併發的請求:

// 傳送非併發請求
async #fetchNonConcurrent(resource: () => Promise<T>): Promise<T> {
    // 非併發情況,如果當前請求還在傳送中,則直接執行當前執行中的方法,並返回結果
  if (this.#isFetching(this.#promise)) {
    await this.#promise
    return this.#value
  }
  // 否則直接執行傳入的方法
  return this.#fetch(resource)
}

#fetchNonConcurrent私有方法只接收引數 resource,即需要包裝的函式。

這邊先判斷當前是否是【傳送中】狀態,如果則直接呼叫 this.#promise,並返回快取的值,結束呼叫。否則將 resource 傳入 #fetch執行。

#fetch私有方法定義如下:

// 執行請求傳送
async #fetch(resource: () => Promise<T>): Promise<T> {
  this.#lastFetch = Date.now()
  this.#promise = resource() // 定義守衛變數,表示當前有任務在執行
  this.#value = await this.#promise
  if (!this.#initialized) this.#initialized = true
  this.#promise = undefined  // 執行完成,清空守衛變數
  return this.#value
}

#fetch 私有方法接收前面的需要包裝的函式,並通過對守衛變數賦值,控制任務的執行,在剛開始執行時進行賦值,任務執行完成以後,清空守衛變數。

這也是我們實際業務開發經常用到的方法,比如發請求前,通過一個變數賦值,表示當前有任務執行,不能在發其他請求,在請求結束後,將該變數清空,繼續執行其他任務。

完成任務。「cacheables」執行過程大致是這樣,接下來我們總結一個通用的快取方案,便於理解和擴充。

四、通用快取庫設計方案

在 Cacheables 中支援五種快取策略,上面只介紹其中的 max-age

這裡總結一套通用快取庫設計方案,大致如下圖:

該快取庫支援例項化是傳入 options引數,將使用者傳入的 options.key作為 key,呼叫CachePolicyHandler物件中獲取使用者指定的快取策略(Cache Policy)。
然後將使用者傳入的 options.resource作為實際要執行的方法,通過 CachePlicyHandler()方法傳入並執行。

上圖中,我們需要定義各種快取庫操作方法(如讀取、設定快取的方法)和各種快取策略的處理方法。

當然也可以整合如 Logger等輔助工具,方便使用者使用和開發。本文就不在贅述,核心還是介紹這個方案。

五、總結

本文與大家分享 cacheables 快取庫原始碼核心邏輯,其原始碼邏輯並不複雜,主要便是支援各種快取策略和對應的處理邏輯。文章最後和大家歸納一種通用快取庫設計方案,大家有興趣可以自己實戰試試,好記性不如爛筆頭。
思路最重要,這種思路可以運用在很多場景,大家可以在實際業務中多多練習和總結。​

六、還有幾點思考

1. 思考讀原始碼的方法

大家都在讀原始碼,討論原始碼,那如何讀原始碼?
個人建議:

  1. 先確定自己要學原始碼的部分(如 Vue2 響應式原理、Vue3 Ref 等);
  2. 根據要學的部分,寫個簡單 demo;
  3. 通過 demo 斷點進行大致瞭解;
  4. 翻閱原始碼,詳細閱讀,因為原始碼中往往會有註釋和示例等。

如果你只是單純想開始學某個庫,可以先閱讀 README.md,重點開介紹、特點、使用方法、示例等。抓住其特點、示例進行鍼對性的原始碼閱讀。
相信這樣閱讀起來,思路會更清晰。

2. 思考面向介面程式設計

這個庫使用了 TypeScript,通過每個介面定義,我們能很清晰的知道每個類、方法、屬性作用。這也是我們需要學習的。
在我們接到需求任務時,可以這樣做,你的效率往往會提高很多:

  1. 功能分析:對整個需求進行分析,瞭解需要實現的功能和細節,通過 xmind 等工具進行梳理,避免做著做著,經常返工,並且程式碼結構混亂。
  2. 功能設計:梳理完需求後,可以對每個部分進行設計,如抽取通用方法等,
  3. 功能實現:前兩步都做好,相信功能實現已經不是什麼難度了~

3. 思考這個庫的優化點

這個庫程式碼主要集中在 index.ts中,閱讀起來還好,當程式碼量增多後,恐怕閱讀體驗比較不好。
所以我的建議是:

  1. 對程式碼進行拆分,將一些獨立的邏輯拆到單獨檔案維護,比如每個快取策略的邏輯,可以單獨一個檔案,通過統一開發方式開發(如 Plugin),再統一入口檔案匯入和匯出。
  2. 可以將 Logger這類內部工具方法改造成支援使用者自定義,比如可以使用其他日誌工具方法,不一定使用內建 Logger,更加解耦。可以參考外掛化架構設計,這樣這個庫會更加靈活可擴充。

相關文章