AWS CDK Goならではの制約とお作法を考えつつハローワールドしてみた

Go版のAWS CDKならではの記法や作法を調べつつ、入門してみました!制約はありつつも、好きな言語で書けるというのはAWS CDKの素敵なところですね!
2023.03.31

こんにちは。AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。

皆さん、AWS CDKでIaCしてますか?

AWS CDKは短い記述でAWSのベストプラクティスを考慮した一連のリソースが作れたりとメリットがありますよね。

ですがAWS CDKの固有の事情(後述)などで検索して出てくるのはTypeScriptを利用したコードが多いですよね。

私は以前Go言語を学習していたのですが、最近書く機会もあまりなく、書いてみたい衝動に駆られました。

ということで、公式のドキュメントやTypeScript版との相違点を考えながらハローワールドしてみました!

実行環境は以下の通りです。

Key Value
Go 1.20.2
OS macOS Monterey
aws cdk 2.69.0

Go言語使いの方が抑えとくと良いポイント

そもそもAWS CDKはJavaScript(TypeScript)で記述されたライブラリ群をaws/jsiiなどの技術で他言語でも利用できるようにしています。(参考: Introduction - The jsii reference

Go言語をお使いの皆さんから見たら違和感を感じる部分があるかもしれませんが、Goで独自にゼロから実装したライブラリではないことを抑えておくと良いかもしれません。

今回利用しているWorking with the AWS CDK in Go - AWS Cloud Development Kit (AWS CDK) v2でも以下のように記載されています。

Unlike the other languages the CDK supports, Go is not a traditional object-oriented programming language. Go uses composition where other languages often leverage inheritance. We have tried to employ idiomatic Go approaches as much as possible, but there are places where the CDK charts its own course.

意訳: CDKがサポートする他の言語とは異なり、Goは伝統的なオブジェクト指向のプログラミング言語ではありません。他の言語では継承を利用することが多いのですが、Goは合成を利用します。私たちは、できるだけGoの慣用的なアプローチを採用するように努めましたが、CDKが独自の道を歩む場所もあります。

実装を確認しながらハローワールドするまで

今回は主にWorking with the AWS CDK in Go - AWS Cloud Development Kit (AWS CDK) v2を見ながら、実装していきました。

その中で拝見した以下のブログと内容がかぶる点もあるかと思いますが、非常にわかりやすかったのでおすすめです(外部サイト)。

初期化で作られるファイル・ディレクトリ構造

cdk init app --language goでさっそくGo言語用のテンプレートを作ってみます。

これにより生成されたファイル群は以下のような構造となっていました。

.
├── README.md
├── cdk.json
├── go.mod
├── hello_go_cdk.go
└── hello_go_cdk_test.go

TypeScriptを指定した場合は以下のようになるので結構違いがありました。

.
├── README.md
├── bin
│   └── hello_go_cdk.ts
├── cdk.json
├── jest.config.js
├── lib
│   └── hello_go_cdk-stack.ts
├── package-lock.json
├── package.json
├── test
│   └── hello_go_cdk.test.ts
└── tsconfig.json

私が調べた範囲では、Goではディレクトリ構成にユーザーごとのスタイルがあるようです。

たとえば以下のようなスタイルが有名なようです。

自分でPoCやおもちゃのプロジェクトを構築しようとしている場合、このプロジェクトレイアウトはやりすぎです。最初は本当にシンプルなものから始めてください(main.goファイルが1つあれば十分です)

と記載がありますが、CDKの初期化でも作られるのは${プロジェクト名}.go(上の例ではhello_go_cdk.go)だけで、後はプロジェクトに応じてユーザーが作っていく感じなのかなと理解しました。

実装内容

少し長いので折りたたんでいますが、hello_go_cdk.goは初期化時点で以下のような実装でした。

初期状態

hello_go_cdk.go

package main

import (
	"github.com/aws/aws-cdk-go/awscdk/v2"
	// "github.com/aws/aws-cdk-go/awscdk/v2/awssqs"
	"github.com/aws/constructs-go/constructs/v10"
	"github.com/aws/jsii-runtime-go"
)

type HelloGoCdkStackProps struct {
	awscdk.StackProps
}

func NewHelloGoCdkStack(scope constructs.Construct, id string, props *HelloGoCdkStackProps) awscdk.Stack {
	var sprops awscdk.StackProps
	if props != nil {
		sprops = props.StackProps
	}
	stack := awscdk.NewStack(scope, &id, &sprops)

	// The code that defines your stack goes here

	// example resource
	// queue := awssqs.NewQueue(stack, jsii.String("HelloGoCdkQueue"), &awssqs.QueueProps{
	// 	VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)),
	// })

	return stack
}

func main() {
	defer jsii.Close()

	app := awscdk.NewApp(nil)

	NewHelloGoCdkStack(app, "HelloGoCdkStack", &HelloGoCdkStackProps{
		awscdk.StackProps{
			Env: env(),
		},
	})

	app.Synth(nil)
}

// env determines the AWS environment (account+region) in which our stack is to
// be deployed. For more information see: https://docs.aws.amazon.com/cdk/latest/guide/environments.html
func env() *awscdk.Environment {
	// If unspecified, this stack will be "environment-agnostic".
	// Account/Region-dependent features and context lookups will not work, but a
	// single synthesized template can be deployed anywhere.
	//---------------------------------------------------------------------------
	return nil

	// Uncomment if you know exactly what account and region you want to deploy
	// the stack to. This is the recommendation for production stacks.
	//---------------------------------------------------------------------------
	// return &awscdk.Environment{
	//  Account: jsii.String("123456789012"),
	//  Region:  jsii.String("us-east-1"),
	// }

	// Uncomment to specialize this stack for the AWS Account and Region that are
	// implied by the current CLI configuration. This is recommended for dev
	// stacks.
	//---------------------------------------------------------------------------
	// return &awscdk.Environment{
	//  Account: jsii.String(os.Getenv("CDK_DEFAULT_ACCOUNT")),
	//  Region:  jsii.String(os.Getenv("CDK_DEFAULT_REGION")),
	// }
}

Working with the AWS CDK in Go - AWS Cloud Development Kit (AWS CDK) v2にも記載があるように、Go言語に合わせてパスカルケースでの命名となっていますね。

Go言語ではパッケージ内で大文字から始まる型、グローバル変数や関数などは公開シンボルとして取り扱います。(※ 別パッケージなど外部から参照したり呼び出したい関数や変数は大文字から始める)

	NewHelloGoCdkStack(app, "HelloGoCdkStack", &HelloGoCdkStackProps{
		awscdk.StackProps{
			Env: env(),
		},
	})

また、Working with the AWS CDK in Go - AWS Cloud Development Kit (AWS CDK) v2に記載されているように、各種パラメータは文字列や数値などのプリミティブな型を使わないようです。

以下のjsii.String("HelloGoCdkQueue")jsii.Number(300)のような形式を取ります。

	queue := awssqs.NewQueue(stack, jsii.String("HelloGoCdkQueue"), &awssqs.QueueProps{
	    VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)),
	})

