Erlangは数年前にほんの少し書いたことがあるだけなのですが、会社では同じフロアにErlangをガリガリ書く部隊1がいます。彼らにちょっと教わりつつ久しぶりに書いてみました。
はじめに
最近のマイブームは映像や音声の配信でおなじみRTMPなので、この記事ではErlangでRTMPを取り扱ってみようかと思います。本当はデータの中身をちゃんと取り扱う所までを書きたかったのですが作業時間があまりなく、対象をハンドシェイクと呼ばれるプロトコル初期のフェーズまでに限定します。
RTMPとは
Adobe社が制定したReal Time Messaging Protocolのことです。その成り立ちからFlashと相性がよく、音声や動画の配信によく用いられる2プロトコルです。
RTMPは仕様が公開されているので、誰でもプロトコルを理解・解析することができます。
http://wwwimages.adobe.com/content/dam/Adobe/en/devnet/rtmp/pdf/rtmp_specification_1.0.pdf
次節で、RTMPの簡単な構造について述べます。
RTMP入門
RTMPはざっくり言うと、一つのTCPコネクション上で複数のデータストリームを同時送信するためのプロトコルです。例えば音声と映像と制御情報を多重化して送信し、それらの時刻同期をとりながら、受信側で多重分離することで各ストリームを再構成できます。
Chunk Stream
RTMPにおいて通信上の基本単位3になるのがチャンク(chunk)です。
チャンクを単位にした通信については、RTMPのなかでも下位レイヤであるRTMP Chunk Stream protocolとして定義されています4。
各チャンクにはストリーム固有のIDが紐付けられていて、例えばID=10は映像、ID=20は音声といったように、別々の要素をチャンク単位で混ぜて一つのストリームにしつつ、送出することができます。IDで区別されるチャンクの並びのことをチャンクストリーム(chunk stream)と呼びます。
ハンドシェイクは?
……というような説明を一応書いてみましたが、実はハンドシェイクについては、通常のチャンク処理とはだいぶ異なる動きをします。
TCPによる接続の確立後、RTMPレイヤにおけるハンドシェイクが行われます。
ハンドシェイクでは、クライアント側はC0、C1、C2と呼ばれるチャンクを、サーバ側はS0、S1、S2と呼ばれるチャンクを、定められたルールに基づいて投げ合います。流れとしては次のような感じになります。
- クライアントはC0、C1をサーバに投げる
- サーバはC0の受信後にS0、S1をクライアントに投げる。ただしC1の受信後であってもよい
- クライアントはS1の受信後にC2をサーバに投げる
- サーバはC1の受信後にS2をクライアントに投げる
一見とてもややこしいように見えますが、クライアントからするとC0/C1を投げたあと、S0/S1を受け取ってC2を投げるだけです。後述しますが、もともとC2はS1を受け取らない限りは作れないようになっています。したがって自然に守れるルールです。
6つのchunkのフォーマットについては、次のように規定されています。エンディアンはどれもビッグエンディアン5です。
chunk | フォーマット | オクテット数 | 内容 |
---|---|---|---|
C0/S0 | version | 1 | プロトコルのバージョン。現在は3です6 |
C1/S1 | time | 4 | タイムスタンプ7 |
zero | 4 | ゼロビットで埋める | |
random | 1528 | 乱数8 | |
C2/S2 | time | 4 | C1(S1)と同じタイムスタンプ値 |
time2 | 4 | C1(S1)を読み取った時点のタイムスタンプ9 | |
random echo | 1528 | 受け取ったC1(S1) と同じ値 |
C2、S2を正常に投げ合った後は、クライアント・サーバ双方が任意のチャンクを流せることになっています。
実装
ハンドシェイクをErlangで実装します。本当はよくない仮定を入れちゃってたりする(1回のrecv
でチャンクが全部取れるとか)等、色々と割り切った実装になっています。
TCPクライアント
TCPクライアントライブラリはOTPに入っているので、ささっと作れます。connect
で接続を確立したらsend
、recv
でデータをやりとりするだけです。とてもいい加減なHTTPクライアントならこんな感じでしょうか。
recvGoogle() ->
Google = "google.co.jp",
Body = "GET / HTTP/1.1\r\n\r\n",
{ok, Sock} = gen_tcp:connect(Google, 80, [binary, {active, false}, {packet, 0}]),
ok = gen_tcp:send(Sock, Body),
Recv = gen_tcp:recv(Sock, 0),
io:format("~p~n", [Recv]),
gen_tcp:close(Sock).
RTMPのハンドシェイク実装
続いて、Erlangによるハンドシェイク周りの実装です。ところどころ手抜きをしていることもあって、非常にさっぱりしているので説明は不要かなと思います。
バイナリデータに対するパターンマッチのおかげで、コードが非常に読みやすくなっているのが分かります。こういうバイナリ処理周りはやっぱりErlangの強さを感じます。
sendC0(Sock) ->
io:format("C0 prepare..."),
Payload = <<3>>,
gen_tcp:send(Sock, Payload),
io:format("send C0: ~p~n", [Payload]).
sendC1(Sock) ->
io:format("C1 prepare..."),
Time = <<1, 2, 3, 4>>, % 適当な値を詰めておく
Zero = <<0, 0, 0, 0>>,
Random = <<0:(8 * 1528)>>, % 本来はちゃんと乱数を入れるべき(1528octet)
Payload = <<Time/binary, Zero/binary, Random/binary>>,
gen_tcp:send(Sock, Payload),
io:format("send C1: ~p~n", [Payload]),
Random.
recvS0S1(Sock) ->
io:format("start S0+S1 recv..."),
% recv S0+S1
{ok, Data} = gen_tcp:recv(Sock, 1537),
<<3, _:4/binary, Time:4/binary, Random:1528/binary>> = Data,
io:format("end S0+S1 recv~n"),
{Time, Random}. % S1から得られたこの2つを元にC2を作る
sendC2(Sock, Time, RandomEcho) ->
io:format("C2 prepare..."),
Time2 = <<4,5,6,7>>, % ここも本来はちゃんとした値を入れるべきだが手抜き
Payload = <<Time/binary, Time2/binary, RandomEcho/binary>>,
gen_tcp:send(Sock, Payload),
io:format("send C2: ~p~n", [Payload]).
recvS2(Sock) ->
io:format("start S2 recv..."),
{ok, Data} = gen_tcp:recv(Sock, 0),
<<Time:32, Time2:32, _/binary>> = Data,
io:format("Time=~p, Time2=~p~n", [Time, Time2]),
io:format("end S2 recv~n").
handshake() ->
Host = "xxx.xxx.xxx.xxx", % 接続先
{ok, Sock} = gen_tcp:connect(Host, 1935, [binary, {active, false}, {packet, 0}]),
sendC0(Sock),
sendC1(Sock),
{Time, Random} = recvS0S1(Sock),
sendC2(Sock, Time, Random),
recvS2(Sock),
gen_tcp:close(Sock).
それで、これ動くの?
Adobe Media Server(AMS)と接続してみた結果、S2が得られる所までは確認しました。確認としては厳密ではないですが、雰囲気ちゃんと動きそうではあります。
しかし……実験してみて10分かったのですが、問題というか謎はAMS側で、返してくるS1/S2チャンクがRTMP仕様に記載されているフォーマットに一致していません。例えばバイナリ形式でこんな感じのS0+S1チャンクが飛んできます(先頭部分のみ)。
03 0f 18 a1 ad 05 00 03 01 .....
先頭の03
はS0のversionなのでこれで正しそうですが、後続のS1に4オクテットのzeroが見当たらないですね……。
仕方ないのでRTMPに詳しい人に聞いたところ「RTMPの仕様? あれ色々といい加減だから公式実装の挙動を信じたほうがいいよ。動けばいいんだよ。S2来てるならいいんじゃない?」とのことでした。
( ゜Д゜)
まとめ
- ものすごくざっくりですが、RTMPプロトコルの最初の部分について書きました
- Erlang実装をしてみました
- 規格を参考にしつつも、最終的には例えばAMSのような有名(公式)実装の挙動を調べるのが大事なようです
つづく……のかなあ。
-
しょうもない質問でも気軽に聞きに行けるのでとても助かります。 ↩
-
最近だとマルチメディア系のトレンドがFlashからHTML5に移っている影響もあり、HLSなど他のプロトコルが使われることも多いようです。 ↩
-
RTMPの仕様としては、あるチャンクの送出中に別のチャンクが割り込むことはなく、必ずチャンク単位での送出がなされることが保証されています。ただし、実際にはTCP以下のレイヤでチャンクはさらに分解されるため、ソケットからrecvする時に中途半端にしかチャンクを読み取れないことがあります。実用的な実装ではそういったケースをケアする必要がありますが、今回提示するコードは、エラーハンドリング等も含めて、あまり真面目にやっていません。 ↩
-
体裁としてはRTMP Chunk Stream protocolという下位層はRTMPの上位層と独立しているということになっていますが、実際は同時に用いられることがほとんどのようです。 ↩
-
RTMPもネットワーク系の仕様だけあって、基本的には全箇所がビッグエンディアンです。しかしMessage ID(この記事には出てきません)はリトルエンディアンらしい。一体なぜなのか……。 ↩
-
他にも例えば、RTMPEでは6などの値が使われたりと、最新規格では常に3というわけでもないようです。 ↩
-
タイムスタンプについては仕様書に定義があるのですが、とりあえず4オクテットの値だと思えば問題ないです。0を含む任意の値を入れても良いとされています。 ↩
-
ユニークな値を送ることで互いのコネクションを区別するために乱数が用いられます。要するに衝突さえしなければいいので、仕様上は十分ランダムな値であればよいとされています。実際は疑似乱数生成器を使って生成することになる値のようです。 ↩
-
time2とtimeの差を見ることで、クライアント-サーバ間の遅延を測定することができそうです。 もっとも、この辺の値は適当でもRTMPサーバ実装は問題なく処理してくれるとの話があり、今回の実装でも適当な値を入れています。 ↩
-
AMSにffmpegをつないでみたり、VLCをつないでみたり、いくつか条件を変えながらWiresharkでパケットを観察しました。ここで提示したパケットキャプチャについても、その時のデータの一部です。 ↩