async, await and try catch

New Zeland

這篇想說一下asyncawait語法的一些小細節,首先從async來說吧,一般來說,async function 是在內部有需要用await等 Promise 結果的時候才使用,也由於這個特性,async function 的回傳值都會是個 Promise,意思就是你回傳非 Promise 的值,會自動被包成 Promise,所以像下面的程式:

async function wow () {
    return Promise.resolve(100);
}

wow().then(v => { console.log(v); });

就等同於:

async function wow () {
    return 100;
}

wow().then(v => { console.log(v); });

和直接回 Promise value 比起來,效能上不會有什麼顯著差異,從建議的實做方法來看就是多一個判斷。再來看看await吧,首先一樣,await一般是用來接 Promise 的,不過其實也是可以接非 Promise value 的

async function wow () {
  var r = await 1;
  console.log(1);
}

wow();
console.log(2);

所以這樣的程式碼也可以正確執行,不過 await 那邊的執行方式還是會維持非同步的(實際上應該是後面的東西都會用 Promise 包起來一次),所以這段程式碼的輸出會是先輸出2再輸出1

再來這點可能比較多人知道,就是連續的多個await不會讓這些非同步操作同時開始:

async function wow () {
  const a = await fetch('/a');
  const b = await fetch('/b');
  const c = await fetch('/c');

  return [a, b, c];
}

這樣其實三個請求會照順序執行,a有結果了才去要bb有結果了才去要c,而不是同時處理,如果要同時發出請求則還是需要用Promise.all,然後不用async了:

function wow () {
  return Promise.all([
    fetch('/a'),
    fetch('/b'),
    fetch('/c')
  ]);
}

不要await的話,也是可以先 assign 給變數的:

function wow () {
  const a = fetch('/a');
  const b = fetch('/b');
  const c = fetch('/c');

  return Promise.all([a, b, c]);
}

然後其實Promise.all是要所有的 Promise 都 fulfilled 時才會 resolve,另外一個角度來看,就是其中只要一個 rejected 的話,就不會 resolve,實際上使用起來變化有點少,而且要做忽略錯誤的fetch也有點麻煩,所以現在 TC39 還有個新的草案叫Promise.allSettled,不管是 resolve 還是 reject,只要所有參數內的 Promise 都結束了,allSettled就會 resolve,目前這草案還在 stage 1,過幾天的會議有望升到 stage 2,不過這是題外話。

最後一個想說的就是await處理 rejected Promise 的問題,如果是從 jQuery 時期就開始寫 Deferred/Promise 的人,可能會很習慣的把 Promise 的兩種狀態拿來當成值的一部份,事實上這也是jQuery.ajax的設計,如果用這種想法來寫await接值的時候,就會覺得很難處理rejected的狀態,因為要用try...catch

async function wow () {
  try {
    const a = await fetch('/a');     
  } catch (error) {
    // deal with non-ok fetch
  }
}

要這樣寫還不如用舊的.then來接看起來還漂亮一點。不過實際上,這是錯誤的理解 Promise,Promise 不是用來取得兩種狀態用的,而是用來非同步取得單一個數值用的機制,而所謂rejected的狀態,其實就是發生非預期狀況(unexpected exception)的情形,這也就是為什麼 ECMAScript 版的 Promise 是用throw Error的方式來 reject Promise。

我一直覺得用 HTTP 請求來比較這兩種設計蠻好理解的,使用 jQuery 的ajax,server 端回非 200 的 status 的話,就會被當成是錯誤,然後回傳的 Promise 就會被 reject,但是在使用 ECMAScript Promise 的fetch中,不管 server 端回應的 status code,fetch 都會 resolve,而會 reject 的情形,就只有網路有問題的時候,像是網路斷線、存取被拒絕(CORS)等完全碰不到遠端主機的情形,也就是對於一個 HTTP 請求來說,真正的非預期狀況,所以如果你有兩種狀況要處理,那應該是回傳值的一部份,後面再用if...else來做分支。

回來看await的使用,究竟應該什麼時候來用try...catch呢,我自己有一個很簡單的初步判斷條件,就是這個取值的程式碼,如果不是非同步,沒有使用await的話,你會不會用try...catch包起來,不會的話,那改成非同步操作的程式碼應該也不用try...catch。不過現實世界當然還是比較難一點,非同步的取值風險和狀況還是比較多的,例如fetch遇到網路問題會 reject,但是還是需要處理這種狀況,不用try...catch的話,怎樣寫比較好呢?我的想法是,用.then/catch處理好需要處理的情形,然後把結果包起來傳回去,所以要處理fetch的非預期狀況的話,就可以改成:

