CDK Constructの依存関係を適切に管理するESLintルール
概要
Webバックエンドエンジニアをしている山梨といいます。
私は普段TypeScriptを使用してバックエンドを開発しており、モジュールの依存関係を管理するために バレルパターン と privateディレクトリ をよく利用しています。
これらの内容をCDKでの開発に合わせて、それぞれのモジュール管理方法と、モジュール管理を適切に行うためのESLintルールについて紹介します。
1. バレルパターンを使用する場合
バレルパターンとは、複数のモジュールからエクスポートを1つのモジュールに集約するデザインパターンです。
参考: https://typescript-jp.gitbook.io/deep-dive/main-1/barrel
CDKでバレルパターンを利用することで、以下のような利点があります。
内容は、以下のスライドを参考にさせていただきました。
1. インポート行が簡潔になる
以下のように、各モジュールを個別にインポートする必要がなくなります
// import { Server } app from './constructs/app/server'
// import { Log } from './constructs/app/log'
import * as app from './constructs/app'
2. 呼び出し元でどのモジュールのConstructかがわかりやすくなる
以下のように<モジュール名>.<Construct名>
とアクセスすることで、どのモジュールのConstructかがわかりやすくなり、可読性が向上します
import * as app from './constructs/app'
export class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
const server = app.Server(this, 'Server')
const lob = app.Server(this, 'Log')
}
}
export { Server } from './server'
export { Log } from './log'
3. どのConstructが外部で使用されるかを意図的に制限できる
以下のようなディレクトリ構成の場合に、外部で使用されるConstructだけをindex.ts
でエクスポートすることで、Constructの公開制限を行うことが可能です
./lib/
├── my-stack.ts
└── constructs
└── server
├── index.ts
├── server.ts // これだけ公開したい
└── log.ts
export { Server } from './server' // Serverだけをエクスポートする
このような利点を発揮するために、バレルパターンを使用しているディレクトリでは、Constructのインポートは必ずindex.ts
から行うようにルール化するのが良さそうです。
しかし、Lintルールがない場合、index.ts
以外のファイルからConstructをインポートしても(エディタ上など)でエラーにならず、ルール違反に気付きにくいです。
そのため、このルールを強制させるためにESLintのルールを作成していきます。
ESLintルール
各tsファイルの内容
export class Log {}
import { Log } from './log'
export class Server {
constructor(/** ...some props */) {
new Log() // Log Constructを呼び出し
}
}
export { Log } from './log'
ESLintのルールは、no-restricted-importsを使用しています。
import eslint from "@eslint/js";
import tsEslint from "typescript-eslint";
export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.strict,
...tsEslint.configs.stylistic,
{
files: ["lib/**/*.ts"],
// 省略
rules: {
// 省略
"@typescript-eslint/no-restricted-imports": [
"error",
{
patterns: [
{
regex: "(\\.?\\.*/)?(constructs/)?/app/(?!index$).*",
message:
"`constructs/app`配下のモジュールは、必ずindex.tsからimportしてください。",
},
],
},
],
},
},
);
実際にserver.ts
のインポートを試みると、エラーになっていることが確認できます
もし、lib/constructs
配下の全てのディレクトリにバレルを導入する場合は、以下のようなルールを利用できます
// 省略
rules: {
"@typescript-eslint/no-restricted-imports": [
"error",
{
"patterns": [
{
"regex": "(\\.?\\.*/)?constructs/[^/]+/(?!index$).*",
}
]
}
]
}
2. privateディレクトリを使用する場合
この項目では、「非公開にしたいモジュール」を privateディレクトリ に配置し、privateディレクトリ内のConstructは外部からインポートできないように強制するESLintルールを紹介します。
バレルパターンでは「外部からインポートされるモジュール(index.ts)」と「外部からインポートされないモジュール(index.ts以外)」を同じ階層に配置していました。
一方privateディレクトリを使用する場合は、以下のように「外部からインポートされるモジュール(private外)」と「外部からインポートされないモジュール(private内)」は別の階層になるため、ディレクトリ構成を見た時に依存関係がより明確になる利点があります。
./lib/
├── my-stack.ts
└── constructs
└── server
├── server.ts // private/log.tsを呼び出している
└── private
└── log.ts
ESLintルール
各tsファイルの内容
export class Log {}
import { Log } from './private/log'
export class Server {
constructor(/** ...some props */) {
new Log() // Log Constructを呼び出し
}
}
ESLintのルールは、no-restricted-importsを使用しています。
import eslint from "@eslint/js";
import tsEslint from "typescript-eslint";
export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.strict,
...tsEslint.configs.stylistic,
{
files: ["lib/**/*.ts"],
// 省略
rules: {
// 省略
"@typescript-eslint/no-restricted-imports": [
"error",
{
patterns: [
{
regex: "(\\.?\\.*/)?(constructs/)?/[^/]+/private/*",
message: "privateディレクトリ内のモジュールはimportできません。",
},
],
},
],
},
},
);
実際にprivate/log.ts
のインポートを試みると、エラーになっていることが確認できます
また、正規表現によりlib/constructs/app/server.ts
からはlib/constructs/app/private
配下のモジュールはインポートできることが確認できます
まとめ
バレルパターンと、privateディレクトリを使用する場合にオススメなESLintルールについて紹介しました。
この記事の執筆段階でESLintのv8がEOLになっていたので、v9のデフォルトであるflatConfigでの書き方を紹介しました。
おまけ
おまけとして、私がCDKで使用しているESLintルールを紹介します。
(おすすめのルールなどありましたらコメントいただきたいです!)
import eslint from "@eslint/js";
import importPlugin from "eslint-plugin-import";
import tsEslint from "typescript-eslint";
export default tsEslint.config(
eslint.configs.recommended,
...tsEslint.configs.strict,
...tsEslint.configs.stylistic,
{
files: ["lib/**/*.ts", "bin/*.ts", "test/**/*.ts"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
projectService: true,
project: "./tsconfig.json",
},
},
plugins: {
import: importPlugin,
},
rules: {
/**
* 無効にするルール
*/
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"no-useless-constructor": "off",
"@typescript-eslint/no-useless-constructor": "off",
/**
* 有効にするルール
*/
"@typescript-eslint/explicit-module-boundary-types": "error",
// NOTE: `@typescript-eslint/require-await`を有効にする場合、`require-await`は無効にする必要がある
// https://typescript-eslint.io/rules/require-await/#how-to-use
"require-await": "off",
"@typescript-eslint/require-await": "error",
// NOTE: `@typescript-eslint/no-empty-function`を有効にする場合、`no-empty-function`は無効にする必要がある
// https://typescript-eslint.io/rules/no-empty-function/#how-to-use
"no-empty-function": "off",
"@typescript-eslint/no-empty-function": "warn",
// NOTE: `@typescript-eslint/no-restricted-imports`を有効にする場合、`no-restricted-imports`は無効にする必要がある
// https://typescript-eslint.io/rules/no-restricted-imports/#how-to-use
"no-restricted-imports": "off",
"@typescript-eslint/no-restricted-imports": [
"error",
{
patterns: [
{
regex: "(\\.?\\.*/)?(constructs/)?/[^/]+/private/*",
message: "privateディレクトリ内のモジュールはimportできません。",
},
],
},
],
"import/order": [
"warn",
{
alphabetize: { order: "asc" },
"newlines-between": "always",
},
],
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "all",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
},
},
// NOTE: `ignores`に指定したパターンはESLintによってグローバルに無視される。
// 参考: https://eslint.org/docs/latest/use/configure/configuration-files#globally-ignoring-files-with-ignores
{
ignores: [
"node_modules",
".vscode",
"package.json",
"jest.config.js",
"*.js",
],
}
);
参考
Discussion