工作記錄 | 基於DocSearch黑一套搜索引擎

記錄一下最近工作中利用DocSearch,基於ServiceWorker和CacheAPI“惡搞”的一套Wiki搜索引擎,挺有意思的。

首先要考慮前端的基礎設施。。

開發者開發一款app前首先考慮的是:自己能調度的硬件資源。硬件資源包括算力(時間資源)、存儲力(空間資源)。

前端這個崗位是比較尷尬的,因爲對我們來說,後端只提供有限的服務:只讀的文件服務。通常一款app的架構基本上都是前端+後端,也就是一款app可以利用2臺機器的算力和存儲力爲自己服役,這2臺機器就是開發者的物質基礎。

在“前後端分離”的大環境下,前端開發者所擁有的資源是有限的。這個限制主要在於服務器的算力上。服務器不能像往常那樣提供任意的計算服務,只能提供靜態文件的訪問權限,對於前端來說,這臺服務器是“read only”的。

在這種充滿挑戰的環境,利用有限的資源開發app就是我們的日常。

然後迴歸主題。

扯了這麼一大通就是爲了證明,原來搜索引擎可以不用服務器的支持。由於“被搜索”的數據庫就是所有markdown文檔的一二三級標題,所有這些標題存儲在index.json(下面簡稱index)作爲【文檔索引】從後端運送到前端,並在前端完成搜索工作。

// index.json的格式
[
    {
        "url": "/path/to/document",
        "keys": [
            "Title1",
            "Title2",
            ... ]
    },
    {
        ...
    }
]

看到了嗎,這就是前後端分離的弱點:想要盡情利用前端算力的前提是要把【計算材料】提前送到前端,而輸送是需要時間的。如果能在後端直接使用材料就省去了這個步驟。

生成文檔索引的時候我是將所有markdown併發執行,節約時間是一方面,這樣還可以導致每次的index.json的順序都不太一樣,排序不分先後,讓每個標題都有均等的機會被搜索到,當然這只是統計意義上的平均,不過感覺還不錯。

而且,index.json不是很大,可以在瀏覽器空閒時間下載並緩存起來:

global.caches.open("index").then(cache => {
  cache.add(new Request("/path/to/index.json"));
});


但緩存是外存,使用的時候還要臨時加載到內存中,這就是懶加載。將index從外存懶加載到內存中需要做一些準備:

  • 我們需要一個變量來存放index;

  • 我們需要一個函數來處理懶加載;

  • 我們需要一個promise來確定外存是否可讀;

  • 我們需要一個算法來在index中搜索關鍵詞;

於是我們需要4個閉包安全的全局變量:

const $index = Symbol('lazy load from cache async function');
const $indexOk = Symbol('promise checking if cache is ok');
const $indexJson = Symbol('store index into a variable');
const $indexSearch = Symbol('function searching keys');

關於UI,我們用docSearch就好了,docSearch是一套搜索框架,但它只包含UI部分,所以應該叫“搜索框”架。這個框架提供了比較簡潔的搜索框UI,支持最多6個層級的搜索結果,就像下圖這樣。

docSearch還提供了友好的交互效果,比如緩存已經搜索過的結果,防抖等細節做的很好。

至於docSearch的後端,是一個叫做algolia的服務器,algolia通過爬取你的網站總結出一套關鍵詞索引,再暴露給docSearch來請求。他的初衷是這樣玩的,但是爲了免費使用,我決定模擬一個服務器,僞造返回數據,達到同樣的檢索效果。

於是輪到我們ServiceWorker上場了(下面簡稱SW)。

SW的歷史比較短,大致是瀏覽器發展到一定程度時,開始模仿Linux的守護進程(daemon),搞了一套應用級的,獨立於前端控制、週期性地執行某種任務或等待處理某些發生事件,不會隨app關閉而停止的守護線程,由於佔用了一個worker線程,於是取名叫Service Worker。

於是我們可以利用SW來攔截docSearch的請求,代碼如下:

self.addEventListener("fetch", event => {
  // 攔截docSearch的請求
  if (/bh4d9od16a.*algolia/.test(event.request.url)) {
    event.respondWith(
      (async () => {
        //   從request中提取出關鍵詞
        const key = new URLSearchParams(
          (await event.request.json()).requests[0].params
).get("query");
        // 懶加載index.json
        const index = await self[$index]();
        // 搜索並返回結果
        return new Response(JSON.stringify(self[$indexSearch](index, key)), {
          headers: { "Content-Type": "application/json; charset=UTF-8" }
        });
      })()
    );
  }
});

爲了避免“全表掃描”,“表”指內存中的列表,匹配到一定數量時應當終止掃描,我們可以通過Array的find、some、any等方法來實現這個效果:具體原理參考《函數式編程中的數組問題》

docSearch支持的6級菜單中我只用了2級,第一級是markdown文件名,第二級是文檔中的各級標題,然後先序遍歷地搜索。在避免全表掃描的時候我設定的上限是5條結果,但前提是等待本次的第二級掃描完。這樣做的結果導致有時候搜到六七條結果,甚至更多,有時候全表掃描完又不到5條,這樣操作的唯一好處在於,可以給用戶一種【神祕感】,有效地掩蓋我的上限值5。

也許說的不太形象,舉個例子,網易雲音樂有個功能叫“定時關閉”,還支持“播完當前歌曲再關閉”,如圖,即每次都要比定的時間稍微長一點。

扯遠了。。

同時,爲了支持正則表達式,我們將用戶輸入的關鍵詞封裝成正則表達式。爲了將搜索能力最大化,還可以將“不合法”的表達式轉換爲普通的包含匹配,以保證用戶的輸入都是合法的:

let matcher;
try {
  matcher = new RegExp(keyword, "i");
} catch (err) {
  matcher = {
    test: str => str.includes(keyword)
  };
}


// usage: matcher.test(index.json)

完美。

然而這個方案還是被老闆一票否決了,原因是SW和Cache必須在https下才能使用,而我們的wiki網站是http。。即使重寫fetch方法來替代SW也無法容忍使用堵塞線程的webStorage來替代Cache。再之index.json較小的情況下還能玩玩內存搜索,【文檔索引】的體積即使線性級增長也要考慮用用web sql來外存搜索

<完>


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章