Springの@AsyncとLazy Fetch

Spring/Spring Bootと、JPA(Hibernate)を使っているプロジェクトにおいて、Lazy Fetchを指定しているParent → Childrenエンティティへのアクセスにおいて、以下の

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: xxx.yyy.Parent.children, could not initialize proxy - no Session

エラーが起きた時の原因と対処を簡単にメモしておきます。

状況と原因

LazyFetchが指定されているので、関連エンティティ(chidren)の取得を行ったタイミングでDBへのアクセスが発生します。で、その際にトランザクションが無効になっていて、エラーとなっています。
まぁ、エラーメッセージのまんまですね…。

状況的には Controller - Service1 - Service2 - Repository の構成をとっていて、 Service1 のメソッドに @Async をつけており、非同期で実行することを想定してます。

要所だけを抜粋したコードにて、比較用に同期バージョンと非同期バージョンの両方を並べて記載します。
この例だと非同期バージョンのみ、当該エラーが起きました。

Controller

private final Service1 service1

@GetMapping("/sync")
public String fooSync() {
    return service1.exec();
}

@GetMapping("/async")
public String fooAsync()  throws Exception {
    CompletableFuture<String> future = service1.execAsync();
    CompletableFuture.allOf(future).join(); // 結果を待つ(この例だと、非同期の意味がなくなるが…)
    return future.get();
}

Service1

private final Service2 service2;

public String exec() {
    Parent parent = service2.query();

    // parent.getChildren()

    return parent.toString();
}

@Async
public CompletableFuture<String> execAsync() {
    Parent parent = service2.query();

    // parent.getChildren() 

    return CompletableFuture.completedFuture(parent.toString());
}

Service2

private ParentRepository repository;

public Parent query() {
    return repository.findById("123");
}

対処(ダメな例)

  • Service2側のクラス/メソッドに @Transactional をつける
  • Service2側のクラス/メソッドに @Transactional(propagation = Propagation.REQUIRES_NEW) をつける

改めて書いてみると当たり前な感じですが、LazyFetchが呼ばれるのはService1側なので、Service2でトランザクションをつけても意味がありません。

対処

以下のどちらかの対処をすればOKです。後者についてはロジックの修正になりますね。

  • Service1側のクラス/メソッドに @Transactional をつける
  • Service2側のメソッド内部でLazy Fetchが行われるよう、関連エンティティの取得もやっておく。

ちなみに、実務で遭遇したのはしょうもないオチだったのですが、 @Async をつけたメソッド内でparallelストリームを使っていたりなんかすると、これまたやはりストリーム内の各処理(スレッド)でそれぞれトランザクションが有効になってないとならないです。

(非同期、並列処理、JPA、このあたりを絡んでくるとハマりどころが多いので、設計・技術選定は定期的に見直さないとなぁ。。)

参考サイト

Spring LazyInitializationException: no Session - Qiita

Loading Lazy loaded Entity in Async Thread in Spring Boot | by Prasim Jain | Medium

aws-sdk-javaのリトライ間隔(エクスポネンシャルバックオフとジッター)を実際に見てみる

昨日の記事の続きです。

jappy.hatenablog.com

aws-sdk-java に限らず AWS SDK 全般の話ですが、リクエスト時にエラーがあった場合、指定された回数だけ内部でリトライが行われます。
このリトライ時、次の試行までの間隔(本記事内では「遅延」と呼びます)を決めるのに、エクスポネンシャルバックオフとジッターというアルゴリズムが使われています。
この数値を実際の aws-sdk-java のクラスを用いて確認してみました。

が、その前に…。実は、検索したらすでにクラスメソッドさんが同じ趣旨の記事を書かれていましたことに気づきました。
二番煎じ感は否めませんが、クラスメソッドさんの場合は Node.js で、本記事は Java 版と、まったくの同じ内容ではないため、記事としてはまとめます。とはいえ、根柢のアルゴリズムは同じなので、似たような結果になるわけですが…。

