js json制表符报错_llhttp是如何使Node.js性能翻倍的?
如果你關注 Node.js 社區,那么你一定記得 Node.js v12 一個非常重磅的功能就是,內核的 HTTP Parser 默認使用 llhttp,取代了老舊的 http-parser,性能提升了 156%。
但知其然也要知其所以然,llhttp 是如何做到這一點的呢?
http-parser 的過去和現狀
之前 Node.js 使用的 http-parser 是 Node.js 中最古老的幾個依賴之一,始于 2009 年(也是 Node.js 的元年)。在那個年代,Node.js 的定位是一個后端開發平臺,其目標之一就是能快速搭建異步的 HTTP Server,所以 http-parser 是最核心的模塊之一,也影響了 Node.js 內部很多代碼、模塊的設計。它的設計上參考了 Ruby 的 mongrel,也同樣使用了小部分來自 Nginx 的代碼,還有一部分代碼是 RY 自己寫的。
http-parser 其實是有很多優點的,比如它的性能足夠好;兼容了很多“非標準”的 HTTP 客戶端;積累了足夠完善的測試用例,這些優點讓它經受住了時代的考驗,畢竟對于一個技術項目而言,十多年已經是一個非常久的時間了。
但是它也有缺點,隨著時間的推移,無數的迭代,它的代碼變得越來越僵硬(“rigid”),很難對它進行大的改動,或者做很大的優化,這也導致它難以維護,任何代碼的變更都可能會引入 bug 或者安全問題。
另外,http-parser 是純 C 實現的,而 Node.js 的受眾主要是 JavaScript 開發者,這些開發者中大部分人都不是 C 語言的專家,這也給維護帶來了困難。
所以,Node.js 需要一個新的 HTTP Parser,這也就是 llhttp 的來源,它起碼需要以下幾種特性:
HTTP Parser 是什么?它在做什么?
我們在這里不過多深入 HTTP 的細節,只是做一些簡單的介紹。
HTTP 是一種應用層的文本協議,建立在 TCP 之上,由三個部分組成:起始行(start-line)、頭部(headers)、傳輸體(body)。
比如下面就是一個完整的 HTTP 請求的例子:
POST /user/starkwang HTTP/1.1 <--- 起始行 Host: localhost:8080 <--- 頭部開始 Connection: keep-alive User-Agent: Mozilla/5.0 (Macintosh; ... Content-Type: application/json; charset=utf-8 <--- 頭部結束{"text": "這里是Body!!"} <--- 傳輸體當然這只是理論,實際上收發、處理一個 HTTP 請求并沒有那么簡單,比如對于 Node.js 的 Runtime 層面而言,它收到的來自傳輸層 TCP 的數據實際上是幾串離散的數據,比如:
POST /user/starkw ------------我是分割線--------------- ang HTTP/1.1 Host: localho ------------我是分割線--------------- st:8080 Connection: keep-alive User-Agent: Mozilla/5.0 (Macintosh; ... Content-Type: appli ------------我是分割線--------------- cation/json; charset=utf-8{"text": "這 ------------我是分割線--------------- 里是Body!!"}HTTP Parser 的工作之一就是把這幾串數據接收并且解析完畢(如果遇到不符預期的輸入則報錯),并將解析結果轉換成文本和 JavaScript 側可以處理的對象。下面是一個便于理解的例子:
{method: 'GET'headers: {'Host': 'localhost:8080','Connection': 'keep-alive','User-Agent': 'Mozilla/5.0 (Macintosh; ...','Content-Type': 'application/json; charset=utf-8'},body: '{"text": "這里是Body!!"}' }(實際上,Node.js 里面是以 IncomingMessage 和 ServerResponse 的形式表示的)
llhttp 是怎么實現的?
如果你學過或者接觸過一點編譯原理的話,很容易就想到 HTTP Parser 本質上是一個有限狀態機,我們接收并解析 start-line、headers、body 時,只是在這個狀態機內部進行轉換而已。
HTTP 狀態機
假設我們正在接收一個 HTTP 請求,我們收到了 HTTP 請求的第一個字符,發現這個字符是 G,那么我們接下來應該期望收到一個 start-line 的方法名 GET。
但如果第二個字符收到了 A,那么就應該立刻報錯,因為沒有任何 HTTP 方法是以 GA 開頭,根據這個步驟,我們就得到了一個很簡單的狀態機:
順著相同的思路,將這個狀態機進一步擴展,我們就可以解析完整的 HTTP 請求了:
https://www.zhihu.com/video/1196039232609669121下面是實際上的 llhttp 內部的狀態機(大圖請看這里):
如何優雅地實現狀態機
理論有了,那么我們接下來的問題就是如何用代碼優雅地實現一個有限狀態機?
比如我們可以用一個 switch 來解決:
const state = 0 function excute() {swtich(state) {case 0:// do something...state = 3case 1:// do something...state = 0// ...} }while(true) {excute() }可是 switch 的問題在于:
使用 DSL 描述狀態機
為了解決上述問題,llhttp 使用了一種基于 TypeScript 的 DSL(領域特定語言) llparse,用于描述有限狀態機。
llparse 支持將描述狀態機的 TypeScript 編譯為 LLVM Bitcode(這也是為啥叫 llparse 的原因)、或者 C 語言、或者 JavaScript。它起碼有以下幾種優勢:
llparse 快速入門
現在關于 llparse 的文檔和文章幾乎沒有,llparse 本身也只有 Fedor Indutny 一個維護者,所以想要理解 llparse 的 API 是有一定難度的。
簡單介紹
llparse 使用 TypeScript 描述一個專用于匹配文本的狀態機,所以它的一切 API 都是為描述一個這樣的狀態機而設計的。
在 llparse 中,所有狀態都是一個節點(node):
// 創建一個狀態 foo const foo = p.node('foo')我們可以通過各種 API 來描述節點之間的轉換關系,或者各種狀態機的動作,這些 API 包括但不僅限于:
- match
- otherwise
- peek
- skipTo
- select
- invoke
- code
- span
下面分別介紹這些 API。
match 與 otherwise
.match() 表示在當前狀態時,嘗試匹配一串連續輸入。
下面這段代碼嘗試連續匹配 'hello',如果匹配成功,那么跳轉到下一個節點 nextNode;否則調用 .otherwise() 跳轉到 onMismatch:
const foo = p.node('foo') foo.match('hello', nextNode).otherwise(onMismatch)peek 與 skipTo
.peek() 表示提前查看下一個字符( peek 有“偷窺”的意思),但是不消費它。
下面的代碼表示,當下一個字符是 'n' 的時候,跳轉到 nextNode,否則使用 .skipTo() 消費一個字符,跳回到 foo 重新循環。
foo.peek('n', nextNode).skipTo(foo)注意,.skipTo() 和 .otherwise() 的區別在于, 前者會消費一個字符,而后者不會。
所以如果我們使用 .otherwise() 替換上面的 .skipTo(),就會收到一個錯誤,告訴我們檢測到了死循環:
foo.peek('n', nextNode).otherwise(foo) //=> Error: Detected loop in "foo" through chain "foo"select
.select() 用于匹配一串文本,并且把匹配到的文本映射到某個值上,然后把這個值傳入下一個狀態。
foo.select({'GET': 0,'POST': 1 }, next)比如,在接收 HTTP 請求的時候,根據規范,開頭的前幾個字符必然是 HTTP 方法名,那么我們可以這樣接收:
const method = p.node('method') method.select({'HEAD': 0, 'GET': 1, 'POST': 2, 'PUT': 3,'DELETE': 4, 'OPTIONS': 5, 'CONNECT': 6,'TRACE': 7, 'PATCH': 8}, onMethod).otherwise(p.error(5, 'Expected method'))invoke 與 code
任何有意義的狀態機最終肯定是要對外部產生輸出的,比如調用外部函數,或者存儲一些狀態到外部的屬性上面,.invoke() 和 .code 就是為此而設計的。
.invoke() 調用一個外部函數,并且根據外部函數的返回值,映射到不同的下個狀態,如果沒有映射,那么跳入錯誤狀態中。
.code.match() 返回一個外部函數的引用。
const onMatch = p.invoke(p.code.match("bar"),{1: nextNode1,2: nextNode2},p.error(1, "bar error") )我們這里調用了外部函數 bar,并且根據返回值確定下一個狀態 nextNode1 或 nextNode2,如果返回值是預期外的,那么跳入錯誤狀態。
span
.span() 表示在一段時間內,為輸入的每個字符產生一次回調。
const callback = p.code.span('onByte') const onByte = p.span(callback)node.match('start', onByte.start(nextNode))nextNode.match('end', onByte.end(nextNextNode))上面我們嘗試匹配 'start',如果匹配成功,那么跳入 nextNode,并且開始為每個匹配到的字符觸發 onByte() 回調,直到匹配完畢 'end',我們結束觸發回調,并且跳入 nextNextNode。
使用 llparse 構建簡單的 Parser
單純地講 API 是很枯燥的,我們來實戰試一試,我們嘗試構建一個匹配 'hello' 的 Parser。
首先我們創建一個 start 狀態節點,代表起始狀態:
import { LLParse } from "llparse" const p = new LLParse("myfsm")const start = p.node('start')我們可以嘗試使用 .match() 連續匹配一串輸入,如果匹配成功,那么跳轉到下一個狀態節點 onMatch;否則跳轉到 onMismatch:
start.match('hello', onMatch).otherwise(onMismatch)然后 onMatch 中,我們使用 .invoke() 產生一個外部調用,調用的是注入的外部方法 onMatch,如果它返回 0,那么就跳轉回到 start 狀態,否則報錯
const onMatch = p.invoke(p.code.match("onMatch"),{0: start},p.error(1, "onMatch error") )于是我們就得到了這樣一個簡單的狀態機:
下面是完整的代碼:
import { LLParse } from "llparse" import { writeFileSync } from "fs"const p = new LLParse("myfsm")// 創建狀態節點 start const start = p.node("start")// 創建調用節點 onMatch const onMatch = p.invoke(p.code.match("onMatch"),{0: start},p.error(1, "onMatch error") )// start 狀態匹配到 hello 之后,進入 onMatch節點 // 否則輸出 expect "hello" start.match("hello", onMatch).otherwise(p.error(1, 'expect "hello"'))// 編譯狀態機 // 狀態機從 start 開始 const artifacts = p.build(start)// 輸出編譯結果 writeFileSync("./output.js", artifacts.js) writeFileSync("./output.c", artifacts.c)運行上述代碼,我們就得到了狀態機的編譯結果 output.js。
然后我們可以試用一下編譯出來的狀態機代碼:
import makeParser from './output'// 傳入外部函數,創建 Parser const Parser = makeParser({onMatch: (...args) => {console.log('match!')return 0} })const parser = new Parser() parser.execute(Buffer.from('hel')) //=> 0,代表沒有報錯 parser.execute(Buffer.from('lo')) //=> 打印 'match!',同樣返回 0注意,我們這里即使把 'hello' 分為兩段輸入狀態機,狀態機依然可以成功匹配。
但如果我們輸入了預期外的字符串,那么就會讓狀態機進入錯誤狀態:
const parser = new Parser() parser.execute(Buffer.from('starkwang')) //=> 返回 1,代表錯誤狀態llhttp 是如何使用 llparse 的?
解析 HTTP 協議,和我們上面構建的這個簡單的 Parser 其實原理上是一樣的(只是前者復雜得多而已),比如下面就是一段解析 HTTP 起始行(start-line)的狀態機代碼(來自 llparse 的 README):
import { LLParse } from 'llparse';const p = new LLParse('http_parser');const method = p.node('method'); const beforeUrl = p.node('before_url'); const urlSpan = p.span(p.code.span('on_url')); const url = p.node('url'); const http = p.node('http');// Add custom uint8_t property to the state p.property('i8', 'method');// Store method inside a custom property const onMethod = p.invoke(p.code.store('method'), beforeUrl);// Invoke custom C function const complete = p.invoke(p.code.match('on_complete'), {// Restart0: method }, p.error(4, '`on_complete` error'));method.select({'HEAD': 0, 'GET': 1, 'POST': 2, 'PUT': 3,'DELETE': 4, 'OPTIONS': 5, 'CONNECT': 6,'TRACE': 7, 'PATCH': 8}, onMethod).otherwise(p.error(5, 'Expected method'));beforeUrl.match(' ', beforeUrl).otherwise(urlSpan.start(url));url.peek(' ', urlSpan.end(http)).skipTo(url);http.match(' HTTP/1.1rnrn', complete).otherwise(p.error(6, 'Expected HTTP/1.1 and two newlines'));const artifacts = p.build(method); console.log('----- BITCODE -----'); console.log(artifacts.bitcode); // buffer console.log('----- BITCODE END -----'); console.log('----- HEADER -----'); console.log(artifacts.header); console.log('----- HEADER END -----');當然你也可以直接去讀 llhttp 的核心狀態機代碼:llhttp/http.ts
為什么 llhttp 會比 http-parser 更快?
總結
參考資料
總結
以上是生活随笔為你收集整理的js json制表符报错_llhttp是如何使Node.js性能翻倍的?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: jdbc连接的问题
- 下一篇: php类和对象-作用,php 类和对象