Node HTTP/2 Server Push 从了解到放弃

9,481

前阵子,在Media看到一篇文章《Node.js can HTTP/2 push!》。看到push这个字眼时,我想到的是WebSocket消息推送。难不成HTTP/2还能像WebSocket那样可以服务端主动推送消息?好厉害,我就一下子来了兴趣。

然而阅读完文章之后,发现理想与现实略有差距。简单的说,HTTP/2 所谓的server push其实是当服务器接收一个请求时,可以响应多个资源。举个栗子:浏览器向服务器请求index.html,服务器不仅把index.html返回了,还可以把index.js,index.css等一起推送给客户端。最直观的好处就是,浏览器不用解析页面再发起请求来获取数据,节约了页面加载时间。

虽然略有差距,但看起来还是挺有意思的,值得去尝试一下。毕竟纸上得来终觉浅,绝知此事要躬行!

HTTP/2

我之前并未使用过HTTP/2,在进行实践之前,总要先了解一下。关于HTTP/2,网上也有很多资料,我这里就简单说一下它最大的优点:快!。这里的快是相比HTTP 1.x 而言的,那为什么它会更快呢?

头部压缩

这里的头部指的是http请求头headers。大家可能会想请求头能有多大呢,跟资源相比算不上啥。其实不然,随着互联网的发展,请求头里携带的数据越来越多了,随随便一个“user-agent”就一长串。另外cookie也会被存放越来越多的信息。更烦的是,一个页面所有的请求,都会带上这些重复的请求头数据。

所以HTTP/2采用HPACK算法,能极大压缩头部数据,减少总体资源请求大小。大致的原理就是维护两本字典,一本静态字典,维护比较常见的头部名称。一本动态字典,维护不同请求的公共的头数据。

多路复用

我们知道,在HTTP 1.x中,我们是可以并行请求的。但是,浏览器对于同一个域名的并行请求是有上限的(FireFox, Chrome上限6个 )。所以很多网站的静态资源站可能会有多个。而且每次请求都要重新建立TCP连接,想必大部分web工程师都了解过TCP三次握手,这个握手的代价也是比较高的。

虽然http1.x里有keep-alive可以避免TCP三次握手,但是keep-alive又是串行的。所以要么并行多握手,要么串行不握手,都不是最好的结果,我们希望的是并行也不握手。

幸运的是HTTP/2解决了这个问题。当客户端与服务端建立连接后,就会在双方建立一个双向流通道。这个流通道,可以同时包含多个消息(http请求),不同消息各自的数据帧在流里可以乱序并行的发送,不会互相影响与堵塞,从而实现了一个TCP链接,并发执行N个http请求。通过提高并发,减少TCP连接开销,HTTP/2的速度得到了很大提升,尤其是在网络延迟比较高的情况下。

这里用展现两张网络请求时间瀑布流对比图:

HTTP 1.1

undefined

HTTP/2

undefined

Server Push

上文中,我们描述了HTTP/2的连接会建立一个双向流通道。Server Push就是在某次流中,可以返回客户端并没有主动要的数据。

上述的头部压缩、多路复用,并不需要开发人员做什么操作,只要开启HTTP/2,浏览器也支持就可以了。但是Server Push就需要开发人员编写代码去操作了。那我们就动手,在Node上玩玩看。

Node HTTP/2 Server Push 实操

Node对HTTP/2支持情况

在Node 8.4.0版本时,就对HTTP/2实验性的支持了。2018年4月24日晚,Node v10终于发布了,然而对于HTTP/2,还是实验性的支持。。。不过社区已经对HTTP/2移除实验性进行讨论了,相信在不远的将来应该能看到Node对HTTP/2更好的支持。因此在这之前,我们可以先去掌握这个知识,做一些实践。

依葫芦画瓢

我们先根据Node文档,创建一个HTTP/2服务。这里需要提的一点就是,目前流行的浏览器都不支持未加密的、不安全的HTTP/2。所以我们必须生成下证书与秘钥,然后通过http2.createSecureServer创建安全的HTTP/2链接。

想自己实践,生成本地证书的同学可以参考这里:传送门

// server.js
const http2 = require('http2')
const fs = require('fs')
const streamHandle = require('./streamHandle/sample')
const options = {
  key: fs.readFileSync('./ryans-key.pem'),
  cert: fs.readFileSync('./ryans-cert.pem'),
}
const server = http2.createSecureServer(options)
server.on('stream', streamHandle)
server.listen(8125)

然后我们再照着文档,编写对流的处理,并推送一个url路径为 '/' 的数据。

// streamHandle/sample.js
module.exports = stream => {
  stream.respond({ ':status': 200 })
  stream.pushStream({ ':path': '/' }, (err, pushStream, headers) => {
    if (err) throw err
    pushStream.respond({ ':status': 200 })
    pushStream.end('some pushed data')
    pushStream.on('close', () => console.log('close'))
  })
  stream.end('some data')
}