dev.classmethod.jp

BackoffStrategy

aws-sdk-java の場合、エラーの種類などに応じてベース遅延や再試行メカニズムが複数用意されており、これらは BackoffStrategy というインタフェース(を実装したクラス)で管理されます。
どれを選択するかによって、遅延の計算結果も変わります。

主なものは以下の2つです。

  • FullJitterBackoffStrategy
    • 5XX エラーなど、非スロット系のエラー時に使われます。
    • DynamoDB の場合はベース遅延が 25 ミリ秒、それ以外の場合はベース遅延が 100 ミリ秒と違いがあります。
  • EqualJitterBackoffStrategy
    • スロットル系エラー時に使われます。
    • ベース遅延は 500 ミリ秒です。

利用シーンが限定的ですが、以下のようなメカニズムもあります(正確には BackoffStrategy の実装クラスではないですが)

  • DefaultBatchWriteRetryStrategy
    • DynamoDBMapper を使っていて BatchWriteItem がエラーになった時に使われます。BatchWriteItem では、書き込み完了できなかった一部のレコードが UnprocessedItems として返却される作りになっており、DynamoDBMapper はこれら未処理レコードを細かく分割して再試行するようになっており、その際のリトライに使われます。

確認してみた

遅延を決めるメソッドは RetryPolicy.java 内にある BackoffStrategy#delayBeforeNextRetryなので、これを呼び出すことで確認できそうです。

