node.jsでバイナリファイルやバイナリデータをいじる


自己紹介

はじめてこのテのものに参加しました
普段は仙台で社内SEとして社内外の業務アプリその他を内製しているおじさんです
node.jsとの出会いは2年ほど前になりますが、社内で使っているWebシステムで利用者が更新したデータを別のブラウザ上でもリロードなしで反映させたいなという要望が出てWebSocket→socket.io→node.jsに行きつきました

今日はnode.jsらしい流行りもの、エレガントにまとまっている記事ではなく、node.jsと関係あるのかも微妙なドロドロとしているお話です

きっかけ

以前投稿したElectronでファイルやフォルダの選択からの流れであるレガシーシステムで使っている特殊なファイルフォーマットなデータのビューアをElectronで作りたいなってことになりまして
一応現状の想定プラットフォームはWindows限定ってことで、そこのところをC#とかで書いてもよかったんですが、そっちの経験値はあまりないので無理くりPure JSで扱おうとした結果がごらんの有様です

特殊なファイルフォーマット

C#か何かでマーシャリングしてあるっぽいそのデータは

  • 画像のビットマップデータとプロパティ値やフラグなどの付随するデータブロックがゴチャっと一体となって1つのファイルにおさまっている
  • データブロックは4096バイト固定
  • さらにデータブロックの中にはCOBOLで言うところのPIC X(256)、RDBではCHAR(256)みたいな固定長の文字列(文字のエンコーディングはShift-JIS)エリアと1バイト長のフラグがいっぱいある
  • さらにやっかいなことに文字列はSJISの文字列からさらに暗号化がなされているらしく、復号用のmy_decrypt.jsがこしらえてある
  • 多バイトデータはリトルエンディアンです

ファイルの頭にある画像サイズによって可変となるビットマップデータ以外はデータがほぼ固定長だったのが救い

いじる前にエンディアンの話

要件の中でリトルエンディアンなる単語が出てきましたが、アセンブラやC言語になじみがないとなかなか耳にする単語じゃないので、まずはエンディアン(バイトオーダー、バイト順)を補足しておくと
例えば0x12345678という32ビットのデータ(16ビットとか64ビットでも同じ)がコンピュータのメモリやディスクにどう書き込まれるかという形式のことです
CPUによって違って(x86系はリトルエンディアン、SPARCはビッグエンディアン)いたり、JavaVMやTCP/IPではビッグエンディアン(ネットワークバイトオーダー)と規定されています

パッと見で人間にわかりやすいのはビッグエンディアンなのですが、コンピュータにとってはリトルエンディアンのほうが処理しやすい・・・らしい

UTF-8やUTF-16などでテキストを保存するときにたまに出てくる BOM ですがこれはバイトオーダーマーク(Byte Order Mark)の略でファイルの先頭につけることでファイルのバイト順を定義するものです

いざ、node.jsでバイナリをいじる

ファイルフォーマットの要件を確認したところで

// JavaScript内で素の文字として取り扱うにはSJISからの変換が必要
var iconv = require('iconv-lite');
var fs = require('fs');
var my_decrypt = require('my_decrypt.js');

fs.readFile('件のデータ.dat', function(err, content){
  if(err){
    console.error(err);
  }
  var result = {
    bitmap: '',
    text: '',
    flags: []
  };
  //  バイト操作用のバイナリバッファを作成する
  var buf = new Buffer(content, 'binary');
  //  BMPファイルのヘッダは
  //  'B', 'M'の後ろ、3バイト目から7バイト目にファイルサイズが32bit整数(リトルエンディアン)で入っている
  var bitmapSize = buf.readUInt32LE(2);
  //  画像データのビットマップファイルとしての長さがわかったので
  //  データとビットマップを分離する
  var bitmap = buf.slice(0, bitmapSize -1);
  //  結果セットにはとりあえずDataURI形式で保存する
  //  ※ただでさえデカくなりがちなBMPに実運用でこれはあまりお勧めしません
  result.bitmap = 'data:image/bmp;base64,' + bitmap.toString('base64');

  var dataBlock = buf.slice(bitmapSize, buf.length - 1);
  //  データブロック先頭の256バイトは何かしらの暗号化がなされた元Shift JISの文字列が入っている
  //  my_decrypt.decrypt()はその復号を行う
  //  SJISはそのままだとJavaScript内部で文字列としては取り扱えないので
  //  iconv-liteを使ってshift_jisからの変換をはかる
  result.text = iconv.decode(my_decrypt.decrypt(dataBlock.slice(0, 0xff)), 'shift_jis');
  //  データブロックの257バイト目には有効なフラグの数が16bit整数(リトルエンディアン)入っていて
  var flags = dataBlock.readUInt16LE(0x100);
  //  フラグは各1バイト
  for(var i = 0; i < flags; i ++){
    result.flags.push(dataBlock.readUInt8(0x102 + i));
  }
  //  終わり
  console.log(result);
});

バイナリファイルは決してこわくない

Bufferクラスがことのほか有能だったこともあって、終わってみればファイルフォーマットのドキュメントを見ながらオフセット計算さえできればそこまで身構えることもない感じです
ただ今回の要件では文字列がShift_JIS、しかもよくわからない暗号化がされているので、バイナリからJavaScriptの「文字」として取り扱うための変換が一番のハマりポイントでした

ArrayBuffer / TypedArray / DataView

一般的なJavaScriptではバイナリを取り扱うクラスとして

  • ArrayBuffer
  • TypedArray(型指定でバイナリの配列を取り扱う)
  • DataView(バイト境界に厳しいTypedArrayではなく、node.jsのBuffer.read* のようにArrayBufferをオフセットで読み書きするやつ)

というセットがあるのですが、エンコーディング変換を行うときにバイト列で読み込んでいるデータをてっとりばやくiconv-liteに流し込めたnode.jsのBufferのほうが都合がよかったのでこっちを使いました

ArrayBuffer系でやる場合は

  1. バイナリのバイト列を一度 Uint8Array か Uint8ClampedArray にする
  2. これを参照する DataView を作る
  3. 整数などのプリミティブな型はオフセットを指定して DataView::get* 系メソッドで読み出し(リトルエンディアンの場合は第二引数をtrue、指定なしのデフォルトはビッグエンディアンらしい)
  4. 文字列は↑の Buffer と同じように DataView::slice() でバイト列として切り出して適宜コード変換

という感じになるでしょうか

文字コードやその他のドロドロにあまり苦労することはなくWebSocket経由でバイナリデータを送受信したいだけなんだよね、などの前提なら普通のブラウザ上でも応用のきく ArrayBuffer の一族を使ったほうがよりいいと思います

結び

ちゃぶ台返しになりますが、たぶんC#でmarshalしたんだろうデータをnodeで使いたいならnode-ffiを使ってC#でunmarshalするDLLを呼ぶのが一番エレガントなのかなと思います

ただ、データのフォーマット表眺めながら1バイトずつ読み書きというのは高校生時代にC言語でプログラミングしてたときを思い出して楽しさもありました
最近のJSはこういうのも扱えるというのはいいですね
まあこういう特殊な処理をしないといけないデータを取り扱う機会が減ってオープンかつ取り扱いが容易なフォーマットでデータがやりとりされるのが一番いいんですけど