Skip to content

Nginx + Koa 开启http/2 server push

2018年5月15日

一看这标题就是不准备好好写的。对的,最近特别忙,只能简单记录一下折腾的东西。

Nginx开启server push

  1. 升级nginx到1.13.9或以上版本(注意1.13.6修改http/2的实现,与一些旧版本客户端不兼容,比如旧版Android okhttp)
  2. nginx配置中加上http2_push_preload on,表示使用preload header来作为server push标识

Node开启server push

Node处理文件内容时加上preload header即可,例如:

link: </main.37d69167.css>; as=style; rel=preload, </main.f06ad8b3.css>; as=style; rel=preload
link: </main.37d69167.css>; as=style; rel=preload, </main.f06ad8b3.css>; as=style; rel=preload

此处比较科学的做法应该是使用一个中间件,在返回内容之前,根据要返回的HTML内容来处理preload header。

因为我主要处理静态html文件,又用的koa,所以将主要逻辑放在了koa-static的setHeaders函数中。setHeaders主要用于在返回静态文件前设置自定义的header,刚好和server push的场景相符。

主要逻辑:

  1. 读取html文件,使用正则表达式匹配出css和js文件的路径(如果有图片也可以一起)
  2. 将这些资源拼接成preload header的值
javascript
const htmlContent = fs.readFileSync(path, 'utf8');
const styleRegExp = /<link(?:.*?)href=['"]?([\w\./]+\.css)['"]?/g
let currentStyle;
const styleList = [];
while(currentStyle = styleRegExp.exec(htmlContent)){
    styleList.push(currentStyle[1]);

}
const scriptRegExp = /<script(?:.*?)src=['"]?([\w\./]+\.js)['"]?/g
let currentScript;
const scriptList = [];
while(currentScript = scriptRegExp.exec(htmlContent)){
    scriptList.push(currentScript[1]);
}
let link = styleList.map((styleFile) => {
    return `<${styleFile}>; as=style; rel=preload`;
}).join(', ');
link += ', ' + scriptList.map((scriptFile) => {
    return `<${scriptFile}>; as=script; rel=preload`;
}).join(', ');
cache[path] = link;
res.setHeader('Link', link);
const htmlContent = fs.readFileSync(path, 'utf8');
const styleRegExp = /<link(?:.*?)href=['"]?([\w\./]+\.css)['"]?/g
let currentStyle;
const styleList = [];
while(currentStyle = styleRegExp.exec(htmlContent)){
    styleList.push(currentStyle[1]);

}
const scriptRegExp = /<script(?:.*?)src=['"]?([\w\./]+\.js)['"]?/g
let currentScript;
const scriptList = [];
while(currentScript = scriptRegExp.exec(htmlContent)){
    scriptList.push(currentScript[1]);
}
let link = styleList.map((styleFile) => {
    return `<${styleFile}>; as=style; rel=preload`;
}).join(', ');
link += ', ' + scriptList.map((scriptFile) => {
    return `<${scriptFile}>; as=script; rel=preload`;
}).join(', ');
cache[path] = link;
res.setHeader('Link', link);

这样就可以实现http/2 server push了。

有缓存不再推送

上面两步都超简单,网上的教程满天飞。这一个标题“有缓存不再推送”内容才是促成本文的原因。

回到http/2 server push的原理,浏览器访问index.html时,server除了返回html,还将css / js / image也一并推送回来,这样浏览器接受完之后,就不用再单独请求一次,从而加快页面的加载。

但是这里有一个矛盾,如果我们的静态资源是有长缓存的,下一次请求的时候该推送还是不该推送呢?如果推送,则相当于是忽略了缓存,白白浪费带宽。

到目前为止,这些仍然是网上文章中的主要结论。于是我就验证了一下,有缓存时是否真的会浪费带宽。于是我打开了chrome://net-internals/#http2,然后找到了活跃的http/2连接。在Source TypeHTTP2 SESSION那一栏中,可以看到详细的HTTP/2通信过程。我截了一些图:

首先是没有缓存的情况下,server push开启:

  1. 浏览器请求完html之后,发现了PUSH_PROMISE,按字面意思理解,也就是server承诺推荐这些资源 push promise
  2. 接下来浏览器接受了这些推送的stream,把资源弄下来了 accept push

到这里一切正常。但是当资源有缓存时,再次请求,server push仍然开启的情况下:

  1. 浏览器收到PUSH_PROMISE,注意最后一行,main.6e607578.jsstream_id12push prmise stream id 12
  2. 接下来搜索这个stream_id=12,找到一串看不懂的东西,看起来跟TCP窗口调整的逻辑很类似? stream id 12
  3. 再接着,罢工了……清楚地写着Abandoned.已放弃,应该是浏览器拒绝了这次推送,注意stream_id仍然是12abandoned

也就是说,在有缓存的情况下,浏览器并不会傻乎乎地再接受推送。试验到这里后有点不可思议,于是又从两个方面做了验证。

  1. 网络带宽 这是chrome://net-internals/#timeline的时间线,比较明显的有15个峰,分别是我发起的15次请求,前5次缓存有效,中间5次没有缓存,后5次缓存有效。可以明显看到,有缓存时带宽是比没缓存时低的,证实有缓存时push并不会真的发生。

    abandoned

  2. 服务端网络IO 在有缓存的情况下,服务端网络IO大约每个请求0.2-0.4M,在无缓存的情况下,服务端网络IO大约每个请求2.2M-2.4M。同样证实有缓存的情况下,push并不会发生。

有缓存不再推送

其实有上面的结论后,不需要再做什么了。但是仍然怀疑这是不是哪一端实现的bug,要不然为什么大家都把这当作一个不可解决的缺陷呢?

那么就假装我不知道上面这一段吧,还是需要自己根据是否有缓存控制是否开启server push。

最容易想到的办法就是cookies了,将已推送过的资源url放入cookies中,下次再请求时进行对比,已有的资源就不再推送。

但是这样的话,cookies会非常庞大。于是有人提出了使用BloomFilter来存放推送信息。

BloomFilter是一个空间和时间复杂度都比较小的算法,主要用于快速进行“有损”存在性判定。所谓存在性判定就是给定一个key,确定它是否存在。而“有损”的意思则是指它并不100%精准。

它的原理可以简单这么理解:首先放一个数组,接下来每一个需要检查的key都做一个hash,映射到这个数组中的某几个位置,如果这几个位置全部为1,则认为这个key存在,否则认为这个key不存在。

考虑到server push的场景,即使不精准也不影响页面打开使用,因此它是适用的。HTTP server软件H2O就是使用了类似的算法。

本来我也打算这么实现一版,但是后来转念一想,我就一个页面,3个资源,好像没必要这么麻烦。不如直接全部hash一把,hash匹配就认为有缓存,hash不匹配就认为没缓存,全部重新推送一遍。

于是就有了类似这样的代码:

javascript
let pushHash = md5(cache[path]);
if(!cookie || cookie !== pushHash){
    res.setHeader('Link', cache[path]);
    res.setHeader('Set-Cookie', 'push=' + pushHash);
}
let pushHash = md5(cache[path]);
if(!cookie || cookie !== pushHash){
    res.setHeader('Link', cache[path]);
    res.setHeader('Set-Cookie', 'push=' + pushHash);
}

简单粗暴有效。

参考: