実践!AWS CDK #7 ファイル分割

題字・息子たち
2021.06.03

はじめに

今回はファイルの分割を行います。
メインのプログラムを見やすくしていきましょう。

前回の記事はこちら。

既存の作り

現在のソースコードはこのようになっています。

lib/devio-stack.ts

import * as cdk from '@aws-cdk/core';
import { CfnVPC, CfnSubnet } from '@aws-cdk/aws-ec2';

export class DevioStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const systemName = this.node.tryGetContext('systemName');
    const envType = this.node.tryGetContext('envType');

    const vpc = new CfnVPC(this, 'Vpc', {
      cidrBlock: '10.0.0.0/16',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-vpc` }]
    });

    const subnetPublic1a = new CfnSubnet(this, 'SubnetPublic1a', {
      cidrBlock: '10.0.11.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1a',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-public-1a` }]
    })
    const subnetPublic1c = new CfnSubnet(this, 'SubnetPublic1c', {
      cidrBlock: '10.0.12.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1c',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-public-1c` }]
    })
    const subnetApp1a = new CfnSubnet(this, 'SubnetApp1a', {
      cidrBlock: '10.0.21.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1a',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-app-1a` }]
    })
    const subnetApp1c = new CfnSubnet(this, 'SubnetApp1c', {
      cidrBlock: '10.0.22.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1c',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-app-1c` }]
    })
    const subnetDb1a = new CfnSubnet(this, 'SubnetDb1a', {
      cidrBlock: '10.0.31.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1a',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-db-1a` }]
    })
    const subnetDb1c = new CfnSubnet(this, 'SubnetDb1c', {
      cidrBlock: '10.0.32.0/24',
      vpcId: vpc.ref,
      availabilityZone: 'ap-northeast-1c',
      tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-db-1c` }]
    })
  }
}

見づらいですね。メインのスタッククラス DevioStack に、作成したいリソースをベタ書きしています。
これでも作ることはできるのですが、このままではリソースが追加されるにつれてひたすらこのファイルが長くなっていくためメンテナンス性が良くありません。この問題を解消するためにリソースごとにファイルを分割したいと思います。

ディレクトリ構成

ディレクトリ構成は次のようにします。

lib
├── devio-stack.ts
└── resource
    ├── vpc.ts
    └── subnet.ts
  • devio-stack.ts
    • メインのプログラム
    • 各リソースクラスを利用してリソースを生成する
  • resource
    • リソースに関するファイル(クラス)を格納するディレクトリ
  • vpc.ts
    • VPC に関するプログラムを記述するファイル
  • subnet.ts
    • サブネットに関するプログラムを記述するファイル

今後リソースが増えるたびに resource ディレクトリにファイルが追加されるイメージです。

実装

VPC

VPC に関するコードは次のようにしました。

lib/resource/vpc.ts

import * as cdk from '@aws-cdk/core';
import { CfnVPC } from '@aws-cdk/aws-ec2';

export class Vpc {
    public vpc: CfnVPC;

    constructor() { };

    public createResources(scope: cdk.Construct) {
        const systemName = scope.node.tryGetContext('systemName');
        const envType = scope.node.tryGetContext('envType');

        this.vpc = new CfnVPC(scope, 'Vpc', {
            cidrBlock: '10.0.0.0/16',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-vpc` }]
        });
    }
}

スタックや Construct としたいわけではなく、単純にファイル分割をしたいだけなので class として定義しています。(継承元無し)
createResources() というパブリックメソッドを用意し、引数に Construct オブジェクトをもらうことでリソースの生成処理を実行します。生成したオブジェクトはパブリックなメンバ変数に代入し、呼び出し側にはこのオブジェクトを使用してもらいます。なお、このクラスのコンストラクタ(イニシャライザー)の処理は空です。

メンバ変数の可視性については色々コメントがあるかもしれませんが、メンテナンス性やコード量を踏まえた結果、私はこの作りで進めようと思います。(メンバ変数を private にしてそれ用の getter 用意して変数名の先頭に _ 付けたり〜というのが煩わしいので)

サブネット

お次はサブネットのコードです。

lib/resource/subnet.ts

import * as cdk from '@aws-cdk/core';
import { CfnSubnet, CfnVPC } from '@aws-cdk/aws-ec2';

export class Subnet {
    public public1a: CfnSubnet;
    public public1c: CfnSubnet;
    public app1a: CfnSubnet;
    public app1c: CfnSubnet;
    public db1a: CfnSubnet;
    public db1c: CfnSubnet;

    private readonly vpc: CfnVPC;

    constructor(vpc: CfnVPC) {
        this.vpc = vpc;
    };