async function wow () {
  const a = await fetch('/a').catch(error => {
    return {
      ok: false,
      status: -1,
      error: error,  
    };
  });
  
  if (a.status === -1) {
    // exception error handling
  }
}

這邊我設計成有非預期狀況時,status code 為-1,並且把 error 資訊也傳回去,然後後面就可以直接拿來判斷是不是非預期狀況,當然也可以把這個處理包成一個自己的myFetch

const myFetch = (url, options) =>
  fetch(url, options)
    .catch(error => {
      ok: false,
      status: -1,
      error: error,  
    });

然後原來的程式就可以直接拿myFetch取代fetch了。

如果要通用一點的,其實有一個叫await-to-js的套件我覺得蠻不錯的,直接拿官方的範例看吧:

import to from 'await-to-js';

async function asyncTaskWithCb(cb) {
  let [err, user] = await to(UserModel.findById(1));
  if (!user) return cb('No user found');
}

它可以包裝 Promise 物件,然後不管那個 Promise 成功還是失敗,它自己都會 resolve,resolve 的值就是[error, value]這樣形式的陣列,一來符合 node 的error-first callbacks,再來就是配合 destructuring assignment 其實程式碼是蠻漂亮的。


ES Module for NPM Package

Queenstown

For English reader:https://github.com/othree/til/blob/master/js/esm-package.md

這個問題我卡蠻久了,最近才解決加上找好一些資訊的來源,目標就是要讓一個 NPM package 同時提供 CommonJS module 和 ES module 的版本,現在很多地方可以用 ES module 了,像是 Node.js 自己有經有在測試用mjs副檔名,webpack 和 rollup 也都支援 ES module 的 bundle,而且要tree shaking的功能也需要使用 ES module,用以前的 CommonJS 是不支援的,不多廢話,直接看怎樣做吧:

{
  "name": "smartypants",
  "version": "0.1.1",
  "main": "smartypants",
  "module": "smartypants.es6.js",
  "jsnext:main": "smartypants.es6.js",
  ...
}

package.json 這樣寫,然後需要提供以下三個檔案:

-rw-r--r--  1 othree  staff  21874 Jul 14 10:38 smartypants.es6.js
-rw-r--r--  1 othree  staff  24885 Jan  9 17:12 smartypants.js
-rw-r--r--  1 othree  staff  21874 Jul 14 10:38 smartypants.mjs

這段是我從smartypants.js那邊拿來的,重點在:

  1. main裡面的檔名不寫副檔名,該檔名要同時提供jsmjs兩種
  2. 多加上module這筆設定

說明一下,Node.js 現在判斷是哪種模組格式的方式是看副檔名,所以一定要mjs的檔案才會當成 ES module,然後剛好解析main檔案時的副檔名會自動補,所以就乾脆拿掉,同時提供smartypants.jssmartypants.mjs兩個檔案,其實都是main用的;再來是module這個設定和 Node.js 以及 NPM 無關,其實是rollup提出來的pkg.module,rollup 如果在解析模組實有看到這個設定,就可以把這個檔案拿來用,當時設計是這個設定 ES module,以前的 main 則是 CommonJS module,雖然是 rollup 提出的,不過 webpack 現在也支援了,範例中還有一筆jsnext:main則是比較早期用的 key。

再更進階一點,還有目標對象的問題,就是產出是瀏覽器用的還是 server 端用的,以前這問題不太常見,不過隨著 server side rendering 越來越普及,這問題就開始比較多人關注了,webpack 就有支援 bundle 的目標對象,也有支援pkg.browser設定,webpack 的issue #5673有不少討論,有興趣的可以參考看看,不過要注意的是browser似乎是第一順位,設定的時候要小心點。


ECMAScript 2015 新功能間的關係

ECMAScript 2015,

這篇想說的是 2015 年的那個 ECMAScript 6(後面簡稱 ES6),也就是之前 ECMAScript Harmony 計畫的主要成果,那版 ES6 其實是這幾年來改動最多的一版,新增了很多的新功能和語法,而這一堆新功能很多是環環相扣的,我以前曾經在 Facebook 上提過,不過那邊的東西容易就消失在網路上,所以還是另開一篇文章來記錄,剛好也可以做為下一篇文章的參考資料。

