M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

TypeScript x Jasmine構成のnpmパッケージ・プロジェクトを試作

TypeScript x Jasmine構成のnpmパッケージ・プロジェクトを試作してみました。まずはNode.jsランタイムで利用するモジュールのためのパッケージです。これをもとに理屈上Browserifyなどによるバンドル化にも対応できるはずですが、まあそれはそれでいろいろありそうなので、今回はスコープアウトです。Node.jsやクライアントサイドMVCに関する書籍を読んでいても思うことですが、ECMAScript言語族(JavaScript、TypeScript、etc...)においてはモジュール・システムおよび依存性解決はとても重たいテーマです。

プロジェクト

ファイルセットは例によってGitHubにて公開しています。
「TypeScript x Jasmine構成のnpmパッケージ・プロジェクト」と言いましたが、もう少し細かく言うと、次の要件を満たす開発環境を構築するために作成しました:

  • npmによるパッケージ管理を行う
  • gulpによるビルド・タスク管理を行う
  • Visual StudioなどのIDEでなくAtomでコーディングできる構成にする
  • TypeScriptによるコーディングを行う
  • 依存パッケージのAPIへのアクセスは*.d.tsファイルが提供する型情報を通じて行う
  • Jasmineによる単体テスト作成を行う
  • 配布用ファイルには*.min.js*.d.ts*.min.js.mapを含める
  • メイン・コードにJasmineのAPIを利用するコードが絶対に紛れ込まないようにする

ビルド方法

開発環境にまだnpmgulp-cliがインストールされていないようであればインストールします。

その上でプロジェクトのルートディレクトリにてnpm installを実行すると、package.jsonの記述内容に基づいて依存性パッケージがダウンロードされます。

その後、次の各種コマンドを実行することでビルドを行うことができます:

コマンド 説明
gulp build main/配下のTypeScriptコードをトランスパイルし、圧縮・難読化(uglify)した上で、dist/配下にファイル出力します。このときメインの成果物である*.min.jsとともに*.d.ts*.min.js.mapも出力されます。
gulp test main/配下のTypeScriptコードとともにtest/配下の同コードもトランスパイルした上で、*.spec.jsを対象にしてJasmineテスト・ドライバーを実行します。圧縮・難読化やソースマップの生成は行いません。
gulp clean ビルド・タスクにより生成されたファイルをすべて削除します。

コーディング

開発環境にまだAtomがインストールされていないようであればインストールします。 AtomにはAtom TypeScriptパッケージをインストールしておきます。

ビルド方法のところで説明したnpm installまでが完了したら、プロジェクトのディレクトリをAtomでオープンします。 所定のディレクトmain/srctest/srcに格納されたTypeScriptファイルを編集し保存すると、自動的にトランスパイが行われて{main|test}/src/generated配下に*.jsファイルが生成されます。

テストのコードからメインのコードをインポートする場合は import ... from '../../main/src/(filename)'形式で記述します。

ファイル構成

├── LICENSE
├── README.md
├── dist                    ... gulp buildの成果物が格納されるディレクトリ
├── gulpfile.js             ... gulp.jsタスクランナーのためのタスク定義ファイル
├── main                    ... メインのコードと設定情報を格納するディレクトリ
│   ├── generated           ... トランスパイルの成果物が格納されるディレクトリ
│   ├── src
│   │   └── example.ts      ... メインのコード(の例)
│   └── tsconfig.json       ... メイン固有の`tsc`オプション
├── node_modules            ... このパッケージの依存性パッケージが格納されるディレクトリ
├── package.json            ... このパッケージのメタ情報
├── test                    ... テストのコードと設定情報を格納するディレクトリ
│   ├── generated           ... トランスパイルの成果物が格納されるディレクトリ
│   ├── src
│   │   └── example.spec.ts ... テストのコード(の例)
│   └── tsconfig.json       ... テスト固有の`tsc`オプション
└── tsconfig.json           ... メインとテストに共通する`tsc`オプション

小路幸也『マイ・ディア・ポリスマン』

マイ・ディア・ポリスマン

マイ・ディア・ポリスマン

この人の作品は面白けれども良くも悪くも内容が軽いです。この作品も同じ。そんなこと言ったってどういう作風・どういうテーマを選ぶかは作者の自由。もちろんそれはそうなのですが、主人公たちは家族とか友情とか男性性/女性性とか、そういう規範的なものに無邪気というか屈託がないというか、ともかく何だかんだ言いつつもあっけなく落ち着きどころを見つけ出してしまうのですから、やっぱり軽い。味気ない。そう思ってしまうのです。

