ESLintだけでは守れない。Dependency cruiserによるアーキテクチャー保護
( レポより )
初めに
今年は家庭菜園にハマり出したのですが、世の中には「押し入れ菜園」なる物がある事を知り、仕事部屋の書棚の一段を綺麗にして植物工場を作り始めたhedrallです。
本記事では、dependency-cruiserというツールを利用してTypeScriptのコードベースに依存関係のルールを設定していく方法をご紹介します。
背景
私はカケハシという会社で入社時に新規プロダクトの立ち上げてから1.5年がったのですが、徐々にコードベースが大きくなってきてコードベースの品質管理の必要性を感じる一方、アーキテクチャーの維持や、チームメンバーとの規約・思想の共有に苦心することが出てきました。
もちろんドキュメントの整備や、ESLint + Prettier
の設定などは行ってきていたのですが、それでカバーできる範囲は限定的です。レビューの工数なども考ると、もっと広範において可能なものは自動テストがかけられる状態を作りたいものです。
そこで、最近は植物工場とコードメトリクス
に関心を持ち初めて調査をしていたところ、 こちらの記事 で紹介されたいた dependency-cruiser を知りました。
dependency-cruiser
はjs
, 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.js
のoptions
に指定することができます。
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
の部分ですが、from
がimport(require)
する側で、to
がexport
する側です。また、中身のpath
, pathNot
などでどのファイルを対象にするかを正規表現で指定します。また、ここで検査されるパスはルートディレクトリからの相対パスになります。TS
でPathAliasを利用している場合は前述の通り、options.tsConfig
にtsconfig.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.json
のdevDependency
に記載されているパッケージへの依存を禁止します。これも気づかないと、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
は独立させておきたいということがあります。
(レポ)
図の通り、feature
間で依存してしまっている部分をアラートしてくれています。ルールの記述はto
部分がちょっと特徴的ですが、正規表現のキャプチャグループを利用して定義することが出来ます。
no-unshared-utl
forbidden: [
{
name: "no-unshared-utl",
from: {
path: "^features/",
},
module: {
path: "^common/",
numberOfDependentsLessThan: 2,
},
},
];
図の通り、common
ディレクトリに共有するコードを集めたとします。この時、どこからも参照されていない物や、1箇所からしか参照されていないモジュールは共有ディレクトリに存在する必要がありません。
(レポ)
共有ディレクトリは膨らんで行ったり、特定のユースケースに特化して独自化したコードが散らかったりするので、エラーにせずとも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つで、
_
から始まるディレクトリ内部のファイルは、同ディレクトリ内のファイルまたは、直上のディレクトリのファイルからのみimport可能
- ex)
src/entity/_private/some.ts
をimportできるのは、src/entity/*.ts
,src/entity/_private/*.ts
-
_
から始まるファイルは同階層に置かれたファイルからのみ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 + クリーンアーキテクチャーを取り入れながら開発をしているため、アーキテクチャー特性を保護するようなカスタムルールも今後検討していきたいと思います。また、循環参照など、いくつか警告が発生した点もあったので、現状のコードの品質改善やリファクタリングにも順次取り組んでいきたいと思います。
長文になりましたが、ご参考になりましたら幸いです。