AWS CDK for Goの特徴(TypeScriptとの違い)

AWS CDK Advent Calendar 2022」の19日目の記事になります。

qiita.com


目次

目次


概要

AWS CDK for Goを触ってみて、特徴やTypeScriptと違う点などを書いてみました。


前提

ここでご紹介するコードは、1.18.7を使用しています。

また、AWS CDKのバージョンは2.52.0を使用しています。


またこちらの記事で出すAWS CDK for Goの特徴というのは、主にTypeScript版との違いになります。


基本的な特徴はこちら公式に載っていますので、所々それらの話も盛り込んでいます。

docs.aws.amazon.com


特徴(TypeScriptとの違い)

init時のディレクトリ構成

cdk init app --language go後に作成されるディレクトリ構成ですが、Goの場合だと以下のようになります。

cdk-goというディレクトリ(リポジトリ)にしました。

❯ tree
.
├── README.md
├── cdk-go.go
├── cdk-go_test.go
├── cdk.json
└── go.mod

TypeScriptの場合はbinlibtestディレクトリに分かれた上で、package.jsonをもとにモジュールのインストールがinitと同時に行われてnode_modulesも生成されます。

Goの方はこんなにシンプルで階層構造もありません。Goで開発する際はよくフラットなディレクトリ構造が使われるため、それに合わせてでしょうか。


init時のファイル内容

Goでは上記の通り、Goファイルはcdk-go.gocdk-go_test.goしか生成されません。

TypeScriptではbinに生成されたファイルにスタック生成コード、libに生成されたファイルにスタック定義コードが書かれますが、Goの方はどちらもまとめて同じ一つのファイル(cdk-go.go)に書かれます。


これがcdk-go.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 CdkGoStackProps struct {
    awscdk.StackProps
}

func NewCdkGoStack(scope constructs.Construct, id string, props *CdkGoStackProps) 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("CdkGoQueue"), &awssqs.QueueProps{
    //     VisibilityTimeout: awscdk.Duration_Seconds(jsii.Number(300)),
    // })

    return stack
}

