Hatena::ブログ(Diary)

日記

2012-10-29 (月)

[][][] 文字ストリームと StringDecoder 21:00

この記事は「東京 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 では

E38182E38184E38186

という 9 バイトにエンコードされます。これが先頭から 4 バイトと残り 5 バイトの二つのチャンクに分割された場合、'い' を正しく文字列化することができません。

同じ状況を REPL で簡単に試すと:

> buf = new Buffer('あいう')
<Buffer e3 81 82 e3 81 84 e3 81 86>
> 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')
<Buffer 42 30 44 30 46 30>
> 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')
<Buffer 4a 30 42 d8 9f df 8a 30>
> 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')
<Buffer ed a1 82 ed be 9f>
> 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 で試すと:

> buf = new Buffer('41424344454647', 'hex')
<Buffer 41 42 43 44 45 46 47>
> decoder = new string_decoder.StringDecoder('base64')
> console.log(decoder.write(buf.slice(0, 5)))
QUJD
> console.log(decoder.write(buf.slice(5, 7)))
REVG
> console.log(decoder.end())
Rw==
ここに至って、入力ストリームは Buffer でサポートされる全エンコーディングに対応したことになります。各安定版 + v0.9 (将来の v0.10) ごとの対応をまとめると、以下のようになります。
エンコーディングv0.2v0.4v0.6v0.8v0.9
ASCII ('ascii')
BINARY ('binary')
UTF-8 ('utf8')
CESU-8 ('utf8')---
UCS-2 ('ucs2')-××
UTF-16LE ('utf16le')---
HEX ('hex')--
BASE64 ('base64')××××
一方、出力ストリームについては未だに取扱注意なエンコーディングがあったりするのですが、それについては機会があれば二巡目にでも書くかもしれません。でもね、このアドカレに参加する人が増えて二巡目は回ってこないくらい盛り上がるといいな! 東京 Node 学園祭 2012 も近いしね。みんな参加してちょ!!!!