文字ストリームと StringDecoder
この記事は「東京 Node 学園祭 2012 アドベントカレンダー」の 15 日目の記事です。
id:jovi0608 によるこのアドカレ 13日目のエントリ「Node API のクラス図を公開しました。」でも明らかなように、Node の重要なコンセプトの一つがストリームです。ストリームについては id:Jxck による東京 Node 学園の発表資料 「Node Academy 7 | ”Stream Stream Stream !!!”」も参考にどぞー。
そのストリームですが、大雑把には入力ストリーム (Readable Stream) と出力ストリーム (Writable Stream) があり、その両方でもあるフィルタストリーム (Filter Stream) や 双方向ストリーム (Duplex Stream) がある。。。 なんて話が上記の id:Jxck による資料には書いてあったりします。それとは別の観点での分類として、ストリームにはバイトストリームと文字ストリームがあると考えることができます。といっても、実装クラスとして分類できるというわけではありません。単に、ストリームを流れるデータがバイト列か文字列か、というだけです (他にも任意の JavaScript オブジェクトを流す、オブジェクトストリームなんかもありますが、このエントリでは扱いません)。
出力ストリームを文字ストリームとして扱うには、write()
メソッドに string
の値を渡します。同時にエンコーディングを渡すこともできて、デフォルトは UTF-8 です。
os.write('hoge hoge', 'utf8');
出力ストリームの場合、write()
のたびに Buffer
を渡すことも文字列を渡すこともできます。つまり、バイトストリームか文字ストリームかという明確な状態は持たないということになります。
一方、入力ストリームを文字ストリームとして扱うには、setEncoding()
メソッドでエンコーディング方式を指定します。エンコーディング方式を省略した場合は UTF-8 になります。
is.setEncoding('utf8');
一度 setEncoding()
を呼び出して文字ストリームとすると、それをバイトストリームに戻す API は提供されていません。引数無しで setEncoding()
を呼び出すことでバイトストリームになるようにしようという提案 (#3643) がされていて、HTTP モジュールの API ドキュメントには (間違って) そう書かれていたこともあるのですが、今のところマージされていません (つーか、ストリームはたくさんあるのに net.Socket
だけ直すってどんだけー)。必要だと思う人は @isaacs の Streams2 が master にマージされる前後が大チャンスなので (たぶん)、プルリクエストするなり #3643 にコメントするなりしませう。
なお、全ての入力ストリームが文字ストリームになれるわけではありません。たとえば zlib
モジュールは現在のところ文字入力ストリームに対応していません。ただし、現在開発中の Streams2 では入力ストリームのベースクラスが setEncoding()
メソッドを持っているので、いずれは zlib
も文字入力ストリームとして使えるようになると思われます。
このエントリではもっぱら文字入力ストリームについて書いていきます。
まず、なぜ文字ストリーム、とりわけ文字入力ストリームが必要なのでしょうか? それは、文字入力ストリームがないと不便で、バグの元になりやすいからです。たとえば、受信したバイト列を文字列に変換してログ出力する例:
is.on('data', function(buf) { console.log('受信データ : ' + buf.toString()); });
このコードは受信したデータが ASCII のように、一文字が 1 バイトにエンコードされたバイト列の場合はうまく動きます。しかし、UTF-8 のように複数バイトにエンコードされている場合、そしてそれが複数のチャンクに分割されている場合には、うまく動かない場合があります。たとえば 'あいう'
という文字列は、UTF-8 では
あ | い | う |
E38182 |
E38184 |
E38186 |
という 9 バイトにエンコードされます。これが先頭から 4 バイトと残り 5 バイトの二つのチャンクに分割された場合、'い'
を正しく文字列化することができません。
同じ状況を REPL で簡単に試すと:
> buf = new Buffer('あいう')> console.log('受信データ : ' + buf.slice(0, 4).toString()) 受信データ : あ� > console.log('受信データ : ' + buf.slice(4, 9).toString()) 受信データ : ��う
このように、まるで文字化けしたような出力になってしまいます。Node の初期の実装 (0.1.95 まで) では、入力ストリームは上記のような実装になっていたため、UTF-8 でエンコーディングされた文字列を正しく扱うことができませんでした。
そこで必要になるのが文字ストリームで、そのために使われるのが string_decoder
モジュールの StringDecoder
クラスというわけです。StringDecoder
は、UTF-8 のように複数バイトでエンコーディングされたバイト列を受け取ると、それが文字の境界で分割されていないかチェックし、もし分割されて不完全なバイト列で終わっていると、その手前の (文字として完全な) バイト列までを文字列に変換します。そして残りのバイト列を覚えておいて、次に受信したバイト列と連結することで、正しく文字列に変換します。
REPL で簡単に試すと:
> decoder = new string_decoder.StringDecoder('utf8') > console.log('受信データ : ' + decoder.write(buf.slice(0, 4))) 受信データ :あ > console.log('受信データ : ' + decoder.write(buf.slice(4, 9))) 受信データ :いう
このように、最初の呼び出しで 4 バイトを渡しても、先頭の 3 バイトである 'あ'
だけが文字列に変換されて、残りの 1 バイトは次の呼び出しで渡した 5 バイトと連結されて 'いう'
に変換されたことがわかります。これにより、前述のコード片や
var data = ''; is.setEncoding('utf8'); is.on('data', function(str) { data += str; });
のように受信した文字列を全部連結して、'end'
イベントでまとめて扱うようなコードが UTF-8 でも安全に動作するようになりました。
StringDecoder
は最初 Utf8Decoder
という名前で、後のコアメンバーである Felix Geisendörfer によって実装されて 0.1.96 にマージされました。名前のように UTF-8 専用だったのですが、0.1.99 で現在の StringDecoder
に変更されてその他のエンコーディングに対応しました。といっても、当時の Node が UTF-8 以外にサポートしていたエンコーディング方式は、ASCII ('ascii'
) と現在は deprecated ということになっている (でも無くさないでくれという要望が根強い) バイナリ ('binary'
) という、1 バイトへのエンコーディング方式だけだったのですが。
その後、v0.4.0 で Node は UCS-2 をサポートするようになりました。これもドイツの方の貢献によるのですが、そのパッチに含まれていたのは Buffer
の対応だけで、StringDecoder
は変更されませんでした。そのため、setEncoding('ucs2')
はうまく動きませんでした。
v0.6.21 の REPL で試すと:
> buf = new Buffer('あいう', 'ucs2')> decoder = new string_decoder.StringDecoder('ucs2') { encoding: 'ucs2' } > console.log(decoder.write(buf.slice(0, 3))) あ > console.log(decoder.write(buf.slice(3, 6))) 䘰
まともにデコードできません。しかし、少なくともデータ交換で使われるエンコーディング方式として現在は UTF-8 がデファクトであり、UCS-2 が使われることはほとんどなかったのでしょう。Node の issue でも要望されたことはなかったような気がします。
それから 1 年以上が経過して、v0.8.0 から Node はサロゲートペアを含む UTF-16 に対応するようになりました。現在の Node では、UCS-2 は UTF-16LE のエイリアスという扱いになっています。そのときにようやく、StringDecoder
は UCS-2/UTF-16LE に対応しました。需要があったのかどうかは不明ですが。。。
v0.8.14 の REPL で試すと:
> decoder = new string_decoder.StringDecoder('ucs2') > console.log(decoder.write(buf.slice(0, 3))) あ > console.log(decoder.write(buf.slice(3, 6))) いう
もちろん、UTF-16 に対応しているので、サロゲートペアの途中で途切れたチャンクでも大丈夫です。
> buf = new Buffer('お𠮟り', 'utf16le')> decoder = new string_decoder.StringDecoder('utf16le') > console.log(decoder.write(buf.slice(0, 4))) お > console.log(decoder.write(buf.slice(4, 8))) 𠮟り
ところで、UTF-8 にサロゲートペアはなく、BMP (基本多言語面、U+0000
〜U+FFFF
) に含まれない文字は 4 バイトでエンコーディングすることになっているのですが、世の中には UTF-8 の文字は 3 バイトまでということに依存していたりするのか、UTF-16 におけるサロゲートペアの上位・下位の各 16bit をそれぞれ 3 バイト、計 6 バイトで表現する CESU-8 と呼ばれるエンコーディング方式もあるらしいです。Oracle とか Oracle とか Oracle で使われていると Wikipedia に書いてありました。UTF-8 としては RFC 違反なわけですが、サロゲートペアに対応していない UCS-2 -> UTF-8 変換ツールを使って UTF-16 を変換しちゃったりすると、CESU-8 なバイト列ができあがっちゃいます。んで、なぜか Node で利用されている JavaScript エンジン、V8 は CESU-8 なバイト列でも (内部形式である) UTF-16LE に変換してくれるので、Node の StringDecoder
でも UTF-8 といいながら CESU-8 なバイト列が一文字 (最長 6バイト) の途中で分断されても大丈夫なようになっています。
> buf = new Buffer('eda182edbe9f', 'hex')> decoder = new string_decoder.StringDecoder('utf8') > console.log(decoder.write(buf.slice(0, 3))) > console.log(decoder.write(buf.slice(3, 6))) 𠮟
いわゆる文字コードではないけれど、Buffer
がサポートしているエンコーディング方式としては他に HEX ('hex'
) と BASE64 ('base64'
) があります。HEX は 1 バイト (たとえば 0x30
) を 2文字 (たとえば '30'
) で表現するので、初期からの StringDecoder
でも適切に扱うことができました。
しかし、3 バイトごとのデータを 4 文字で表現する BASE64 の場合は、適切にバイト境界を扱わないと正しくエンコード (文字コードの場合は文字をバイト列にエンコードして、それを文字列にデコードするのですが、BASE64 の場合はバイト列から文字列にする方向がエンコードなのだ) することができません。それだけなら UTF-8 などと同じようにすればいいだけなのですが、BASE64 ではさらにパディングというものがあり、一番最後に 3 バイト未満のデータが残った場合も考慮する必要があります。しかし、従来の StringDecoder
にはメソッドが write()
しかなく、ストリームの終端で呼び出されるインタフェースがありませんでした。そのため、StringDecoder
では BASE64 をサポートしていなかったのですが、最近リリースされたばかりの v0.9.3 からは StringDecoder
に end()
メソッドが追加され (当然、net.Socket
などの既存ストリームはそれを呼び出すように変更されています)、BASE64 にも対応しました。
v0.9.3 の REPL で試すと: