Nginx + Koa 开启http/2 server push
2018年5月15日
一看这标题就是不准备好好写的。对的,最近特别忙,只能简单记录一下折腾的东西。
Nginx开启server push
- 升级nginx到1.13.9或以上版本(注意1.13.6修改http/2的实现,与一些旧版本客户端不兼容,比如旧版Android okhttp)
- 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的场景相符。
主要逻辑:
- 读取html文件,使用正则表达式匹配出css和js文件的路径(如果有图片也可以一起)
- 将这些资源拼接成preload header的值
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 Type
为HTTP2 SESSION
那一栏中,可以看到详细的HTTP/2通信过程。我截了一些图:
首先是没有缓存的情况下,server push开启:
- 浏览器请求完html之后,发现了
PUSH_PROMISE
,按字面意思理解,也就是server承诺推荐这些资源 - 接下来浏览器接受了这些推送的stream,把资源弄下来了
到这里一切正常。但是当资源有缓存时,再次请求,server push仍然开启的情况下:
- 浏览器收到PUSH_PROMISE,注意最后一行,
main.6e607578.js
的stream_id
为12
- 接下来搜索这个
stream_id=12
,找到一串看不懂的东西,看起来跟TCP窗口调整的逻辑很类似? - 再接着,罢工了……清楚地写着
Abandoned.
已放弃,应该是浏览器拒绝了这次推送,注意stream_id
仍然是12
也就是说,在有缓存的情况下,浏览器并不会傻乎乎地再接受推送。试验到这里后有点不可思议,于是又从两个方面做了验证。
网络带宽 这是
chrome://net-internals/#timeline
的时间线,比较明显的有15个峰,分别是我发起的15次请求,前5次缓存有效,中间5次没有缓存,后5次缓存有效。可以明显看到,有缓存时带宽是比没缓存时低的,证实有缓存时push并不会真的发生。服务端网络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不匹配就认为没缓存,全部重新推送一遍。
于是就有了类似这样的代码:
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);
}
简单粗暴有效。
参考: