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

head ( レポより )

初めに

今年は家庭菜園にハマり出したのですが、世の中には「押し入れ菜園」なる物がある事を知り、仕事部屋の書棚の一段を綺麗にして植物工場を作り始めたhedrallです。

本記事では、dependency-cruiserというツールを利用してTypeScriptのコードベースに依存関係のルールを設定していく方法をご紹介します。

背景

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

そこで、最近は植物工場とコードメトリクスに関心を持ち初めて調査をしていたところ、 こちらの記事 で紹介されたいた dependency-cruiser を知りました。

dependency-cruiserjs, tsファイルの依存関係を可視化したり、ルールを作成してlintすることができるツールです。

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

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

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

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

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

使い方

導入法方

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

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

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

実行

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

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

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

グラフ画像の生成

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

サンプル (公式)

公式のサンプル (レポ)

画像の生成には dot コマンドが利用可能である必要があるので、graphviz を参照して、installします。Macの場合は brew install graphviz するだけです。

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

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

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

設定 (.dependency-cruiser.js)

こちら に記載がある通りです。CLIのoptionに直接記載することも可能ですが、デフォルトで適用したい設定(の一部)は.dependency-cruiser.jsoptionsに指定することができます。

includeOnly

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

exclude, filter

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

tsConfig

tsconfig.jsonの位置を指定します。PathAlias(@src/**など)を利用している場合は、こちらの設定が自動的に反映されます。 例えば、tsconfig.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でのみ指定可能)

こちら の通りですが、安定度というコードメトリクスを計算してくれます。対応するレポータは一部だけで、-T metrics を指定するとみやすいです。

ルール

基本的な書き方

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

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

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

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

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

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

ちなみに、glob表現は使えず正規表現だけです。globから正規表現を自動生成したい場合はこちら の記事も参考にして下さい。

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

有用なルール

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

デフォルトのルールから

no-circular

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

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

no-orphans

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

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

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

no-non-package-json

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

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

not-to-dev-dep

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

not-to-spec

forbidden: [{
  severity: 'error',
  from: {},
  to: {
    path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
  },
}]

.spec.tsファイルのimportを禁止します。あってはならないことをしっかり守れます。

ドキュメントから

features-not-to-features

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 (レポ)

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

no-unshared-utl

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

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

no-unshared-utl (レポ)

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

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

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

// 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の例

- 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
    1. _から始まるファイルは同階層に置かれたファイルからのみimport可能
  • ex) src/entity/_private.tsをimportできるのは、src/entity/*.ts

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

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 + クリーンアーキテクチャーを取り入れながら開発をしているため、アーキテクチャー特性を保護するようなカスタムルールも今後検討していきたいと思います。また、循環参照など、いくつか警告が発生した点もあったので、現状のコードの品質改善やリファクタリングにも順次取り組んでいきたいと思います。

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