今回は以下の4つのパターンで試しています。と言いつつ、3番目と4番目は中身がほぼ同じなので、実質3パターンですね…。

  • DEFAULT
    • DynamoDB 以外のサービスへのリクエストでのスロットルエラー用(ベース遅延 25 ミリ秒の FullJitterBackoffStrategy
  • DYNAMODB_DEFAULT
    • DynamoDB へのリクエストでのスロットルエラー用(ベース遅延 100 ミリ秒の FullJitterBackoffStrategy
  • DEFAULT(throttleError)
    • DynamoDB 以外のサービスへのリクエスト時のスロットルエラー用(ベース遅延 500 ミリ秒の EqualJitterBackoffStrategy
  • DYNAMODB_DEFAULT(throttleError)
    • DynamoDB へのリクエストでのスロットルエラー用(ベース遅延 500 ミリ秒の EqualJitterBackoffStrategy

また、リトライ回数は10回までの結果を出力させてます。
デフォルトのリトライ回数は高々10回であり、かつ、最大遅延が20秒に設定されているので、回数を増やしていってもあまり面白い結果にはなりません。

gist.github.com

実際の結果は以下の通りとなりました。
(ジッターの効果によってランダムなバラつきが発生するため、結果は実行するたびに変わります)

000, DEFAULT:     56, DYNAMODB_DEFAULT:      5, DEFAULT(throttleError):    472, DYNAMODB_DEFAULT(throttleError):    420
001, DEFAULT:    152, DYNAMODB_DEFAULT:      3, DEFAULT(throttleError):    710, DYNAMODB_DEFAULT(throttleError):    657
002, DEFAULT:    345, DYNAMODB_DEFAULT:     27, DEFAULT(throttleError):   1748, DYNAMODB_DEFAULT(throttleError):   1553
003, DEFAULT:      6, DYNAMODB_DEFAULT:    104, DEFAULT(throttleError):   2544, DYNAMODB_DEFAULT(throttleError):   3162
004, DEFAULT:    813, DYNAMODB_DEFAULT:     28, DEFAULT(throttleError):   7258, DYNAMODB_DEFAULT(throttleError):   4162
005, DEFAULT:   1279, DYNAMODB_DEFAULT:    798, DEFAULT(throttleError):  13568, DYNAMODB_DEFAULT(throttleError):  10524
006, DEFAULT:   1308, DYNAMODB_DEFAULT:    565, DEFAULT(throttleError):  12845, DYNAMODB_DEFAULT(throttleError):  16461
007, DEFAULT:   3089, DYNAMODB_DEFAULT:   2425, DEFAULT(throttleError):  17140, DYNAMODB_DEFAULT(throttleError):  12536
008, DEFAULT:   1517, DYNAMODB_DEFAULT:   4147, DEFAULT(throttleError):  17606, DYNAMODB_DEFAULT(throttleError):  13507
009, DEFAULT:  17415, DYNAMODB_DEFAULT:   3009, DEFAULT(throttleError):  10704, DYNAMODB_DEFAULT(throttleError):  13932

エクスポネンシャルバックオフは巷でもよく聞くので、遅延は指数的に増加するものかと思いきや、ジッターのおかげでかなりブレがありますね。

また、DynamoDB の 5XX エラーの遅延はかなり短いこと、スロットルエラーの遅延は長めなことも、改めて確認できました。

aws-sdk-javaのClientConfigurationのリトライポリシーはDynamoDBだけ異なる

タイトルの通りですが、aws-sdk-javaで利用するClientConfigurationのリトライポリシーのデフォルトは、DynamoDBだけ異なるようです。

DynamoDBを利用していて、500エラーに遭遇する機会が増えてきており、その際に調べていて知りました。

はじめに

AWS の利用全般における、エラー再試行と、再試行におけるエクスポネンシャルバックオフとジッターについては、下記の公式ドキュメントにまとめられています。

docs.aws.amazon.com

また、その中でも DynamoDB については、下記のブログ記事にまとめられています。
この記事、かなり詳しく書かれてますので、DynamoDB を使っている場合、一読しておくとよさそうです。

aws.amazon.com

リトライポリシーの違いについて

先に結論ですが、デフォルトのリトライポリシーの設定値を以下の表にまとめました。違いは「エラー時のリトライ試行数」と「ベース遅延(非スロットル系エラー)」ですね。

DynamoDB それ以外
エラー時のリトライ試行数 10回 3回
ベース遅延(非スロットル系エラー) 25ミリ秒 100ミリ秒
ベース遅延(スロットル系エラー) 500ミリ秒 500ミリ秒
最大遅延 20秒 20秒

(コードを読んで確認しただけなので、見逃しはあるかもしれません)

ClientConfigurationとリトライポリシー

aws-sdk-java において、各AWSサービスへのアクセスを行う各クライアントは、 ClientConfiguration に指定された設定が適用されます。
リトライポリシーについてもそれらの設定の一部となっています。

デフォルト値については、GitHub を見るのが確実だと思います。

https://github.com/aws/aws-sdk-java/blob/1.11.974/aws-java-sdk-core/src/main/java/com/amazonaws/ClientConfiguration.java

リトライポリシーについては、 PredefinedRetryPolicies.DEFAULT がデフォルトなようですね。

public static final RetryPolicy DEFAULT_RETRY_POLICY = PredefinedRetryPolicies.DEFAULT;

ところで、クライアントの作成は、各サービスごとに用意されたビルダーを使って生成することが多いと思います。

DynamoDB であれば次のように AmazonDynamoDBAsyncClientBuilder を使う形ですね(実際にはメソッドチェーンを使った流れるようなインタフェースで書くことが多いですが、説明のために分けて書いています)

AmazonDynamoDBAsyncClientBuilder builder = AmazonDynamoDBAsyncClientBuilder.standard();

// builder に対して Credentials, Region, ClientConfiguration 等をセットしていく

AmazonDynamoDB client = builder.build();

こちらのビルダー内部では、ClientConfiguration を作成するファクトリを使っており、DynamoDB の場合は AmazonDynamoDBClientConfigurationFactory が該当します。

https://github.com/aws/aws-sdk-java/blob/1.11.974/aws-java-sdk-dynamodb/src/main/java/com/amazonaws/services/dynamodbv2/AmazonDynamoDBClientConfigurationFactory.java

上記クラスを見るとわかりますが、リトライポリシーだけ、 PredefinedRetryPolicies.DEFAULT ではなく PredefinedRetryPolicies.DYNAMODB_DEFAULT という専用のデフォルト値を使うように指定されています。

    @Override
    protected ClientConfiguration getDefaultConfig() {
        return super.getDefaultConfig().withRetryPolicy(PredefinedRetryPolicies.DYNAMODB_DEFAULT);
    }

こういった実装になっていることで、エラー時のリトライ試行数、ベース遅延(非スロットル系エラー)に関して、DynamoDBとそれ以外でデフォルト値が異なっています。

なぜデフォルト値がDynamoDBだけ異なるか?

これは、公開されている情報だけではっきりとした理由はわかりませんでした。
特性上、短期間でのリトライを繰り返すことで、自己修復によってリクエストが成功する可能性の高いサービスだから、ということでしょうか…。

実際に使っていく場合は、デフォルトの設定だけでなく、運用していくワークロードに応じて適切な設定値を決めていくことが大事だとは思いますが、こういった違いがあることを知っておくと設定値を決める参考にもなるかしれません。

ClientConfigurationの上書き設定時は注意

リトライポリシー以外にもタイムアウトの設定など ClientConfiguration は様々な設定を定義できます。
そのため、リトライポリシーはデフォルト値を使っていても、無意識のうちにリトライポリシーが PredefinedRetryPolicies.DYNAMODB_DEFAULT から PredefinedRetryPolicies.DEFAULT に入れ替わっていないか、気をつけるとよさそうです。

例えば、以下のコードでは、独自に new した ClientConfiguration をセットするため、リトライポリシーは PredefinedRetryPolicies.DEFAULT になります。

ClientConfiguration config = new ClientConfiguration()
        .withConnectionTimeout(30 * 1000);

AmazonDynamoDB client = AmazonDynamoDBAsyncClientBuilder.standard()
        .withClientConfiguration(config)
        .build();

回避する場合は、下記のようにリトライポリシーも明示的に再設定してあげましょう。

ClientConfiguration config = new ClientConfiguration()
        .withRetryPolicy(PredefinedRetryPolicies.DYNAMODB_DEFAULT)
        .withConnectionTimeout(30 * 1000);

AmazonDynamoDB client = AmazonDynamoDBAsyncClientBuilder.standard()
        .withClientConfiguration(config)
        .build();

Fargate + Firelensでのログ出力について考える

Fargate + ECSで動かしているバッチが終了した時に、実行結果のサマリーを通知(SNSファンアウト)したいと思ったのですが、色々と方法がありそうなので、つらつらとメモ。

前提としては、こんな感じです。

  • バッチ本体はJavaで実装。ログはLogback + SLF4Jのテンプレ構成。
  • ログの出力量が多いので、CloudWatch LogsにはDEBUGレベルのログは流したくない。
  • AWSのインフラはCDKで構築。

タスク終了時のイベント通知を利用する

一番シンプルで王道な方法は、タスク停止時のイベントアラートを利用する方法。

dev.classmethod.jp

ただ、この方法だと、終了した時に、ジョブの実行結果の詳細を含めた通知ができないんですよね…(まぁそれは別途どこかに記録してそれを利用するべき、という説もありますが…)

アプリケーションのログを利用する

ということで、次の選択肢として、アプリケーションログを拾って通知する方法を考えます。

ログの取り扱いは、Firelensログドライバ(サイドカーコンテナ)を利用しています(これも定石どおりですね)
前述の通り、Javaで実装された場合なので、logback.xmlを修正し、Fargate上で実行されたアプリケーションのログは、標準出力に流れるようにしてあります。

その後の構成としては、 Firelens → Firehose → S3 といった流れで、S3にログが溜まります。
さて、この方法だと、ログを保存するには完全マネージドで申し分ないのですが、バッチの終了を検知して通知することができません。

そこで考えられるのは、以下の方法です。

  1. Firehoseのデータ変換用Lambdaを仕込み、その中で終了ログを拾って通知する。
  2. S3のイベント通知を元にLambdaを発火させ、その中で終了ログを拾って通知する。
  3. Firelens (Fluent Bit)の設定をカスタムし、終了ログはCloudWatch Logsへ、全てのログはFirehoseへ送る。

AとBは手間がかかるのでないな、と思ったのですが、Cをやる場合も、カスタム設定ファイルを使う場合は、AWS公式のイメージだとダメで、自前でFirelens用のイメージをビルドしてあげないとならないみたいで、思ったよりは手間がかかりそう…。
というのも、Fargate 起動タイプを使用するタスクの場合、サポートされる config-file-type の値は file のみだからです。

参考記事

docs.aws.amazon.com

qiita.com

qiita.com

sioncojp.hateblo.jp

S3 SelectのJSON行(JSON Lines)を色々試す

S3 Selectでは、CSV, JSON(行 or ドキュメント), Parquetといったファイル形式がサポートされていますが、このうち、扱いやすさと柔軟性の高そうなJSON行について、調べてみました。

サンプルファイル

今回用意したファイルはこちら。

{ "id": "1", "name": "suzuki", "age": 20 }
{ "id": "2", "name": "tanaka", "birthplace": "Tokyo", "hobby": ["baseball"] }
{ "id": "3", "name": "yamada", "age": 25, "birthplace": "Tokyo", "hobby": ["tennis", "soccer"] }
{ "id": "4", "name": "sato", "birthplace": "Saitama", "hobby": ["baseball"] }

ポイントは、

  • 行ごとに項目の過不足がある
  • 配列型が存在

という点です。

確認はマネジメントコンソールからS3 Selectを直接実行しています(SDK経由だと、出力の形式が少し変わるかもしれません)

  • ファイル形式: JSON
  • JSONタイプ: JSON
  • 圧縮: なし

全件取得 : select * from s3object s

[
    {
        "id": "1",
        "name": "suzuki",
        "age": 20
    },
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    },
    {
        "id": "4",
        "name": "sato",
        "birthplace": "Saitama",
        "hobby": [
            "baseball"
        ]
    }
]

全件、全属性を出力。問題ありませんね。

数値型で絞り込み(結果が1件) : select * from s3object s where s.age > 20

{
    "id": "3",
    "name": "yamada",
    "age": 25,
    "birthplace": "Tokyo",
    "hobby": [
        "tennis",
        "soccer"
    ]
}

条件を指定した場合。こちらも想定どおり。

数値型で絞り込み(結果が複数件) : select * from s3object s where s.age >= 20

[
    {
        "id": "1",
        "name": "suzuki",
        "age": 20
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    }
]

1つ上の結果との比較ですが、結果が1件の時はドキュメント型、複数件の時はリスト型で返るみたいですね。

配列型で絞り込み(NGな例) : select * from s3object s where s.hobby = 'baseball'

[]

配列型の項目の検索はこれではダメみたいです。

配列型で絞り込み(OKな例) : select * from s3object s where s.hobby[0] = 'baseball'

[
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "4",
        "name": "sato",
        "birthplace": "Saitama",
        "hobby": [
            "baseball"
        ]
    }
]

配列のインデックスを指定してあげればOK。
ちなみに、存在しないインデックスを指定した場合は、エラーにはならず、空のリストが返ります。

ちなみに、インデックスを問わず、contains的な検索をしたい場合は、 'soccer' in s.hobby と書くようです。

文字列で絞り込み(LIKE) : select * from s3object s where s.birthplace like '%o'

[
    {
        "id": "2",
        "name": "tanaka",
        "birthplace": "Tokyo",
        "hobby": [
            "baseball"
        ]
    },
    {
        "id": "3",
        "name": "yamada",
        "age": 25,
        "birthplace": "Tokyo",
        "hobby": [
            "tennis",
            "soccer"
        ]
    }
]

LIKE検索はサポートされています。

文字列で絞り込み(NOT EQUAL) : select * from s3object s where s.birthplace != 'Tokyo'

{
    "id": "4",
    "name": "sato",
    "birthplace": "Saitama",
    "hobby": [
        "baseball"
    ]
}

birthplace項目自体が存在し、かつ、 Tokyo 以外のものが返ります。

項目が存在しないものを探す : select * from s3object s where s.birthplace IS NULL

{
    "id": "1",
    "name": "suzuki",
    "age": 20
}

項目が存在しないものを取得する場合は IS NULL でできます。

ルートレベル以外での検索 : select * from s3object[*].hobby s where s[1] = 'soccer'

{
    "_1": [
        "tennis",
        "soccer"
    ]
}

直近でこれを駆使する機会はなさそうなので触りだけですが、詳しくは公式ドキュメントに記載されています。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/dev/s3-glacier-select-sql-reference-select.html#s3-glacier-select-sql-reference-attribute-access

まとめ

ほぼ予想どおりの動きをしてくれるみたいなので、問題なく使えそうでした。

TypeScriptで配列をn個ずつに分割する

配列をn個ずつの二次元配列にするワンライナーです(Rubyeach_slice 的なやつ) 使い古されたネタなので、いろんな書き方が可能だと思いますが…。

const arraySplit = <T = object>(array: T[], n: number): T[][] =>
  array.reduce((acc: T[][], c, i: number) => (i % n ? acc : [...acc, ...[array.slice(i, i + n)]]), []);

使い方はこんな感じ。

const result = arraySplit<number>([1, 2, 3, 4, 5], 2); // <- [[1, 2], [3, 4], [5]]

JS版として、以下のQiita記事にあるコメントを参考にさせていただきました(実質、それをTypeScript化した形です)

qiita.com

AWS CDKをプロダクションで使うための私見プラクティス

AWS CDK (Cloud Development Kit)の登場によって、ようやく理想的なInfrastructure as Codeが実現できるようになってきたなと感じています。
素で書くCloudFormationテンプレートやAnsible等と比べて、特に以下のあたりが便利ですね。

  • 型があり、補完が効く
  • ロール、ポリシー周りの記述がgrantXxxだけで直感的に記述できる
  • 変更に伴う変更セット周りの面倒くささが解消される

CDKをプロダクションで使っていくために、「こうするといいんじゃないかな」と思う個人的なプラクティスをまとめてみました。

(注) CDKのバージョンは執筆時点での最新バージョンである1.19.0を対象としています。
また、言語は問わない内容にしたつもりですが、TypeScriptしか触っていませんので、TypeScriptが前提です。

書き方はサンプルリポジトリを参考にする

まず、プロジェクトの構成は cdk init app で作られたものを土台にし、下手に弄らないほうが無難だと思います。
この構成だと、lib 以下にスタックの定義を記述していくことになると思います。

公式のサンプルリポジトリに豊富なサンプルがあるので、近しい構成のものを参考にしながら、膨らませていくとよいと思います。

github.com

書けるものはdescriptionを書く

Stack, Lambda, API等、説明(description)が記載できるものについては横着せずに、書いておくのがよいと思います。

const sampleStack = new SampleStack(app, 'SampleStack', {
  description: 'はじめてのAWS CDKスタックです'
});

マネージメントコンソールで一覧で見た時に用途が明確になりますし、コードのコメントのかわりとしても使えます。

~/.aws/credentialsのデフォルトプロファイルに注意

cdk deploy とすると、あまりに簡単にデプロイができてしまいますが、オプション未指定時は ~/.aws/credentials のデフォルトプロファイルが使われることに注意しましょう(事故の元になります)。

常にプロファイルを指定してデプロイするよう心がけたほうが安全だと思います。

$ cdk deploy --profie staging

コードを短くすることにこだわりすぎない

CFnテンプレートを書くのに比べて短い記述量で済むのがCDKのメリットの一つですが、記述を短くすることを最優先にせずに、バランスを考えた方がよい気がしています。

例えば、3つのLambda関数を作成する場合、以下のようにループを用いて書くことができます。

for (const name of ['fn1', 'fn2', 'fn3']) {
  new lambda.Function(this, name, {
    runtime: lambda.Runtime.NODEJS_12_X,
    code: lambda.Code.asset('src'),
    handler: `${name}.handler`
  });
}

多少冗長でも以下のようにありのままに書いた方がよい気がしています。直感的に内容が分かりますし、例えば、後で一部の関数だけメモリやタイムアウトの値を変えたくなった時に素直に対応ができます。
このあたり、DRYの原則に反しますが、テストコードを書く時の感覚に近いかもしれません。

new lambda.Function(this, 'fn1', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn1.handler'
});

new lambda.Function(this, 'fn2', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn2.handler'
});