首先要從Map/Set這兩個新的資料型態說起,ECMAScript 一直以來都只有少少的資料型態,直到 ES6 才加了些新的,其中比較容易注意到的就是 Map 和 Set 了,其實這兩種資料型態以前就是直接用 object 來 array 來做,兩邊蠻接近的,最主要的差異則是 Map 的 key 可以是任意型態, 而以前 object 的 key 只能是字串,Set 是 unique 的,array 則否,另外就是在適合的情境下,現在 Map/Set 的效能不一定會比較差。

Map、Set 其實都算是 collection 的資料,所以會需要有個方法可以遍歷所有元素,像是 array 的forEach或是 for...in 語法,不過 for...in 先拿到的東西是 key,還需要拿 key 去取元素實際的值:

for (let k in arr) {
  let v = arr[k];
}

一直以來,其實開發者社群都希望有個語法能直接取 collection 內的元素,所以像是 CoffeeScript 就是把for...in換成直接拿到 value,於是 ES6 就有了個for...of語法,可以遍歷 collection 類型的資料並直接取得值:

for (let v of arr) {
    
}

ES6 的這個語法,其實底層是透過iteration protocols這些內部協定來運作的,包括了 iterable protocol 和 iterator protocol,for...of其實就是透過 iterable protocol 去拿物件的 iterator,利用 iterator 來遍歷元素,所以自己寫的物件也可以實做 iterable protocol,然後就可以讓該物件支援for...of語法了;除此之外,iterator 是不能重複使用的,所以其實每次for..of,都是拿一個該物件的新的 iterator,而為了可以簡單產生這個 iterator,又有了generator function,generator function 每次執行都會回傳一個新的 iterator(精確一點說是 generator object,同時是 iterator 也是 iterable),正好適合這個情境。

Iterable protocol 的定義其實很簡單,就是定義怎樣把 generator function 放在物件裡的方式,實際上是利用 ES6 另一個新的資料型態:Symbol來達成的,為什麼不直接定個屬性名稱給它呢?最主要就是不要讓這些內部 protocol 的東西在for...in操作的時候被遍歷到,所以定義了新的 Symbol 型別,利用它的特性來把內部 protocol 做了一定程度的保護,iterable protocol 就是把 generator function 用一個預先定義好的 Symbol 來儲存,這個 Symbol 又稱為Symbol.iterator,這種預先定義好的 Symbol 則統稱為 Well-Known Symbols,ES6 其實定義了好幾個,不是只有 iterable 用的到,透過定義這些 Well-Known Symobls,可以介入改變一些 JavaScript 比較基礎的運作。

Well-Known Symobls 可以做到的事情,其實有點像是改變程式語言的運作,而這種類型的機制又稱為meta programming,除了 Well-Known Symobls 之外,ES6 其實還提供了ProxyReflect,這兩個東西應該比較多人知道 Proxy 是幹嘛的,對 Reflect 比較陌生,其實 Reflect 有點像是為了 JavaScript 一些設計不好的地方,想了解詳細一點推薦可以看主要的 polyfill [harmony-reflect][] 的 Wiki 頁:Why should I use this library?

最後總結一下,這串從 Map/Set 開始,接著連到for...of語法、Iterator、Generator、Symbol、Well-Known Symobls 最後到 meta programming 的 Proxy 和 Reflect,其實也差不多佔了 1/4 的 ES6 新功能。


eslint-plugin-pep8-blank-lines

eslint-plugin-pep8-blank-lines,

我的第二個 ESLint plugin 終於進 beta 了,這是我自己期望很久的檢查規範,上一次介紹 ESLint plugin 的時候就有說到接下來想處理空行,其實 ESLint 內建的 rule 已經有蠻多是用來檢查空行的了,不過沒有一個能符合我想要的規範,我想要的規範其實很簡單,就是希望能在大一點的物件中間能多一點空行,比較有段落的感覺,這樣閱讀起來感覺也比較好(如上圖),剛好我這兩年寫了一點 Python,有用 Flake8 做語法檢查,其中的PEP8 coding style 中關於空行的規範,就符合我想要的樣子,而且很簡單,這個規範是在大部分地方都允許最多一行空行,但是最上層(top level)的 function, class 前後要兩行空行。

