外向きの HTTP
別のサーバー(Webhook、Slack の Incoming Webhook、サードパーティの API)を呼び出すアプリケーションは Cybertrain::HttpClient を使います。これは require "cybertrain" には含まれていません。使う場所で require し、https の URL があれば TLS の層も require してください:
require "json"
require "cybertrain/http_client"
require "cybertrain/net/tls" # https:// のときだけ。システムの libssl をリンクする
client = Cybertrain::HttpClient.new(timeout: 5, deadline: 15, max_body: 1 << 20)
begin
res = client.post(ENV["SLACK_WEBHOOK_URL"], JSON.generate({ "text" => "Deployed" }),
content_type: "application/json")
Cybertrain.logger.warn("slack answered #{res.status}") unless res.success?
rescue Cybertrain::HttpClient::Error => e
Cybertrain.logger.warn("slack: #{e.message}") # host:port は示すが、パスは決して含まない
end
res = client.get("https://api.example.com/v1/status", headers: { "Authorization" => "Bearer #{token}" })
res.status # 200
res.header("ETag") # 名前の大文字小文字は問わない。res.headers では小文字
res.body # バイト列。チャンク形式は取り除かれている
これがある理由:Spinel の Net::HTTP は、TLS ハンドシェイクと最初のバイト以降のすべての読み取りを、ブロッキングする C の呼び出しとして行います。そのそれぞれが、止まったエンドポイントが望むだけ長く OS のワーカーを占有し、デフォルトの SPINEL_WORKERS=1 ではそれが Web サーバー全体です。HttpClient がネットワークでワーカーをブロックすることは決してありません:
- すべての待ちに上限がある。 接続(ノンブロッキングの connect。Spinel の
TCPSocket.newはconnect_timeout:を無視します)、TLS ハンドシェイクの各手順、各読み取り、各書き込みは最大timeout秒(デフォルトは 5)待ち、リクエスト全体では最大deadline秒(デフォルトは 15)待ちます。どちらかを過ぎるとHttpClient::TimeoutErrorです。読み取りはread_nonblockで満たされるバイトバッファーを通るので、すでに受け取ったバイトを待つことはありません。 - 名前解決はブロックする。 ランタイムには
getaddrinfoのノンブロッキング版がありません:リクエストを行うスレッドは DNS を待ちます(通常は数ミリ秒ですが、リゾルバーが死んでいるとシステムのタイムアウト、1 回の試行につき約 5 秒かかることがあります)。デッドラインはその時間も数えますが、中断はできません。 - 公開されていない宛先は拒否される。 何かを送る前に:ループバック(
127.0.0.0/8、::1)、プライベート(10/8、172.16/12、192.168/16、fc00::/7)、リンクローカル(クラウドのメタデータサービスを含む169.254/16、fe80::/10)、未指定(0.0.0.0/8、::)、マルチキャスト、ブロードキャスト、CGNAT(100.64/10)、ドキュメント用とベンチマーク用の範囲、そしてそれらの IPv4 射影、NAT64、6to4 の形式です(Cybertrain::Net::Address.blocked_reason)。ホスト名は一度だけ解決され、解決されたアドレスがそのまま検査され、接続先になります(再び問い合わせることはありません)。そのため、プライベートなアドレスに解決される名前(/etc/hostsのエントリー、DNS リバインディング)も、リテラルと同じように拒否されます。TLS 証明書は引き続き名前に対して検証されます。検査はアドレスごとです:拒否されたアドレスはスキップされ、すべてのアドレスが拒否されたときにだけリクエストが失敗します。HttpClient::BlockedAddressErrorがその理由を伝えます。自分のネットワーク上のサービスや、テストでローカルのサーバーを呼び出すには、config/app.rbでc.http_allow_private = trueを設定する(またはCYBERTRAIN_HTTP_ALLOW_PRIVATE=1)か、HttpClient.newにallow_private: trueを渡します。URL がユーザーから来る可能性があるときは、常に無効のままにしてください。 - 解決されたすべてのアドレスを試す。 リゾルバーが返した順に、IPv6 も IPv4 も同じように試します:あるアドレスが拒否する、到達できない、リセットする、
timeout以内に応答しない場合は次のアドレスに接続し、そのすべてをdeadline以内に行います。すべてが失敗すると、最後のアドレスのエラーを出します。これによって macOS のlocalhostが動きます。macOS ではlocalhostはまず::1に解決され、127.0.0.1で待ち受けているサーバーはそれを拒否するからです。Cybertrain::Mailerも同じ方法で接続します。 - TLS は openssl パッケージのものです:オペレーティングシステムの信頼ストアに対する
VERIFY_PEER(SSL_CERT_FILE/SSL_CERT_DIRで別の場所を指せます。パッケージにはカスタムのストアのためのX509::Storeがありません)、ホスト名の検査、SNI、TLS 1.2 以降。https の URL にはホスト名が必要です:IP アドレスは証明書の名前に一致しません。require "cybertrain/net/tls"がないと、https のリクエストはその旨を伝えるHttpClient::TLSErrorを出します。cybertrain/http_clientだけでは libssl をリンクすることはありません。 - ブラウザーではなく、通知用のクライアント:
Connection: close付きの HTTP/1.1 で、リクエストごとに接続は 1 つ、keep-alive なし、リダイレクトなし(3xx はそのまま返されます)、プロキシなし、cookie なし、圧縮なしです。レスポンスはContent-Length、チャンク形式、または接続が閉じるまでの読み取りのいずれでもかまいません。ヘッダーは 64 KiB かつ 100 行、ボディはmax_body(デフォルトは 1 MiB)が上限で、それを超えるとHttpClient::TooLargeErrorです。HTTP/1.x でない、または途中で終わるレスポンスはHttpClient::BadResponseError、拒否された・リセットされた接続や、解決できない名前はHttpClient::RefusedErrorです。TLS ハンドシェイク中にリセットされた・閉じられた接続(TLS を話さないサーバー)は、OpenSSL が拒否したハンドシェイクと同じく、どのプラットフォームでもHttpClient::TLSError(「TLS handshake failed」)です。これらはすべてHttpClient::Errorです。 - エラーにパスやクエリが含まれることはない。 Webhook の URL はそこに秘密を持っているからです:メッセージが示すのはホストとポートだけです。形式の壊れた URL や CR/LF を含むヘッダーは
ArgumentErrorで、これもその内容を繰り返しません。Host、Connection、Content-Length、Transfer-Encodingはクライアントが管理し、User-Agentのデフォルトはcybertrain/<version>です。
リクエストは呼び出したスレッドで実行されます。これは配信中のリクエストと同じくグリーンスレッドなので、待っている間も他の接続は配信され続けます。ソケットの層(cybertrain/net/socket.rb:上限付きの connect、バッファー付きの読み取り、上限付きの書き込み)は HTTP の層から分かれているので、他のプロトコルからも使えます。設計:docs/design.md D24。
WebSocket フィードを購読する
サーバーの動作中に外部のフィード(市場のティック、デバイスのテレメトリー、イベントストリーム)を受信するアプリケーションは、Cybertrain::WebSocket.subscribe でそれを購読します。HTTP クライアントと同じく require "cybertrain" には含まれていません。購読を書く場所で require し、wss:// には TLS の層も require してください:
# config/app.rb(または bin/<app>.rb が Cybertrain::Main.run の前に require するファイル)
require "json"
require "cybertrain/websocket"
require "cybertrain/net/tls" # wss:// のときだけ。システムの libssl をリンクする
Cybertrain::WebSocket.subscribe("trades", ENV["FEED_URL"],
headers: { "Authorization" => "Bearer #{ENV["FEED_TOKEN"]}" },
stale_after: 30)
.on_open { |conn| conn.send_text(JSON.generate({ "op" => "subscribe", "channel" => "trades" })) }
.on_message { |conn, text| Cybertrain::DB.with { |c| c.execute("INSERT INTO trades(raw) VALUES (?)", [text]) } }
cybertrain/websocket を require すると、フィードが Cybertrain::Background に登録されます。Application#serve がスケジューラーの後にこれを開始し、シャットダウン時に停止するので、呼び出すものは何もありません。購読ごとに 1 本のスレッドが付きます。スレッドは接続し、on_open を実行し(購読のメッセージはここで送ってください:再接続のたびに再び実行されます)、その後、各メッセージを Connection(send_text、send_binary、close、そして :text か :binary の last_opcode)とともに on_message に渡します。ブロックは引数を受け取り、self は使えません(Spinel には instance_eval がありません)。
- いつ実行されるか。 フィードは、スケジューラーと同じく、バイナリが配信を行うときに始まります(バックグラウンド処理)。
migrate、db ...、Main.commandのコマンドとCybertrain::Test::Clientでは決して始まりません。CYBERTRAIN_WORKER=0(config.worker = false)では、そのプロセスでは動きません(単に始まらないだけです。唯一の知らせはスケジューラーの「run elsewhere」の行で、WebSocket.start!を直接呼んだ場合は自分でログを出します):複数のプロセスが配信する場合は、1 つを除いてすべてで無効にしてください。そうしないと、それぞれがフィード全体を受信します。subscribeはサーバーが始まる前に呼ぶ必要があります。動作中にフィードを追加したり削除したりすることはできません。名前は一意で(ArgumentError)、on_messageのないフィードは、ポートをバインドする前に起動を失敗させます(error: WebSocket subscription "trades" has no on_message、終了コード 1)。 - オプション。
headers:(ハンドシェイクに追加するヘッダー)、timeout:(接続中の各待ちと各書き込みに 5 秒)、max_message:(1 MiB。それより大きいメッセージは 1009 で接続を閉じます)、ping_interval:(15)とstale_after:(45)、そしてallow_private:(下記のHttpClientと同じ)。 - 待機と再接続。 失敗のたびに、スレッドは
min(60, 2 ** (attempt - 1))秒、前後 20 % のジッター付きで待ちます:1、2、4、... 60。60 秒続いた接続は、再び 1 から始まります。すべての待ちに上限があります。WebSocket.stop!は、サーバーが何をしても約 2 秒以内に戻ります:すべてのスレッドを合わせて最大 2 秒待ち、接続中のフィードはクローズ(1000)を送ってサーバーの応答を最大 1 秒待ち、戻った時点ですべてのフィードのconnected?は false です(まだ閉じている途中のスレッドは自分で終わります)。 - 無音。 フィードは接続を閉じないまま静かになることがあるので、スレッドはフレームが何もないまま
ping_interval秒たつと ping を送り(その後は間隔ごとに 1 回)、pong も含めてフレームがstale_after秒まったく届かなければ、接続を閉じて再接続します。stale_afterはping_intervalより大きくなければならず、ping_intervalは 1 以上でなければなりません(subscribeからのArgumentError)。 - ブロック内のエラー。
on_messageでの例外はログに記録され、接続はそのままです。on_openでの例外はログに記録され、接続は閉じられて再試行されます。購読していないフィードは役に立たないためです。 -
ログに出るもの。
いつ レベル 行 開始 INFO WebSocket started: trades (wss://feed.example.com:443)接続 INFO WebSocket "trades" connected to feed.example.com:443切断 WARN WebSocket "trades" disconnected: Cybertrain::WebSocket::ClosedError: feed.example.com:443: closed by the server (1001) (retry in 2s)無音 WARN WebSocket "trades" silent for 45s; reconnectingブロックが例外を出した ERROR WebSocket "trades" on_message failed: KeyError: ...(またはon_open)パスとクエリがログやエラーメッセージに出ることは決してありません:トークンを含んでいることが多いからです。
- エラー。 すべて
Cybertrain::WebSocket::Errorで、メッセージにはホストとポートだけが含まれます。ハンドシェイクが済んだ後は、失われた接続やサーバーによるクローズはClosedError(close_code、close_reason。リセットやクローズフレームなしの終了は 1006)です。タイムアウトした書き込みや TLS で失敗した書き込みは、引き続きTimeoutErrorやTLSErrorです。接続中はRefusedError、TimeoutError、TLSError、BlockedAddressError(公開されていないアドレスで、外向きの HTTP とまったく同じくデフォルトで拒否され、同じallow_private:とhttp_allow_privateの設定を持ちます)、またはHandshakeError(101 でない、Sec-WebSocket-Acceptが誤っている、またはサーバーが拡張やサブプロトコルで応答した)です。ProtocolError(サーバーがフレーミングを壊した。クライアントは 1002 を送ります)とTooLargeErrorはいつでも起こりえます。 - ヘルス。
Cybertrain::WebSocket.feed_status("trades")はStatusを返します:connected?、last_message_at(エポック秒。最初のメッセージの前は 0)、reconnects。これはGET /upには含まれません。自前のチェックで使ってください。3 つは 1 つのスナップショットとしてではなく順に読まれ、connected?は接続ができた時点、on_openの実行前に true になります。 on_messageは短く保つ。 スレッドはwait_readableかsleepで待つので、止まったフィードが待っている間 OS のワーカーを占有することはありません。占有するものは 2 つあります:各接続の試行はブロッキングするgetaddrinfoでホスト名を解決します(外向きの HTTP の「名前解決はブロックする」を参照。SPINEL_WORKERS=1では、死んだリゾルバーがフィードごと・試行ごとに 1 回、リゾルバーのタイムアウトの間すべてのリクエストを止めます。URL に IP アドレスを書けば問い合わせを避けられます)。そしてon_messageの中の SQLite の呼び出しは、定期タスクと同じく戻るまで占有します(バックグラウンド処理):SPINEL_WORKERS=1では、長い書き込みがすべてのリクエストを止めます。忙しいフィードでは小さな行を 1 つ挿入するか、メッセージをメモリーに集めてScheduler.everyのタスクからまとめて書き込んでください。- 作られていないもの。 拡張(
permessage-deflate)とサブプロトコルはネゴシエートされません。プロキシ、リダイレクト、cookie のサポートも、メッセージの自動保存も、WebSocket サーバー(ActionCable)もありません。テキストフレームが有効な UTF-8 かは検査されません(RFC 6455 の 8.1 節からの逸脱):バイト列は届いたまま渡されます。Connectionが有効なのはコールバックの間だけです。リクエストやタスクから送信することはサポートされていません。
再接続のループなしの 1 本の接続(スクリプト、1 回だけの読み取り)には、同期的なクライアントを使います:
require "cybertrain/websocket"
require "cybertrain/net/tls" # wss:// のときだけ
client = Cybertrain::WebSocket::Client.connect("wss://feed.example.com/v1", timeout: 5)
client.send_text("hello")
msg = client.receive(10) # 最大 10 秒待つ。何も来なければ nil
msg.ws_opcode # :text または :binary
msg.ws_data # バイト列
client.close
設計:docs/design.md D33。
メールを送る
Cybertrain::Mailer は、SMTP サーバー(プロバイダーのサブミッションサーバー、またはローカルのリレー)を通じてプレーンテキストのメールを送ります。HTTP クライアントと同じく require "cybertrain" には含まれておらず、TLS は別に require します:
require "cybertrain/mailer"
require "cybertrain/net/tls" # starttls(デフォルト)と tls のため。システムの libssl をリンクする
# 設定は CYBERTRAIN_SMTP_HOST、_PORT、_USER、_PASSWORD、_FROM、_TLS から:
begin
Cybertrain::Mailer.deliver(to: user.email, subject: "Welcome", body: "Hello #{user.name},\n...")
rescue Cybertrain::Mailer::Error => e
Cybertrain.logger.warn("mail: #{e.message}") # パスワードは決して含まない
end
# または、Config を経由せず明示的に設定する:
mailer = Cybertrain::Mailer.new(host: "smtp.example.com", port: 587, tls: "starttls",
user: "app", password: ENV["SMTP_PASSWORD"].to_s,
from: "noreply@example.com", timeout: 10, deadline: 60)
mailer.deliver(to: "a@example.com, b@example.com", subject: "Report", body: report)
| 変数 | 属性 | デフォルト |
|---|---|---|
CYBERTRAIN_SMTP_HOST |
smtp_host |
""(設定されるまで Mailer.new は ArgumentError を出す) |
CYBERTRAIN_SMTP_PORT |
smtp_port |
0:starttls なら 587、tls なら 465、none なら 25 |
CYBERTRAIN_SMTP_USER |
smtp_user |
"":AUTH なし |
CYBERTRAIN_SMTP_PASSWORD |
smtp_password |
""(秘密の値) |
CYBERTRAIN_SMTP_FROM |
smtp_from |
"" |
CYBERTRAIN_SMTP_TLS |
smtp_tls |
"starttls"。tls または none |
- TLS のモード。
starttlsは平文で接続し、サーバーが STARTTLS を提供することを求め(なければMailer::TLSError)、他に何かを話す前にアップグレードします。サーバーが STARTTLS の応答の後に送ってきたバイトは拒否されます。tlsは最初のバイトから TLS を話します(ポート 465)。どちらも HTTP クライアントと同じく証明書をhostに対して検証し(IP アドレスではなくホスト名を指定してください)、どちらにもrequire "cybertrain/net/tls"が必要です:それがないと、deliverは接続する前に、その旨を伝えるMailer::TLSErrorを出します。noneは平文で、同じマシン上のリレーやテストサーバー向けです:ユーザーが設定されている場合、ホストがループバックアドレスに解決されない限り拒否し(接続する前にMailer::InsecureError)、解決される場合はそのループバックアドレスにだけ接続します。 - パスワードがログに出たり表示されたりすることはない。 起動バナー、
help、ログ、どのエラーメッセージにも含まれず、送られるのは TLS 越しに(またはループバックへ)AUTH PLAIN コマンドの中だけです。パスワードを保持しているMailerやConfigをpやinspectしないでください。 - すべての待ちに上限がある。 外向きの HTTP と同じく:接続、TLS の各手順、各応答は最大
timeout秒(デフォルトは 10)待ち、配信全体では最大deadline秒(デフォルトは 60)待ちます。それを超えるとMailer::TimeoutErrorです。名前解決は呼び出したスレッドをブロックします。 - やり取り: 220 の挨拶、
EHLO(名前はhelo:。デフォルトはlocalhost)、STARTTLSと再度のEHLO、ユーザーが設定されていればAUTH PLAIN(サーバーが PLAIN を提供する必要があります)、MAIL FROM、受信者ごとに 1 つのRCPT TO、DATA、QUIT。それ以外のコードの応答はMailer::RejectedErrorで、そのsmtp_codeはサーバーのもの(421、535、550 など)、メッセージには手順とサーバーのテキストが含まれます。戻り値は、メッセージに対するサーバーの応答です("250 2.0.0 Ok: queued as ...")。 - メッセージ:
Date、From、To、Subject、MIME-Version: 1.0、Content-Type: text/plain; charset=UTF-8。ASCII 以外を含む件名は RFC 2047 でエンコードされます(=?UTF-8?B?...?=。文字の境界で分割されます)。本文の改行は CRLF になり、.で始まる行はドットスタッフィングされます。Content-Transfer-Encodingは、ASCII なら7bit、UTF-8 でサーバーが 8BITMIME を広告していれば8bit(MAIL FROM ... BODY=8BITMIME付き)、それ以外や 998 バイトより長い行がある場合はbase64です。Message-IDは追加しません(サブミッションサーバーが追加します)。 - 拒否されるもの。 接続する前に
ArgumentErrorで:素の ASCII のlocal@domainでないアドレス(表示名、<>、空白、カンマ、引用符、CR、LF は不可。最大 254 バイト)、CR、LF、NUL を含む(ヘッダーインジェクション)または 998 バイトを超える件名、有効な UTF-8 でない、または NUL を含む本文、100 を超える受信者。to:は 1 つのアドレス、またはカンマで区切った複数のアドレスを受け取ります。 - まだないもの: HTML とマルチパートのメッセージ、添付ファイル、テンプレート、表示名、
Cc/Bccヘッダー(代わりに複数の受信者に送ってください)、AUTH LOGIN/CRAM-MD5、SMTPUTF8 のアドレス、パイプライン、配信キュー(配信は呼び出したスレッド、つまりリクエストと同じくグリーンスレッドで実行されます)。cybertrain generate authenticationは今もパスワードのリセットを書き出しません。
設計:docs/design.md D27。