Expressでのhttp2 + server pushを使う

nodejsのExpressをhttp2サポートして、server pushを試したときのメモ。

今回は手元でサクッと試すので、自己証明書を使う。

自己証明書の作成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
openssl genrsa -h
usage: genrsa [args] [numbits]
-des encrypt the generated key with DES in cbc mode
-des3 encrypt the generated key with DES in ede cbc mode (168 bit key)
-seed
encrypt PEM output with cbc seed
-aes128, -aes192, -aes256
encrypt PEM output with cbc aes
-out file output the key to 'file
-passout arg output file pass phrase source
-f4 use F4 (0x10001) for the E value
-3 use 3 for the E value
-engine e use engine e, possibly a hardware device.
-rand file:file:...
load the file (or the files in the directory) into
the random number generator

秘密鍵作成を作成

1
2
# openssl genrsa -des3(暗号化アルゴリズム) -passout pass:(パスワード) -out (秘密鍵ファイル名)(キー長)
openssl genrsa -des3 -passout pass:password -out keys/private.key 2048

秘密鍵を確認する

1
2
3
4
5
6
7
8
9
openssl rsa -text < private.key
Enter pass phrase:

Private-Key: (2048 bit)
modulus:
...
...
...
-----END RSA PRIVATE KEY-----

秘密鍵からRSA公開鍵の作成

1
2
3
# openssl rsa -in (秘密鍵ファイル名) -pubout -out (公開鍵ファイル名) -passin pass:(パスワード)
openssl rsa -in private.key -pubout -out public.key -passin pass:password
writing RSA key

CSRを作成

1
2
3
4
5
6
7
8
9
10
# openssl req -new -key (秘密鍵ファイル名) -out (CSRファイル名)
openssl req -new -key private.key -out server.csr

openssl req -text < server.csr
Certificate Request:
Data:
...
...
...
-----END CERTIFICATE REQUEST-----

証明書(CRT)を作成

1
2
# 365日有効な証明書を作成
openssl x509 -req -days 365 -signkey private.key < server.csr > server.crt

expressセットアップ

続いでexpressのセットアップ。ES nextでサーバーを書くのでbabel-nodeを使ってexpressを起動する
(ちなみにproductionではoverheadが大きいので使うことは推奨されていない)

1
npm i -D babel-cli babel-preset-latest

http2のモジュールはnode-http2Expressで動かない。(Express ver5では対応されるはず)
spdyを使ってhttp2に対応させる。

1
npm i -S express spdy morgan file-stream-rotator

node_http2の場合の実装例

ちなみにnode_http2を使った場合は以下の感じで対応するようになる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import htttp2 from 'http2';
option = {
key: fs.readFileSync('./example/localhost.key'),
cert: fs.readFileSync('./example/localhost.crt'),
passphrase: 'password'
}

http2.createServer(options, (req, res) => {
// pushするリソースのStreamを作り、データを書き込んで送信する。
if (res.push) {
const push = res.push('/pushed.js');
push.writeHead(200);
fs.createReadStream(path.join(__dirname, '/pushed.js')).pipe(push);
}
fs.createReadStream(path.join(__dirname, '/client.js')).pipe(res);
});

Expressサーバーを作成する。
実際の作成したものはこちらから

エントリポイント(index.js)は以下のようになる

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import express from 'express';
import spdy from 'spdy';
import morgan from 'morgan';
import path from 'path';
import fs from 'fs';
import FileStreamRotator from 'file-stream-rotator';

import routes from './routes/index.js'

// when index.js execute app/index.js
const rootDir = __dirname + '/..';
const viewPath = `${rootDir}/app/views`;

const port = 3000;
const app = express();
const options = {
key: fs.readFileSync(`${rootDir}/keys/private.key`), // private key
cert: fs.readFileSync(`${rootDir}/keys/server.crt`), // cert file
passphrase: 'password'
};
const logDir = path.join(`${rootDir}/app/log`);
const accessLogOption = {
date_format: 'YYYYMMDD',
filename: path.join(logDir, 'production-%DATE%.log'),
frequency: 'daily',
verbose: false
};

app.use(express.static('app/views'));
app.use(express.static('app/public'));
app.use('/', routes);

fs.existsSync(logDir) || fs.mkdirSync(logDir);
if(app.get('env') === 'production') {
const accessLogStream = FileStreamRotator.getStream(accessLogOption);
app.use(morgan({ format: 'common', stream: accessLogStream }));
} else {
app.use(morgan({ format: 'dev', date: 'clf', immediate: true }));
app.use(morgan({ format: 'common' }));
}

spdy.createServer(options, app)
.listen(port, (error) => {
if (error) {
console.error(error);
return process.exit(1);
} else {
console.log(`Listening on port: ${port}.`);
}
});

module.exports = app;

サーバーをビルドして起動できるるようににnpm scriptを追加

1
2
3
4
5
6
7
8
9
10
{
...
"scripts": {
"build": "babel --presets es2015 -d dist src",
"prestart": "npm run build",
"start": "node dist/index.js",
"dev": "babel-node --debug --presets es2015 -- src/index.js --debug"
},
...
}

curl with http2

curlでhttp2出来るようにlibcurlにnghttp2オプションを追加する

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
brew install curl --with-nghttp2
brew link curl --force