然后我们打开chrome,访问https://127.0.0.1:8125 发现页面显示的一直是 some datasome pushed data这个主动推送的数据不知在哪里。打开网路请求面板,也没有任何其他请求。

百思不得其解阿,但我又不想止步于此,怎么办呢?

从娃娃抓起

我决定先写一个正常的HTTP/2业务请求,代码如下:

module.exports = (stream, headers) => {
  const path = headers[':path']
  if (path.indexOf('api') >= 0) {
    // 请求api
    stream.respond({ 'content-type': 'application/json', ':status': 200 })
    stream.end(JSON.stringify({ success: true }))
  } else if (path.indexOf('static') >= 0) {
    // 请求静态资源
    const fileType = path.split('.').pop()
    const contentType = fileType === 'js' ? 'application/javascript' : 'text/css'
    stream.respondWithFile(`./src${path}`, {
      'content-Type': contentType
    })
  } else {
    // 请求html
    stream.respondWithFile('./src/index.html')
  }
}

代码大意就是,判断请求链接,当请求地址带有api字眼时就返回一个json,当请求地址带有static时,就返回对应路径的静态资源。其他情况就返回一个html文件。

html文件内容为:

<!DOCTYPE html>
<html>
<head>
  <meta charset=utf-8>
  <title>HTTP/2 Server Push</title>
  <link rel="shortcut icon" type=image/ico href=/static/favorite.ico>
  <link href=/static/css/app.css rel=stylesheet>
</head>
<body>
  <h1>HTTP/2 Server Push</h1>
  <script type=text/javascript src=/static/js/test.js></script>
</body>
</html>

运行后我们再打开chrome,访问https://127.0.0.1:8125 ,我们能看到页面正常渲染了,查看网络面板,发现协议也已经是HTTP/2。

undefined

这样我们就开发了一个非常简单的HTTP/2应用。下一步,我们再加上server push的功能,当访问index.html的请求时,我们主动将js的资源返回,看看浏览器是怎么样的响应情况。

抓出一个葫芦娃

module.exports = (stream, headers) => {
 const path = headers[':path']
 if (path.indexOf('api') >= 0) {
   // 请求api部分代码-略
 } else if (path.indexOf('static') >= 0) {
   // 请求静态资源部分代码-略
 } else {
   // 请求html时 主动推送js文件
   stream.pushStream(
 	  { ':path': '/static/js/test.js' },
     (err, pushStream, headers) => {
   	if (err) throw err
   	pushStream.respondWithFile('./src/static/js/test2.js', {
   	  'content-type:': 'application/javascript'
   	})
   	pushStream.on('error', console.error)
     }
   )
   stream.respondWithFile('./src/index.html')
 }
}

代码大意就是,当客户端请求index.html时,服务端除了返回index.html文件,顺便把test2.js这个文件推给服务端,客户端如果再次请求 https://127.0.0.1:8125/static/js/test.js 时,就会直接获取到test2.js

这里我用test2.js的目的是为了方便的知道,客户端请求的到底是服务端推送的test2.js文件,还是直接通过服务器再次请求获取到的test.js文件。

其中test.js会在页面打印:This is normal js. test2.js会在页面打印:This is server push js.

按照期望,应该是后者。然后我们打开chrome,访问 https://127.0.0.1:8125,展现如下结果:

undefined

!!!!掀桌!!!!

这个展示结果并不是意料中的打印出This is server push js,页面请求的js文件还是正常网络请求的,并非是我主动推送的test2.js。我翻山越岭搜遍祖国内外,终于在Node的一条issue下看到类似的问题:http2 pushStream not providing files for :path entries (CHROME 65, works in FF)

Works in FireFox ??????? Chrome的bug ??????

你照着文档写代码,结果却不像文档所展示,各种排查没有用,最终发现是一些非主观的原因,程序员最大的痛苦莫过于此....然后我夹杂着痛苦心塞和峰回路转的心情,打开了自己的Firefox,访问页面,展现如下结果:

undefined

这回终于对了!可以看到,页面中打印的是test2.js文件的输出结果。

最开始依葫芦画瓢没用,其实也是因为Chrome的bug。不管怎么样,我们还是往前迈进了巨大的一步。

ps: 本人chrome版本66.0.3359.117,依旧有此bug

鸡肋

虽然我们前进了一大步,可是面临了一个很尴尬的问题:我们的静态资源更多是托管在cdn上的。那我们实际场景就会遇到如下情况:

  1. 所有网站的资源,包括html/css/js/image等,都是在一台业务服务器上的。抱歉同学,你的业务服务器的带宽本来就低,怕是吃不消这么多静态资源的并发请求,你本来就慢的无可救药了。
  2. 网络路由走后端,即html走后端,其他静态资源托管cdn。抱歉同学,静态资源都在cdn上的,你的业务服务器怎么去推?
  3. 完全的前后端分离,html与其他静态资源都是在cdn上。这种情况下,还是有点用处的,但效果并不会很出色。因为HTTP/2本身就支持多路复用,已经减少了TCP三次握手带来的网络消耗。server push仅仅只是降低了浏览器解析html的时间,对于现代浏览器来说,这太微乎其微了。(ps: 就在我写文章之时,恰好看到某云服务商支持了server push。)

