HTTP/2 クライアント実装サンプル (非TLS)

(2018.6) 実装をやり直しました。だいぶスッキリ。

HTTP/2 を理解するために, 簡単に実装してみました。実用性を求めるわけではありません。

このページでは, 非TLS (平文) で接続します。

ソースコードの全部, Makefile はこちら; network · master · netsphere / my-cpp-lib · GitLab

実装の方針

Linux と Windows (mingw32/64) でテストしています。

出発点として, こちらを利用しました; HTTP2最速実装をmain()関数だけで簡単に説明する(非SSL編) HTTP/2 は, 滅多矢鱈に複雑なため, main() だけで実装するのは無理筋だと思います。ある程度関数を分けて, 処理を纏めています。

また, 現状では HTTP/1.1 サーバのほうが多いと思われるので, HTTP/1.1 101 Switching Protocols で, HTTP/2 に upgrade するようにしました。 [2022-06] RFC 9113 (June 2022) で, HTTP/1.1 からの upgrade は廃止された (obsolete). h2c upgrade token も廃止。

標準C++の流儀のために, ソケット通信は, basic_streambuf を拡張したものを使います; streambuf を拡張し, ソケット対応 [C++]

実行結果

以下で説明するコードで実行すると, 次のようになります。はい, できました!!

決め打ちで, http://nghttp2.org/ にアクセスしています。二つ目のリソースとして, /httpbin/ を取得します。

後述しますが, HEADERS フレームと DATA フレームを使って, リソースを取得します.

status_code = 101: HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
main:260 S payload length:0 bytes, type:SETTINGS, flags:00, stream_id:0
main:272 R payload length:18 bytes, type:SETTINGS, flags:00, stream_id:0
main:285 S payload length:0 bytes, type:SETTINGS, flags:01, stream_id:0
send_HEADERS_frame:197 S payload length:68 bytes, type:HEADERS, flags:04, stream_id:3
:authority: nghttp2.org
:method: GET
:path: /httpbin/
:scheme: http
recv_streams:79 R payload length:198 bytes, type:HEADERS, flags:04, stream_id:1
:status: 200
date: Sat, 30 Jun 2018 12:26:14 GMT
content-type: text/html
last-modified: Tue, 08 May 2018 13:53:22 GMT
etag: "5af1abd2-19d8"
accept-ranges: bytes
content-length: 6616
x-backend-header-rtt: 0.001403
server: nghttpx
via: 2 nghttpx
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
recv_streams:79 R payload length:6616 bytes, type:DATA, flags:01, stream_id:1
====DATA====

<!DOCTYPE html>
<!--[if IEMobile 7 ]><html class="no-js iem7"><![endif]-->
<!--[if lt IE 9]><html c====/DATA====
recv_streams:79 R payload length:0 bytes, type:SETTINGS, flags:01, stream_id:0
recv_streams:79 R payload length:83 bytes, type:HEADERS, flags:04, stream_id:3
:status: 200
date: Sat, 30 Jun 2018 12:26:14 GMT
content-type: text/html; charset=utf-8
content-length: 11299
access-control-allow-origin: *
access-control-allow-credentials: true
x-backend-header-rtt: 0.006455
server: nghttpx
via: 1.1 nghttpx
x-frame-options: SAMEORIGIN
x-xss-protection: 1; mode=block
x-content-type-options: nosniff
recv_streams:79 R payload length:11299 bytes, type:DATA, flags:01, stream_id:3
====DATA====
<!DOCTYPE html>
<html>
<head>
  <meta http-equiv='content-type' value='text/html;charset=utf8'>
  <m====/DATA====
main:321 S payload length:0 bytes, type:GOAWAY, flags:00, stream_id:0

実装

まず, main() 関数から。

接続

main()関数の最初から, 接続するところまで.

Http2Reader クラスは, 共通ルーチンのページを参照ください. (2018.6) SocketStreamBuf を使うよう, 書き直しました。

HTTP/2 では, TCP_NODELAY オプションを有効にします。

