maybe daily dev notes

私の開発日誌

AWS CDK Tips: コンストラクトで構造化しよう

AWS CDK TIpsシリーズの記事です。

CDKのコードが散らかってきた、スタックのコードがベタに数百行以上もあって読みづらい… そんな状況に直面したことはないですか?今回はCDKコードを構造化して、可読性・保守性を高めるコツを紹介します。

背景

前回の記事で、CDKのスタックを不必要に分けるのは避けたい旨を紹介しました。

tmokmss.hatenablog.com

しかしながら、全リソースを1スタックにまとめたとき生じうる問題の1つが、そのスタックのコードが長大になることです。長いコードは認知負荷も上がります。そもそもコードを読みやすくするためにスタックを手頃なサイズに分割している、という話を聞くことすらあります。

例えば、以下のコードは読みづらくなってきたスタックの例です。ざっと概観を見ていただくだけで大丈夫です。スタック定義の中に必要なリソースがフラットに定義されています。

こうしたコードには次のデメリットがあるでしょう:

  • 1メソッド (constructor) としては長大で、ぱっと理解しづらい
  • すべての変数が同じスコープにあり、変数間の依存関係が分かりづらい
  • リソースID (第2引数) が重複したら1つでもエラーになるため、命名に工夫が必要
    • 前提として、CDKのコンストラクトはコンストラクトツリーとして管理され、1つの階層(scope)の中でリソースIDはユニークである必要があります

界隈の言葉を借りれば、凝集度が低いとも言えると思います。 CDKのベストプラクティスにもあるとおり、ここはCDKのコンストラクトを使ってコードを整理・構造化していきましょう。

コンストラクトで構造化する

構造化とは物事を親子関係で管理する方法だと理解しています。日常生活でも多くの人が無意識に使っていて、例えばドキュメントを書くときに箇条書きでネストさせるのも一つの構造化ですね。また、説明するときに抽象的な段階から徐々に深掘りして具体化するのも構造化の一例でしょう。

CDKコードでも同じ話で、構造化されたコードはメリットが多いです。まずは構造化を実現するための方法から見ていきましょう。

方法

CDKにおいて構造化はコンストラクトを使って行うのが一般的です。これは上記の通りCDKのリソースはコンストラクトツリーで管理されるものだからです。ツリーとはすなわち構造であり、この仕組みを利用しない手はありません。

CDKのコンストラクトは実はいつも皆が目にしています ― s3.Bucketec2.Vpc もコンストラクトの1つなのです。それらはCDKが公式に提供しているものですが、以下のように自分でコンストラクトを定義することも可能です:

import { Construct } from 'constructs';

// 入力のインターフェース
export interface MyConstructProps {
}

// コンストラクトの本体
export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);
    // ここに必要なリソースを定義する
    // 書き方はスタックのconstructor内と同じ
  }
}

こうして定義したコンストラクトは、スタックや他のコンストラクトなど、他のCDKコードから呼び出すことができます。この仕組みを使って、関連するリソースをコンストラクトにまとめ、もともとのフラットなコードを構造化して整理しましょう。

export class MyStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: MyStackProps) {
    super(scope, id, props);

    // 自作コンストラクトの呼び出し
    new MyConstruct(this, "MyConstruct", {});
  }
}

実際にこの方法でスタックを定義しているコード例は、こちらもご覧ください(関連記事)。

github.com

次にこのようなコードのメリットを考えてみます。

構造化のメリット

コンストラクトを使ってそれぞれのリソースを適当な凝集度でまとめれば、冒頭のスタックのコードはこのように整理されます:

export class BackendStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        const auth = new Auth(this, 'Auth');
        const storage = new Storage(this, 'Storage');
        const handler = new Handler(this, 'Handler', {
            userPool: auth.userPool,
            userPoolClient: auth.userPoolClient,
            connectionIdTable: storage.connectionIdTable,
        });
        const websocket = new WebSocket(this, 'Websocket', {
            authHandler: handler.authHandler,
            websocketHandler: handler.websocketHandler,
        });
    }
}

このコードには次のメリットがあるでしょう:

  • スタック内の構成が、一見してより分かりやすい
    • 大きく分けて認証、データ、LambdaのHandler、WebSocket APIの4パーツに分かれていることは少なくとも一目でわかります
  • リソースIDの重複をあまり気にしなくてよい
    • コンストラクトに分けることで、コンストラクトツリーのscopeがスタックから各コンストラクトになるため、重複を注意すべきscopeがより狭くなると言えるでしょう
    • 何百行のコードのなかで重複を気にするのは大変ですが、1つのscope内に定義されるリソースの数が減るため、重複を避けるのはより簡単になります
  • スタックの分割も容易
    • 例えばWebSocket APIやLambda関数だけを別のスタックに切り出したいという場合も、コンストラクト単位で容易にリファクタ可能です (もちろん物理的なリソースの置き換えは考慮する必要がありますが!)
    • 密に関連するリソースが各コンストラクト内にまとまっているためです