In Go, missing values in AWS CDK objects such as property bundles are represented by nil. Go doesn’t have nullable types; the only type that can contain nil is a pointer. To allow values to be optional, then, all CDK properties, arguments, and return values are pointers, even for primitive types. This applies to required values as well as optional ones, so if a required value later becomes optional, no breaking change in type is needed.

私なりに以下のように理解しました。

  • TypeScriptの場合はオプショナル(必須ではない)なパラメータや引数を利用できる
  • Goでnilを渡せるようにjsii.Number(300)のようなポインタを返す関数を用意している
    • Go言語では関数に対してデフォルト引数がありません
    • 通常 Functional Options PatternBuilder Patternと呼ばれる実装による解決方法があります
    • ですが、上記のようにjsiiでGoでもCDKコードを利用できるようにする制約があるため、このような方法になった(と理解しました)
    • AWS CDK for Goの特徴(TypeScriptとの違い) - 365歩のテックでも同様のことを述べられていると思います

jsii.Stringの実装

// 以下のように与えられた文字列のポインタを返しているだけ
func String(v string) *string { return &v }

ハローワールドしてみる

それっぽいお作法を理解できた気がするので、SSM ParameterStoreのリソースを作ってみます。

GO版のAWS CDKのgodocのトップはこちらですね。

なお、2023年3月31日現在だと、ちょっと一部の表示がおかしいように思えたのでaws/aws-cdkにてIssueとして起票しています。

上記のDirectoriesに各AWSサービスのためのパッケージが確認できます。

今回はawsssmCreating a new SSM Parameters in your CDK appを参考にコードを書いてみました。

まずは必要なパッケージを取得します。

go get github.com/aws/aws-cdk-go/awscdk/v2/awsssm

そして以下のようにhello_go_cdk.goを変更しました。

func NewHelloGoCdkStack(scope constructs.Construct, id string, props *HelloGoCdkStackProps) awscdk.Stack {
	var sprops awscdk.StackProps
	if props != nil {
		sprops = props.StackProps
	}
	stack := awscdk.NewStack(scope, &id, &sprops)

	// The code that defines your stack goes here
	awsssm.NewStringParameter(stack, jsii.String("Hello"), &awsssm.StringParameterProps{
		StringValue:   jsii.String("Hello, Go CDK!!"),
		ParameterName: jsii.String("HelloFromGoCDK"),
	})
	return stack
}
コード全体像
package main

import (
	"github.com/aws/aws-cdk-go/awscdk/v2"
	"github.com/aws/aws-cdk-go/awscdk/v2/awsssm"
	"github.com/aws/constructs-go/constructs/v10"
	"github.com/aws/jsii-runtime-go"
)

type HelloGoCdkStackProps struct {
	awscdk.StackProps
}