这么一说,这就是个鸡肋啊!到头来竹篮打水一场空?

天生我材必有用

做人还是不能轻易的放弃治疗。再仔细想想,还是有一些应用场景的---初始化的API请求。

现在很多单页应用,往往有很多的初始化请求,获取用户信息、获取页面数据等等。而这些都是需要html加载完,然后js加载完,然后再去执行的。而且很多时候,这些数据不加载完,页面都只能空白显示。可是单页应用的js资源往往又很大。一个vendor包好几兆也很常见。等浏览器加载并解析完这么大的包,可能已经很多时间消耗了。这时候再去请求一些初始化API,如果这些API又比较费时的话,页面就要多空白很长时间。

但如果能在请求html时,我们就把初始化的api数据推送给客户端,当js解析完再去请求时,就能马上获取到数据,这就能节省宝贵的白屏时间。说干就干,我们再次动手实践!

module.exports = (stream, headers) => {
  const path = headers[':path']
  if (path.indexOf('api') >= 0) {
    // 请求api
    stream.respond({ 'content-type': 'application/json', ':status': 200 })
    stream.end(JSON.stringify({ apiType: 'normal' }))
  } else if (path.indexOf('static') >= 0) {
    // 请求静态资源代码-略
  } else {
    // 请求html
    stream.pushStream({ ':path': '/api/getData' }, (err, pushStream, headers) => {
      if (err) throw err
      pushStream.respond({ ':status': 200 , 'content-type': 'application/json'});
      pushStream.end(JSON.stringify({ apiType: 'server push' }))
    });
    stream.respondWithFile('./src/index.html')
  }
}

同样的,我让正常请求api与服务端推送的api数据做一些差异,以便于更直观的判断是否获取了服务端推送的数据。然后在前端的js文件中写如下请求,并打印出请求结果:

window.fetch('/api/getData').then(result => result.json()).then(rs => {
  console.log('fetch:', rs)
})

令人遗憾的是,我们的到的是如下的结果:

undefined

请求的结果表示这并不是server push的数据。吃一堑长一智,这会不会又是浏览器的什么bug?亦或者是不是fetch不支持获取server push的数据?我马上用XMLHttpRequest又写了一版:

window.fetch('/api/getData').then(result => result.json()).then(rs => {
  console.log('fetch:', rs)
})

const request = new XMLHttpRequest();
request.open('GET', '/api/getData', true)
request.onload = function(result) {
  console.log('ajax:', JSON.parse(this.responseText))
};
request.send();

结果如下:

undefined

!!!!掀桌!!!!

竟然还真的是fetch不支持http2 server push!

还是鸡肋

其实除了fetch不支持外,还有一个比较致命的问题,就是这个server push,在当下的node服务器上,不能对服务端推送资源的url进行模糊匹配。也就是说,如果一个请求有url动态参数的话,其实是匹配不到的。像我例子中的stream.pushStream({ ':path': '/api/getData' }, pushHandle),如果前端请求的接口是 /api/getData?param=1,那就得不到server push的数据了。

另外,它仅支持GET请求与HEAD请求,POST、PUT这些也是不支持的。

针对fetch这个问题,我又了搜了下祖国内外,也没得出个所以然来。这也变相的说明,目前社区里针对server push这个特性使用的还很少,遇到问题时,很难快速的去定位与解决问题。

所以,似乎在推送api上,它的应用场景又局限了,仅适用于推送固定URL的初始化GET请求。

苦海无边回头是岸

综上所述,我得出的结论就是:目前在Node上,使用server push,极大的情况与概率是不合适的,是付出大于收益的。主要由于如下原因:

  1. 截止Node v10.0.0,HTTP/2依旧是一个实验性的模块;
  2. 浏览器支持极差;如上述的Chrome的bug,fetch对server push的不支持;
  3. 推送静态资源的实际场景非常少,而且速度提升在理论上也不会很明显;
  4. 推送API仅支持固定的URL,不能携带任何动态参数。

注:上述内容仅局限在Node服务,其他服务器本人未有研究,不一定有上述问题

虽然server push我目前觉得不好用,但是HTTP/2还是个好东西的,除了我文章开头讲的那些好处外,HTTP/2还有很多新奇的有用的特性,诸如流优先级、流控制等一些特性,本文并未讲到。大家可以去了解了解,对我们未来开发高性能的web应该肯定有很多帮助!

本文所涉及源码:https://github.com/wuomzfx/http2-test

原文链接:https://yuque.com/wuomzfx/article/eh551s