いつかまたパタゴニアに

主にソフトウェア開発周りで気づいたことなどをまとめています

CDKコンストラクトにおける引数としてのクラス設計戦略

CDKのL2コンストラクトを設計する上で、引数の型定義に悩むことは多いと思います。どうにかユーザの使いやすい(L1の複雑さを露出させない)型定義にしたいですよね。

そんな時、ちょいちょいクラス引数を活用することがあります。 いくつかのパターンで経験してきたので、実際の設計戦略例のようなものをまとめてみようと思います。

今の時点での頭の中の整理メモなので、より良いアイデア、設計方針などあればぜひフィードバック頂ければと思います!

Enum-like classes

エンジンバージョンなどにおいて、ある決まったバリエーションのstring型の引数を渡すことがあります。 この時、単純にstringを渡すよりも、Enum-like classesを利用するのがおすすめです。

概要

例として、RDSのTLS通信用CA証明書の定義があります。

export class CaCertificate {
  // Enum likeなファクトリーメソッド
  public static readonly RDS_CA_2019 = CaCertificate.of('rds-ca-2019');

  // コンストラクタ代わりの柔軟なファクトリーメソッド
  public static of(identifier: string) {
    return new CaCertificate(identifier);
  }

  // constructor定義
  // constructorはprivateなため、.of()経由でのインスタンス生成のみ行える
  private constructor(private readonly identifier: string);

  // メンバ変数のgetter
  public toString(): string {
    return this.identifier;
  }
}

ユーザーは以下のように使うことができます。

// 事前に定義されたバージョンの証明書を作成
const ca2019Cert1 = CaCertificate.RDS_CA_2019;

// 未定義の証明書を使いたい場合、stringベタ書きで定義
const ca2019Cert2 = CaCertificate.of('rds-ca-2019');

このようにEnumのようにに事前定義されたバージョンのファクトリーメソッドを呼び出せますし、.of()を使うことで自由なstringを渡すこともできます。

単純なEnumでも似たことは実現できますが、.of()により未定義のstringも設定できるのがEnum like classの強みです。バージョンが追加が激しくenumの更新が間に合わないようなケースに役立つと思います。

.of()とかgetterって必要?

同様なことは、constructorをpublicにすることでも実現できます。

export class CaCertificate {
  public static readonly RDS_CA_2019 = new CaCertificate('rds-ca-2019');

  // constructor定義
  public constructor(public readonly identifier: string);
}

CDKのデザインガイドではこちらの書き方を紹介していましたし、実装量も少ないのでこれでいいんじゃないかな〜と思ってます。

ちょっと小話

本家CDKでは「新バージョン出るたびにファクトリーメソッド追加していくのってキリなくない?もうやめない?」というメンテナもいますが、今のところは新バージョンを追加するPRもどんどんマージされています。 このタイプのPRはとっても簡単なので、未対応なものに気づいたらぜひコントリビュートしてみましょう!

複数の引数をまとめたclass

L1に複数の引数を渡すとき、それぞれが密接に関わり合うようなケースが存在します。 この複雑性をclass内に閉じ込め、ユーザーに余計な負担をかけないことを目指します。

こちらも実例を元に説明します。

 EFSのレプリケーション設定

EFS(Elastic File System)では別のファイルシステムへの自動レプリケーション設定を行うことができます。 レプリケーション先のファイルシステムとしては以下の3パターンがあります。

それぞれの場合において、L1でのパラメータには以下の値を設定します。

レプリケーションタイプ fileSystemId region availabilityZoneName
Regional undefined リージョン名 undefined
One Zone undefined リージョン名 AZ名
Existing レプリケーション先のファイルシステムID undefined undefined

単純にこれらのL1プロパティをL2のプロパティとして全て露出させた場合、ユーザーはこの組み合わせ通りの引数を自身で作成する必要があります。

// Regional
const regionalConfig = {
  region: 'ap-northeast-1'
};

// One Zone
const oneZoneConfig = {
  region: 'ap-northeast-1',
  availabilityZoneName: 'ap-northeast-1a'
};

// Existing
const existingConfig = {
  fileSystemId: 'file-system-id'
};

このオブジェクトに対して、不適切な組み合わせに対するL2内でのバリデーションをしっかり実装はしますが、あまり使いやすいものであるとは言えなさそうです。 そこで、この複雑さをクラス内に閉じ込め、直感的なファクトリーメソッドで設定を作成できるようにします。

最終的なユーザー側での使い方はこちらです。

// Regional File System
// 引数のregion名は省略可能。その場合、スタックがデプロイされるリージョンが自動で指定される。
const regionalCofig = efs.ReplicationConfiguration.regionalFileSystem('ap-northeast-1');

// One Zone File System
const efsManagedSingleAzCofig = efs.ReplicationConfiguration.oneZoneFileSystem('us-east-1', 'us-east-1a');

// Existing File System
declare const destinationFileSystem: efs.FileSystem;
const userManagedConfig = efs.ReplicationConfiguration.existingFileSystem(destinationFileSystem);

各パターンごとにpublic staticなファクトリーメソッドを用意することで、必要な引数だけを正確に設定することができます。 自前で設定を作成するよりも遥かに使いやすいなーと思っていただけるでしょうか。

これを実現するための実装は以下のとおりです。自由な設定でのインスタンスを作られないよう、抽象クラス(ReplicationConfig class)として定義した上で、各設定用のクラスは抽象クラスの具象クラスとして定義しています。