興味本位にTransformストリームを実装する

Browserifyのドキュメントを読む中で必然的にNode.jsのAPIについてのドキュメントも参照することとなり、その中でも以前抄訳をしたStream APIに関数するものを読むうちに、試しにTransformストリームを実装してみたくなりました。

Transformストリームの作り方

というわけで2つのストリームを作ってみました:

  1. 文字列の内容を行ごとに分割するTransformストリーム
  2. 行ごとに分割された文字列末尾の改行文字を除去するTransformストリーム

カスタムメイドのTransformストリームは他の種類のストリームと同様にNode.jsの標準ライブラリの助けを借りて簡単に作ることができます:

// 何はともあれStream APIのモジュールを読み込み
var stream = require('stream');
// それを使用してカスタムメイドのTransformを生成する関数を定義
var makeMyStream = function() {
  // Transformコンストラクタを利用して独自の変換処理を行うストリームを生成
  return new stream.Transform({
    /* ここに種々のオプションとともに、
    stream._transform()メソッドの実装となる関数を指定 */
    transform: function(chunk, encoding, callback) {
      callback();
    }
  });
};

1. 文字列の内容を行ごとに分割するTransformストリーム

var stream = require('stream');

// ビジネスロジックで使用する定数
var CR = '\r', LF = '\n';

// Readable#push(any)プロキシを作成する関数。
// Streamとそのサブ型のメソッドは、thisにただしくレシーバが設定されていないと機能しない。
// このためReadable#pushのFunctionインスタンス参照を直接他の関数に渡して
// 後者の関数内で呼び出しを行う、というようなことを行うとエラーとなってしまう。
// このプロキシ作成関数はその引数にthis(that)=レシーバをとり、
// このthisに対してReadable#push(any)呼び出しを行う関数を生成することで問題を回避する。
var pushProxy = function(that) {
  return function(chunk) { return that.push(chunk); };
};
// Readableから文字列を読み取って行ごとに分割するTransform。
// このTransformのWritableの側面はチャンクとしてBufferを消費します。
// 一方Readableの側面はチャンクとしてstringを生産します。
// 分割された行文字列(生産されたチャンク)の末尾にはCR、LF、もしくはCRLFが付随します。
var makeLines = function() {
  // mayPush関数の前回処理で判断保留された「残り」が格納される変数
  var remaining = null;
  // 行分割を行い適宜Readable#pushを実行する関数。
  // 引数として文字列チャンクとReadable#pushを行う関数の参照を受け取り、
  // 必要に応じて行分割を行ってReadable#pushを実行する。
  // 文字列チャンクの終端は行の半ばである可能性があるから(その可能性の方が高いから)、
  // この関数は「文字列チャンク内における最後の行」をエンクロージング・スコープの変数remainingにキャッシュする。
  // キャッシュされた文字列は次回mayPushが呼び出されたときに
  // 新しい文字列チャンクの先頭に連結されて真っ先に処理対象となる。
  var mayPush = function(chunk /* :string */, push /* :(string) => boolean */) {
    // 前回処理で判断保留された「残り」が存在するかどうかチェック
    if (remaining !== null) {
      // 存在した場合は、今回処理の文字列チャンクの先頭に連結する
      chunk = remaining + chunk;
      remaining = null;
    }
    // 文字列チャンク内の現在読み取り中の文字位置を指す添字(i)、
    // 「現在の行」の左端の文字列チャンク内における文字位置を指す添字(left)、
    // 文字列チャンクの全長(len)
    var i = 0, left = 0, len = chunk.length;
    // 文字列チャンクの終端まで1文字ずつ処理
    for (; i < len; i ++) {
      // 現在読み取りi位置の文字を取得
      var ch = chunk.charAt(i);
      // 現在文字がLFかどうかチェック
      if (ch === LF) {
        // LFである場合、即座に行の切り出しを行う
        // 現在位置+1が切り出し対象の右端(exclusive)の添字
        var right = i + 1;
        // 切り出した文字列をReadable#push(string)に渡す
        push(chunk.substring(left, right));
        // 切り出した行の右端の添字を次回処理の行の左端として設定
        left = right;
      } else if (ch === CR) {
        // 現在文字がCRである場合、次の1文字についても判断を行う
        // iをインクリメント
        i ++;
        // 文字列チャンクの終端に達していないかチェック
        if (i < len) {
          // 終端に達していなかった場合
          // 「次の1文字」を取得
          ch = chunk.charAt(i);
          // LFでない場合は読み取り位置をリセット
          if (ch !== LF) i --;
          // 現在位置+1が切り出し対象の右端(exclusive)の添字
          var right = i + 1;
          // 切り出した文字列をReadable#push(string)に渡す
          push(chunk.substring(left, right));
          // 切り出した行の右端の添字を次回処理の行の左端として設定
          left = right;
        } else {
          // 終端に達していた場合は何もせずに即座にループを抜ける
          break;
        }
      }
    }
    // ループ処理後も未処理分として残った文字列を
    // 今回処理で判断保留された「残り」として変数に格納する
    remaining = chunk.substring(left);
  };
  return new stream.Transform({
    // 変換の結果としてチャンクは必ず文字列となるので
    // Readableの側面は objectMode = true
    readableObjectMode: true,
    transform: function(chunk /* :Buffer|string|any */, encoding, callback) {
      // チャンクを文字列化した上で行分割にかける。
      // 1チャンクに対して0〜n回のReadable#push(string)メソッドを呼び出すため、
      // pushメソッドの呼び出しを代行するプロキシを第2引数として渡す。
      // プロキシを介さなくてはならない理由についてはpushProxy関数の説明を参照のこと。
      mayPush(chunk.toString(), pushProxy(this));
      // 処理の完了を示すコールバック実行
      callback();
    },
    final: function(callback) {
      // 行分割を行う関係で最後のtransform(_transform)メソッド呼び出しの後にも
      // 未処理の文字列が残っている可能性がある(その可能性高い)。
      // このためfinal(_final)メソッドでもう1度mayPush関数を呼び出す。
      mayPush('', pushProxy(this));
      // 処理の完了を示すコールバック実行
      callback();
    }
  });
};

