実践！Schematics

これは [Angular アドベントカレンダー2018](https://qiita.com/advent-calendar/2018/angular) の13日目の記事です。

## はじめに

このエントリでは、Angular CLIで利用されている[Schematics](https://github.com/angular/angular-cli/blob/master/packages/angular_devkit/schematics/README.md)について書きたいと思います。

Schematicsは "A scaffolding library for the modern web" と謳われているとおり、Webフロントエンドプロジェクトのための汎用的なスキャフォルド・ワークフローエンジンです。
Angular CLIを触ったことがある方は、 `ng new my-project` や `ng generate component MyComponent` のようにAngularプロジェクトを管理するためのコマンドを実行した記憶があると思いますが、これらのタスクも裏ではSchematicsを利用することで実現されています。

@puku0x さんが[Schematicsを作ってみよう](https://qiita.com/puku0x/items/462a038133e7233dfaed)の記事の中で、Schematicsの基本的な始め方や、独自Schematicsの作り方について解説されているので、僕のエントリでは実際にSchematicsを公開するにあたって感じた所感のような部分に重点をおいて書いていきます。

## ng add で動作するSchematicsを作ってみた

このエントリを書くにあたって、折角ですので自分でSchematicsを作って公開してみました。

https://github.com/Quramy/angular-language-service-schematics

このSchematicsはAngular CLIで作成したプロジェクトに対して、`@angular/language-service` を利用可能にします。


次のように実行します。

```sh
$ ng new my-project
$ cd my-project
$ ng add @quramy/angular-lang-service
```

`@angular/language-service` は、Angular向けのTypeScript Language Service Pluginです。
設定するとAngularのHTMLテンプレートでの補完やエラーチェック機能をエディタから利用できるようになります[^ts-plugin]。
僕はVimでTypeScriptを書いているため、Angularを書くときは必須といっていい機能です。

[^ts-plugin]: https://qiita.com/Quramy/items/1fc9aee235b79236775c#angular

Language Serviceを適用するには次の手順で設定をおこないます。

- 必要なpluginをNPMでインストールする
- tsconfigにpluginを利用する設定を追記する

今回作成したSchematicsはこの手順を自動化するものです。

初めてつくるSchematicsとしてはちょうどよい粒度かなと思い、このテーマにしてみました。
コードとしても相当にシンプルなので、以下に記載します。

```ts:src/lang-service/index.ts
import { Rule, SchematicContext, Tree, SchematicsException, chain } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';

export function install(_options: any): Rule {
  return chain([addDevDependencies, modifyTsConfig]);
}

export function addDevDependencies(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {

    const buf = tree.read('package.json');
    if (!buf) {
      throw new SchematicsException('cannot find package.json');
    }
    const content = JSON.parse(buf.toString('utf-8'));
    content.devDependencies = {
      ...content.devDependencies, 
      '@angular/language-service': 'latest',
    };
    tree.overwrite('package.json', JSON.stringify(content, null, 2));

    _context.addTask(new NodePackageInstallTask());
    return tree;
  };
}

export function modifyTsConfig(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {

    const buf = tree.read('tsconfig.json');
    if (!buf) {
      throw new SchematicsException('cannot find tsconfig.json');
    }
    const content = JSON.parse(buf.toString('utf-8'));
    content.compilerOptions = {
      ...content.compilerOptions,
      plugins: [
        ...content.compilerOptions.plugins || [],
        { "name": "@angular/language-service" },
      ],
    };
    tree.overwrite('tsconfig.json', JSON.stringify(content, null, 2));
    return tree;
  };
}
```

単純ですね。コードのポイントとしては下記くらいです。

- addDevDependenciesでpackage.jsonの更新 + その後のNPM installを予約
- modifyTsConfigでtsconfig.jsonへのplugin設定追記
- install関数で、addDevDependenciesとmodifyTsConfigをチェインさせる

ちょっと気づかなかったのは、 `ng add` で実行可能なSchematicsをつくるためにはSchematicsの定義体であるcollection.jsonにて、 `ng-add` というキーで登録しておく必要がある、という点くらいでしょうか。

```json:collection.json
{
  "$schema": "./node_modules/@angular-devkit/schematics/collection-schema.json",
  "schematics": {
    "ng-add": {
      "description": "Add angular language service plugin",
      "factory": "./built/lang-service/index#install"
    }
  }
}
```

## Schematicsのテスト

今回、初めてSchematicsを触ってみて感じたのは、すばり**テストの書きやすさ**です。

フロントエンドにおけるスキャフォルドやプロジェクトジェネレートという文脈では、過去にも様々なツールが出回っています。
有名どころだとyeomanなどです。

自分でyeomanのgeneratorを書いたことがあればわかると思うのですが、これのテストって結構面倒くさいんですよね。

- テスト用にfixtureとしてプロジェクト相当のディレクトリ構造を用意する
- テストで特定のディレクトリのファイルを変更するが、適切にロールバックさせる必要がある
- 上記にまつわる `fs` 関連のユーティリティを自前で用意する
- etc...

Schematicsでは、プロジェクトのファイル構造がTreeというインターフェイスで抽象化されており、ファイルの変更や追加はこのTreeに対して行います。
仮想ファイルシステム、みたいな感じでしょうか。

抽象化されている、ということはテスト用のTreeを作って、それに対して操作を行えばよいわけです。実際に今回つくったSchematicsの場合、テストコードは次のようにしましました。

```ts:src/lang-service/index_spec.ts
import * as path from 'path';

import {
  SchematicTestRunner,
  UnitTestTree,
} from '@angular-devkit/schematics/testing';

import { getFileContent } from '@schematics/angular/utility/test';

const collectionPath = path.join(__dirname, '../../collection.json');

function createTestApp(appOptions: any = { }): UnitTestTree {
  const baseRunner = new SchematicTestRunner('schematics', collectionPath);

  const workspaceTree = baseRunner.runExternalSchematic(
    '@schematics/angular',
    'workspace',
    {
      name: 'workspace',
      version: '7.1.2',
      newProjectRoot: 'projects',
    },
  );

  return baseRunner.runExternalSchematic(
    '@schematics/angular',
    'application',
    {
      ...appOptions,
      name: 'example-app',
    },
    workspaceTree,
  );
}

describe('lang-service', () => {
  it('should modify package.json and tsconfig.json', () => {

    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('ng-add', {}, createTestApp());

    expect(tree.files).toContain('/package.json');
    expect(tree.files).toContain('/tsconfig.json');

    const packageJson = JSON.parse(getFileContent(tree, '/package.json'));

    expect(packageJson.devDependencies['@angular/language-service']).toBe('latest');

    const tsconfig = JSON.parse(getFileContent(tree, '/tsconfig.json'));
    expect(tsconfig.compilerOptions.plugins.map((_: { name: string }) => _.name)).toContain('@angular/language-service');
  });
});
```

Schematicsによって書き換えられたjsonファイルの中身を検証する、というだけのシンプルなケースです。

ここで注目してほしいのは `createTestApp` というヘルパー関数です。
ここでテスト用のTreeを作成するようにするために `SchematicTestRunner` というテスト用のrunnerを用いています。

また、今回のSchematicsは「Angular CLIで作成されたプロジェクトに対して」という事前条件を敷いています。
したがって、「Angular CLIが作成するプロジェクト」というfixtureが必要になるわけですが、Schematicsの「別のSchematicsをTreeに対して実行可能」という特徴がここで活きてきます。
Angular CLI自体がもっているSchematics（実体は `@schematics/angular` でNPM install可能）を事前に適用させるだけで必要なfixtureが作れます。
わざわざテスト用の `package.json` や `tsconfig.json` を含むようなfixture directoryなどを用意する必要もありませんでした。

テストがちゃんと書けるというのは本当に良いもので、実際、今回の初Schematics作成にあたって実際に僕が自分で自分のSchematicsを `ng add` したのは最後に公開した際の一度切りです。

また、説明が前後しましたがSchematicsのテストで利用しているjasmineの設定などはSchematics本体に組み込みの `blank` というSchematicsできちんとスキャフォルドされるように設計されていて、この部分にも尊みを感じました。

## おわりに

この記事では実際に簡単なSchematicsを作り、旧来の類似ツールとの違いについて書いてきました。

TreeやContextといった Schematics組み込みのインターフェイスについてはあまり詳しく触れませんでしたが、Schematicsは旧来のツールよりもテスタビリティの点で優れているということが伝われば幸いです。

またSchematics自体はAngularに依存していないので、例えばいまReactやVue.jsなどの別フレームワークを使っている方でも、ちょっとしたスキャフォルドタスクをSchematicsで用意する、といった使い方もできそうです。
積極的に使っていけるとよいと思った次第です。

明日は @kiita312 さんです。乞うご期待！