自从 2015 年 HTTP/2 标准正式发布以来,各大主要 Web 服务器以及各大主要浏览器已经陆续完成了对终版 HTTP/2 协议的支持。对于各位站长来说,这项技术也已经不是什么新鲜玩意。然而 HTTP/2 的一项子功能“服务器推送”——尽管当初被吹得很火——却没有跟上 HTTP/2 推广的步伐。
HTTP/2 服务器推送
HTTP/2 服务器推送是一种提升首屏加载速度的技术,它允许 Web 服务器在收到浏览器的请求之前提前发送一些资源给客户端。比如说某个页面 index.html
使用了 a.css
和 b.js
两个子资源,Web 服务器在返回 index.html
的内容后表示“你可能还需要这两个文件”将 a.css
和 b.js
的内容一并发送给了客户端浏览器,于是浏览器就不需要另外去单独请求这两个文件。
看起来一切都是这么美好,然而现实情况却没有这么简单。首要问题就是 Web 服务器怎样知道客户端需要什么,如果推送了不必要的资源——比如某个资源已经被浏览器缓存——不仅不能提升加载速度还会造成网络带宽的浪费。
当然还有另外一个更重要原因:我大 Nginx 根本不支持 HTTP/2 服务器推送,你想体验一把都不行。。。
这里得夸奖一下我们的老大哥 Apache Httpd,在很早之前就支持了,但 Apache 并未能(完全)解决过分推送的问题。在这里笔者给大家安利的是另外一款 Web 服务器:H2O
H2O:为 HTTP/2 而生的 Web 服务器
H2O 是一款新的 Web 服务器,它在 HTTP/2 正式标准化的那年发布稳定版 1.0 版本,口号就是“可完全利用 HTTP/2 的特性”,而且号称比 Nginx 还快。这里笔者并不打算比较两者的性能,但对 HTTP/2 的支持的确是 H2O 走在了前面。
注:Nginx 有个第三方模块用于实现 HTTP/2 服务器推送,实测还不能正常使用。
HTTP/2 服务器推送实践
H2O 安装的话很简单。笔者是 macOS 用户可以 brew install h2o
直接安装,Linux 用户可以使用官方的 rpm 镜像包安装。
要启用 HTTP/2 首先要启用 SSL,要启用 SSL 就首先得有个证书。笔者这里直接创建了一个指向本机(127.0.0.1)的域名然后用 Let's engypt
生成了有效证书;如果读者没有此条件也可以用 openssl 生成一个自签名证书然后将其加入系统钥匙串(网上资料很多,也有专门生成自签名证书的网站)。
H2O 的配置文件是含有少量扩展的 YAML 文件,简单配置如下:
hosts:
"test.eoitek.net:80": # 你的域名
listen:
port: 80 # 监听 80 端口
paths:
/:
redirect: https://test.eoitek.net/ # 重定向至 HTTPS
access-log: /dev/stdout # 测试时简单的把 log 输出至控制台
"test.eoitek.net:443": # 域名
listen:
port: 443 # 监听 443 端口
ssl:
certificate-file: /Users/Carter/.acme.sh/test.eoitek.net_ecc/fullchain.cer # 公钥(完全证书链,用于自动 OCSP stapling)
key-file: /Users/Carter/.acme.sh/test.eoitek.net_ecc/test.eoitek.net.key # 私钥
minimum-version: TLSv1.2 # 最小支持 TLS 版本(想支持 IE 8 需要设置为 TLSv1.0)
dh-file: dhparam.pem # DH秘钥(openssl dhparam -out dhparam.pem 2048)
cipher-preference: server # 让服务器决定使用的加密套件
cipher-suite: "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" # 支持的加密套件
paths:
/:
mruby.handler: | # 注入 mruby 脚本
lambda do |env|
[399, {"link" => "; rel=preload; as=style"}, []]
end
file.dir: . # 映射的服务器路径
access-log: /dev/stdout # 将 log 输出至控制台
http2-casper: ON # 启用 [cache-aware server-push](https://h2o.examp1e.net/configure/http2_directives.html#http2-casper)
compress: ON # 启用即时压缩(同时会启用 brotli 压缩支持)
其中的 mruby 脚本是启用 HTTP/2 服务器推送的重点。注入的脚本是一个 lambda 表达式,env 是函数参数,包括了客户端请求信息。返回值是一个数组,格式为 [HTTP 状态码, { 返回头 }, [返回体]]
。特别的,当 HTTP 状态码为 399 时表示将本次请求交给其他处理程序处理(即把这段 mruby 脚本当做中间件使用)。所以这段脚本的意思即“将所有的请求添加返回头 link: ; rel=preload; as=style
”。
H2O 会识别 mruby 脚本输出的 link
返回头,当基本条件成立时就会将对应的文件(示例中为“/test.css”)推送给客户端。
启动 H2O(因为监听了 80 和 443 端口,所以需要 sudo 权限),可以看到 H2O 帮我们自动搞定了 OCSP stapling
$ sudo h2o -c h2o.conf
[INFO] raised RLIMIT_NOFILE to 10240
h2o server (pid:15880) is ready to serve requests
fetch-ocsp-response (using OpenSSL 1.1.0g 2 Nov 2017)
fetch-ocsp-response (using OpenSSL 1.1.0g 2 Nov 2017)
sending OCSP request to http://ocsp.int-x3.letsencrypt.org
sending OCSP request to http://ocsp.int-x3.letsencrypt.org
/Users/Carter/.acme.sh/test.eoitek.net_ecc/fullchain.cer: good
This Update: Nov 25 14:00:00 2017 GMT
Next Update: Dec 2 14:00:00 2017 GMT
verifying the response signature
/Users/Carter/.acme.sh/test.eoitek.net_ecc/fullchain.cer: good
This Update: Nov 25 14:00:00 2017 GMT
Next Update: Dec 2 14:00:00 2017 GMT
verifying the response signature
verify OK (used: -VAfile /tmp/pszPkNSxUe/issuer.crt)
[OCSP Stapling] successfully updated the response for certificate file:/Users/Carter/.acme.sh/test.eoitek.net_ecc/fullchain.cer
verify OK (used: -VAfile /tmp/S5a00hOcQ6/issuer.crt)
[OCSP Stapling] successfully updated the response for certificate file:/Users/Carter/.acme.sh/test.eoitek.net_ecc/fullchain.cer
在 Chrome 浏览器中测试,Network 中带 Push / XXX
字样的“伪”请求即为由服务器推送到客户端的文件。
如果看得不很清楚,可以使用 nghttp
调试 HTTP/2
流量:
$ nghttp -nv https://test.eoitek.net # -n 表示丢弃返回体,-v 表示输出冗余调试信息
[ 0.008] Connected
The negotiated protocol: h2
[ 0.010] send SETTINGS frame
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[ 0.010] send PRIORITY frame
(dep_stream_id=0, weight=201, exclusive=0)
[ 0.010] send PRIORITY frame
(dep_stream_id=0, weight=101, exclusive=0)
[ 0.010] send PRIORITY frame
(dep_stream_id=0, weight=1, exclusive=0)
[ 0.010] send PRIORITY frame
(dep_stream_id=7, weight=1, exclusive=0)
[ 0.010] send PRIORITY frame
(dep_stream_id=3, weight=1, exclusive=0)
[ 0.010] send HEADERS frame
; END_STREAM | END_HEADERS | PRIORITY
(padlen=0, dep_stream_id=11, weight=16, exclusive=0)
; Open new stream
:method: GET
:path: /
:scheme: https
:authority: test.eoitek.net
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.28.0
[ 0.010] recv SETTINGS frame
(niv=1)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[ 0.010] recv WINDOW_UPDATE frame
(window_size_increment=16711681)
[ 0.010] recv SETTINGS frame
; ACK
(niv=0)
[ 0.010] recv (stream_id=13) :method: GET
[ 0.010] recv (stream_id=13) :scheme: https
[ 0.010] recv (stream_id=13) :authority: test.eoitek.net
[ 0.010] recv (stream_id=13) :path: /test.css
[ 0.010] recv (stream_id=13) accept: */*
[ 0.010] recv (stream_id=13) accept-encoding: gzip, deflate
[ 0.010] recv (stream_id=13) user-agent: nghttp2/1.28.0
[ 0.010] recv PUSH_PROMISE frame
; END_HEADERS
(padlen=0, promised_stream_id=2)
[ 0.010] recv (stream_id=2) :status: 200
[ 0.010] recv (stream_id=2) server: h2o/2.3.0-DEV
[ 0.010] recv (stream_id=2) link: ; rel=preload; as=style
[ 0.010] recv (stream_id=2) date: Sun, 26 Nov 2017 13:47:14 GMT
[ 0.010] recv (stream_id=2) date: Sun, 26 Nov 2017 13:47:14 GMT
[ 0.010] recv (stream_id=2) content-type: text/css
[ 0.010] recv (stream_id=2) last-modified: Sat, 25 Nov 2017 15:02:22 GMT
[ 0.010] recv (stream_id=2) etag: "5a1985fe-8a88"
[ 0.010] recv (stream_id=2) accept-ranges: none
[ 0.010] recv (stream_id=2) x-content-type-options: nosniff
[ 0.010] recv (stream_id=2) content-encoding: gzip
[ 0.010] recv (stream_id=2) vary: accept-encoding
[ 0.010] recv (stream_id=2) x-http2-push: pushed
[ 0.010] recv HEADERS frame
; END_HEADERS
(padlen=0)
; First push response header
[ 0.010] recv (stream_id=13) :status: 200
[ 0.010] recv (stream_id=13) server: h2o/2.3.0-DEV
[ 0.010] recv (stream_id=13) link: ; rel=preload; as=style
[ 0.010] recv (stream_id=13) date: Sun, 26 Nov 2017 13:47:14 GMT
[ 0.010] recv (stream_id=13) date: Sun, 26 Nov 2017 13:47:14 GMT
[ 0.010] recv (stream_id=13) content-type: text/html
[ 0.010] recv (stream_id=13) last-modified: Sat, 29 Jul 2017 13:49:11 GMT
[ 0.010] recv (stream_id=13) etag: "597c9257-28a"
[ 0.010] recv (stream_id=13) accept-ranges: none
[ 0.010] recv (stream_id=13) x-content-type-options: nosniff
[ 0.010] recv (stream_id=13) content-encoding: gzip
[ 0.010] recv (stream_id=13) vary: accept-encoding
[ 0.010] recv (stream_id=13) set-cookie: h2o_casper=AAAAAAADoA; Path=/; Expires=Tue, 01 Jan 2030 00:00:00 GMT; Secure
[ 0.010] recv HEADERS frame
; END_HEADERS
(padlen=0)
; First response header
[ 0.010] recv DATA frame
; END_STREAM
[ 0.010] recv DATA frame
; END_STREAM
[ 0.011] send GOAWAY frame
(last_stream_id=2, error_code=NO_ERROR(0x00), opaque_data(0)=[])
上面输出中可以很清楚的看到:HTTP/2 是通过发送数据帧来达成双端通信的。服务端给客户端推送了一个 PUSH_PROMISE
帧,表示服务器推送的文件,它一样带有返回头,只是没有请求头。
特别的,服务器给客户端发送了一个名为 h2o_casper 的 Cookie,这个 Cookie 就是用来标识客户端缓存的。H2O 通过这个 Cookie 识别已经给客户端推送过哪些文件(客户端缓存了哪些文件),从而最大限度避免浪费带宽。读者可能会发现再次刷新浏览器就算禁用缓存服务器也不会向客户端推送文件,就是这个 Cookie 在起作用。