於是這個 ESLint plugin 的主要目標,就是把 PEP8 這部分的規範搬過來,一開始想的實做方式有兩個,其一是參考padding-line-between-statements的作法,比較兩個相鄰 token/node 間的 line number,另一個則是用sourceCode來一行一行看,不過同時也要知道該行的 context 是什麼才能判斷,所以也是跑不掉要進去看 AST,加上我想要玩玩看 JavaScript 的 AST,所以最終我是選擇第一種作法,不過不是用 ESLint 內建的 walker,而是在Program:exit的時候才用自己寫的 walker 進去看 AST;而經過一輪重構後,現在的架構其實是靈活度很高的,我實際上做出了一個比 padding-line-between-statements 還要更多功能的規範定義格式,然後根據這個格式寫出我想要的空行規範,只是目前還沒開介面出來給使用者輸入自訂的空行規範就是了;其實我自己覺得這個 plugin 實做的理想型式應該還是要用實做方案二,並搭配使用 ESLint 的 AST walker,實際上 padding-line-between-statements 也就是這樣做,會這樣想最主要的原因是現在的實做只看 AST,但是 AST 其實不能 100% 表達原來的程式碼,這也是這次開發經驗中我最大的體悟,所以其實一些奇怪地方的空行就會很難抓到,例如await 1這兩個 token 中間如果有空行就會跳過,不過會在這種地方放空行的情形應該都是蠻少見的,所以目前也沒打算繼續改下去,短期內都會以處理 bug 為主,過陣子應該會試著加上 fix 的功能,總之歡迎測試並回報問題,雖然有寫測試,不過還沒什麼實際跑在真實的程式碼上,目前唯一的就是它自己的 code base 本身是有用吧,另外就是使用時如果是搭配其它 style 可能會需要把其它 style 的空行規則關掉,例如搭配standardjs時的.eslintrc範例:

{
  "extends": "standard",
 
  "plugins": [
    "pep8-blank-lines",
    "no-parameter-e"
  ],
 
  "rules": {
    "semi": [2, "always"],
    "no-extra-semi": 2,
    "comma-dangle": ["error", "always-multiline"],
    "no-multiple-empty-lines": 0,
    "pep8-blank-lines/pep8-blank-lines": 2,
    "no-parameter-e/no-parameter-e": 2
  }
}

這組其實也是我目前在用的設定啦~


Tern 0.22 released

大約七月初的時候,我開始接手幫忙維護Tern,Tern 是一個獨立的 JavaScript inference engine,用於協助撰寫 JavaScript 程式碼,就和之前介紹過的 Microsoft 的 LSP 後面的 Language Server 一樣,都是獨立於編輯器/IDE之外,不過 TernJS 是 2013 年就有開始發展的,所以是走自己的溝通介面;其實我幾年前也有幫忙貢獻過 TernJS,以前弄過我還有印象的有 Promise 支援、fetch 的定義、CoffeeScript plugin。

後來作者Marijn暫停維護 Tern 跑去弄其他東西像是AcornCodeMirror還有 ProseMirror 等(這位很厲害,改天再來介紹),並公開找人接手,在一些文字內有找到他的說法是說現在這個架構有些問題處理不了,很難再發展下去了,總之所以就停了一年多沒更新了,我也是斷斷續續注意到這個狀況,不過在研究 LSP 的時候發現其實還蠻多東西是依賴 TernJS 的,讓他這樣荒廢下去好像有點可惜,認真考慮了一兩週後決定接手維護工作,考慮的點主要在於不知道能不能順利接手處理問題,因為 TernJS 的 code base 實在不容易理解,尤其是我沒有相關的 compiler、工具的訓練和開發經驗,以前那些貢獻其實都是花很大心力下去才弄出來的,幾乎是處於那種「程式碼會動了,但是我不知道為什麼」的狀態,不過這兩年相關的知識補了不少,還玩了好一陣子的 JavaScript AST,有覺得比較看的懂 TernJS 的程式碼了,就心一橫報名說要幫忙維護了,Marijn 看到我過去有發過一些 PR 後,很迅速的就開協作者權限給我了。

正式開始接手後,我就開始把要做的事情整理出來,我的目標是在保持現有架構之下,盡可能的繼續支援新語法,直到真的這個架構撐不住為止,所以一開始就是把一些落後的語法支援和定義補上,這次發佈的0.22 版就是包括 0.21 之後的一些小 bugfix,還有我加入之後開始弄的 async/await、async iteration(包括for await of) 以及**支援,下一版我會開始一些內部的修改、還有看看 bug,不過 Emacs 相關的我現在是真的無法處理。