C++
[RAW]
  1. int main(int argc, char* argv[])
  2. {
  3. //------------------------------------------------------------
  4. // 接続先ホスト名.
  5. // HTTP2に対応したホストを指定します.
  6. //------------------------------------------------------------
  7. unique_ptr<URI> uri( URI::parse("http://nghttp2.org/", nullptr) );
  8. // Not HTTP/2
  9. //unique_ptr<URI> uri( URI::parse("http://www.nslabs.jp/", nullptr) );
  10. //------------------------------------------------------------
  11. // TCPの準備.
  12. //------------------------------------------------------------
  13. #ifdef _WIN32
  14. WSADATA wsaData;
  15. if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
  16. return 1;
  17. #endif
  18. int error = 0;
  19. // ホスト名を解決
  20. addrinfo* res = ngethostbyname(uri->getHost().c_str(), uri->getPort(),
  21. SOCK_STREAM);
  22. if ( !res ) {
  23. fprintf(stderr, "host not found: %s\n", uri->getHost().c_str());
  24. return 1;
  25. }
  26. SOCKET _socket = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
  27. if (_socket < 0) {
  28. fprintf(stderr, "Fail to create a socket.\n");
  29. freeaddrinfo(res);
  30. return 1;
  31. }
  32. // 必須. See https://http2.github.io/faq/
  33. // man 7 TCP
  34. int flag = 1;
  35. int r = setsockopt(_socket, IPPROTO_TCP, TCP_NODELAY, (char*) &flag, sizeof(flag));
  36. if ( r < 0 ) {
  37. error = get_error();
  38. //::shutdown(_socket, SHUT_RDWR);
  39. //close_socket(_socket);
  40. return 1;
  41. }
  42. if (connect(_socket, res->ai_addr, res->ai_addrlen) < 0) {
  43. fprintf(stderr, "connect() error.\n");
  44. freeaddrinfo(res);
  45. return 1;
  46. }
  47. // もういらない.
  48. freeaddrinfo(res);
  49. res = nullptr;
  50. SocketStreamBuf conn;
  51. conn.attach(_socket);
  52. Http2Reader reader;
  53. reader.attach(&conn);

HTTP/2 は, 過去との互換性のため, 80番ポートを使います。次のいずれかでHTTP/2として接続します。

  1. HTTP/1.1 のリクエストとして最初のリクエストを送信し, リクエストヘッダに Connection: Upgrade, HTTP2-Settings を含める。サーバが対応していれば, HTTP/2 にアップグレード.
  2. HTTP/2 の client connection preface を送信し, サーバから接続を切られた場合は, HTTP/1.1 として接続しなおす。

現状では, (1) の方法の方がいいと思う。

[2022-06] RFC 9113 (June 2022) で, HTTP/1.1 からの upgrade は廃止された (obsolete). h2c upgrade token も廃止。かならず (2) で実装せよ.

C++
[RAW]
  1. // HTTP/1.1 サーバの可能性が高い場合は, HTTP/1.1 から upgrade する.
  2. // HTTP2-Settings ヘッダは, ペイロードだけをbase64url するので、すべてデ
  3. // フォルト値なら空でよい.
  4. string upgrade = string("GET ") + uri->getPath() + " HTTP/1.1\r\n" +
  5. "Host: " + uri->getHost() + "\r\n" +
  6. "Connection: Upgrade, HTTP2-Settings\r\n" +
  7. "Upgrade: h2c\r\n" +
  8. "HTTP2-Settings: " + "" + "\r\n\r\n";
  9. r = conn.sputn(upgrade.c_str(), upgrade.length() );
  10. if ( r < 0 ) {
  11. error = get_error();
  12. conn.close();
  13. return 1;
  14. }
  15. // response が HTTP/1.1 101 Switching Protocols かどうか
  16. string line = reader.getline();
  17. int status_code = get_response(line);
  18. printf("status_code = %d: %s\n", status_code, line.c_str()); // DEBUG
  19. // HTTP/1.1 ヘッダを読みとばす
  20. while ( (line = reader.getline()) != "" )
  21. printf("%s\n", line.c_str());
  22. if (status_code != 101) {
  23. fprintf(stderr, "Not HTTP/2\n");
  24. return 1;
  25. }

connection preface 〜 リソース受信

接続を確立すると, まず, クライアントから, client connection preface として, 決まった文字列を送信します。

次に, 相互に SETTINGS フレームやりとりし, 通信条件を確認します。お互いに, ACK で受け取ったことを知らせます。

一つ目のリソースの要求は, 最初の HTTP/1.1 リクエストに含まれます。下の例では, 二つ目のリクエストを送っています。

send_HEADERS_frame() は, 共通ルーチンのページを見てください。

C++
[RAW]
  1. // まず最初に SETTINGS フレームを必ず交換します.
  2. // SETTINGS フレームを受信したら、設定を適用したことを伝えるために必ずACKを送ります.
  3. //
  4. // Client -> Server SettingsFrame
  5. // Client <- Server SettingsFrame
  6. // Client -> Server ACK
  7. // Client <- Server ACK
  8. //
  9. // Client -> Server HEADERS_FRAME (GETなど)
  10. // Client <- Server HEADERS_FRAME (ステータスコードなど)
  11. // Client <- Server DATA_FRAME (Body)
  12. //
  13. // Client -> Server GOAWAY_FRAME (送信終了)
  14. //------------------------------------------------------------
  15. // upgrade の場合でも, 送信必要.
  16. string pri = CLIENT_CONNECTION_PREFACE;
  17. r = conn.sputn( pri.c_str(), pri.length() );
  18. if ( r < 0 ) {
  19. error = get_error();
  20. conn.close();
  21. return 1;
  22. }
  23. //------------------------------------------------------------
  24. // client connection preface = Settingsフレーム (0x4) の送信.
  25. // 全てデフォルト値を採用するためpayloadは空です。
  26. // SettingsフレームのストリームIDは0です.(コネクション全体に適用されるため)
  27. // upgrade 前に送信しているが、再度送信が必須。
  28. // => これを送らないと, エラーになる.
  29. const char settingframe[FRAME_HDR_LENGTH] = {
  30. 0x00, 0x00, 0x00,
  31. 0x04, // SETTINGS
  32. 0x00, 0x00, 0x00, 0x00, 0x00 };
  33. dump_frame_header('S', settingframe);
  34. r = conn.sputn( settingframe, FRAME_HDR_LENGTH );
  35. if ( r < 0 ) {
  36. error = get_error();
  37. conn.close();
  38. return 1;
  39. }
  40. //------------------------------------------------------------
  41. // server connection preface = Settingsフレームの受信.
  42. //------------------------------------------------------------
  43. const char* frame = reader.read_frame();
  44. dump_frame_header('R', frame);
  45. assert(*(frame + 3) == 0x4); // SETTINGS
  46. // スキップ (手抜き)
  47. //int payload_length = 0;
  48. //ntoh3(frame, &payload_length);
  49. //bufptr += 9 + payload_length; readleft -= (9 + payload_length);
  50. //------------------------------------------------------------
  51. // ACKの送信.
  52. // ACKはSettingsフレームを受け取った側が送る必要がある.
  53. // ACKはSettingsフレームのフラグに0x01を立ててpayloadを空にしたもの.
  54. //
  55. dump_frame_header('S', settingframeAck);
  56. r = conn.sputn( settingframeAck, FRAME_HDR_LENGTH );
  57. if (r < 0 ) {
  58. error = get_error();
  59. conn.close();
  60. return 1;
  61. }
  62. // 二つ目のファイルを要求 -- これがないと, クライアントからの送信が正しいか確認できない
  63. map<string, string> m;
  64. m.insert(make_pair(":method", "GET"));
  65. m.insert(make_pair(":path", "/httpbin/") );
  66. m.insert(make_pair(":scheme", "http"));
  67. m.insert(make_pair(":authority", uri->getHost()));
  68. r = send_HEADERS_frame(&conn, 3, m);
  69. if ( r < 0 ) {
  70. int error = get_error();
  71. conn.close();
  72. return 1;
  73. }
  74. // ストリームを受信する.
  75. recv_streams(reader);
  76. recv_streams(reader);

recv_streams() については, 共通ルーチンのページをご覧ください。

後処理

送受信が終わったら, 後始末.

C++
[RAW]
  1. //------------------------------------------------------------
  2. // GOAWAYの送信.
  3. // これ以上データを送受信しない場合は GOAWAYフレーム (0x7) を送信します.
  4. // ストリームIDは「0x00」(コネクション全体に適用するため)
  5. //------------------------------------------------------------
  6. static const char goawayframe[17] = {
  7. 0x00, 0x00, 0x00,
  8. 0x07, // GOAWAY
  9. 0x00, 0x00, 0x00, 0x00, 0x00,
  10. 0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00 };
  11. dump_frame_header('S', goawayframe);
  12. r = conn.sputn( goawayframe, 17 );
  13. if ( r < 0 ) {
  14. error = get_error();
  15. conn.close();
  16. return 1;
  17. }
  18. //------------------------------------------------------------
  19. // 後始末.
  20. //------------------------------------------------------------
  21. conn.close();
  22. return 0;
  23. }