brew info curl
curl: stable 7.50.3 (bottled) [keg-only]
Get a file from an HTTP, HTTPS or FTP server
https://curl.haxx.se/
/usr/local/Cellar/curl/7.50.3 (366 files, 2.6M) *
Built from source on 2016-09-18 at 16:08:22 with: --with-nghttp2
From: https://github.com/Homebrew/homebrew-core/blob/master/Formula/curl.rb
==> Dependencies
Build: pkg-config ✔
Optional: openssl ✔, libidn ✘, rtmpdump ✘, libssh2 ✘, c-ares ✘, libmetalink ✘, libressl ✘, nghttp2 ✔
==> Options
--with-c-ares
Build with C-Ares async DNS support
--with-gssapi
Build with GSSAPI/Kerberos authentication support.
--with-libidn
Build with support for Internationalized Domain Names
--with-libmetalink
Build with libmetalink support.
--with-libressl
Build with LibreSSL instead of Secure Transport or OpenSSL
--with-libssh2
Build with scp and sftp support
--with-nghttp2
Build with HTTP/2 support (requires OpenSSL or LibreSSL)
--with-openssl
Build with OpenSSL instead of Secure Transport
--with-rtmpdump
Build with RTMP support
...

ということで、http2できるのか試して見る。

```sh
curl -I --http2 https://www.cloudflare.com/

HTTP/2 200
date: Sun, 18 Sep 2016 07:26:23 GMT
content-type: text/html
set-cookie: __cfduid=dc330f6d51f40d20cfc7056c62e193fc21474183583; expires=Mon, 18-Sep-17 07:26:23 GMT; path=/; domain=.cloudflare.com; HttpOnly
last-modified: Wed, 14 Sep 2016 17:39:07 GMT
cf-cache-status: HIT
expires: Sun, 18 Sep 2016 11:26:23 GMT
cache-control: public, max-age=14400
server: cloudflare-nginx
cf-ray: 2e4311c74c242e09-NRT
cf-h2-pushed: </js/jquery-2.1.4-min.js>

http2を確認

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
npm start

curl -kiv https://127.0.0.1:3000/
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 3000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
* CAfile: /usr/local/etc/openssl/cert.pem
CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS handshake, Server key exchange (12):
* TLSv1.2 (IN), TLS handshake, Server finished (14):
* TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
* TLSv1.2 (OUT), TLS change cipher, Client hello (1):
* TLSv1.2 (OUT), TLS handshake, Finished (20):
* TLSv1.2 (IN), TLS change cipher, Client hello (1):
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
* ALPN, server accepted to use h2
* Server certificate:
* subject: C=AU; ST=Some-State; O=Internet Widgits Pty Ltd
* start date: Sep 17 14:59:31 2016 GMT
* expire date: Sep 17 14:59:31 2017 GMT
* issuer: C=AU; ST=Some-State; O=Internet Widgits Pty Ltd
* SSL certificate verify result: self signed certificate (18), continuing anyway.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fd47080e200)
> GET / HTTP/1.1
> Host: 127.0.0.1:3000
> User-Agent: curl/7.50.3
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS updated)!
< HTTP/2 200
HTTP/2 200
< x-powered-by: Express
x-powered-by: Express
< content-type: application/json; charset=utf-8
content-type: application/json; charset=utf-8
< content-length: 16
content-length: 16
< etag: W/"10-RUik8aEaJBEg+XocNzeBeg"
etag: W/"10-RUik8aEaJBEg+XocNzeBeg"
...

HTTP/2 200 でレスポンス確認できた。
引き続きServer Pushの対応。

Server Push

実装的には、レスポンスとして返却されるhtmlファイルからjsやcssファイルを抽出し、
新しいStreamを作成して、データを書き込み送信する。

ざっくりと以下のようなミドルウェアを作成にする。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// viewPath はviewファイルへのパス

router.get('/push', (req, res) => {
const html = fs.readFileSync(`${viewPath}/index.html`);

// files = { 'filename': [ { 'path': 'file/to/path', 'mime': 'file-mime-type' } ]}
const pushFIles = files[`${viewPath}/index.html`];
pushFIles.forEach((file) => {
const option = Object.assign(stremOption, {'response': {'content-type': file.mime}});

// create push stream
const stream = res.push(`${publicPath}${file.path}`, option);

stream.on('error', (error) => {
console.error(error);
});
stream.end();
});

res.end(html);
});

Chromeでnet-internals(chrome://net-internals/#events)を使って確認する。
/push にアクセスして、chrome://net-internals/#eventsからHTTP2_SESSIONを確認。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
t=3850 [st=10]  HTTP2_SESSION_RECV_PUSH_PROMISE
--> :method: GET
:path: /app/public//js/main.js
:scheme: https
:authority: localhost:3000
accept: */*
--> id = 7
--> promised_stream_id = 6
t=3850 [st=10] HTTP2_SESSION_RECV_PUSH_PROMISE
--> :method: GET
:path: /app/public/js/app.js
:scheme: https
:authority: localhost:3000
accept: */*
--> id = 7
--> promised_stream_id = 8

** HTTP2_SESSION_RECV_PUSH_PROMISE ** となりサーバーからpushされていることがわかる。

Expressでのhttp2とserver push は想像よりも簡単に対応できるのかもしれないという気がした。

作成したサンプル

express-with-http2-and-server-push-example

参考にしたページ

Optimize Your App with HTTP/2 Server Push Using Node and Express
node-spdy
HTTP/2 with curl
Using cURL with HTTP/2 on Mac OS X

Comments