func NewHelloGoCdkStack(scope constructs.Construct, id string, props *HelloGoCdkStackProps) awscdk.Stack {
	var sprops awscdk.StackProps
	if props != nil {
		sprops = props.StackProps
	}
	stack := awscdk.NewStack(scope, &id, &sprops)

	// The code that defines your stack goes here
	awsssm.NewStringParameter(stack, jsii.String("Hello"), &awsssm.StringParameterProps{
		StringValue:   jsii.String("Hello, Go CDK!!"),
		ParameterName: jsii.String("HelloFromGoCDK"),
	})
	return stack
}

func main() {
	defer jsii.Close()

	app := awscdk.NewApp(nil)

	NewHelloGoCdkStack(app, "HelloGoCdkStack", &HelloGoCdkStackProps{
		awscdk.StackProps{
			Env: env(),
		},
	})

	app.Synth(nil)
}

func env() *awscdk.Environment {
	return &awscdk.Environment{
		Region: jsii.String("ap-northeast-1"),
	}
}

この状態でcdk deployコマンドを実行することで、実際にAWS上にリソースが作られていることを確認しました!

20230331_cdk_go_hello_parameter

テストを書いてみる

初期化した段階ではhello_go_cdk_test.goは以下のようにコメントアウトされていました。

package main

// import (
// 	"testing"

// 	"github.com/aws/aws-cdk-go/awscdk/v2"
// 	"github.com/aws/aws-cdk-go/awscdk/v2/assertions"
// 	"github.com/aws/jsii-runtime-go"
// )

// example tests. To run these tests, uncomment this file along with the
// example resource in hello_go_cdk_test.go
// func TestHelloGoCdkStack(t *testing.T) {
// 	// GIVEN
// 	app := awscdk.NewApp(nil)

// 	// WHEN
// 	stack := NewHelloGoCdkStack(app, "MyStack", nil)

// 	// THEN
// 	template := assertions.Template_FromStack(stack)

// 	template.HasResourceProperties(jsii.String("AWS::SQS::Queue"), map[string]interface{}{
// 		"VisibilityTimeout": 300,
// 	})
// }

まずはコメントアウトを外しつつ以下のように生成されるCloudFormationのテンプレートに特定のパラメータが含まれていることを確認しました。

func TestHelloGoCdkStack(t *testing.T) {
	app := awscdk.NewApp(nil)
	stack := NewHelloGoCdkStack(app, "MyStack", nil)
	template := assertions.Template_FromStack(stack, nil)
	// template should have a resource of type AWS::SSM::Parameter
	template.HasResourceProperties(jsii.String("AWS::SSM::Parameter"), map[string]interface{}{
		"Name": "HelloFromGoCDK",
	})
}

この状態でgo testを実行するとPASSとなります。

次にスナップショットテストを書きたくて方法を探してみたのですが、以下の参考記事によるとbradleyjkemp/cupaloyが利用できるようですので、参考にして書き足してみました。

go get github.com/bradleyjkemp/cupaloy/v2を実行したのち、以下のようにスナップショットテストを書き足します。

func TestHelloGoCdkStackSnapshot(t *testing.T) {
	app := awscdk.NewApp(nil)
	stack := NewHelloGoCdkStack(app, "MyStack", nil)
	template := assertions.Template_FromStack(stack, nil)
	cupaloy.SnapshotT(t, template.ToJSON())
}
テストファイル全体像
package main

import (
	"testing"

	"github.com/aws/aws-cdk-go/awscdk/v2"
	"github.com/aws/aws-cdk-go/awscdk/v2/assertions"
	"github.com/aws/jsii-runtime-go"
	"github.com/bradleyjkemp/cupaloy/v2"
)

// Resource properties check test
func TestHelloGoCdkStack(t *testing.T) {
	app := awscdk.NewApp(nil)
	stack := NewHelloGoCdkStack(app, "MyStack", nil)
	template := assertions.Template_FromStack(stack, nil)
	// template should have a resource of type AWS::SSM::Parameter
	template.HasResourceProperties(jsii.String("AWS::SSM::Parameter"), map[string]interface{}{
		"Name": "HelloFromGoCDK",
	})
}

// Snap Shot test
func TestHelloGoCdkStackSnapshot(t *testing.T) {
	app := awscdk.NewApp(nil)
	stack := NewHelloGoCdkStack(app, "MyStack", nil)
	template := assertions.Template_FromStack(stack, nil)
	cupaloy.SnapshotT(t, template.ToJSON())
}

この状態でgo testを実行することで無事PASSできました。

最後に

  • Go版のCDKならではの書き方・制約がある
    • 制約の理由や解消法を調べると案外楽しかったです
  • とはいえ、好きなGoでかけるのは楽しい

今回はとりあえずハローワールドだけしたのですが、次回移行にもう少しソースコードを読み込んでTypeScriptとGoの違いがどのようになっているのか確認したいと思います。

以上今泉でした。この記事が誰かの時間を1秒でも削れればうれしいです。