    public createResources(scope: cdk.Construct) {
        const systemName = scope.node.tryGetContext('systemName');
        const envType = scope.node.tryGetContext('envType');

        this.public1a = new CfnSubnet(scope, 'SubnetPublic1a', {
            cidrBlock: '10.0.11.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1a',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-public-1a` }]
        })
        this.public1c = new CfnSubnet(scope, 'SubnetPublic1c', {
            cidrBlock: '10.0.12.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1c',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-public-1c` }]
        })
        this.app1a = new CfnSubnet(scope, 'SubnetApp1a', {
            cidrBlock: '10.0.21.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1a',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-app-1a` }]
        })
        this.app1c = new CfnSubnet(scope, 'SubnetApp1c', {
            cidrBlock: '10.0.22.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1c',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-app-1c` }]
        })
        this.db1a = new CfnSubnet(scope, 'SubnetDb1a', {
            cidrBlock: '10.0.31.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1a',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-db-1a` }]
        })
        this.db1c = new CfnSubnet(scope, 'SubnetDb1c', {
            cidrBlock: '10.0.32.0/24',
            vpcId: this.vpc.ref,
            availabilityZone: 'ap-northeast-1c',
            tags: [{ key: 'Name', value: `${systemName}-${envType}-subnet-db-1c` }]
        })
    }
}

こちらも VPC のコードと同様の作りになっています。
1 点異なるのはコンストラクタに CfnVPC オブジェクトを受け取っている部分です。サブネットの生成には VPC の ID が必要なのですが、このように あるリソースの生成に必要となるリソース はコンストラクタで受け取り、プライベートなメンバ変数に保持するというプログラムにしていきます。

メイン

さて、これらの変更によってメインのプログラムはどう変わるでしょうか。
結果はこちら。

lib/devio-stack.ts

import * as cdk from '@aws-cdk/core';
import { Vpc } from './resource/vpc';
import { Subnet } from './resource/subnet';

export class DevioStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new Vpc();
    vpc.createResources(this);

    // Subnet
    const subnet = new Subnet(vpc.vpc);
    subnet.createResources(this);
  }
}

どうでしょう?
ずいぶん見やすくなったんじゃないでしょうか。

このメインプログラムはパッと見ただけで、次のような処理を行っていることが容易に推測できます。

  • VPC リソースを生成していること
  • サブネットリソースを生成していること
  • サブネットの生成には VPC の情報を利用していること

Before に比べて格段にコード量が減り、視認性も上がりましたね。細かい生成処理を見たい場合は各ファイルの中を確認すればいいわけです。

テスト

まずはこの状態でテストを実行してみましょう。

$ npm run build && npm test

> devio@0.1.0 build
> tsc


> devio@0.1.0 test
> jest

 PASS  test/devio.test.ts
  ✓ Context (36 ms)
  ✓ Vpc (35 ms)
  ✓ Subnet (86 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.057 s, estimated 4 s
Ran all test suites.

バッチリ。
以前書いた 3 つのテストすべて pass しています。これがテストのいいところですね。ガッツリプログラムを変更しても中身がおかしくなっていないことを保証してくれます。

今後のことも考えて、テストコードも実装ファイルと同様のディレクトリ構成にしましょう。
こんな感じ。

test
├── devio.test.ts
└── resource
    ├── vpc.test.ts
    └── subnet.test.ts

こちらは単純なファイル作成とコピペだけなのですが、一応結果を貼っておきます。

VPC

test/resource/vpc.test.ts

import { expect, countResources, haveResource } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as Devio from '../../lib/devio-stack';

test('Vpc', () => {
    const app = new cdk.App();
    const stack = new Devio.DevioStack(app, 'DevioStack');

    expect(stack).to(countResources('AWS::EC2::VPC', 1));
    expect(stack).to(haveResource('AWS::EC2::VPC', {
        CidrBlock: '10.0.0.0/16',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-vpc' }]
    }));
});

サブネット

test/resource/subnet.test.ts

import { expect, countResources, haveResource } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as Devio from '../../lib/devio-stack';

test('Subnet', () => {
    const app = new cdk.App();
    const stack = new Devio.DevioStack(app, 'DevioStack');

    expect(stack).to(countResources('AWS::EC2::Subnet', 6));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.11.0/24',
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-public-1a' }]
    }));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.12.0/24',
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-public-1c' }]
    }));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.21.0/24',
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-app-1a' }]
    }));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.22.0/24',
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-app-1c' }]
    }));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.31.0/24',
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-db-1a' }]
    }));
    expect(stack).to(haveResource('AWS::EC2::Subnet', {
        CidrBlock: '10.0.32.0/24',
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ 'Key': 'Name', 'Value': 'undefined-undefined-subnet-db-1c' }]
    }));
});

メイン

test/devio.test.ts

import { expect, haveResource } from '@aws-cdk/assert';
import * as cdk from '@aws-cdk/core';
import * as Devio from '../lib/devio-stack';

test('Context', () => {
  const app = new cdk.App({
    context: {
      'systemName': 'starwars',
      'envType': 'prd'
    }
  });
  const stack = new Devio.DevioStack(app, 'DevioStack');

  expect(stack).to(haveResource('AWS::EC2::VPC', {
    Tags: [{ 'Key': 'Name', 'Value': 'starwars-prd-vpc' }]
  }));
});

Context に関するテストのみメインファイルに残し、その他は各リソースファイルに分割しました。

再テスト

$ npm run build && npm test

> devio@0.1.0 build
> tsc


> devio@0.1.0 test
> jest

 PASS  test/devio.test.ts
 PASS  test/resource/vpc.test.ts
 PASS  test/resource/subnet.test.ts

Test Suites: 3 passed, 3 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.219 s, estimated 4 s
Ran all test suites.

問題ありませんね。

これにてファイル分割完了です!

GitHub

今回のソースコードは コチラ です。

おわりに

徐々にプログラムが整理されてきました。が、まだまだ改善の余地はありますね。VPC とサブネットの生成処理で同じようなプログラムを宣言/実行しています。

  • createResources() でのリソース生成
  • リソース名のプレフィックス作成
    • systemName & envType

次はこれらの処理を共通化してしまいましょう!

次回のお題は「抽象化」です。

リンク