查看原文
其他

【第1835期】llhttp是如何使Node.js性能翻倍的?

王伟嘉 前端早读课 2020-10-12

前言

今日早读文章由Node.js Core Collaborator,腾讯云@王伟嘉授权分享。

正文从这开始~~

如果你关注 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,或者至少不能更差;

  • 最好使用 JS/TS 实现,便于社区维护;

  • 对外接口尽量保持一致,便于无缝升级。

  • HTTP Parser 是什么?它在做什么?

我们在这里不过多深入 HTTP 的细节,只是做一些简单的介绍。

HTTP 是一种应用层的文本协议,建立在 TCP 之上,由三个部分组成:起始行(start-line)、头部(headers)、传输体(body)。

比如下面就是一个完整的 HTTP 请求的例子:

  1. POST /user/starkwang HTTP/1.1<--- 起始行

  2. Host: localhost:8080<--- 头部开始

  3. Connection: keep-alive

  4. User-Agent: Mozilla/5.0(Macintosh; ...

  5. Content-Type: application/json; charset=utf-8<--- 头部结束


  6. {"text": "这里是Body!!"} <--- 传输体

当然这只是理论,实际上收发、处理一个 HTTP 请求并没有那么简单,比如对于 Node.js 的 Runtime 层面而言,它收到的来自传输层 TCP 的数据实际上是几串离散的数据,比如:

  1. POST /user/starkw

  2. ------------我是分割线---------------

  3. ang HTTP/1.1

  4. Host: localho

  5. ------------我是分割线---------------

  6. st:8080

  7. Connection: keep-alive

  8. User-Agent: Mozilla/5.0(Macintosh; ...

  9. Content-Type: appli

  10. ------------我是分割线---------------

  11. cation/json; charset=utf-8


  12. {"text": "这

  13. ------------我是分割线---------------

  14. 里是Body!!"}

HTTP Parser 的工作之一就是把这几串数据接收并且解析完毕(如果遇到不符预期的输入则报错),并将解析结果转换成文本和 JavaScript 侧可以处理的对象。下面是一个便于理解的例子:

  1. {

  2. method: 'GET'

  3. headers: {

  4. 'Host': 'localhost:8080',

  5. 'Connection': 'keep-alive',

  6. 'User-Agent': 'Mozilla/5.0 (Macintosh; ...',

  7. 'Content-Type': 'application/json; charset=utf-8'

  8. },

  9. body: '{"text": "这里是Body!!"}'

  10. }

实际上,Node.js 里面是以 IncomingMessage 和 ServerResponse 的形式表示的

llhttp 是怎么实现的?

如果你学过或者接触过一点编译原理的话,很容易就想到 HTTP Parser 本质上是一个有限状态机,我们接收并解析 start-line、headers、body 时,只是在这个状态机内部进行转换而已。

HTTP 状态机

假设我们正在接收一个 HTTP 请求,我们收到了 HTTP 请求的第一个字符,发现这个字符是 G,那么我们接下来应该期望收到一个 start-line 的方法名 GET。

但如果第二个字符收到了 A,那么就应该立刻报错,因为没有任何 HTTP 方法是以 GA 开头,根据这个步骤,我们就得到了一个很简单的状态机:

顺着相同的思路,将这个状态机进一步扩展,我们就可以解析完整的 HTTP 请求了:

下面是实际上的 llhttp 内部的状态机

https://indutny.github.io/jsconfeu-2019/reveal.js/images/graph.svg

如何优雅地实现状态机

理论有了,那么我们接下来的问题就是如何用代码优雅地实现一个有限状态机?

比如我们可以用一个 switch 来解决:

  1. const state = 0

  2. function excute() {

  3. swtich(state) {

  4. case 0:

  5. // do something...

  6. state = 3

  7. case 1:

  8. // do something...

  9. state = 0

  10. // ...

  11. }

  12. }


  13. while(true) {

  14. excute()

  15. }

可是 switch 的问题在于:

  • 大型的状态机会存在成百上千种状态,把这么多状态放在一个巨大的 switch 中,是很反人类的事情;

  • 对于解析文本协议而言,有很多重复的动作,比如“连续匹配 POST 这四个字符”,这种“连续匹配”的行为是可以进一步抽象的,而不是写成多个状态;

使用 DSL 描述状态机

为了解决上述问题,llhttp 使用了一种基于 TypeScript 的 DSL(领域特定语言) llparse,用于描述有限状态机。

llparse 支持将描述状态机的 TypeScript 编译为 LLVM Bitcode(这也是为啥叫 llparse 的原因)、或者 C 语言、或者 JavaScript。它起码有以下几种优势:

  • 不需要手写大量重复的状态机转换的代码

  • 可以输出到多种语言,其中 llhttp 使用的是 C 语言

  • 可以在编译期间对状态机进行静态分析和优化,合并冗余的状态

llparse 快速入门

现在关于 llparse 的文档和文章几乎没有,llparse 本身也只有 Fedor Indutny 一个维护者,所以想要理解 llparse 的 API 是有一定难度的。

简单介绍

llparse 使用 TypeScript 描述一个专用于匹配文本的状态机,所以它的一切 API 都是为描述一个这样的状态机而设计的。

在 llparse 中,所有状态都是一个节点(node):

  1. // 创建一个状态 foo

  2. const foo = p.node('foo')

我们可以通过各种 API 来描述节点之间的转换关系,或者各种状态机的动作,这些 API 包括但不仅限于:

  • match

  • otherwise

  • peek

  • skipTo

  • select

  • invoke

  • code

  • span

下面分别介绍这些 API。

match 与 otherwise

.match() 表示在当前状态时,尝试匹配一串连续输入。

下面这段代码尝试连续匹配 'hello',如果匹配成功,那么跳转到下一个节点 nextNode;否则调用 .otherwise() 跳转到 onMismatch:

  1. const foo = p.node('foo')

  2. foo.match('hello', nextNode).otherwise(onMismatch)

peek 与 skipTo

.peek() 表示提前查看下一个字符( peek 有“偷窥”的意思),但是不消费它。

下面的代码表示,当下一个字符是 '\n' 的时候,跳转到 nextNode,否则使用 .skipTo() 消费一个字符,跳回到 foo 重新循环。

  1. foo.peek('\n', nextNode).skipTo(foo)

注意, .skipTo().otherwise() 的区别在于, 前者会消费一个字符,而后者不会。

所以如果我们使用 .otherwise() 替换上面的 .skipTo(),就会收到一个错误,告诉我们检测到了死循环:

  1. foo.peek('\n', nextNode).otherwise(foo)

  2. //=> Error: Detected loop in "foo" through chain "foo"

select

.select() 用于匹配一串文本,并且把匹配到的文本映射到某个值上,然后把这个值传入下一个状态。

  1. foo.select({

  2. 'GET': 0,

  3. 'POST': 1

  4. }, next)

比如,在接收 HTTP 请求的时候,根据规范,开头的前几个字符必然是 HTTP 方法名,那么我们可以这样接收:

  1. const method = p.node('method')

  2. method

  3. .select({

  4. 'HEAD': 0, 'GET': 1, 'POST': 2, 'PUT': 3,

  5. 'DELETE': 4, 'OPTIONS': 5, 'CONNECT': 6,

  6. 'TRACE': 7, 'PATCH': 8

  7. }, onMethod)

  8. .otherwise(p.error(5, 'Expected method'))

invoke 与 code

任何有意义的状态机最终肯定是要对外部产生输出的,比如调用外部函数,或者存储一些状态到外部的属性上面, .invoke().code 就是为此而设计的。

.invoke() 调用一个外部函数,并且根据外部函数的返回值,映射到不同的下个状态,如果没有映射,那么跳入错误状态中。

.code.match() 返回一个外部函数的引用。

  1. const onMatch = p.invoke(

  2. p.code.match("bar"),

  3. {

  4. 1: nextNode1,

  5. 2: nextNode2

  6. },

  7. p.error(1, "bar error")

  8. )

我们这里调用了外部函数 bar,并且根据返回值确定下一个状态 nextNode1 或 nextNode2,如果返回值是预期外的,那么跳入错误状态。

span

.span() 表示在一段时间内,为输入的每个字符产生一次回调。

  1. const callback = p.code.span('onByte')

  2. const onByte = p.span(callback)


  3. node.match('start', onByte.start(nextNode))


  4. nextNode.match('end', onByte.end(nextNextNode))

上面我们尝试匹配 'start',如果匹配成功,那么跳入 nextNode,并且开始为每个匹配到的字符触发 onByte() 回调,直到匹配完毕 'end',我们结束触发回调,并且跳入 nextNextNode。

使用 llparse 构建简单的 Parser

单纯地讲 API 是很枯燥的,我们来实战试一试,我们尝试构建一个匹配 'hello' 的 Parser。

首先我们创建一个 start 状态节点,代表起始状态:

  1. import{ LLParse} from"llparse"

  2. const p = newLLParse("myfsm")


  3. const start = p.node('start')

我们可以尝试使用 .match() 连续匹配一串输入,如果匹配成功,那么跳转到下一个状态节点 onMatch;否则跳转到 onMismatch:

  1. start

  2. .match('hello', onMatch)

  3. .otherwise(onMismatch)

然后 onMatch 中,我们使用 .invoke() 产生一个外部调用,调用的是注入的外部方法 onMatch,如果它返回 0,那么就跳转回到 start 状态,否则报错

  1. const onMatch = p.invoke(

  2. p.code.match("onMatch"),

  3. {

  4. 0: start

  5. },

  6. p.error(1, "onMatch error")

  7. )

于是我们就得到了这样一个简单的状态机:

下面是完整的代码:

  1. import { LLParse } from "llparse"

  2. import { writeFileSync } from "fs"


  3. const p = new LLParse("myfsm")


  4. // 创建状态节点 start

  5. const start = p.node("start")


  6. // 创建调用节点 onMatch

  7. const onMatch = p.invoke(

  8. p.code.match("onMatch"),

  9. {

  10. 0: start

  11. },

  12. p.error(1, "onMatch error")

  13. )


  14. // start 状态匹配到 hello 之后,进入 onMatch节点

  15. // 否则输出 expect "hello"

  16. start.match("hello", onMatch).otherwise(p.error(1, 'expect "hello"'))


  17. // 编译状态机

  18. // 状态机从 start 开始

  19. const artifacts = p.build(start)


  20. // 输出编译结果

  21. writeFileSync("./output.js", artifacts.js)

  22. writeFileSync("./output.c", artifacts.c)

运行上述代码,我们就得到了状态机的编译结果 output.js。

然后我们可以试用一下编译出来的状态机代码:

  1. import makeParser from './output'


  2. // 传入外部函数,创建 Parser

  3. const Parser = makeParser({

  4. onMatch: (...args) => {

  5. console.log('match!')

  6. return 0

  7. }

  8. })


  9. const parser = new Parser()

  10. parser.execute(Buffer.from('hel'))

  11. //=> 0,代表没有报错

  12. parser.execute(Buffer.from('lo'))

  13. //=> 打印 'match!',同样返回 0

注意,我们这里即使把 'hello' 分为两段输入状态机,状态机依然可以成功匹配。

但如果我们输入了预期外的字符串,那么就会让状态机进入错误状态:

  1. const parser = newParser()

  2. parser.execute(Buffer.from('starkwang'))

  3. //=> 返回 1,代表错误状态

llhttp 是如何使用 llparse 的?

解析 HTTP 协议,和我们上面构建的这个简单的 Parser 其实原理上是一样的(只是前者复杂得多而已),比如下面就是一段解析 HTTP 起始行(start-line)的状态机代码(来自 llparse 的 README):

  1. import { LLParse } from 'llparse';


  2. const p = new LLParse('http_parser');


  3. const method = p.node('method');

  4. const beforeUrl = p.node('before_url');

  5. const urlSpan = p.span(p.code.span('on_url'));

  6. const url = p.node('url');

  7. const http = p.node('http');


  8. // Add custom uint8_t property to the state

  9. p.property('i8', 'method');


  10. // Store method inside a custom property

  11. const onMethod = p.invoke(p.code.store('method'), beforeUrl);


  12. // Invoke custom C function

  13. const complete = p.invoke(p.code.match('on_complete'), {

  14. // Restart

  15. 0: method

  16. }, p.error(4, '`on_complete` error'));


  17. method

  18. .select({

  19. 'HEAD': 0, 'GET': 1, 'POST': 2, 'PUT': 3,

  20. 'DELETE': 4, 'OPTIONS': 5, 'CONNECT': 6,

  21. 'TRACE': 7, 'PATCH': 8

  22. }, onMethod)

  23. .otherwise(p.error(5, 'Expected method'));


  24. beforeUrl

  25. .match(' ', beforeUrl)

  26. .otherwise(urlSpan.start(url));


  27. url

  28. .peek(' ', urlSpan.end(http))

  29. .skipTo(url);


  30. http

  31. .match(' HTTP/1.1\r\n\r\n', complete)

  32. .otherwise(p.error(6, 'Expected HTTP/1.1 and two newlines'));


  33. const artifacts = p.build(method);

  34. console.log('----- BITCODE -----');

  35. console.log(artifacts.bitcode); // buffer

  36. console.log('----- BITCODE END -----');

  37. console.log('----- HEADER -----');

  38. console.log(artifacts.header);

  39. console.log('----- HEADER END -----');

当然你也可以直接去读 llhttp 的核心状态机代码:llhttp/http.ts

为什么 llhttp 会比 http-parser 更快?

llparse 在编译 DSL 时有静态分析的过程,这期间会做一些状态机的优化,例如状态合并。比如匹配 ‘POST’、‘PUT’、‘PATCH’ 的时候,就会合并第一个 ‘P’ 字符的状态,而不是生成三个独立的中间态;

http-parser 为了 C 代码的可读性和 DRY,存在很多抽象,这些抽象其实是一种 trade-off,是存在性能损失的。而 llparse 完全不需要考虑编译结果的可读性,所以甚至可以在编译出的 C 代码里大量使用 goto 替代条件判断,这里也间接提升了性能。

总结

llhttp 使用有限状态机解析 HTTP 协议,保证性能的同时也提升了代码的可维护性;

为了使状态机的代码简洁明了,llparse 被设计了出来,这是一门基于 TypeScript 的用于描述解析文本的状态机 DSL;

llparse 提供了一系列 API 对状态机的行为进行抽象,并且可以把状态机编译到 LLVM Bitcode、C、JavaScript。

参考资料

  • llhttp - new HTTP 1.1 parser for Node.js by Fedor Indutny | JSConf EU 2019

  • https://indutny.github.io/jsconfeu-2019/reveal.js/index.html

  • https://github.com/nodejs/http-parser

  • https://github.com/nodejs/llhttp

  • https://github.com/nodejs/llparse

关于本文 作者:@Starkwang 原文:https://zhuanlan.zhihu.com/p/100660049

@王伟嘉曾分享过


【第1132期】如何用人类的方式进行 Code Review


【第1134期】如何用人类的方式进行 Code Review (二)


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存