module.exports = makeLines;

2. 行ごとに分割された文字列末尾の改行文字を除去するTransformストリーム

var stream = require('stream');

var CR = '\r', LF = '\n';
var mayTrim = function(chunk /* :string | any */) {
  // チャンクを文字列化する
  chunk = chunk.toString();
  // 「最後の文字」の添字を計算
  var last = chunk.length - 1;
  // 添字次第で処理を分ける
  if (last === -1) {
    // -1の場合、対象文字列は空文字列ということ。そのまま返す
    return chunk;
  } else {
    // それ以外の場合
    // 「最後の文字」を取得
    var ch = chunk.charAt(last);
    // CRかどうか判定
    if (ch === CR) {
      // 該当した場合は最後の1文字を除去した文字列を返す
      return chunk.substring(0, last);
    }
    // LFかどうか判定
    if (ch === LF) {
      // 添字がまだ正の数かどうか判定
      if (0 < last) {
        // 「最後の文字」の1つ前の文字を取得
        ch = chunk.charAt(last - 1);
        // それがCRである場合は最後の2文字を除去して返す
        // CRでない場合は最後の1文字を除去して返す
        return chunk.substring(0, ch === CR ? last - 1 : last);
      }
      // 「最後の文字」がLFで、その添字が0ならば空文字列を返す
      return '';
    }
    // 最後の文字がCRでもLFでもないならそのまま返す
    return chunk;
  }
};

// 文字列チャンクの末尾の改行文字を除去するTransformストリーム。
// チャンクとして文字列(stringもしくはStringのインスタンス)以外が渡された場合でも
// Object#toString()により文字列化を強制した上で末尾処理を行う。
var trimEol = function() {
  return new stream.Transform({
    // 文字列レベルの操作が前提となるので
    // Readable/Writableのいずれも objectMode = true
    readableObjectMode: true,
    writableObjectMode: true,
    // Transformストリームのビジネスロジック(_transformメソッドの実装)
    transform: function(chunk, encoding, callback) {
      // チャンクの内容次第で適宜改行文字を除去してからプッシュする
      this.push(mayTrim(chunk));
      // 処理の完了を示すコールバック実行
      callback();
    }
  });
};

module.exports = trimEol;

Transformストリームを使ってみる

何の変哲もないですが、最前作成したTransformストリームを使ってみます:

var fs = require('fs');
var process = require('process');
var makeLines = require('./makeLines');
var trimEol = require('./trimEol');

var fooTxt = fs.createReadStream('./foo.txt', {
  encoding:'utf-8'
});

fooTxt.pipe(makeLines()) // 行ごとに分割する(チャンクの単位も切り替わる)
  .pipe(trimEol()) // 行の末尾の改行文字を除去する(チャンクの単位はそのまま)
  .pipe(process.stdout); // 結果を標準出力に書き出す(改行除去された読みにくいテキストが出力される)