Angular Schematics 覚書

2021-08-16

これは何

Schematics について、触ったり実装を読んだりして理解したことを書き留めておくもの。

Schematics とは

Angular Schematics は複雑なロジックに対応したジェネレータの枠組みである。 タスクランナーも、タスクの具体的な実装もあまり区別せずに Schematics と呼んでいる。

ng add <package>ng generate のようなコマンドたちも Schematics で実現されており、以下に示すようなものがその例である。

Rule と Task

Schematics の仕組みには RuleTask という、何らかの処理を行うことについて異なる2つの概念がある。 それぞれについて説明する。

Rule

Rule は、公式のドキュメントにも記載があるように、 Tree という仮想ファイルシステムを受けて、そこに変換を適用し、新たな Tree を返す関数を定義する概念である。

// Rule を定義する関数(= RuleFactory)の例

function sampleRuleFactory(options: any): Rule {
  (tree: Tree, context: SchematicsContext) => {
    // ファイルの作成
    tree.create('somefile', 'blah blah')

    return tree;
  }
}

Schematics にとって主体となる処理は Rule として定義され、進行していく。 何らかの Schematics を定義する際に、ユーザが自由に組み立てることができるのは基本的にこの Rule を返す高次関数、 RuleFactory のみである。

CLI から呼び出された Schematics が呼び出す処理のエンドポイントは予め指定された、もしくは default export された RuleFactory となるため、形式的には1つの Rule しか定義できない。 そのため、いくつかの Rule を合成する手段として chain() が用意されている。

chain() は与えられた Rule[] を内部的に rxjs の mergeMap を用いて結合する。 このため、与えた Rule たちは基本的に順序通りに適用される。

後述する Task も、 RuleFactory で包んで呼び出す形を取るのが一般的である。 ただし、呼び出された Task はその場で実行されるのではなく、キューイングされるだけである。

Task

Task は、システムコマンドなど、プログラムの外のツールを呼び出したりするための補助的な概念である。 Schematics そのものにビルトインで用意された Task がいくつかあり、 Rule と違ってユーザが自由に定義するためのインターフェースは積極的に提供されていない。 提供されるビルトインの Task の例として、node package のインストールを行うもの、別の Schematics を実行するものなどが挙げられる。

また、実行されるタイミングも Rule とは異なり、 Post Process 的位置付けである。 これは、Schematics 全体のフローを司る部分の実装を見ると直観的にわかる。

Task はそれ同士の依存関係を持つことができ、Rule から呼び出す際の addTask() に、それ以前の addTask() の返り値として得た TaskId[] を渡すことで優先順位が計算される。

// 依存関係を指定した Task 呼び出しの例

const taskIds = [];

function installNodePackages() {
  return (_host: Tree, context: SchematicContext) => {
    taskIds.push(context.addTask(new NodePackageInstallTask()));
  };
}

function installNodePackages() {
  return (_host: Tree, context: SchematicContext) => {
    taskIds.push(context.addTask(new RunSchematicTask('other-schematics', { args }), taskIds));
  };
}

小ネタ集

非同期の解決を待って結果を Rule に使いたい

何らかの API と通信をして、その結果をテンプレートに埋め込むなど、非同期の解決を待つような Rule をどうするかという話。以下のサイトを参考に、流れてきたデータを使って型の辻褄を合わせる。

function waitingForResolveAsync(options: any): Rule {
  return (host: Tree, context: SchematicContext) => {
    const observer = new Observable<any>((sub) => {
      someAsyncFunction()
        .then((result) => sub.next(result))
        .catch((err: any) => {
          sub.error(err);
          throw new SchematicsException(err?.message);
        });
    }).pipe(
      take(1),
      mergeMap((data) => {
        return callRule(
          yourRuleFactory(data),
          host,
          context
        );
      })
    );

    return observer;
  };
}

任意の関数を Task として実行する

これは Rulechain させたとき、同時にスタートして非同期の解決順が Rule の適用順になると勘違いして、 Task の依存制御の仕組みを用いて順序を保証しようと考えた時に書いたコードである。

先述のとおり、 Rule を用いれば複数の処理を順番通りに適用させられるし、他の Schematics を実行する RunSchematicTask があるなどの理由から、基本的に Task はユーザが自由に組み立てられる必要性があまりない。

別の Schematics をいちいち定義する必要がない程度のメリットではあるが、一応載せておく。