最後一段來說說目前感想吧,Tern 真是我目前為止看過最難理解的 code 了,不知道是不是會寫 compiler 的人腦袋都會轉換到常人無法理解的形狀,我目前為止看的第二辛苦的 code base 是 Kibana 的,不過 Kibana 單純只是東西很多,找入口找很久,Tern 難的點在於它用了很多 side effect 來做事,而且 code 內沒什麼文件說明,所以像下面這行我就花了很多時間才看懂實際上做什麼事:

infer(node.right, scope, new HasMethodCall(":Symbol.iterator", [], null,
                                           new HasMethodCall("next", [], null,
                                                             new GetProp("value", target))))

這行程式碼是先拿node.right:Symbol.iteratormethod 的執行結果,再看它的nextmethod 的執行結果,然後取最後這個結果的valueproperty 的資訊(可能的 type 之類的)塞給 target 物件,然後這行下面你又看不到 target 做何用,因為 target 物件是在上面已經有和其它會回傳的物件有建立關聯的;除此之外,這裡有個new GetProp,其它地方還有個AVal.getProp又是不同功能,一開始看的真的是黑人問號...


命名記錄 1

Карта Одинокой Горы

記錄一下這兩年自己覺得還不錯的命名,本來是想累積更多再貼的。

Herculus

海克力士,用在測試服務名稱上,是希臘神話中的大力士,曾經完成十二偉業,這兩年還有部電影,取名源由其實是 Fate/stay night 中,他做為 Berserk 的 Servant 所持有的寶具,稱為 十二の試練,試練就聯想到 test ~

Erebor

用在資料庫相關的服務,哈比人歷險記的孤山的精靈語,因為孤山裡面有很多矮人的寶物,資料庫內的資料某種層面來說也算是個寶物。

Olympus

應該不用介紹來源了,用在 client 端自己寫的 API helper,取這個名字的原因是覺的客戶端跟 server 端要資料就如同在跟眾神請求一樣。


SmooshGate

之前應該沒在這邊提過這件事,總之就是之前 TC39 有個 flatten 的 proposal,搞一搞後,發現這東西實做啟用後 MooTools 會爛掉,詳細的原因 Google 那邊有一篇文章解釋,標題就是 SmooshGate,Smoosh 這個單字其實有點少見,我大概查一下,雖然有說是 flatten、squash 的意思,不過目前覺得應該是作畫時把顏料抹平那種動作感覺最對吧,總之,當初為了這個問題第一個提案的改名就是smoosh,不過這個單字整體感覺和 flatten 差距實在太大,所以出現一堆聲音,有的是建議加上"use es2019"的 statement 來開啟flatten,像是 stirct mode 一樣,有的建議其他名字,當然也有些人是覺得管 MooTools 去死的,不過因為 Don't Break the Web 的大原則所以還是要處理這個問題。

其實我覺得當初提案 smoosh 的人搞不好是故意挑這個字的,藉此增加話題性引發討論和建議,效果其實很好,在 Twitter 上還出現了#SmooshGate這樣的 hashtag,不過,總之在最近一次五月的 TC39 會議,確定 proposal 改新的名稱:flat,雖然詞性不同,不過大家都還蠻可以接受,或許也可能是相較於 smoosh 來說很可以接受吧。


ESLint Plugin 入門

ESLint

最近寫了個簡單的 ESLint plugin,來記錄一下一些基礎知識,我做的 plugin 很簡單,叫做eslint-plugin-no-parameter-e,這個 ESLint plugins 做的事情只是檢查所有 function 的參數,然後如果有任何一個參數名是e的話就警報,這條 rule 其實是為了避免把errorevent簡寫成e,會容易混淆。

接下來進入正題,ESLint 基本上就是透過ESPree這個 parser 先把程式碼轉為ESTree相容的 AST,EStree 是個 de facto standard,是從 Mozilla Spider Monkey 用的 AST 演化而來,現在幾乎做 JavaScript 工具,會需要轉 AST 的話都會用這個格式;有了 AST 後,才來分析 AST 做檢查,然後現在有工具叫AST Explorer,非常方便,可以線上直接修改 code sample 看 AST 變化,可以用它來看你想要處理的 code 的 AST 結構,至於怎麼寫 rule 就看個人了,基本上就是監聽要注意的 node,然後檢查 AST 結構,有問題就呼叫 report 這樣。

