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.Bucket
や ec2.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);
}
}
こうして定義したコンストラク トは、スタックや他のコンストラク トなど、他の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);
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が Default
と Api
の2つのリソースを定義してみました。このとき、合成後のCFnテンプレートは以下のようになります:
"BackendApi7423A747" : {
"Type" : "AWS::ApiGatewayV2::Api" ,
"Metadata" : {
"aws:cdk:path" : "MyStack/BackendApi/Default/Resource"
} ,
"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" );
new BackendApi( this , "Api" );
}
}
ちなみにリソースIDが Default
のリソースノードは Construct.defaultChild
メソッド で取得可能です。Escape hatchを使う ときも少し便利ですね。
リソースIDの命名規則 はPascalCaseがおすすめ
細かい話にはなりますが、リソースIDはPascalCaseで命名 するのがおすすめです。理由はCFnが自動生成する名前を見やすくするためです。より詳細は以前こちらにまとめています:
qiita.com
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 } ,
} );
コンストラク トはCDKを使っていれば日常的に定義するものです。毎度定型コードを入力するのは大変なので、コードスニペット を使って一瞬で入力できると便利です。
スニペット の登録方法はこちら 。また、こちらは私が使っているスニペットのjsonファイル です。ついでに aws-cdk-lib
から各モジュールをインポートするコードも楽に書けるようにしてます。
まとめ
CDKを書く際はぜひコンストラク トも活用してみましょう。