『AWS CDK Advent Calendar 2023』21日目の記事です。
目次
GoFデザインパターンとは
「オブジェクト指向におけるコード設計のデザインパターン」といったもので、具体的には、およそ30年ほど前に出版された書籍『オブジェクト指向における再利用のためのデザインパターン(※)』で紹介されているパターン集のことです。
この書籍は通称『GoF本』と呼ばれている本で、GoFとは「Gang of Four」の略で、この共著者の4人のことを表すそうです。紹介されている手法は全23パターンから成ります。
※『オブジェクト指向における再利用のためのデザインパターン』 (ソフトバンクパブリッシング) 著: エーリヒ・ガンマ、ラルフ・ジョンソン、リチャード・ヘルム、ジョン・ブリシディース 監訳: 本位田真一, 吉田和樹
AWS CDKではGoFデザインパターンが使われている?
はい。まず、AWS CDKはTypeScriptで書かれたOSS(私もコントリビュートさせていただいています!)であり、巨大なコード・モジュール群から成り立っています。そしてその巨大なコードの中には、実はその「GoFデザインパターン」が一部使われているのです。
AWS CDKではどのGoFデザインパターンが使われている?
この記事の本題となりますが、その巨大なOSSであるAWS CDKで使われているGoFデザインパターンを3つご紹介します。
Strategyパターン
Strategyパターンとは?
クライアント(呼び出し元)と独立してアルゴリズムを定義し、かつクライアントが意識せず動的に振る舞いを変更可能にする手法です。
少し言い換えると、何かのロジック(アルゴリズム)をいくつか定義し、それらを呼び出す際に分岐処理いらずで処理を切り替えられるというようなことができるようになります。
CDKのどこで使われている?
「Validation」という機能で使われています。
※Validation機能の詳細は以下記事をご覧ください。
この「Validation」という機能についてざっくり書くと、CDKにおけるnodeクラス(Construct・Stackクラスなどが持つインスタンス)が持つ「addValidation()」というメソッドを使ってバリデーションルールを登録し、cdk synth, deploy時にバリデーションを発火してくれる機能です。
addValidationメソッドの引数には、「IValidation」というinteface型のインスタンスを渡します。IValidationはvalidateというメソッドを実装する必要があるインターフェースです。(つまり、IValidationをimplementsするクラスを作り、validateメソッドを作ってその中にバリデーションロジックを定義し、そのクラスのインスタンスをスタックやコンストラクト内で生成し、node.addValidationメソッドの引数に渡します。)
具体的には以下のCDKコードのように使用します。
このaddValidationメソッドによって、nodeクラス内部(ユーザからはアクセスできない)の「_validations」という変数(配列型)に先ほど定義したIValidationを実装するクラスのインスタンスが格納され、cdk synth, deployの中で、nodeクラスの持つ「validate()」というメソッドがこの_validationsに格納されたバリデーションロジックを順次発火してくれる、という仕組みになります。
そしてこれのどこがStrategyパターンなの?と言う話をすると、まずこのCDKにおけるValidation機能で登場するクラス群をクラス図で表すと以下のようになります。
このうち、Strategyパターンに関するクラスを抽出すると、以下のようになります。
どうでしょうか。先ほど上げた緑色のStrategyパターンの概念クラス図と同じに見えませんか?(以下再掲)
そして、「内部で使われている」というからには、CDKの内部実装にも触れないといけませんよね。
上記で説明したValidation機能のCDK内部実装における該当コードを記載します。
※こちらの該当コードはいわゆるCDK本体と言われるaws-cdkリポジトリでなくconstructsリポジトリになります。
https://github.com/aws/constructs/blob/10.x/src/construct.ts
Nodeクラスの実装、具体的にはその中にある_validations
変数、addValidation
メソッド、validate
メソッドを取り上げます。
まず、addValidationで渡したバリデーションロジッククラスのインスタンスを格納する_validations変数です。
これがaddValidationメソッドです。先ほど例にあげたCDKコードでスタッククラスで呼び出したメソッドです。
そして、メインのvalidateメソッドです。このメソッドは、ユーザーが直接CDKスタッククラスなどで呼ぶものではなく、cdk synth, deployによって発火されるメソッドとなります。
具体的には、このvalidateメソッドがStrategyパターンにあたります。
赤枠で囲ったところを見て見ます。
this._validations
という配列変数(バリデーションクラスを格納)をループで回し、各要素のvalidate()
メソッドを呼んでいます。このvalidate()
メソッドは、先ほどIValidationインターフェースを実装して作った自作クラスで定義したvalidateメソッドです。つまり、自作のバリデートメソッドが呼ばれています。
この処理がまさにStrategyパターンなんです。
このループで回している配列の各要素は、実態は自分で作った自作バリデーションクラスのインスタンスですが、IValidationをimplementsする、IValidation interface型です。そしてIValidationというinterfaceであれば必ずvalidate()メソッドを持っています。
つまり、_validationsの各要素が何の実クラスなのか知らなくとも、配列の各要素はvalidate()メソッドを持つIValidation型であるため、呼び出し元(nodeクラス)からすると何かよくわからないけど配列に格納されたインスタンスのメソッドをただ呼び出すだけで、validate()の中身がどんな処理かも知らずに実行できる、という仕組みになります。これにより、配列の要素がバリデーターAクラスなのかバリデーターBクラスなのか、パラメータの数値の上限値をバリデートするのか文字数の制限をするのか、どんな内容・クラスであれNodeクラスはこのシンプルなコードで各バリデーションを実行することができるようになるのです。
これは「ポリモーフィズム」という特性を利用した手法です。
個人的な意見ですが、現代のオブジェクト指向での設計・コーディングにおいて、StrategyパターンはすべてのGoFデザインパターンの中でも最も使いやすく・最も有用なパターンだと思っています。
もしこのような実装をせずに愚直に実装する場合、interfaceでなく実クラスを受け取り、バリデーションクラスの数だけ変数が登場したりif文などの条件分岐を駆使したりして各バリデートメソッドを呼び出すようなことになるので、非常に複雑なコードとなってしまいます。
このStrategyパターンによるポリモーフィズムを用いた抽象化を行うことで、実体・中身を意識せず、条件分岐無しでシンプルに処理の切り替えを行うことができました。
注意ですが、Strategyパターンの真髄はinterfaceを用いて抽象的にインスタンスを扱う、つまり「そのメソッドにinterface型で渡されたインスタンスのメソッドを呼び出す」というところであるので、本例のように「配列をループする」というところは必要ありません。配列でなくとも、引数でinterface型のインスタンスをもらいそのメソッドを実行する、そして呼び出し元はその引数にどんなインスタンスを渡しても、そのインスタンスが該当interfaceを実装するクラスであれば、柔軟にアルゴリズムを切り替えて呼び出すことができる、というところがStrategyパターンとなります。
Singletonパターン
Singletonパターンとは?
あるクラスのコンストラクタが、何度呼び出されても一つしかインスタンスが作成されないことを保証する手法で、一度作られたインスタンスを使い回すというような実装方法となります。
ただし、現代のオブジェクト指向においてあまり推奨されていないパターンとされています。が、CDK内でも使用されていて非常に有用なため(実はいわゆるSingletonパターンとは少し実現方法が異なるので)ご紹介します。
CDKのどこで使われている?
「SingletonFunction」コンストラクトで使われています。
これは、Custom ResourceのハンドラーとしてLambda関数を使う場合などで有用な、何度呼び出されてもLambdaが一つしか作られないことを保証するLambda functionを生成するConstructクラスです。
例えば、CDKスタック内で複数のイメージをECRにpushするようなケースで、「それらのECRのイメージを読み込んで脆弱性がないかスキャンするカスタムリソースLambda」を作成するシチュエーションを考えます。
Lambda.NodejsFunctionやLambda.Functionなどの普通のLambdaで実装する場合、スキャンしたいECRイメージ(インスタンス)ごとにカスタムリソースLambdaを呼び出すようなスタックコードとなりますが、そのカスタムリソースLambdaインスタンスを生成(呼び出す)するたびにLambdaが作られます。
実際は同じコードでカスタムリソース処理を行いたいだけなのに、同じコードのLambdaが呼び出した分だけ生成されてしまうのはリソースがもったいないですよね。
そのような場合、このSingletonFunctionで実装することで、何度そのコンストラクトを呼び出しても1つのLambdaだけが生成されて同じLambdaリソースを使い回すことができるようになり、無駄なリソース削減が可能になります。
さて、SingletonFunctionの説明が長くなりました。Singletonパターンの例としてSingletonFunctionを挙げましたが、実はこのSingletonFunction内部で使われている実装はいわゆるSingletonパターンの実装方法と多少異なり、CDKならではの実装方法になっています。
そのため、先に本来のSingletonパターンの実装をご紹介しておきます。
ここでも一応CDKコードを例に挙げて、本来のSingletonパターンを自前実装してみます。具体的には、みんな大好きNodejsFunctionをカスタマイズして、SingletonなNodejsFunctionコンストラクトを実装してみます。
まず、一番特徴的なのは、constructor
がprivate
となっています。クラスの外、つまりスタッククラス等からconstructor
(new MySingletonFunction()
)経由でこのクラスのインスタンスを生成することはできず、このクラス内部でしかconstructor
を呼び出すことはできません。
代わりにこのクラスは、このクラス自体の型を持つインスタンスをstatic
な変数として定義しておきます(singleton
変数)。
private static singleton: MySingletonFunction; private constructor(scope: Construct, id: string, props: NodejsFunctionProps) { super(scope, id, props); }
そして、static
メソッドとしてgetInstance
メソッドを定義します。static
メソッドとは、クラスのインスタンスを生成せずともそのクラスのメソッドを呼び出すことができるメソッドです。(普通はクラスのインスタンスを生成して変数に格納し、その「変数.メソッド」というような形で呼び出しますが、staticメソッドではインスタンスを生成せずに直接「クラス.メソッド」というように呼び出せます。)
つまりこのクラスのインスタンスをスタッククラスなどで呼び出す場合、通常のようにnew MySingletonFunction()
でインスタンス生成するのではなく、MySingletonFunction.getInstance()
を呼ぶことでこのインスタンスを生成します。
このメソッドの中身は以下になります。
static getInstance( scope: Construct, id: string, props: NodejsFunctionProps, ): MySingletonFunction { if (this.singleton === undefined) { this.singleton = new MySingletonFunction(scope, id, props); } return this.singleton; }
これは、呼び出した時に、先ほどstaticなインスタンス変数として定義したthis.singleton
がundefinedでないか、つまりすでにインスタンスが格納されていないかをチェックします。
undefinedな場合、この変数にこのクラスのインスタンスをセット(new MySingletonFunction
)してあげます。コンストラクタがprivateでしたがこれはクラス内での呼び出しなので呼び出すことができます。
そして、メソッドの戻り値としてこのthis.singleton
、つまり今生成したMySingletonFunctionのインスタンスを返してあげます。
また2回目以降にこのgetInstanceメソッドを呼び出す時は、すでにthis.singleton
にはインスタンスがセットされている(undefinedではない)ため、新たにインスタンスを生成せず、元々セットされていたインスタンスを返します。
このようにして、何度呼び出されても一つのインスタンスしか生成されないようにする(同じインスタンスを使い回す)ということがSingletonパターンの実装になります。
ここまでがSingletonパターンの自前実装でしたが、CDKのSingletonFunctionクラスに戻ります。
CDKのSingletonFunctionクラスは、いわゆるSingletonパターンの実装方法と多少異なり、CDKならではの実装方法になっていると書きました。
では、SingletonFunctionではどのような実装方法でSingletonを保証しているのでしょうか。
まずこのように、SingletonFunctionクラスにはprivate lambdaFunction: LambdaFunction
変数が定義されています。
そしてコンストラクタ内でthis.lambdaFunction = this.ensureLambda(props);
が呼ばれています。
このensureLambda
メソッドはprivateメソッドとなっています。
nodeクラスの持つtryFindChild
メソッドで指定したLambdaリソースがCDKスタック内にあるか確認し、すでにあればそのリソースを返す実装になっています。もしなければ、リソースを生成して返します。
const existing = cdk.Stack.of(this).node.tryFindChild(constructName); if (existing) { return existing as LambdaFunction; } return new LambdaFunction(cdk.Stack.of(this), constructName, props);
このように、いわゆるSingletonパターンの実装方法である、constructorをprivateにしてstaticメソッドを通してインスタンスを生成・使い回す方法ではありません。かつ、CDK特有のtryFindChild
メソッドを使ってチェックしたり、その自身のクラス(SingletonFunction
)とは異なるLambdaFunction
のインスタンスを返している点など、CDK特有の実装方法となっています。ただ、やっていることはまさにSingletonパターンと同じことですよね。
というように、CDKのSingletonFunctionクラスではその名の通りSingletonパターンが使われているのですが、実装方法はCDKならではの方法となっている、というようなご紹介となりました。
Visitorパターン
Visitorパターンとは?
データ構造から操作を分離することで、既存のオブジェクトに対する新しい操作をデータ構造に変更を加えずに追加できる手法です。
CDKのどこで使われている?
「Aspect」という機能で使われています。
実はこのAspectですが、CDKのドキュメントで「Visitorパターンを使用している」と明記されています。
Aspects employ the visitor pattern.
このAspectは、スコープ内の全ての要素に操作の適用・検証する機能で、一括で特定のリソースに同じ設定を加える、みたいな風に使います。コンプライアンス・セキュリティのガードレールなどのために使われることも多いかなと思います。
※以下例は公式ドキュメントで紹介されている「スタック内の全てのS3バケットにバージョニング設定がされているかをチェックして、オンになっていないバケットがあったらエラーにする」ようなAspectの使い方になります。
そして最初にVisitorパターンは、「データ構造から操作を分離することで、既存のオブジェクトに対する新しい操作をデータ構造に変更を加えずに追加できる手法」と書きました。
よくわからないですよね。。。
CDKのAspectを例にお話しすると、「データ構造」が「Resource」や「Construct」のことで、「操作」というのが「Aspect」による検査ロジックになります。
このように「Resource」や「Construct」自体に操作・処理を埋め込むのでなく、別クラス(Aspect用クラス)として「操作」を分離するような構造にすることで、検査ロジックをユーザーが自由に定義できるよね、というお話しになります。
オブジェクト指向ではデータを持つオブジェクト自体がメソッド(操作)を持つのが普通ですよね。もしそれにならって検査ロジックをデータ構造そのものが持った場合、Bucketクラス自身が検査ロジックをメソッドとして持つようになり、CDK提供のクラスは基本的にはユーザーはいじれないので、決まったロジック(CDK側が提供するメソッド)でしか検査することができなくなってしまいます。
それが、このようにデータ構造と操作を分離することで、ユーザーが自由に操作を実装できる、みたいなことができるようになるわけです。
そしてこのAspectですが、実際にCDK内部でどう実装されているかを見てみます。
こちらが、cdk synthなどで実行されるsynthesize
メソッドになります。この中で、invokeAspects
というメソッドが呼ばれています。
この中がまさにVisitorパターンになります。
コード中に載せた説明がそれにあたるのですが、ざっくり言うと、
- CDKスタック内のツリー構造の一番てっぺん(
root
)から走査(≠操作)してスタック内のConstructを見ていき - そのConstruct(=データ構造)に、定義したAspectをループして各Aspectのメソッド(
visit
)(=操作)を適用して - さらにそのConstructの子供達をループして同じ処理を加える
というような処理となっています。これがCDKにおけるAspect、つまりVisitorパターンの実装方法になるわけです。
この処理に登場するクラス群をクラス図に起こしてみるとこのようになります。
しかし、上の方のVisitorパターンの概念図で挙げたクラスの形と何やらあまり一緒には見えません。
実は、CDKでのVisitorパターンと言っているAspectの実装は、Visitorパターンのセオリーとは細かい実装方法が異なるのです。
いわゆるVisitorパターンのセオリー実装は、以下のようになっています。
- ①構造自体(CDKで言うConstruct) がacceptというメソッドを持つ
- ②visitor側がデータ構造の各サブクラスごとの型の引数のメソッドを「全て」用意
- ③acceptメソッドの引数でvisitorを渡し、その中で
visitor.visit(this)
呼び出し- 自分自身を渡すことで(動的に)メソッド/型の選択を行うダブルディスパッチ
- ④ObjectStructure(構造保持オブジェクト)により構造を走査
細かい実装の説明は省きますが、このようなVisitorパターンのセオリー実装はダブルディスパッチと呼ばれる実装手法を用いた方法になります。
しかしこれはデータ構造の種類が限られている前提であり、CDKの様に構造の種類が膨大・かつ可変なケースでは現実的に不可能です。(全てのAspect用クラスが全てのコンストラクト・リソースの「具象」クラスを受け取るvisitメソッドを定義しなければならないため)
そのためCDK(Aspect)ではダブルディスパッチでなく、全てのコンストラクト・リソースの「抽象」(node: IConstruct
)を受け取るvisitメソッドでinstanceof
での操作対象リソース分岐が必要になります。
(※こちらの説明はかなり端折っているためスルーで大丈夫です。)
というように、CDKではAspect機能でVisitorパターンが使われているが、いわゆるVisitorパターンのセオリー実装とは異なる、しかしそれはCDKの構造上仕方がないため、代替実装によって賄っている。という内容でした。
補足
AWS Dev Day 2023 Tokyo
実はこのネタ、AWS Dev Day 2023 Tokyoで登壇した「AWS CDKで学ぶGoFデザインパターン〜IaCにもコード設計〜」という発表内容をもとに少し掘り下げて書いてみたものになります。
セッションでは他に7パターンご紹介していて、それらはCDKの内部実装ではなく、CDKでコードを書く際にGoFデザインパターンを使うとどうなるかという内容になっています。
良かったらぜひご覧下さい。
実際どうなの?
上記AWS Dev Day 2023 Tokyoでの発表のスピンオフ的な登壇として、「AWS CDKで"使う"GoFデザインパターン 〜実際どうなの?〜」という発表もしました。
良かったらぜひ。
AWS CDK × GoFデザインパターンのサンプルコード
CDKでGoFデザインパターンをどう使うのか、というサンプルコードをGitHubに載せています。
CDKのスタックやコンストラクトなど、具体的なコードが載っているので、こちらもぜひ見てもらえたら嬉しいです。
まとめ
AWS CDKの内部実装で使われているGoFデザインパターンを3つ紹介しました。
ちょっとマニアックな内容となってしまいましたが、内部実装を見る良いきっかけや、普段の実装で使えるヒントになればいいなと思います。