ESLintだけでは守れない。Dependency cruiserによるアーキテクチャー保護

[自ブログより](https://blog.hedrall.work/posts/20221120-dependency-cruiser)

<img src="https://github.com/sverweij/dependency-cruiser/blob/develop/doc/assets/ZKH-Dependency-recolored-320.png?raw=true" style="max-width: 320px" />

本記事では、dependency-cruiserというツールを利用してTypeScriptのコードベースに依存関係のルールを設定していく方法をご紹介します。意外と小規模、初期段階から導入しておきたいルールが多くありますので是非参考にしてみてください。

# 背景

私は現職の入社時に新規プロダクトの立ち上げてから1.5年がったのですが、徐々にコードベースが大きくなってきてコードベースの品質管理の必要性を感じる一方、アーキテクチャーの維持や、チームメンバーとの規約・思想の共有に苦心することが出てきました。
もちろんドキュメントの整備や、`ESLint + Prettier`の設定などは行ってきていたのですが、それでカバーできる範囲は限定的です。レビューの工数なども考ると、もっと広範において可能なものは自動テストがかけられる状態を作りたいものです。

そこで、最近は`コードメトリクス`に関心を持ち初めて調査をしていたところ、 [こちらの記事](https://blog.logrocket.com/how-to-use-static-code-analysis-to-write-quality-javascript-typescript/) で紹介されたいた [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) を知りました。

<br />

`dependency-cruiser` は`js`, `ts`ファイルの依存関係(import, exportの関係)を可視化したり、ルールを作成してテストすることができるツールです。

そもそも、`TypeScript`にはパッケージの概念がなく、`export`するとプロジェクト全体にexportされてしまうため、綺麗な依存関係を保護し続けるには開発者それぞれのスキルが求められます。そのため、思いも寄らぬ依存関係が発じたり、循環参照が発生したりという不具合はある程度の規模の開発者であれば(よく)経験されることと思います。

とはいえ、「依存関係を管理」というとなんか小難しいというか、そんな必要ってある？って感じる方も多いのではないでしょうか？

しかしながら、実際は`dependency-cruiser`を利用することでプロジェクトの初期段階から導入しておきたい有用な設定がいくつも存在することがわかりました。また、`ESLint`などと比較しても導入が簡単で、CI化のために必要な機能も揃っています。

また、個人的に期待した点としてはアーキテクチャの保護があります。
私の周りでも最近はDDDやクリーンアーキテクチャーなど採用するプロジェクトも多くなってきましたが、アーキテクチャーを導入すること即ち、なんらかの`制約`を設けることであり、特に依存関係(ディレクトリ構成)などはそれぞれのチームで決まったパターンを持っていることと思います。
しかし、アーキテクチャ制約をドキュメント管理し、共有し、浸透させ、侵害されないように守り続けるのには、人数が多くなればなるほど計り知れない労力がかかるものです。そこで、`dependency-cruiser`のようなツールで明文化・自動化しておくことは開発効率やアーキテクチャ保護を考える上で有効な施策になると思われます。

本記事では `dependency-cruiser`の基本的な利用法方と、有用なルール・活用方法に関して考えていきたいと思います。

# 使い方

## 導入法方

```shell
npm i -D dependency-cruiser
npx depcruise --init
```

実行すると、いくつか対話式で質問され、完了すると `.dependency-cruiser.js` がRootディレクトリに吐き出されます。

(設定ファイルが`js`なのはありがたいですね。)

## 実行

`.dependency-cruiser`にはデフォルトのルールが定義されていますので、内容は後ほど紹介いたします。
まずは実行してみます。

```shell
npx depcruise --config .dependency-cruiser.js .
```

`depcruise`コマンドの引数を`.`にしていますが、指定したディレクトリ配下に対してテストが実行されます。

## グラフ画像の生成

`dependency-cruiser` は依存関係をテストをするだけではなく、画像として可視化することもできます。

サンプル (公式)

![公式のサンプル](https://raw.githubusercontent.com/sverweij/dependency-cruiser/master/doc/assets/sample-dot-output.png)
([レポ](https://github.com/sverweij/dependency-cruiser/blob/develop/doc/rules-tutorial.md))

画像の生成には `dot` コマンドが利用可能である必要があるので、[graphviz](https://graphviz.org/download/) を参照して、installします。Macの場合は `brew install graphviz` するだけです。

では、画像の出力をしてみます。

```bash
depcruise --config .dependency-cruiser.js . --output-type dot | dot -T svg > dependency-graph.svg
```

これで`svg`として依存関係のグラフが生成されます。依存関係にルール違反などがある場合は、それも図中に図示してくれます。<br />
(プロジェクト全体だとコンポーネントが多すぎてみにくい場合は、引数に指定するディレクトリを絞ってみてください。)

# 設定 (.dependency-cruiser.js)

[こちら](https://github.com/sverweij/dependency-cruiser/blob/develop/doc/cli.md) に記載がある通りです。CLIのoptionに直接記載することも可能ですが、デフォルトで適用したい設定(の一部)は`.dependency-cruiser.js`の`options`に指定することができます。

### `includeOnly`

`depcruise`コマンドに引数で渡したディレクトリ配下のうち、`includeOnly`に指定された正規表現にマッチするファイルだけがテストされます。`dist`, `node_modules`など個別に除外するのは大変なので、通常はこれを指定する事になると思います。

### `exclude`, `filter`

逆に除外する正規表現を設定できます。

### `tsConfig`

`tsconfig.json`の位置を指定します。PathAlias(`@src/**`など)を利用している場合は、こちらの設定が自動的に反映されます。
例えば、`tsconfig.json`が

```json
{
  "baseUrl": ".",
  "paths": {
    "@api/*": ["./*"]
  }
}
```

となっており、 `import '@api/src/index.ts';` があった場合は、`src/index.ts`に置き換えられてから依存関係が分析されます。

### --output-type, -T (CLIでのみ指定可能)

`Jest`の様にレポーターを指定することで、さまざまな形式で結果を出力できるようになっています。

- err <= default
- err-long
- dot
- ddot
- archi/cdot
- flat/fdot
- mermaid
- err-html
- markdown
- html
- csv
- teamcity
- text
- json
- anon
- metrics

`dot`は先ほどSVG画像を出力するのに利用しました。`markdown`は実験的な機能ということになっていますが、Github ActionsからPRに自動コメントするのに便利そうです。
(`mermaid`はプロジェクト単位だと大きすぎてブラウザが固まってしまいました。)

### --metrics (CLIでのみ指定可能)

[こちら](https://github.com/sverweij/dependency-cruiser/blob/develop/doc/cli.md#metrics---generate-a-report-with-stability-metrics-for-each-folder) の通りですが、安定度というコードメトリクスを計算してくれます。対応するレポータは一部だけで、`-T metrics` を指定するとみやすいです。

# ルール

## 基本的な書き方

`.dependency-cruiser.js`の全体的な構成は以下の通りです。

```js
module.exports = {
  forbidden: [ /* ... */ ],
  allowed: [ /* ... */ ],
  allowedSeverity: [ /* ... */ ],
  required: [ /* ... */ ],
  extends: '...',
  options: { /* ... */ },
}
```

基本的には`forbidden`に許可されない依存関係を記述していきます。また、必要があれば`required`には期待される(ないと困る)依存関係を定義していきます。

続いて、それぞれのルールの書き方は以下の通りです。


```js
module.exports = {
  forbidden: [
    {
      name: `テストディレクトリ配下の依存`,
      comment: 'testディレクトリ外から、testディレクトリ配下を参照してはいけない',
      severity: 'error',
      from: { pathNot: '^(test/)' },
      to: { path: '^(test/)' },
    }
  ],
};
```

まずは, `name`, `comment`にこのルールの名前と説明を記載します。(デフォルトのレポーターでは`name`だけが表示されます) <br />
`severity`には`ESLint`のように、このルールを侵害した時のエラーレベルを `"error" | "warn" | "info" | "ignore"`で選択することができます。<br/>
重要なのは、`from`, `to`の部分ですが、`from`が`import(require)`する側で、`to`が`export`する側です。また、中身の`path`, `pathNot`などでどのファイルを対象にするかを正規表現で指定します。また、ここで検査されるパスはルートディレクトリからの相対パスになります。`TS`でPathAliasを利用している場合は前述の通り、`options.tsConfig`に`tsconfig.json`を指定することで相対パスに統一されます。

> ちなみに、`glob`表現は使えず正規表現だけです。`glob`から正規表現を自動生成したい場合は[こちら](https://blog.hedrall.work/posts/20221120-glob) の記事も参考にして下さい。

また、`from`, `to`の表現は条件によっては`module`になることもあるので、細かいルールを設定したい場合はチェックしてみて下さい。

## 有用なルール

冒頭にも書きましたが、「依存関係のテスト」ってどんな有用なものがあるのかピント来づらい部分もあると思いますが、実際はすぐに導入したくなるようなルールがたくさんあります。
`.dependency-cruiser.js`にデフォルトで記載されるルールと、ドキュメントに紹介されている例などからピックアップして、有用なルールの紹介をしたいと思います。

### デフォルトのルールから

#### `no-circular`

```js
forbidden: [{
  severity: 'warn',
  from: {},
  to: {
    circular: true,
  },
}]
```

循環参照ダメ。絡まるとかなりデバッグしにくいバグに繋がりますが、意外と普段の開発で見落としがちな部分です。

#### `no-orphans`

```js
forbidden: [{
  severity: 'warn',
  from: {
    orphan: true,
    pathNot: [
      '(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$', // .hoge みたいなファイル
      '\\.d\\.ts$', // 型定義ファイル
      '(^|/)tsconfig\\.json$', // tsconfig.json
      '(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$', // config系
    ],
  },
  to: {},
}]
```

`orphan: true`によって孤立ファイルを禁止します。地味ですが、リファクタリングする際に完全に孤立したファイルは削除することができるので、そういった確認に便利なルールです。`from`ではもともと孤立することがわかっているファイルは除外する形で、それ以外の全ファイルを対象にしています。

#### `not-to-deprecated`

```js
forbidden: [{
  severity: 'warn',
  from: {},
  to: {
    dependencyTypes: ['deprecated'],
  },
}]
```

`deprecated(非推奨)`になったnpmモジュールへの参照を禁止します。`dependencyTypes`には他にもnpmにまつわる色々な設定などができます。

#### `no-non-package-json`

```js
forbidden: [{
  severity: 'error',
  from: {},
  to: {
    dependencyTypes: ['npm-no-pkg', 'npm-unknown'],
  },
}]
```

`package.json`に記載されていないモジュールへの参照を禁止します。`npm i -g ***`でインストールされているモジュールを読み込んでいることに気づかずCI上で(なぜか)エラーする、みたいな罠から初心者を救ってくれます。

#### `not-to-dev-dep`

```js
forbidden: [{
  severity: 'error',
  from: {
    path: '^(src)',
    pathNot: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
  },
  to: {
    dependencyTypes: ['npm-dev'],
    pathNot: [
      'aws-sdk', // AWS Lambdaの場合
    ]
  },
}]
```

`package.json`の`devDependency`に記載されているパッケージへの依存を禁止します。これも気づかないと、CI上で`npm i --prod=true`を実行した際に`devDependency`はインストールされずビルドエラーが発生するなどという凡ミスから救ってくれます。<br />
(`to.pathNot`には`Webpack`でいう`externals`に設定するパッケージを指定したりできます。)

#### `not-to-spec`

```js
forbidden: [{
  severity: 'error',
  from: {},
  to: {
    path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
  },
}]
```
`.spec.ts`ファイルのimportを禁止します。あってはならないことをしっかり守れます。

### ドキュメントから

#### `features-not-to-features`

```js
forbidden: [
  {
    name: "features-not-to-features",
    comment: "One feature should not depend on another feature (in a separate folder)",
    severity: "error",
    from: { path: "(^features/)([^/]+)/" },
    to: { path: "^$1", pathNot: "$1$2" },
  },
];
```

Repoの画像を引用しますが、このように `features` ディレクトリの中に、いくつか `feature` があるとして、それぞれの`feature`は独立させておきたいということがあります。

![features-not-to-features](https://raw.githubusercontent.com/sverweij/dependency-cruiser/develop/doc/recipes/isolating-peer-folders/rules-applied.svg)
([レポ](https://github.com/sverweij/dependency-cruiser/blob/develop/doc/rules-tutorial.md))

図の通り、`feature`間で依存してしまっている部分をアラートしてくれています。ルールの記述は`to`部分がちょっと特徴的ですが、正規表現のキャプチャグループを利用して定義することが出来ます。

#### `no-unshared-utl`

```js
forbidden: [
  {
    name: "no-unshared-utl",
    from: {
      path: "^features/",
    },
    module: {
      path: "^common/",
      numberOfDependentsLessThan: 2,
    },
  },
];
```

図の通り、`common`ディレクトリに共有するコードを集めたとします。この時、どこからも参照されていない物や、1箇所からしか参照されていないモジュールは共有ディレクトリに存在する必要がありません。

![no-unshared-utl](https://github.com/sverweij/dependency-cruiser/raw/develop/doc/recipes/shared-or-not/before.svg)
([レポ](https://github.com/sverweij/dependency-cruiser/blob/develop/doc/rules-tutorial.md))

共有ディレクトリは膨らんで行ったり、特定のユースケースに特化して独自化したコードが散らかったりするので、エラーにせずともwarnにしておくだけでリファクタリングのヒントになります。
ちなみに、`numberOfDependentsLessThan`を利用する場合は、`to` => `module`に変わっています。

#### `deprecateしたモジュールに、追加の依存を発生させないようにする`

あるモジュールをdeprecatedにしても、すぐに削除できない場合があります。その場合は以下の通り2つのルールを組み合わせて、新規の依存を規制することができます。

```js
// deprecatedモジュールへの参照を許容する対象
// (既存で依存している物など)
const KNOWN_DEPRECATED_THINGY_DEPENDENTS = [
  "^src/features/search/caramba\\.ts$",
  /* ... */
];

{
  forbidden: [
    {
      name: "deprecatedモジュールへの依存",
      severity: "error",
      from: {
        pathNot: [
          "^src/common",
            ...KNOWN_DEPRECATED_THINGY_DEPENDENTS
        ]
      },
      to: {
        path: "^src/common/deprecated",
      }
    },
    {
      name: "既存の許容するdeprecatedモジュールへの依存",
      severity: "warn",
      from: {
        path: KNOWN_DEPRECATED_THINGY_DEPENDENTS,
        pathNot: [
          "^src/common"
        ]
      },
      to: {
        path: "^src/common/deprecated",
      }
    }
  ]
}
```

1つ目のルールで新規の依存をエラーさせ、2つ目のルールでは既存の依存を警告だけします。

# 活用

## Github Actionsで実行し、PRに自動コメントする

`dependency-cruiser`には`depcruise-fmt`というコマンドが入っており、`json`で吐き出された結果を別のレポーター形式に変換することができます。
`GithubActions`上で実行する際は、標準出力への表示(`err`レポーター)と、PRへの自動コメント(`markdown`レポーター)の2種類の形式で結果を表示したいため、`depcruise-fmt`を活用します。

workflowの例
```yaml
- name: 1. dependency-cruiserを実行する
  run: depcruise --config .dependency-cruiser.js -T json > result.json

- name: 2. 結果を標準出力する
  run: depcruise-fmt -T err result.json

- name: 3. Markdown出力用のファイルを作成し、タイトルを書き込む
  run: echo '# dependency-cruiserによる依存関係分析結果' > comment.md

- name: 4. 結果をMarkdowでファイルに書き込む
  run: depcruise-fmt -T markdown >> comment.md

- name: 5. find-commentを利用して、既存の自動コメントがあれば置き換えます
  uses: peter-evans/find-comment@v2
  id: fc
  with:
    issue-number: ${{ github.event.pull_request.number }}
    comment-author: 'github-actions[bot]'
    body-includes: dependency-cruiserによる依存関係分析結果

- name: 6. コメントを投稿する
  uses: peter-evans/create-or-update-comment@v2
  with:
    comment-id: ${{ steps.fc.outputs.comment-id }}
    issue-number: ${{ github.event.pull_request.number }}
    body-file: comment.md
    edit-mode: replace
    token: ${{ secrets.GITHUB_TOKEN }}
```

## 個人的におすすめのルール

### 内部パッケージの定義

TSは仕様上`export`をするとプロジェクト全体に公開されてしまうので、例えば大きなファイルをいくつかのファイルに整理整頓して分割すると、分割した定義はglobalに`export`されてしまいます。
これを`import`できる範囲を絞ることができれば、無駄な依存関係の可能性を相当削減することができます。
そこで、内部パッケージチックな挙動を実現するルールを作成してみたので、アイディアとして参考にしていただけると幸いです。

ルールは以下の2つで、

1. `_`から始まるディレクトリ内部のファイルは、同ディレクトリ内のファイルまたは、直上のディレクトリのファイルからのみimport可能
  - ex) `src/entity/_private/some.ts`をimportできるのは、`src/entity/*.ts`, `src/entity/_private/*.ts`
2. 2. `_`から始まるファイルは同階層に置かれたファイルからのみimport可能
  - ex) `src/entity/_private.ts`をimportできるのは、`src/entity/*.ts`

ただし、ユニットテストから読み込むことがあるので、`*.spec.ts`には制約がかからない様にしています。

```js
forbidden: [
  {
    name: `1. '_'から始まるディレクトリ内部のファイルは、同ディレクトリ内のファイルまたは、直上のディレクトリのファイルからのみimport可能`,
    severity: 'error',
    from: { path: ['(.*)\\/.*\\.ts'], pathNot: ['.*\\.spec\\.ts$'] },
    to: {
      path: ['_\\w+\\/\\w+\\.ts$'],
      pathNot: ['$1\\/_\\w+\\/\\w+\\.ts$', '$1\\/\\w+\\.ts$'],
    },
  },
  {
    name: `2. '_'から始まるファイルは同階層に置かれたファイルからのみimport可能`,
    severity: 'error',
    from: { path: ['(.*)\\/.*\\.ts$'], pathNot: ['.*\\.spec\\.ts$'] },
    to: {
      path: ['.*\\/_\\w+.ts$'],
      pathNot: ['$1\\/_\\w+.ts$'],
    },
  },
]
```

こういった1つ1つのルールはある意味自己流にはなりますが、CI化できるのでチームそれぞれに合ったルールをメンバー間で合意し運用していくのも難しくないと思います。

## まとめ

以上で、`dependency-cruiser`の利用法方と、有用なルール・活用法方のご紹介をしました。現職ではDDD + クリーンアーキテクチャーを取り入れながら開発をしているため、アーキテクチャー特性を保護するようなカスタムルールも今後検討していきたいと思います。また、循環参照など、いくつか警告が発生した点もあったので、現状のコードの品質改善やリファクタリングにも順次取り組んでいきたいと思います。

長文になりましたが、ご参考になりましたら幸いです。