文字ストリームと 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 のエイリアスという扱いになっています。そのときにようやく、StringDecoderUCS-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+0000U+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 からは StringDecoderend() メソッドが追加され (当然、net.Socket などの既存ストリームはそれを呼び出すように変更されています)、BASE64 にも対応しました。

v0.9.3 の REPL で試すと: