フロントエンドの依存の可視化、バリデーション
フロントエンドの依存を可視化したい
バックエンドのフルスタックWebフレームワークだとcontrollerはここ、modelはここ、viewはこことあらかじめディレクトリ構造が決まっていることが多いですが、フロントエンドのライブラリーはそれらと比べると自由度が高く、ディレクトリ配置は会社ごとに違うことも多いです。
dependency-cruiserはJavaScript, TypeScript, CoffeeScriptをサポートしている依存の可視化、バリデーションライブラリーであり、ReactやVue、Svelteをサポートしています。
Misskeyのコードベース(Vue)を使ってdependency-cruiserでフロントエンドの依存周りについてどんなことができるか見ていきます。
環境構築
Misskeyのインストール
$ git clone git@github.com:misskey-dev/misskey.git
$ cd misskey
# HEAD ebdb4431804cb23670054a0d37928ba92c84a0a4
$ nodenv install 20.10.0
$ pnpm i
dependency-cruiserのインストール
$ cd packages/frontend/
$ pnpm add -D dependency-cruiser
$ pnpm exec dependency-cruise
Usage: dependency-cruise [options] [files-or-directories]
Validate and visualize dependencies.
Details: https://github.com/sverweij/dependency-cruiser
Options:
--init [oneshot] set up dependency-cruiser for use in your environment (<<< recommended!)
-c, --config [file] read rules and options from [file] (e.g. .dependency-cruiser.js) (default: true)
-T, --output-type <type> output type; e.g. err, err-html, dot, ddot, archi, flat, d2, mermaid, text or json (default:
"err")
-m, --metrics calculate stability metrics (default: false)
-f, --output-to <file> file to write output to; - for stdout (default: "-")
-I, --include-only <regex> only include modules matching the regex
-F, --focus <regex> only include modules matching the regex + their direct neighbours
--focus-depth <number> the depth to focus on - only applied when --focus is passed too. 1= direct neighbors,
2=neighbours of neighbours etc. (default: 1)
-R, --reaches <regex> only include modules matching the regex + all modules that can reach it
-H, --highlight <regex> mark modules matching the regex as 'highlighted'
-x, --exclude <regex> exclude all modules matching the regex
-X, --do-not-follow <regex> include modules matching the regex, but don't follow their dependencies
--ignore-known [file] ignore known violations as saved in [file] (default:
.dependency-cruiser-known-violations.json)
-S, --collapse <regex> collapse a to a folder depth by passing a single digit (e.g. 2). When passed a regex collapses
to that pattern. E.g. "^packages/[^/]+/" would collapse to modules/ folders directly under
your packages folder.
-p, --progress [type] show progress while dependency-cruiser is busy (choices: "cli-feedback", "performance-log",
"ndjson", "none")
-P, --prefix <prefix> prefix to use for links in the dot and err-html reporters
-C, --cache [cache-directory] (experimental) use a cache to speed up execution. The directory defaults to
node_modules/.cache/dependency-cruiser
--cache-strategy <strategy> (experimental) strategy to use for detecting changed files in the cache. (choices: "metadata",
"content")
-i, --info shows what languages and extensions dependency-cruiser supports
-V, --version output the version number
-h, --help display help for command
Other options:
see https://github.com/sverweij/dependency-cruiser/blob/main/doc/cli.md
--initで設定ファイル(.dependency-cruiser.cjs)を生成します。
$ pnpm exec depcruise --init
✔ It looks like this is an ESM package. Is that correct? … yes
✔ Where do your source files live? … src
✔ Do your test files live in a separate folder? … yes
✔ Where do your test files live? … test
✔ Looks like you're using a 'tsconfig.json'. Use that? … yes
✔ Full path to your 'tsconfig.json › tsconfig.json
✔ Also regard TypeScript dependencies that exist only before compilation? … yes
✔ Successfully created '.dependency-cruiser.cjs'
tsconfigを指定することでpath aliasも考慮して依存を解析してくれる模様。
コードスメルの解析
コードスメルを解析します。--include-onlyを指定して外部ファイルが出ないように抑制。
$ pnpm exec depcruise --include-only "^src" src
warn no-orphans: src/scripts/get-user-name.ts
warn no-orphans: src/scripts/collect-page-vars.ts
warn no-orphans: src/components/global/MkError.stories.meta.ts
warn no-circular: src/ui/deck/deck-store.ts →
src/pizzax.ts →
src/account.ts →
src/components/MkSigninDialog.vue →
src/components/MkModalWindow.vue →
src/components/MkModal.vue →
src/os.ts →
src/components/MkDriveSelectDialog.vue →
src/components/MkDrive.vue →
src/components/MkDrive.file.vue →
src/router.ts →
src/pages/settings/deck.vue →
src/ui/deck/deck-store.ts
warn no-circular: src/ui/_common_/common.ts →
src/instance.ts →
src/os.ts →
src/components/MkDriveSelectDialog.vue →
src/components/MkDrive.vue →
src/components/MkDrive.file.vue →
src/router.ts →
src/pages/settings/navbar.vue →
src/navbar.ts →
src/ui/_common_/common.ts
(後略)
no-orphansとno-circularの警告が出ました。
no-orphansが他から使われていない可能性の高い孤立ファイルの検出。今回ならget-user-name.ts、collect-page-vars.tsは可能性が高そうです。
no-circularが循環参照になっている部分の出力。例えばdeck-store.tsがpizzax.tsを使っていて、pizzax.tsがaccount.tsを使っていて、辿っていくとdeck.vueが最初のdeck-store.tsに戻ってしまっているのではないか。
他の設定オプション
dependency-cruiserには設定ファイルで設定できる項目とコマンドラインオプションで設定できる項目が別れています。解析よりのところはファイルで指定します。
生成した.dependency-cruiser.cjsにあらかたの意味合いが書かれています。
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'warn',
comment:
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
from: {},
to: {
circular: true
}
},
{
name: 'no-orphans',
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
"add an exception for it in your dependency-cruiser configuration. By default " +
"this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " +
"files (.d.ts), tsconfig.json and some of the babel and webpack configs.",
severity: 'warn',
from: {
orphan: true,
pathNot: [
'(^|/)[.][^/]+[.](js|cjs|mjs|ts|json)$', // dot files
'[.]d[.]ts$', // TypeScript declaration files
'(^|/)tsconfig[.]json$', // TypeScript config
'(^|/)(babel|webpack)[.]config[.](js|cjs|mjs|ts|json)$' // other configs
]
},
to: {},
},
{
name: 'no-deprecated-core',
comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"bound to exist - node doesn't deprecate lightly.",
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'core'
],
path: [
'^(v8/tools/codemap)$',
'^(v8/tools/consarray)$',
'^(v8/tools/csvparser)$',
'^(v8/tools/logreader)$',
'^(v8/tools/profile_view)$',
'^(v8/tools/profile)$',
'^(v8/tools/SourceMap)$',
'^(v8/tools/splaytree)$',
'^(v8/tools/tickprocessor-driver)$',
'^(v8/tools/tickprocessor)$',
'^(node-inspect/lib/_inspect)$',
'^(node-inspect/lib/internal/inspect_client)$',
'^(node-inspect/lib/internal/inspect_repl)$',
'^(async_hooks)$',
'^(punycode)$',
'^(domain)$',
'^(constants)$',
'^(sys)$',
'^(_linklist)$',
'^(_stream_wrap)$'
],
}
},
{
name: 'not-to-deprecated',
comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'deprecated'
]
}
},
{
name: 'no-non-package-json',
severity: 'error',
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
"available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " +
"in your package.json.",
from: {},
to: {
dependencyTypes: [
'npm-no-pkg',
'npm-unknown'
]
}
},
{
name: 'not-to-unresolvable',
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
from: {},
to: {
couldNotResolve: true
}
},
{
name: 'no-duplicate-dep-types',
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
"in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " +
"maintenance problems later on.",
severity: 'warn',
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ["type-only"]
}
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-test',
comment:
"This module depends on code within a folder that should only contain tests. As tests don't " +
"implement functionality this is odd. Either you're writing a test outside the test folder " +
"or there's something in the test folder that isn't a test.",
severity: 'error',
from: {
pathNot: '^(test)'
},
to: {
path: '^(test)'
}
},
{
name: 'not-to-spec',
comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
from: {},
to: {
path: '[.](spec|test)[.](js|mjs|cjs|ts|ls|coffee|litcoffee|coffee[.]md)$'
}
},
{
name: 'not-to-dev-dep',
severity: 'error',
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
from: {
path: '^(src)',
pathNot: '[.](spec|test)[.](js|mjs|cjs|ts|ls|coffee|litcoffee|coffee[.]md)$'
},
to: {
dependencyTypes: [
'npm-dev',
],
// type only dependencies are not a problem as they don't end up in the
// production code or are ignored by the runtime.
dependencyTypesNot: [
'type-only'
],
pathNot: [
'node_modules/@types/'
]
}
},
{
name: 'optional-deps-used',
severity: 'info',
comment:
"This module depends on an npm package that is declared as an optional dependency " +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
"dependency-cruiser configuration.",
from: {},
to: {
dependencyTypes: [
'npm-optional'
]
}
},
{
name: 'peer-deps-used',
comment:
"This module depends on an npm package that is declared as a peer dependency " +
"in your package.json. This makes sense if your package is e.g. a plugin, but in " +
"other cases - maybe not so much. If the use of a peer dependency is intentional " +
"add an exception to your dependency-cruiser configuration.",
severity: 'warn',
from: {},
to: {
dependencyTypes: [
'npm-peer'
]
}
}
],
// 後略
}
- no-circular: 循環参照がないか
- no-orphans: 孤立しているファイルがないか
- no-deprecated-core: 非推奨のnode coreモジュールに依存していないか
- not-to-deprecated: 非推奨のnodeモジュールに依存していないか
- no-non-package-json: package.jsonのdependenciesに書かれていないnpmパッケージを使っていないか
- not-to-unresolvable: 解決できないモジュールを使っていないか
- no-duplicate-dep-types: pakcage.json内の重複の検出
- not-to-test: テストのみを含むフォルダのコードに依存していないか
- not-to-spec: specファイルに依存していないか
- not-to-dev-dep: devDependenciesのみにいるnpmパッケージに依存していないか
- optional-deps-used: オプショナルの依存を使用していないか
- peer-deps-used: peer dependencyに依存していないか
依存のグラフ化
GraphViz dotが入っていれば依存を画像化できます。(インタラクティブなHTMLにも出力可能)
いくつか種類が選べるのですがMisskeyのコードベースはある程度ファイルがあるのである程度まとめてくれるものが良い気がします。
pnpm exec depcruise --include-only "^src" --output-type ddot src | dot -T jpg > dependency-graph.jpg
$ pnpm exec depcruise --include-only "^src" --output-type archi src | dot -T jpg > archi-graph.jpg
コード品質の出力
不安定度(instability)はコード品質を計測する一つの指標です。
- 求心性結合 Ca: 外部から該当モジュールが依存されている数
- 遠心性結合Ce: モジュールが使っている依存の数
としたとき、不安定度 = Ce / (Ce + Ca)で計算できます。低い方がいいです。
dependency-cruiserだと--output-type metricsで出力できます。
$ pnpm exec depcruise --include-only "^src" --output-type metrics src |grep -v impl
name N Ca Ce I (%)
--------------------------------------------------------------- ------ ------ ------ ------
src/_dev_boot_.ts 1 0 1 100%
src/directives/follow-append.ts 1 0 1 100%
src/scripts/gen-search-query.ts 1 0 1 100%
src/scripts/theme-editor.ts 1 0 1 100%
src/widgets 69 2 144 99%
src/boot/main-boot.ts 1 1 26 96%
src/ui/deck.vue 1 1 26 96%
src/boot 3 2 46 96%
src/pages/admin-user.vue 1 1 22 96%
src/components/index.ts 1 1 20 95%
src/pages/channel.vue 1 1 20 95%
src/pages/settings/general.vue 1 1 20 95%
src/ui/universal.vue 1 1 19 95%
src/pages/instance-info.vue 1 1 18 95%
src/pages/settings/profile.vue 1 1 18 95%
src/pages/about.vue 1 1 16 94%
src/pages/page.vue 1 1 16 94%
src/pages/user 32 6 96 94%
(中略)
src/widgets/WidgetDigitalClock.vue 1 1 0 0%
src/widgets/WidgetFederation.vue 1 1 0 0%
src/widgets/WidgetInstanceCloud.vue 1 1 0 0%
src/widgets/WidgetInstanceInfo.vue 1 1 0 0%
src/widgets/WidgetJobQueue.vue 1 1 0 0%
src/widgets/WidgetMemo.vue 1 1 0 0%
src/widgets/WidgetNotifications.vue 1 1 0 0%
src/widgets/WidgetOnlineUsers.vue 1 1 0 0%
src/widgets/WidgetPhotos.vue 1 1 0 0%
src/widgets/WidgetPostForm.vue 1 1 0 0%
src/widgets/WidgetProfile.vue 1 1 0 0%
src/widgets/WidgetRss.vue 1 1 0 0%
src/widgets/WidgetRssTicker.vue 1 1 0 0%
src/widgets/WidgetSlideshow.vue 1 1 0 0%
src/widgets/WidgetTimeline.vue 1 1 0 0%
src/widgets/WidgetTrends.vue 1 1 0 0%
src/widgets/WidgetUnixClock.vue 1 1 0 0%
src/widgets/WidgetUserList.vue 1 1 0 0%
src/workers 2 2 0 0%
src/workers/draw-blurhash.ts 1 1 0 0%
src/workers/test-webgl2.ts 1 1 0 0%
どちらかというと部品を使う側のpagesが高く、使われる側のwidgetsが低い傾向。
依存関係のマトリクスを出力
HTML出力することで依存関係のマトリクスを出力できます。
componentsがどこのcomponentsに依存してるか、どのcomponentsがどのpagesで使われているかわかると思わぬデグレを防げそうです。
$ pnpm exec depcruise --include-only "^src/components.*vue|^src/pages.*vue" --output-type html src > dependencies.html
MkButtonがいろんなところで使われているのがわかりますね。
諸説ありますが、MkButtonとMkAnnouncementDialogだと粒度が違うのでMkButtonをcomponentsでなくatomsのディレクトリに入れるとコードの見通しがよくなるかもしれません。
最後に
以上です。
ドキュメントにあるように各モジュールが独立しているか(features-not-to-features)、つまりアーキテクチャの遵守度をバリデートするのにも使えるのでかなり汎用性の高いツールと言えそうです。