new lambda.Function(this, 'fn3', {
  runtime: lambda.Runtime.NODEJS_12_X,
  code: lambda.Code.asset('src'),
  handler: 'fn3.handler'
});

Stackのコンストラクタだけで記述を済ませず、シンプルにする

独自のStackクラスのコンストラクタ内ですべての処理を記載しようとすると、モノによっては、やはり長くなってしまいます。
1つ1つのリソース定義はメソッドに抽出するなどして、コンストラクタでは、全体像がひと目で把握できるシンプルな構造になっていると、後のメンテナンスがしやすいと思います。

import cdk = require('@aws-cdk/core');
import sns = require('@aws-cdk/aws-sns');
import subs = require('@aws-cdk/aws-sns-subscriptions');
import sqs = require('@aws-cdk/aws-sqs');

export class SqsSnsStack extends cdk.Stack {

  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const queue = this.createQueue();

    const topic = this.createTopic();

    topic.addSubscription(new subs.SqsSubscription(queue));
  }

  private createQueue(): sqs.Queue {
    return new sqs.Queue(this, 'HelloCdkQueue', {
      visibilityTimeout: cdk.Duration.seconds(300)
    });
  }

  private createTopic(): sns.Topic {
    return new sns.Topic(this, 'HelloCdkTopic');
  }
}