また最近はCloudFormationのマネジメントコンソールで、スタック内のリソースをコンストラクトツリーに沿って表示できるようになりました。こちらについてもコンストラクトで構造化していたほうが見やすいので、1つの大きなメリットでしょう。

なお、コンストラクトの良い分け方はケースバイケース(a.k.a. 模索中)です。チームで開発している場合は、メンバーと相談しながら認知負荷の低い形を目指すのも良いでしょう。個人的には、少なくともコンストラクト間で循環依存が発生するような状況さえ避ければ、機能ごとに良い感じに分ければ十分だと思います。一点、S3バケットやDynamoDBテーブルなどステートフルなリソースはリファクタがより困難なので、より慎重に配置したほうが良いかもしれません。

コンストラクトを書く際のTips

以降はいくつかの関連する便利情報をお伝えします。

リソースIDに Default を使いロジカルIDを短縮する

コンストラクトの中にコンストラクトを配置…のようにツリーのネストが深くなると、ロジカルIDが長い問題が生じることがあります。(ロジカルIDはテンプレート合成後のCloudFormationのIDです。)

長いと何が困るかというと、CFnが自動生成するリソース名が分かりづらくなることです。S3バケットやDynamoDBテーブルなどはCFnに自動命名させがちですが、これらはロジカルIDから名前が生成され、ロジカルIDが長い場合は後ろからtruncateされます。結果として ${ロジカルIDの前半 (あまり情報量がない)}-${ランダム文字列} のような名前になってしまうのです。

バケットの区別が付きづらい状況

この問題を緩和するためには、リソースIDに Default という文字列を積極的に使いましょう。リソースIDからロジカルIDを生成する際、DefaultというIDは除去されるためです。例を見てみましょう:

export class BackendApi extends Construct {
  constructor(scope: Construct, id: string, props: BackendApiProps) {
    super(scope, id);
    // リソースIDによるSynth後のテンプレートの違いを見たい
    new HttpApi(this, 'Default');
    new HttpApi(this, 'Api');
  }
}

// スタックのコード
export class MyStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        new BackendApi(this, "BackendApi");
    }
}

リソースIDが DefaultApi の2つのリソースを定義してみました。このとき、合成後のCFnテンプレートは以下のようになります:

// リソースID = DefaultのCloudFormation生成結果
"BackendApi7423A747": {
  "Type": "AWS::ApiGatewayV2::Api",
  "Metadata": {
    "aws:cdk:path": "MyStack/BackendApi/Default/Resource"
  },
  // ...

// リソースID = ApiのCloudFormation生成結果
"BackendApiApi24DA8825": {
  "Type": "AWS::ApiGatewayV2::Api",
  "Metadata": {
    "aws:cdk:path": "MyStack/BackendApi/Api/Resource"
  },
  // ...

リソースIDが Default の場合は、ロジカルIDが少し短くなっていることに注目してください。 BackendApi7423A747 vs BackendApiApi24DA8825 一段階なら少しの変化ですが、ネストが重なると大きな変化です。もちろんリソースIDが Default のリソースは1つのコンストラクトの中に1つまでしか存在できないので、効果的な箇所に使いましょう。

また、この仕組みを使うとリファクタの際も便利です。例えばスタック直下に定義していた "Api" というリソースIDのリソースを別のコンストラクトの中に移動したい場合、ロジカルIDを保ったまま移動可能です。

export class MyStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);
        // このリソースを…
        new HttpApi(this, "Api");

        // BackendApiコンストラクトの中に移動する
        new BackendApi(this, "Api");
        // BackendApiの中でHttpApiはDefaultなので、HttpApiのロジカルIDは保持される
        // 再デプロイしても同一リソースとして扱われ、replacementが発生しない
    }
}

ちなみにリソースIDが Default のリソースノードは Construct.defaultChild メソッドで取得可能です。Escape hatchを使うときも少し便利ですね。

リソースIDの命名規則はPascalCaseがおすすめ

細かい話にはなりますが、リソースIDはPascalCaseで命名するのがおすすめです。理由はCFnが自動生成する名前を見やすくするためです。より詳細は以前こちらにまとめています:

qiita.com

// パスカルケース これが良し (第2引数に注目!)
const upperCamelTable = new Table(this, 'ItemTable', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

// 以下はメリット薄い
const kebabTable = new Table(this, 'item-table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});
const snakeTable = new Table(this, 'item_table', {
  partitionKey: { name: 'id', type: AttributeType.STRING },
});

VSCodeコードスニペットを活用する

コンストラクトはCDKを使っていれば日常的に定義するものです。毎度定型コードを入力するのは大変なので、コードスニペットを使って一瞬で入力できると便利です。

スニペットの登録方法はこちら。また、こちらは私が使っているスニペットのjsonファイルです。ついでに aws-cdk-lib から各モジュールをインポートするコードも楽に書けるようにしてます。

まとめ

CDKを書く際はぜひコンストラクトも活用してみましょう。