📝

CDK Constructの依存関係を適切に管理するESLintルール

2024/10/28に公開

概要

Webバックエンドエンジニアをしている山梨といいます。

私は普段TypeScriptを使用してバックエンドを開発しており、モジュールの依存関係を管理するために バレルパターンprivateディレクトリ をよく利用しています。

これらの内容をCDKでの開発に合わせて、それぞれのモジュール管理方法と、モジュール管理を適切に行うためのESLintルールについて紹介します。

1. バレルパターンを使用する場合

バレルパターンとは、複数のモジュールからエクスポートを1つのモジュールに集約するデザインパターンです。
参考: https://typescript-jp.gitbook.io/deep-dive/main-1/barrel

CDKでバレルパターンを利用することで、以下のような利点があります。
内容は、以下のスライドを参考にさせていただきました。

https://speakerdeck.com/gotok365/aws-cdk-reusability?slide=34

1. インポート行が簡潔になる

以下のように、各モジュールを個別にインポートする必要がなくなります

lib/my-stack.ts
// import { Server } app from './constructs/app/server'
// import { Log } from './constructs/app/log'
import * as app from './constructs/app'

2. 呼び出し元でどのモジュールのConstructかがわかりやすくなる

以下のように<モジュール名>.<Construct名>とアクセスすることで、どのモジュールのConstructかがわかりやすくなり、可読性が向上します

lib/my-stack.ts
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')
  }
}
lib/constructs/app/index.ts
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
lib/constructs/app/index.ts
export { Server } from './server' // Serverだけをエクスポートする

このような利点を発揮するために、バレルパターンを使用しているディレクトリでは、Constructのインポートは必ずindex.tsから行うようにルール化するのが良さそうです。

しかし、Lintルールがない場合、index.ts以外のファイルからConstructをインポートしても(エディタ上など)でエラーにならず、ルール違反に気付きにくいです。
そのため、このルールを強制させるためにESLintのルールを作成していきます。

ESLintルール

各tsファイルの内容
lib/constructs/app/log.ts
export class Log {}
lib/constructs/app/server.ts
import { Log } from './log'

export class Server {
   constructor(/** ...some props */) {
     new Log() // Log Constructを呼び出し
   }
}
lib/constructs/app/index.ts
export { Log } from './log'

ESLintのルールは、no-restricted-importsを使用しています。

https://typescript-eslint.io/rules/no-restricted-imports/#how-to-use

https://eslint.org/docs/latest/rules/no-restricted-imports

eslint.config.mjs
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配下の全てのディレクトリにバレルを導入する場合は、以下のようなルールを利用できます

eslint.config.mjs
// 省略
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ファイルの内容
lib/constructs/app/private/log.ts
export class Log {}
lib/constructs/app/server.ts
import { Log } from './private/log'

export class Server {
   constructor(/** ...some props */) {
     new Log() // Log Constructを呼び出し
   }
}

ESLintのルールは、no-restricted-importsを使用しています。

eslint.config.mjs
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での書き方を紹介しました。

https://eslint.org/blog/2024/09/eslint-v8-eol-version-support/

おまけ

おまけとして、私がCDKで使用しているESLintルールを紹介します。
(おすすめのルールなどありましたらコメントいただきたいです!)

eslint.config.mjs
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",
    ],
  }
);

参考

https://speakerdeck.com/gotok365/aws-cdk-reusability

https://typescript-jp.gitbook.io/deep-dive/main-1/barrel

https://eslint.org/docs/latest/rules/no-restricted-imports

Discussion