func main() {
    defer jsii.Close()

    app := awscdk.NewApp(nil)

    NewCdkGoStack(app, "CdkGoStack", &CdkGoStackProps{
        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")),
    // }
}


CdkGoStackPropsというstructに、NewCdkGoStackmainenvというfunctionがあります。

NewXxxStackがスタック定義で(TypeScriptではlib内)、mainがスタック生成(TypeScriptではbin内)になります。


main()関数にあるdefer jsii.Close()ですが、CDKアプリが自動的にクリーンアップするようにするためのものとのこと(上記公式参照)です。


envという関数はTypeScriptの方にはなくGo版特有の(最初から書かれている)関数で、AccountRegionを持つawscdk.Environmentの構造体を返すものになります。


ちなみに初期化時のテストコードはこちらになります。

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 cdk-go_test.go
// func TestCdkGoStack(t *testing.T) {
//     // GIVEN
//     app := awscdk.NewApp(nil)

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

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

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


jsiiによるポインタ変換

上記コードを見ていると、所々で、特にパラメータ指定の際にjsii.Number, jsii.Stringのような関数でパラメータで指定する値を囲っています。

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


jsii.Numberjsii.Stringは、与えた変数のポインタを返すだけの関数です。

実はこれは、Go版CDKでのパラメータ指定の際のルールとなっています。


というのも、Go言語にはNULL許容型が存在せず、nilを含めることができる型はポインタ型のみになります。(「のみ」と言うと語弊が生まれそうですがここではGoの話は深くは触れず上記公式を訳して載せています)

そこで、省略可能なパラメータに対して「プロパティを指定しない」ということを実現するためにnilが可能なポインタ型を渡すことで、それを実現する仕組みになっています。(上記公式参照)

主に使うのがこの辺りです。

  • jsii.String
  • jsii.Number
  • jsii.Bool
  • jsii.Time


これがGo版のCDKでの大きな特徴の一つで面倒なところですが、慣れればどうということはない(?)と思います。(見通しは悪くなりますが・・・)


CDKモジュールのフィールド名やメソッド名はパスカルケース

TypeScript版ではキャメルケースかと思いますが、Go版では基本的にCDKで提供されるフィールド名やメソッド名はパスカルケース(大文字始まり)になります(上記公式参照)。

これは「Public(パッケージ外)な呼び出しはパスカルケースでないと呼び出せない」というGoの言語仕様になります。

※Goでコーディングをする際に、プライベート(正しくはパッケージ)のスコープであればキャメルケースになります。


例です。TypeScriptで書かれたサンプルをコピーでGoに持ってこようとするときは注意が必要です。

cfnAppRunner.SetHealthCheckConfiguration(&awsapprunner.CfnService_HealthCheckConfigurationProperty{
    Path:     jsii.String("/"),
    Protocol: jsii.String("HTTP"),
})


エスケープハッチができない

はい。これはGo版ならではの非常に大きい問題です。

まずCDKにはL2 Constructという、いくつか必須な値を指定するだけでベストプラクティスに則ってリソースを作ってくれる便利な機能があります。

また、CloudFormationに則った指定の仕方でリソースを構築できる L1 Constructというものもあります。


L2 Constructは簡単にリソースが作れて便利な一方、パラメータが抽象化されているため、L2では指定できないパラメータが存在します。

そのため、L2 Constructで作ったリソースをL1 Construct型にキャストして、後からL1でしか指定できないパラメータを上書きする方法があり、それが「エスケープハッチ(escape hatch)」です。


しかし、現状CDK for Goではエスケープハッチ(escape hatch)がサポートされていないのです。

CDK has a concept called the escape hatch , which allows you to modify an L2 construct's underlying CFN Resource to access features that are supported by CloudFormation but not yet supported by CDK. Unfortunately, CDK for Go does not yet support this functionality, so we have to create the resource through the L1 construct. See this GitHub issue for more information and to follow progress on support for CDK escape hatches in Go.

Workshop Studio


じゃあどうするんだと。L2 Constructで指定できないパラメータを指定したい場合はL1 Constructじゃないとダメなのかと。

一応、エスケープハッチをする方法があります。jsii.Getというメソッドを使えば可能です。

var cfnAppRunner awsapprunner.CfnService
jsii.Get(apprunnerServiceL2.Node(), "defaultChild", &cfnAppRunner)

cfnAppRunner.SetAutoScalingConfigurationArn(autoScalingConfigurationArn)


ただしこのjsii.Getは非推奨なので、仕方なく使っているというような感じです。。。

pkg.go.dev


スナップショットテスト

CDKのテストとしてほぼ絶対出てくるであろう「スナップショットテスト」。

TypeScript(javascript)のjestのような、Goのtestモジュールにはそれらしいメソッドが生えてなくてGo版ではどうやるんだろうと思い調べてみると、以下の記事( ゆっきー (@WinterYukky) / Xさん)が見つかりました。(前回の記事に続きこれまたお世話になりました!)

zenn.dev


以下のように、スタックからtemplateを生成して、そのJSON化したものをcupaloy.SnapshotTに渡してあげるとスナップショットが実行できます。簡単ですね。

   stack := NewAppRunnerStack(app, "AppRunnerStack", appRunnerStackProps)

    template := assertions.Template_FromStack(stack, nil)
    templateJson := template.ToJSON()

    t.Run("Snapshot Test", func(t *testing.T) {
        cupaloy.SnapshotT(t, templateJson)
    })


スナップショットテストのアセット問題

これもCDKテストあるあるなお話です。

例えばCDKスタックにLambdaがあり、スナップショットテストを実行すると、なぜか何も変えていないはずなのに差分が出る。


それは、LambdaのS3のアセットが異なるからです。

(string) (len=5) "S3Key": (string) (len=68) "ca238babaa07a7d537b501694f32a05cf504782641428e35cc11dadb55f628c8.zip"


アセットは、AWS CDKライブラリやアプリにバンドルできるローカルファイル、ディレクトリ、または Docker イメージです。

docs.aws.amazon.com


TypeScript版CDKでは、JestのSnapshot Serializerを使って簡単にこの差分を無視して、スナップショットテストで毎回余計な差分が出ないようにすることができます。

しかし、Go版CDKではどうするのでしょうか?



わかりませんでした。。。

なのでゴリゴリに、スタックのテンプレートをJSON化したものをループして、無視したいキーのバリューを自前でマスキングする(というか空にする)ことで、アセットの差分を無くすようにしました。

もっと良い方法が欲しい。。。そういうモジュールがあるのか、はたまた自分でモジュール作るか。。。

func convertSnapshot(templateJson *map[string]interface{}) map[string]interface{} {
    resources := (*templateJson)["Resources"].(map[string]interface{})
    for key := range resources {
        if valProperties, ok := resources[key].(map[string]interface{})["Properties"]; ok {
            properties := valProperties.(map[string]interface{})
            if valCode, ok := properties["Code"]; ok {
                code := valCode.(map[string]interface{})
                if _, ok := code["S3Key"]; ok {
                    (*templateJson)["Resources"].(map[string]interface{})[key].(map[string]interface{})["Properties"].(map[string]interface{})["Code"].(map[string]interface{})["S3Key"] = ""
                }
            }
        }
    }
    return resources
}


おまけ

以下はTypeScriptとの違いというよりは、Goならではの開発方法のようなものになります。

Workspacesモード

CDKで開発をする際、恐らく複数のコード群が出てくることがあるのではないでしょうか。

まずCDKのコード群、またLambdaやECSで使用するコード群などですね。


このような際に、1つのリポジトリにそれらのディレクトリを用意してルートディレクトリのgo.modを共有してビルドも可能なのですが、それぞれで必要なものをまとめたgo.modを用意(マルチモジュール)してあげたくなります。

(ちなみにTypeScriptではaws-lambda-nodejsを使えば、一つのpackage.jsonファイルでLambdaとCDK定義でそれぞれいい感じにバンドルしてくれます。)


Goでは、(以前より簡単に)マルチモジュールが実現できる「Workspacesモード」という機能がバージョン1.18から導入されました。

これにより、サブディレクトリごとにgo.modを用意して、マルチモジュールで開発・ビルドすることができるようになります。

go.dev


以下コマンドを叩くことで、Workspacesモードになります。

go work init moduleA moduleB


すると、以下のようなgo.workファイルが作成され、マルチモジュール開発が可能になります。

go 1.18

use (
    ./moduleA
    ./moduleB
)


実装例

実際にAWS CDK for Goで作ってみた例があるので、よければご覧下さい。(例ではApp Runnerを作っています。)

go-to-k.hatenablog.com


最後に

GoのCDKを触ってみたところ、結構TypeScript版と違うところがあったのでまとめてみました。

正直TypeScript版のCDKと比べるとまだまだ発展途上なところはありますが、それでもGoで書けるのは嬉しいです!