第二點,npm module 的名稱要用eslint-plugin-開頭,官方說的規則,應該不遵守還是可以抓的到,不過就還是遵守一下免的有意外。

第三點,測試其實 ESLint 有 RuleTester 可以拿來寫測試用:

const rule = require('../rule.js')
const RuleTester = require('eslint').RuleTester

const ruleTester = new RuleTester();

ruleTester.run('no-parameter-e', rule, {
  valid: [
    'function a (event) {}',
  ],
  invalid: [
    {
      code: 'function e (e) {}',
      errors: [{ message }],
    }
  ],
});

很方便,都不用 test framework 了,並且有特別要求 valid 和 invalid 都要有 test case,不然測試就會失敗。

然後測試的時候是每個 rule 獨立跑,每個 plugin 可以有多個 rule,很多 plugin 是把不同 rule 都獨立一個檔案,每個 rule 可以丟的東西除了檢查外還有不少,像是說明文件、自動修復的動作等,詳見官方文件,我一開始是參考eslint-plugin-import的,不過現在初心者應該也可以先看我的eslint-plugin-no-parameter-e,東西更少一些。

下一個想來挑戰處理空行,看了一下感覺是比較困難啊~


Immer 原理

前陣子有個蠻有趣的 library 叫Immer,是 MobX 的開發者 Michel Weststrate 做的,這個 library 做的事情很有趣,它整合了 immutable 資料和原生資料的特性,反過來從缺點來看,immutable 資料型態的問題就是操作比較不方便,所有的修改動作都要透過 method 來執行,不能直接用 assign 的,有時候要改比較深層一點的資料就很麻煩,像 Facebook 的immutable.js就需要用getInupdateIn來處理:

getIn({ x: { y: { z: 123 }}}, ['x', 'y', 'z']) // 123

const original = { x: { y: { z: 123 }}}
setIn(original, ['x', 'y', 'z'], 456) // { x: { y: { z: 456 }}}

用陣列丟每層的屬性名稱,也有一些是用.切分的 path 來處理這個問題(像是prop1.prop2.prop3這種結構),而原生資料的缺點,在這個場景來看當然就是不 immutable 了,Immer 就提出了一個新的構想,把這兩者的優點結合在一起,讓資料可以保持 immutable 特性,又可以直接修改,當然不能直接修改 JavaScript 行為,所以還是有些地方需要等價交換,就是修改資料的時候,要包進 produce function 內:

const nextState = produce(baseState, draftState => {
    draftState.push({ todo: "Tweet about it" })
    draftState[1].done = true
})

然後得到的nextStatebaseState就會是不同物件,就像是 immutable 物件一樣行為,所以如果沒修改就還是同個物件,初看覺得有點黑魔法,不過思考過後覺得也不是不能做,有了些假想的實做方法後去研究了一下程式碼,不太意外的其實在 produce 裡面拿到的 draft 物件,是一個Proxy包裝過的物件,然後 immutable 相關的邏輯都做在 Proxy 內,produce 跑完後再把新的值 finalize 取出用 plain object 傳回給nextState,當然因為 Proxy 是比較新的東西,所以針對 ES5 也有另外的處理,我大致看一下就是比較土法煉鋼的下去比對,至於為什麼不全部都這樣做應該是效能考量吧。其實我覺得比起實做的原理,能想到這樣設計實在是很厲害,不像大部分人早就放棄了,還持續思考是不是有更好的作法可以整合兩種資料格式的優點才有機會找到這條路。

最後,Immer 這名字的由來,雖然在德文有這單字,不過我判斷應該還是從 immersive 來的吧。


PEG.js

pegjs

知道這東西也好一陣子了,最近才真的第一次用,感覺還不錯,很久沒有因為東西會動而這麼高興了,大概也是太久沒努力離開舒適圈的關係吧。

總之,最近想著要做出類似一些搜尋引擎支援的條件語法,像是 and、or、not 之類的,稍微花了點時間調查一下確定要正確的處理就是要個 parser,沒錯,就是 compiler 最前面那個 parser,身為非 CS 領域出身的人,compiler 我一直是朦懂朦懂的,parser 到產生 AST 那塊算是比較清楚一些,因為像是 Babel、還有以前幫忙過的TernJS都是先 parse 程式碼產生 AST 才開始做事,不過這次和以前不一樣的是我要從頭開始建立一個語法的 parser,然後因為是網頁前端要用的,所以就找到了PEG.js這個用 JavaScript 寫的 parser generator,相較於手工的 parser,這種工具只要有定義好的語法(grammer)給它,它就可以產生出對應的 parser,至於什麼是語法(grammer)呢,例如下面這段就是:

IdentifierName ::
    IdentifierStart
    IdentifierName IdentifierPart

IdentifierStart ::
    UnicodeIDStart
    $
    _
    \ UnicodeEscapeSequence

IdentifierPart ::
    UnicodeIDContinue
    $
    _
    \ UnicodeEscapeSequence
    <ZWNJ>
    <ZWJ>

UnicodeIDStart ::
    any Unicode code point with the Unicode property &ldquo;ID_Start&rdquo;

UnicodeIDContinue ::
    any Unicode code point with the Unicode property &ldquo;ID_Continue&rdquo;

這段是從 ECMAScript Spec 內找出來的,identifier 名稱格式的語法(grammer)定義,其實還算蠻好理解的,而 PEG.js 也有自己定的語法格式,只要使用該格式定義好語法,就可以產生出 parser 來,不過當我開始寫的時候,才發現到一個問題:我不知道 parse 後要產生什麼東西,這時我才意識到,在開始定義語法之前,我應該要先想清楚後續的產出物(例如 AST)的結構,和要如何使用這個 parser 的產出物實做出真正想要的效果。

以我的目標來說,我希望可以做出簡單的邏輯組合,包括 and、or、not 和 parenthesized expression(括號包起來的),其實我一開始的想法也沒很明確,只是覺得應該可以用樹狀結構加上遞迴來實做後面的判斷,然後參考了Kibana裡面 Kuery 的語法,也算是慢慢的把語法和 AST 的組合方式定義出來,當時做的語法我還有放在gist上,語法和 AST 定義好的時候,其實後面應用端的 script 還沒寫,不過因為結構很簡單,所以我已經確信一定可以運作了,後來隔一天果然不花什麼時間就把應用端的 script 也寫好,之後還花時間作了些手工測試,修正了一些語法上的細節問題,像是支援&|這些符號之類的,還有符號兩邊不用空格等等。

還有一點想特別說的是,其實一開始定義語法的時候,我是沒有想要去參考 Kibana 的,雖然我當時就知道 Kibana 的 Kuery 語法和我的需求很像,而且也是用 PEG.js 做的,不過我開始寫語法定義沒多久就卡關了,卡關的地方就是,一開始就是 and、or、not、parenthesized expression 都有可能出現,但是這無法用/的方式來處理,因為 PEG.js 的 parser 不會解析到一半發現不對就游標往回退(backtracking),然後我就卡關了,我可以寫出 and 加上判斷,支援以下兩種查詢:

keyword
keyword1 and keyword2

但是卻無法更進一步加上支援or,結果只好去參考 Kuery 語法,發現奇妙的寫法,以下是我後來成品的定義:

start
  = orQuery?

orQuery
  = left:andQuery Or right:orQuery
  / andQuery

andQuery
  = left:notQuery And right:andQuery
  / notQuery

notQuery
  = Not right:subQuery
  / subQuery

subQuery
  = '(' ws* query:orQuery ws* ')'
  / queryValue

如此,or查詢支援兩種內容,第一種是and查詢語句,第二種才是真的or查詢,但是他的第一個元素是and查詢,也就是說雖然是or查詢的判斷,但是卻先去看有沒有and查詢,然後and查詢也是類似的定義,實際上先去找有沒有not的語句,然後not會去看有沒有子查詢(parenthesized expression),整個讓人覺得很神奇,仔細下去推敲也確實可以理解判斷的過程,不過在邏輯上我還不太能完全通透的理解。第一次看到這種定義方式時,覺得很神妙,不過也有想說這應該是什麼常見的 grammer 寫法,後來去查了一下 ECMAScript Spec,發現也是這樣的作法,看來真的算是個 convention 了吧(看起來是left recursive),真不知道第一個寫出這種 grammer 的人腦袋裝什麼。

最後我的成果有丟一個可以讓人用的版本上 GitHub,也有用 NPM 發佈,叫simple-search-query,詳細用法可以參考 README,至於完整的語法定義就在query目錄內,還在補測試就是。


此類別所有文章