STABLEなものだけで構築できそうならば、CDKを採用する

CDKは新しいツールであり、2018年8月にDeveloper Previewとして発表された頃からGitHubをウォッチしていますが、アップデートが頻繁にあります。
まぁ、GAになってからは落ち着いた感じもしますが、それでも、今後、破壊的な変更がある可能性はゼロではないと思います。

公式のAPIリファレンスを見ると、各サービスごとに、CDKでの安定性(stability)ステータスが確認できます。例えば、Lambdaの場合は下記URLです。

https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-readme.html

ちょっと乱暴ですが、ステータスが実験的(EXPERIMENTAL)になっているものの利用は避け、安定(STABLE)なものだけで実現可能な場合にのみ、CDKを採用する、というのも手です。
使いたいオプションがサポートされていなかったり、という罠にハマることが少ないと思うので…(まぁIssueなりPullRequestを送ればよい話ではありますが…)。

まぁ、サーバレスなアプリケーションを作る定番サービス(DynamoDB, Lambda, API Gateway, SNS, SQS, etc)はいずれもSTABLEなので、多くのケースでは実戦で使えると思ってます。

Runtime Contextをうまく活用する

CDKにはContextという形で、パラメータ値をスタックに流し込むことができます。

https://docs.aws.amazon.com/cdk/latest/guide/context.html

うまく活用することで、デプロイ時の不測の変更を防いだり、環境(ステージ)間の差をコードから分離して管理することができるようになります。

参考記事

CloudFormationのベストプラクティスに基づいて使う

CDKは新しいAWSのサービスというわけではなく、実態はCloudFormationであり、CFnをプログラム的に扱えるようにうまく抽象化したものです。
そのため、CDKを使っていく上でも、CFnの知識は不可欠であり、CFnには成熟したベストプラクティスが存在するので、まずはそれを身につけた上で使っていきましょう。

https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/best-practices.html