详解基于Node.js的HTTP/2 Server实践
虽然HTTP/2目前已经逐渐的在各大网站上开始了使用,但是在目前最新的Node.js上仍然处于实验性API,还没有能有效解决生产环境各种问题的应用示例。因此在应用HTTP/2的道路上我自己也遇到了许多坑,下面介绍了项目的主要架构与开发中遇到的问题及解决方式,也许会对你有一点点启示。
配置
虽然W3C的规范中没有规定HTTP/2协议一定要使用ssl加密,但是支持非加密的HTTP/2协议的浏览器实在少的可怜,因此我们有必要申请一个自己的域名和一个ssl证书。
本项目的测试域名是you.keyin.me,首先我们去域名提供商那把测试服务器的地址绑定到这个域名上。然后使用Let'sEncrypt生成一个免费的SSL证书:
sudocertbotcertonly--standalone-dyou.keyin.me
输入必要信息并通过验证之后就可以在/etc/letsencrypt/live/you.keyin.me/下面找到生成的证书了。
改造Koa
Koa是一个非常简洁高效的Node.js服务器框架,我们可以简单改造一下来让它支持HTTP/2协议:
classKoaOnHttpsextendsKoa{
constructor(){
super();
}
getoptions(){
return{
key:fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/privkey.pem')),
cert:fs.readFileSync(require.resolve('/etc/letsencrypt/live/you.keyin.me/fullchain.pem'))
};
}
listen(...args){
constserver=http2.createSecureServer(this.options,this.callback());
returnserver.listen(...args);
}
redirect(...args){
constserver=http.createServer(this.callback());
returnserver.listen(...args);
}
}
constapp=newKoaOnHttps();
app.use(sslify());
//...
app.listen(443,()=>{
logger.ok('appstartat:',`https://you.keyin.cn`);
});
//receiveallthehttprequest,redirectthemtohttps
app.redirect(80,()=>{
logger.ok('httpredirectserverstartat',`http://you.keyin.me`);
});
上述代码简单基于Koa生成了一个HTTP/2服务器,并同时监听80端口,通过sslify中间件的帮助自动将http协议的连接重定向到https协议。
静态文件中间件
静态文件中间件主要用来返回url所指向的本地静态资源。在http/2服务器中我们可以在访问html资源的时候通过服务器推送(Serverpush)将该页面所依赖的js\css\font等资源一起推送回去。具体代码如下:
constsend=require('koa-send');
constlogger=require('../util/logger');
const{push,acceptsHtml}=require('../util/helper');
constdepTree=require('../util/depTree');
module.exports=(root='')=>{
returnasyncfunctionserve(ctx,next){
letdone=false;
if(ctx.method==='HEAD'||ctx.method==='GET'){
try{
//当希望收到html时,推送额外资源。
if(/(\.html|\/[\w-]*)$/.test(ctx.path)){
depTree.currentKey=ctx.path;
constencoding=ctx.acceptsEncodings('gzip','deflate','identity');
//serverpush
for(constfileofdepTree.getDep()){
//serverpushmustbeforeresponse!
//https://huangxuan.me/2017/07/12/upgrading-eleme-to-pwa/#fast-skeleton-painting-with-settimeout-hack
push(ctx.res.stream,file,encoding);
}
}
done=awaitsend(ctx,ctx.path,{root});
}catch(err){
if(err.status!==404){
logger.error(err);
throwerr;
}
}
}
if(!done){
awaitnext();
}
};
};
需要注意的是,推送的发生永远要先于当前页面的返回。否则服务器推送与客户端请求可能就会出现竞争的情况,降低传输效率。
依赖记录
从静态文件中间件代码中我们可以看到,服务器推送资源取自depTree这个对象,它是一个依赖记录工具,记录当前页面depTree.currentKey所有依赖的静态资源(js,css,img...)路径。具体的实现是:
constlogger=require('./logger');
constdb=newMap();
letcurrentKey='/';
module.exports={
getcurrentKey(){
returncurrentKey;
},
setcurrentKey(key=''){
currentKey=this.stripDot(key);
},
stripDot(str){
if(!str)return'';
returnstr.replace(/index\.html$/,'').replace(/\./g,'-');
},
addDep(filePath,url,key=this.currentKey){
if(!key)return;
key=this.stripDot(key);
if(!db.has(key)){
db.set(key,newMap());
}
constkeyDb=db.get(key);
if(keyDb.size>=10){
logger.warning('Pushresourcelimitexceeded');
return;
}
keyDb.set(filePath,url);
},
getDep(key=this.currentKey){
key=this.stripDot(key);
constkeyDb=db.get(key);
if(keyDb==undefined)return[];
constret=[];
for(const[filePath,url]ofkeyDb.entries()){
ret.push({filePath,url});
}
returnret;
}
};
当设置好特定的当前页currentKey后,调用addDep将方法能够为当前页面添加依赖,调用getDep方法能够取出当前页面的所有依赖。addDep方法需要写在路由中间件中,监控所有需要推送的静态文件请求得出依赖路径并记录下来:
router.get(/\.(js|css)$/,async(ctx,next)=>{
letfilePath=ctx.path;
if(/\/sw-register\.js/.test(filePath))returnawaitnext();
filePath=path.resolve('../dist',filePath.substr(1));
awaitnext();
if(ctx.status===200||ctx.status===304){
depTree.addDep(filePath,ctx.url);
}
});
服务器推送
Node.js最新的API文档中已经简单描述了服务器推送的写法,实现很简单:
exports.push=function(stream,file){
if(!file||!file.filePath||!file.url)return;
file.fd=file.fd||fs.openSync(file.filePath,'r');
file.headers=file.headers||getFileHeaders(file.filePath,file.fd);
constpushHeaders={[HTTP2_HEADER_PATH]:file.url};
stream.pushStream(pushHeaders,(err,pushStream)=>{
if(err){
logger.error('serverpusherror');
throwerr;
}
pushStream.respondWithFD(file.fd,file.headers);
});
};
stream代表的是当前HTTP请求的响应流,file是一个对象,包含文件路径filePath与文件资源链接url。先使用stream.pushStream方法推送一个PUSH_PROMISE帧,然后在回调函数中调用responseWidthFD方法推送具体的文件内容。
以上写法简单易懂,也能立即见效。网上很多文章介绍到这里就没有了。但是如果你真的拿这样的HTTP/2服务器与普通的HTTP/1.x服务器做比较的话,你会发现现实并没有你想象的那么美好,尽管HTTP/2理论上能够加快传输效率,但是HTTP/1.x总共传输的数据明显比HTTP/2要小得多。最终两者相比较起来其实还是HTTP/1.x更快。
Why?
答案就在于资源压缩(gzip/deflate)上,基于Koa的服务器能够很轻松的用上koa-compress这个中间件来对文本等静态资源进行压缩,然而尽管Koa的洋葱模型能够保证所有的HTTP返回的文件数据流经这个中间件,却对于服务器推送的资源来说鞭长莫及。这样造成的后果是,客户端主动请求的资源都经过了必要的压缩处理,然而服务器主动推送的资源却都是一些未压缩过的数据。也就是说,你的服务器推送资源越大,不必要的流量浪费也就越大。新的服务器推送的特性反而变成了负优化。
因此,为了尽可能的加快服务器数据传输的速度,我们只有在上方push函数中手动对文件进行压缩。改造后的代码如下,以gzip为例。
exports.push=function(stream,file){
if(!file||!file.filePath||!file.url)return;
file.fd=file.fd||fs.openSync(file.filePath,'r');
file.headers=file.headers||getFileHeaders(file.filePath,file.fd);
constpushHeaders={[HTTP2_HEADER_PATH]:file.url};
stream.pushStream(pushHeaders,(err,pushStream)=>{
if(err){
logger.error('serverpusherror');
throwerr;
}
if(shouldCompress()){
constheader=Object.assign({},file.headers);
header['content-encoding']="gzip";
deleteheader['content-length'];
pushStream.respond(header);
constfileStream=fs.createReadStream(null,{fd:file.fd});
constcompressTransformer=zlib.createGzip(compressOptions);
fileStream.pipe(compressTransformer).pipe(pushStream);
}else{
pushStream.respondWithFD(file.fd,file.headers);
}
});
};
我们通过shouldCompress函数判断当前资源是否需要进行压缩,然后调用pushStream.response(header)先返回当前资源的header帧,再基于流的方式来高效返回文件内容:
- 获取当前文件的读取流fileStream
- 基于zlib创建一个可以动态gzip压缩的变换流compressTransformer
- 将这些流依次通过管道(pipe)传到最终的服务器推送流pushStream中
Bug
经过上述改造,同样的请求HTTP/2服务器与HTTP/1.x服务器的返回总体资源大小基本保持了一致。在Chrome中能够顺畅打开。然而进一步使用Safari测试时却返回HTTP401错误,另外打开服务端日志也能发现存在一些红色的异常报错。
经过一段时间的琢磨,我最终发现了问题所在:因为服务器推送的推送流是一个特殊的可中断流,当客户端发现当前推送的资源目前不需要或者本地已有缓存的版本,就会给服务器发送RST帧,用来要求服务器中断掉当前资源的推送。服务器收到该帧之后就会立即把当前的推送流(pushStream)设置为关闭状态,然而普通的可读流都是不可中断的,包括上述代码中通过管道连接到它的文件读取流(fileStream),因此服务器日志里的报错就来源于此。另一方面对于浏览器具体实现而言,W3C标准里并没有严格规定客户端这种情况应该如何处理,因此才出现了继续默默接收后续资源的Chrome派与直接激进报错的Safari派。
解决办法很简单,在上述代码中插入一段手动中断可读流的逻辑即可。
//...
fileStream.pipe(compressTransformer).pipe(pushStream);
pushStream.on('close',()=>fileStream.destroy());
//...
即监听推送流的关闭事件,手动撤销文件读取流。
最后
本项目代码开源在Github上,如果觉得对你有帮助希望能给我点个Star。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。