モチベーション
大昔の記事で、CDKスタックをコンストラクトで構造化することをおすすめしました。
この方法を取るとき、ネストの深い子コンストラクトに対して、親の階層からプロパティを渡すのが面倒に感じられることがあります。Propsのバケツリレー やprop drillingなどと呼ばれる問題です。
プロパティを渡すためにはそれぞれにプロパティのinterface定義やプロパティを渡すコードが必要で、それを親・子・孫と伝播させていくのはいかにも大変ですよね。
本記事では、そうした面倒を回避するための方法を提案します。なお、一般に普及した方法ではないので、導入の際は可読性などの観点でチームで議論することを推奨します。
提案する方法
アイデアの概要を、まずはコード例で示します:
// コンストラクトに渡したいプロパティ interface ApiServiceContext { logBucket: Bucket; environment: string; } // ContextProviderのコード (ほぼコピペで良い) export class ApiServiceContextProvider extends Construct { private static contextKey = 'apiService:context'; // ContextProviderごとにユニークな値 constructor(scope: Construct, id: string, props: ApiServiceContext) { super(scope, id); this.node.setContext(ApiServiceContextProvider.contextKey, props); } static getContext(scope: Construct): ApiServiceContext { const context = scope.node.tryGetContext(this.contextKey); if (!context) { // ユーザーの使い方に誤りがあるので、わかりやすいエラーを投げる throw new Error('ApiServiceContextProvider is missing.'); } return context; } } // 実際のコンストラクト export class ApiService extends Construct { constructor(scope: Construct, id: string, props: { apiName: string }) { super(scope, id); // getContextでプロパティを取得 const { logBucket, environment } = ApiServiceContextProvider.getContext(this); // logBucket、environmentを使った実装... } }
これは ApiService
という自作コンストラクトを定義したときの例です。props
を経由せずに、ApiServiceContextProvider
を介して必要な変数 (logBucket
, environment
) が取得されていることが分かります。
このコンストラクトを使う側は、以下のコードを書きます:
export class MyStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const logBucket = new Bucket(this, 'LogBucket'); // ApiService専用のContextProvider let context = new ApiServiceContextProvider(this, 'Default', { environment: 'production', logBucket, }); // contextコンストラクトをscopeにしてコンストラクトを定義 new ApiService(context, 'UserApi', { apiName: 'user' }); // 別のコンストラクトも同様にコンテキストを参照可能 new ApiService(context, 'OrderApi', { apiName: 'order' }); // 孫のConstructも親のContextを参照可能 (prop drillingの回避) const child = new SomeConstruct(context, 'Child', {...}); new ApiService(child, 'Api', { apiName: 'grand-child' }); // 他のContextProviderが必要な場合は、ネストしていけば良い context = new DatabaseContextProvider(context, 'Default', {...}); new Database(context, 'UserDB', {...}); } }
実用的なプロジェクトでの導入例はこちらです。
ポイント
この方法のポイントは下記のとおりです:
1. Reactライクな ContextProvider パターン
Propsを渡す代わりに、ContextProvider という新しい概念を導入します。これはReactのContext APIにインスパイアされたものですが、CDKには元々コンテキストの概念があるため、存外に馴染みの良いものです。ContextProvider は、Propsを使うことなく、コンストラクトツリーの子孫にプロパティを渡すことを可能にします。
2. 暗黙の規約とエラーメッセージ
ApiService を使う際は、必ず ApiServiceContextProvider も合わせて定義する必要があります。これは暗黙の規約ですが、ユーザーはsynth時のエラーメッセージ (ApiServiceContextProvider is missing.) で容易に気づくことができます。Reactで useContext をProviderの外で使ったときと同じ体験ですね。
3. 論理IDへの影響なし
ContextProvider を作成する際は id=Default にすることで、CloudFormation論理IDに影響を与えません。これは、論理ID計算の際に、Default というidは無視されるためです (参照。) 複数種類の ContextProvider が要求される場合は、必要なContextProviderをネストさせます。この時も、すべて id=Default に設定できるので、論理IDに影響は与えません。
利点
概要は伝わりましたか?次に、この方法の嬉しさを説明します。
1. Prop drillingの解消
最大の利点は、深くネストした子コンストラクトに対して、中間層を経由せずに直接プロパティを渡せることです。中間層のPropsインターフェース定義や受け渡しコードが不要になり、コードの見通しが良くなります。
2. 型安全性
ContextProvider は型安全に利用できるため、ユーザーが誤ったコンテキストを渡すリスクを減らします。また、もし他のコンストラクトからもコンテキストを参照したい場合は、staticメソッドの getContext を使って、型安全にコンテキストを取得できます。
3. コンストラクトの独立性を向上
Propsで渡す場合、interface定義が面倒で、代わりにStackPropsをそのまま渡していくような実装も、よくしてしまいがちでした。これにより、個々のコンストラクトがStack側に依存し、プロジェクト間の再利用性が低下することもしばしばでした。
ContextProvider は、各コンストラクトライブラリがそれぞれ専用のものを提供する想定です。これにより、個々のコンストラクトは自分自身が必要とする情報のみを要求できるため、コンストラクトの独立性が保たれ、再利用性やテスタビリティが向上します。
もちろん、Propsで渡すほうが好都合なこともあるため、ContextProvider で受け取るかPropsで受け取るかは適宜判断が必要になるでしょう。実際、StackPropsをそのまま渡す実装で困ることは多くないですしね。
4. スコープの制御
コンテキストのスコープは、ContextProvider の子以下のスコープに限定されます。また、ContextProvider の子で同じ ContextProvider を作成すると、コンテキストを上書きすることもできます。これらの仕様を理解すれば、異なるコンテキストを要求する別の子コンストラクトがあっても、柔軟に値を渡すことができます。
5. 多言語対応
jsiiの規約に違反しない仕組みのため、多言語向けコンストラクトライブラリで利用することも可能です。
元々のProp drillingの例の図を書き換えると、以下のようになります。Propの受け渡しが減って見やすいですね。
欠点
欠点も考えておきます。
1つ目は、可読性・認知負荷の問題です。ContextProviderのコンストラクトを作成し、それをscopeとして別のコンストラクトを作成する実装は、あまり見慣れないものです。
let context = new ApiServiceContextProvider(this, 'Default', { environment: 'production', logBucket, }); // ここでcontextがscope(第1引数)になる new ApiService(context, 'UserApi', { apiName: 'user' });
React(JSX)的な記法で書くと以下のようなものなのですが、CDKの記法だと少しややこしくなりますね。これは慣れで解決する問題と思います。
// これが <Stack> <ApiService /> </Stack> // こうなっただけ <Stack> <ApiServiceContextProvider> <ApiService /> </ApiServiceContextProvider> </Stack>
2つ目は、id=Default
を使っていることです。Default は1つのスコープに付き1度までしか使えないので、複数の ContextProvider を同じscope内で作成するときには不都合です。
const context = new ApiServiceContextProvider(this, 'Default', { environment: 'production', logBucket, }); // logBucketの異なるコンテキスト。idが被るので作成できない const context2 = new ApiServiceContextProvider(this, 'Default', { environment: 'production', logBucket: anotherLogBucket, });
今回id=Defaultは論理IDに影響を与えないようにするために使っているにすぎないので、その点を妥協すれば、他の文字列でも問題ありません。適当な短い文字列を使うのも有効でしょう。また、上記の状況が生じないように、ContextProvider経由で渡すプロパティはコンストラクト間で差の生じにくいものだけにすることも重要でしょう。
3つ目は、ContextProviderを定義するコードを、コンストラクトごとに書く必要があることです。機械的に書くことはできますが、少々面倒ですね。これはGenericsが使えれば型安全に共通化できそうですが、jsii制約下では静的に定義するしかなさそうです。15行程度の量のため、これくらいなら良いかと思っています。
// ContextProviderごとに、毎度この程度のコードが必要 export class FooContextProvider extends Construct { private static contextKey = 'foo:context'; constructor(scope: Construct, id: string, props: FooServiceContext) { super(scope, id); this.node.setContext(FooContextProvider.contextKey, props); } static getContext(scope: Construct): FooServiceContext { const context = scope.node.tryGetContext(this.contextKey); if (!context) { throw new Error('FooContextProvider is missing.'); } return context; } }
使っていくうちに他にも欠点が見えてくるかもしれませんが、今のところは、致命的な欠点は見つけられませんでした。Prop drillingが煩わしいという方、ぜひお試しください。
おわりに
1年前にこのポストをしていたのですが、ようやく良さげなアイデアが降ってきました。
AWS CDKでも、ネストされたコンストラクトへのpropsバケツリレーが煩雑になることがよくあるので、ReactのuseContext相当のAPIがあると良いかも
— Masashi Tomooka (@tmokmss) October 14, 2024
すでにcontextはあるので、より型安全なAPIで包みたいねhttps://t.co/mlc9z42NT4
いかがでしょうか?ぜひご意見ください!
今四半期のもなちゃん
3ヶ月ぶりの更新になりました 🥺 N度目の抱卵で、最近は翼がぴっちり閉じなくなってます。
背景は消しゴムマジックで消してやりました✌