[AWS CDK] Construct IDはパスカルケースで命名するのが良い

[AWS CDK Tipsシリーズ](https://tmokmss.hatenablog.com/entry/aws_cdk_tips)の記事です。

## TL;DR
CDKでConstructのコンストラクタ第2引数で渡すIDは、パスカルケース (`PascalCase`) にしましょう。
理由は、CDK (CloudFormation) により自動で命名されるリソースの名前を見やすくするためです。

## 前提知識
この記事の前提となる知識をまとめます。すでにご存知の方は読み飛ばしてください。

### Construct IDとLogical ID
Construct IDは、CDKでConstructをnewするときに第2引数に渡す文字列です。
これはConstruct treeの階層(scope)内でのユニークなIDとなります。

例えば以下のコードでは、 `ItemTable` がConstruct IDです。

```ts:CDKのコード
new Table(this, 'ItemTable', {
    partitionKey: { name: 'id', type: AttributeType.STRING },
});
```

Logical IDは、CloudFormationテンプレート内でリソースを判別するためのIDです。

例えば以下のテンプレートでは、 `ItemTable8236C20F` がLogical IDです。

```json:CloudFormationテンプレート
  "Resources": {
    "ItemTable8236C20F": {
      "Type": "AWS::DynamoDB::Table",
      "Properties": {
        "KeySchema": [
          {
            "AttributeName": "id",
            "KeyType": "HASH"
          }
        ],
        "AttributeDefinitions": [
          {
            "AttributeName": "id",
            "AttributeType": "S"
          }
        ],
      },
      "Metadata": {
        "aws:cdk:path": "SampleStack/ItemTable/Resource"
      }
    }
  }
```

前提知識は以上です。

## なぜConstruct IDはパスカルケースが良いか
今回の記事では、上記のConstruct IDを、パスカルケース (`PascalCase`) で命名することをおすすめします。以下に理由を説明します。

まず大前提として、CDKでは、[**リソースの名前を明示的に指定しないことが推奨**](https://docs.aws.amazon.com/cdk/v2/guide/best-practices.html#:~:text=Use%20generated%20resource%20names%2C%20not%20physical%20names)されています。
これは、下記のような問題を防ぐためのベストプラクティスです:

* 同じアカウントに同じConstructを含むスタックをデプロイしようとしたが、名前の重複でデプロイに失敗する
* デプロイ時に[Replacement型の変更](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html)があった場合、デプロイに失敗する

例外はあるかもしれませんが、多くの場合このプラクティスに沿うのが良いでしょう。

リソース名を明示的に指定しない場合、CDK (より正確にはCloudFormation) は、リソースの**Logical IDから自動で名前を生成**します。例えば、[DynamoDBのテーブル名](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-dynamodb-table.html#cfn-dynamodb-table-tablename)などに、この動作が明記されています。


ではそのLogical IDがどこからくるか？ 実はCDKではConstruct IDからLogical IDが計算されます。[現在最新のコード](https://github.com/aws/aws-cdk/blob/7966f8d48c4bff26beb22856d289f9d0c7e7081d/packages/%40aws-cdk/core/lib/private/uniqueid.ts#L32-L71)では下記の部分です。


```ts:makeUniqueIdメソッドの実装
  const hash = pathHash(components);
  const human = removeDupes(components)
    .filter(x => x !== HIDDEN_FROM_HUMAN_ID)
    .map(removeNonAlphanumeric)
    .join('')
    .slice(0, MAX_HUMAN_LEN);
  return human + hash;
```

`components` の配列の中に、Construct IDの配列 (Construct treeの親からトラバースして順に格納したもの) が入っています (後で具体例を示します。) 返り値の `human + hash` がLogical IDになります。[^1]

今回の話で重要なのは、上記コードの以下2点の動作です:

* `removeNonAlphanumeric` メソッドにより、Construct IDからアルファベットと数字以外の文字は消去される
* `join('')` により、親子関係にあるConstructのConstruct IDはそのまま (区切り文字など無しで) 結合される

結果として、Construct IDに `kebab-case` や `snake_case` を用いた場合、`-` や `_` で単語区切りしていたとしても、その情報はLogical IDから消え去ります。
あるいは `camelCase` を用いた場合は、Constructの親子間でConstruct IDを結合した際に、始めと終わりの単語間の区切りがわからなくなります。

上記の理由から、**Logical IDにおいても単語区切りを明確にするためには、パスカルケースがベスト**となります。

Logical IDはデプロイされるリソースの名前にも使われるため、Logical IDがわかりやすくなることで、結果的にリソース名も分かりやすくなるのです。

## 具体例
理屈はこの辺りにして、実際に具体例を見てみましょう。

ここでは、いろいろな命名規則を試しながらDynamoDB Tableを定義します。

```ts:bin.ts
import { App, Stack, StackProps } from 'aws-cdk-lib';
import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';

const app = new App();
new Stack(app, 'SampleStack');

// パスカルケース
const upperCamelTable = new Table(stack, 'ItemTable', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// ケバブケース
const kebabTable = new Table(stack, 'item-table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// スネークケース
const snakeTable = new Table(stack, 'item_table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// ネストした例を見せるための空コンストラクト
const parent = new Construct(stack, 'Empty');

// パスカルケース (ネストしたコンストラクト)
const upperCamelNestedTable = new Table(parent, 'ItemTable', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// キャメルケース (ネストしたコンストラクト)
const lowerCamelNestedTable = new Table(parent, 'itemTable', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});
```

それぞれ、リソース名は下記にようになります。参考のため、中間のLogical IDも併記しています。

|Construct ID|Logical ID|作成されたDynamoDB テーブルの名前|
|-|-|-|
|✅ `ItemTable`|`ItemTable276B2AC8`|SampleStack-**ItemTable**276B2AC8-VB3DKE2ELB1F|
|🔺 `item-table`|`itemtable72FF5C9E`|SampleStack-**itemtable**72FF5C9E-1N4MKNCQS7AMI|
|🔺 `item_table`|`itemtableA4405118`|SampleStack-**itemtable**A4405118-TTGOKVKJ0LBT|
|✅ `Empty/ItemTable`|`EmptyItemTable8236C20F`|SampleStack-**EmptyItemTable**8236C20F-XRXTXX77TKV0|
|🔺 `Empty/itemTable`|`EmptyitemTableADCE80D1`|SampleStack-**Emptyitem**TableADCE80D1-1JXG5XRQ5T3SG|

ご覧のように、パスカルケース以外ではリソース名の単語区切りが不明瞭になります。
パスカルケースのみ、**リソース名がもともとの単語区切り情報を保持できています**。

上記はDynamoDB Tableの例ですが、StepFunctionのステートマシンやLamba関数など、多くのリソース名で同じことが言えます。[^2]

Construct IDをパスカルケースで命名することで、多くのケースでリソース名が分かりやすくなる事がわかりました。

## まとめ
Construct IDは `PascalCase` で命名するのが無難です。


:::note info
こちら↓の記事もコンストラクトID命名に関する考え方がまとまっているのでオススメです！
[AWS CDK の Construct ID はどのように命名するべきか？](https://dev.classmethod.jp/articles/best-way-to-name-aws-cdk-construct-id/)
:::

[^1]: `hash` の必要性は、`human` だけでは一意にならない可能性があるためです。例えばcomponentsの配列として次のような2つが同じテンプレート内に存在する場合、`hash` がなければLogical IDが重複します: `["Parent1", "Child"], ["Parent", "1Child"]` -> いずれも `human="Parent1Child` になる

[^2]: ただし、S3ではバケット名に大文字のアルファベットを含むことができません。この場合は、CDKが自動で命名するS3のバケット名はどうやっても分かりずらくなります。とはいえ、明示的にバケット名を指定すると、S3バケット名はグローバルでユニークな必要があるため、Constructの再利用可能性が著しく悪化します。S3バケット名については、名前のわかりにくさは妥協してタグを活用するのが良いかもしれません。また、サービスによっては、Logical IDが一定の長さで切り詰められ、前方だけがリソース名の一部に利用されます。このため、Constructが深くネストされている場合(=Logical IDが非常に長い場合)などに、生成されたリソース名がわかりづらくなることもあります。