// レプリケーション設定用の抽象クラスを定義
export abstract class ReplicationConfiguration {
  public static existingFileSystem(destinationFileSystem: IFileSystem): ReplicationConfiguration {
    return new ExistingFileSystem({ destinationFileSystem });
  }

  public static regionalFileSystem(region?: string): ReplicationConfiguration {
    return new RegionalFileSystem({ region });
  }

  public static oneZoneFileSystem(region: string, availabilityZone: string): ReplicationConfiguration {
    return new OneZoneFileSystem({ region, availabilityZone });
  }

  public readonly destinationFileSystem?: IFileSystem;
  public readonly region?: string;
  public readonly availabilityZone?: string;

  constructor(options: ReplicationConfigurationProps) {
    this.destinationFileSystem = options.destinationFileSystem;
    this.region = options.region;
    this.availabilityZone = options.availabilityZone;
  }
}

// 抽象クラスをextendsした各パターンごとのclassを定義
// ユーザー管理ファイルシステム用
class ExistingFileSystem extends ReplicationConfiguration {
  constructor(props: ExistingFileSystemProps) {
    super(props);
  }
}
// EFS管理MultiAZファイルシステム
class RegionalFileSystem extends ReplicationConfiguration {
  constructor(props: RegionalFileSystemProps) {
    super(props);
  }
}
// EFS管理SingleAZファイルシステム
class OneZoneFileSystem extends ReplicationConfiguration {
  constructor(props: OneZoneFileSystemProps) {
    super(props);
  }
}

同等なことは、抽象クラスを使わずともconstructor関数をprivateにすることでも実現できます。こちらの方が実装量は減るので見通しは立ちやすいと思います。

export class ReplicationConfiguration {
  public static existingFileSystem(destinationFileSystem: IFileSystem): ReplicationConfiguration {
    return new ReplicationConfiguration({ destinationFileSystem });
  }

  public static regionalFileSystem(region?: string): ReplicationConfiguration {
    return new ReplicationConfiguration({ region });
  }

  public static oneZoneFileSystem(region: string, availabilityZone: string): ReplicationConfiguration {
    return new ReplicationConfiguration({ region, availabilityZone });
  }

  public readonly destinationFileSystem?: IFileSystem;
  public readonly region?: string;
  public readonly availabilityZone?: string;

  private constructor(options: ReplicationConfigurationProps) {
    this.destinationFileSystem = options.destinationFileSystem;
    this.region = options.region;
    this.availabilityZone = options.availabilityZone;
  }
}

個人的には後者が好きなのですが、メンテナには前者の方法を推奨されました。なにか理由があるかもしれません。

デザインガイドとの乖離

CDKのデザインガイドでは、可能な限り引数は並列に定義し、バリデーションで定義パターンを保証するのが推奨されています。(synthで不適切な設定が分かる)

しかし、実際問題としてはクラスに制約を閉じ込めたほうが、TypeScriptの静的解析の時点でバリデーションを行えるため遥かに使いやすいと思っています。 本来はJavaなどの別言語に変換したときに使いやすい(?)ことが理由らしいですが、意外とCDKのメンテナもデザインガイドとは逆の指摘をする方針になりつつあります。個人的にも、デザインガイドに引っ張られすぎずにPRを送っておくと良いかなと思います。

この他の実装例もたくさんありますが、いかにpublic staticなメソッドで直感的にインスタンスを作れるようになるかが共通したポイントです。

個人的に好きな参考実装例をいくつか置いておきます。

  • EventBridge Schedule

    • やや癖のあるstringの生成を、非常に直感的な引数で実現しています。
  • Duration

    • 日頃から猛烈にDuration.minutes(5)と呼び出していましたが、public staticなファクトリーメソッドであることを最近まで意識していませんでした。。

最後に

記事内で紹介したEFSのレプリケーション設定については、AWSCDKの公式Roadmap内で"優れたコミュニティからの貢献"として紹介されました。わーい!

"""Below are some of the great contributions from the community! """

github.com

単純にL1の引数をそのままL2に露出させるだけではなく、今回紹介したようにユーザフレンドリーなファクトリーメソッドを用意したところが評価いただけたのかなと思っています。

ロードマップに継続してPRを紹介してもらえるよう、引き続きちょこちょことコントリビュートしていきたいなと思っています。

追記

CDKのデザインガイドにおけるFlatルールは、ネストした引数を設定することを避けろというものでした。

// For example, instead of:
new Bucket(this, 'MyBucket', {
  bucketWebSiteConfiguration: {
    errorDocument: '404.html',
    indexDocument: 'index.html',
  }
});

// Prefer:
new Bucket(this, 'MyBucket', {
  websiteErrorDocument: '404.html',
  websiteIndexDocument: 'index.html'
});

今回のEFSのケースではInterfaceで定義したreplicationConfigurationを1つのクラスで受け取るようにしたので、むしろデザインガイドに則っているのかもしれません(?)。

// before
new FileSystem(this, 'FileSystem', {
  replicationConfiguration: {
    region: 'us-east-1',
    availabilityZoneName: 'us-east-1a'
  },
}),

// after
new FileSystem(this, 'FileSystem', {
  replicationConfiguration: ReplicationConfiguration.oneZoneFileSystem('us-east-1', 'ud-east-1a'),
}),