Visual Studio Code + TypeScript + ApexでAWS Lambdaファンクションを開発する

typescript-logo-400x400

Visual Studio CodeにはIntelliSenseという強力なコードアシスタントの機能が備わっています。AWS Lambdaファンクションの開発でもこのIntelliSenseの恩恵をフルに受けたいと考え、TypescriptでAWS Lambdaファンクションを開発する環境を整えてみました。

やること

TypeScript → JavaScriptへのコンパイル〜Lambdaファンクションのデプロイまでをシングルコマンドで行える環境を整えます。 Lambdaファンクションのデプロイについては、Lambdaファンクション本体と依存パッケージ一式をバンドルする方法として以下の2通りの方法をご紹介します。

  • zipで固めてデプロイ
  • webpackで1ファイルにバンドルしてデプロイ

LambdaファンクションのデプロイにはApexを使います。本ブログ記事の手順はApexに依存する部分もありますが、他デプロイツールにも適用可能な部分も多いかと思います。Apexは使わない、、という方も一旦読み進めていただければと思います。

なお、サンプルコードはAWSの公式ドキュメントの画像ファイルのサムネイルを作成するチュートリアルのコードをベースにしています。

前提となる環境

  • OS : macOS Sierra 10.12.3
  • Visual Studio Code : 1.10.1
  • Typescript : 2.2.1
  • Node.js : 4.3.2
  • AWS SDK for Javascript : 2.12.0
  • Apex : 0.12.0
  • webpack : 2.2.1

Visual Studio Code、Node.jsはインストール済みの前提で話を進めます。

開発環境の準備

Apexのインストール

$ curl https://raw.githubusercontent.com/apex/apex/master/install.sh | sh
$ apex version
Apex version 0.12.0

Apexについては拙著のブログ記事も合わせて参照ください。 なおブログ記事は若干情報が古い部分がありますので、最新情報については公式ドキュメントを確認ください。

ApexでAWS Lambdaファンクションを管理する

プロジェクトフォルダの作成と初期化

プロジェクトフォルダを作成し、apex initでプロジェクトの雛形を生成します。 「Project name」と「 Project description」を入力すると自動的にCloudWatch Logsのフル権限を持ったIAMロールが作成されます。

AWSのクレデンシャル情報は環境変数に設定されたもの、または「~/.aws/credentials」のデフォルトプロファイルのものが使用されます。特定プロファイルを利用したい場合はapex init --profile プロファイル名のようにプロファイル名を指定してコマンドを実行します。

$ mkdir ts_example
$ cd ts_example
$ apex init
  Enter the name of your project. It should be machine-friendly, as this
  is used to prefix your functions in Lambda.

    Project name: ts_example

  Enter an optional description of your project.

    Project description: ts_example

〜後略〜

デフォルトでサンプルのLambdaファンクション(function/hello/index.js)が作成されます。これは不要なので削除しておきます。

$ rm -rf functions/hello

これでプロジェクトフォルダは以下の状態となります。「functions」フォルダの下に実際にLambdaファンクションを作成していく形になります。

ts_example
├── functions
└── project.json

Typescript、AWS SDK、TSLint、型定義ファイルのインストール

Typescript、AWS SDK、型定義ファイルをインストールします。またTypescript向けの静的コード解析ツールであるTSLintも合わせてインストールしておきます。

今回型定義ファイルはDefinitelyTypedにある以下の2つを利用しました。

後者のNode.js用の型定義ファイルは必須ではありませんが、Lambdaファンクション内でprocessなどのNode.jsのオブジェクトを利用する場合はインストールが必要です。 AWS SDK用の型定義ファイルはAWS SDKのv2.7.0からSDK本体に付属するようになったので別途インストールは不要です。

## package.json生成
$ npm init

## パッケージインストール
$ npm install --save-dev typescript tslint aws-sdk @types/aws-lambda @types/node

パッケージのバージョンは以下の通りです。

"devDependencies": {
  "@types/aws-lambda": "0.0.7",
  "@types/node": "^7.0.5",
  "aws-sdk": "^2.20.0"
}

「src」フォルダの作成

「functions」フォルダの下に今回作成するLambdaファンクション用に「CreateThumbnail」フォルダを作成します。さらにその下に TypeScriptのソースファイル(*.tsファイル)を配置する「src」フォルダを作成します。

$ mkdir -p functions/CreateThumbnail/src
ts_example
├── functions
│   └── CreateThumbnail
│       └── src
├── node_modules
├── package.json
└── project.json

S3のPutイベント用の型定義ファイルの作成

「@types/aws-lambda」にはAPI GatewayのEventの型は定義されていますが、その他のEventについては型定義がありませんので自前で用意する必要があります。今回はS3のPut Eventの型定義ファイルを以下のように作成しました。

export declare interface S3Event {
    Records: Array<Record>
}

declare interface Record {
    eventVersion: string;
    eventSource: string;
    awsRegion: string;
    eventTime: string;
    eventName: string;
    userIdentity: UserIdentity;
    requestParameters: RequestParameters;
    responseElements: ResponseElements;
    s3: S3;
}

declare interface UserIdentity {
    principalId: string;
}

declare interface RequestParameters {
    sourceIPAddress: string;
}

declare interface ResponseElements {
    "x-amz-request-id": string;
    "x-amz-id-2": string;
}

declare interface S3 {
    s3SchemaVersion: string;
    configurationId: string;
    bucket: Bucket;
    object: Object;
}

declare interface Object {
    key: string;
    size: number;
    eTag: string;
    versionId: string,
    sequencer: string,
}

declare interface Bucket {
    name: string;
    ownerIdentity: UserIdentity;
    arn: string;
}

作成した型定義ファイルは「src」フォルダに配置します。ファイル名は「s3_event.d.ts」としました。

ts_example
├── functions
│   └── CreateThumbnail
│       └── src
│           └── s3_event.d.ts
├── node_modules
├── package.json
└── project.json

Visual Studio Codeの設定

TSLint用のエクステンションをインストールします。

vscode+typescript+apex-1

TSlintの設定

プロジェクト全体でTSLintのルールを共有するためにプロジェクトルートに「tslint.json」を配置します。ルールの中身についてはよしなに設定ください。

これで土台となる開発環境は整いました。

ts_example
├── functions
│   └── CreateThumbnail
│       ├── built
│       └── src
│           └── s3_event.d.ts
├── node_modules
├── package.json
├── project.json
└── tslint.json

TypeScriptのコンパイル設定(tsconfig.json)

プロジェクトルートに「tsconfig.json」を作成し、「src」フォルダ以下のtsファイルをコンパイルし「built」フォルダに出力するよう設定します(「built」フォルダはtsファイルのコンパイル時に自動的に作成されるので手動での作成は不要です)。 「tsconfig.json」は複数のLambdaファンクションで共用するため、プロジェクトルートに配置しつつコンパイル時に各Lambdaファンクションフォルダにコピーして使う形にします。

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es2015",
        "noImplicitAny": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,
        "outDir": "./built",
        "allowJs": true
    },
    "include": [
        "./src/**/*"
    ]
}
ts_example
├── functions
│   └── CreateThumbnail
│       ├── node_modules
│       ├── package.json
│       └── src
│           └── s3_event.d.ts
├── node_modules
├── package.json
├── project.json
├── tsconfig.json
└── tslint.json

コンパイルのtargetes2015に設定しました。今回作成したサンプルコードは問題なく動作しましたが、AWS LambdaのNode.jsはv4.3.2であるためES2015対応は限定的です。 AWS Lambdaで確実に動作するコードを生成するためにはtargetes5にする必要がありますが、この方法は後ほど紹介します。

Lambdaファンクションの作成

依存パッケージのインストール

「functions/CreateThumbnail」に移動して画像ファイルのリサイズ処理に利用するパッケージgmとその型定義ファイルをインストールします。「gm」はLambdaファンクションのバンドルに含めるため、--save-devではなく--saveでインストールしておきます。

## 「CreateThumbnail」フォルダ下にpackage.jsonを生成
$ cd functions/CreateThumbnail
$ npm init

〜中略〜

## 依存パッケージインストール
$ npm install --save gm
$ npm install --save-dev @types/gm

gm のバージョンは以下の通りです。

  "dependencies": {
    "gm": "^1.23.0"
  },
  "devDependencies": {
    "@types/gm": "^1.17.29"
  }

Lambdaファンクションの作成

「src」フォルダに「index.ts」ファイルを作成します。今回は以下のようなコードを書きました(せっかくなのでasync/awaitに入門してみました)。

import { Callback, Context } from "aws-lambda";
import * as AWS from "aws-sdk";
import { S3Event } from "./s3_event";
import * as gm from "gm";
import * as util from "util";

const im = gm.subClass({ imageMagick: true });
const MAX_WIDTH  = 100;
const MAX_HEIGHT = 100;

// get reference to S3 client
const s3 = new AWS.S3();

const getSize = (buffer: any): Promise<gm.Dimensions> => {
    return new Promise((resolve, reject) => {
        im(buffer).size((err, size) => {
            if (err) {
                reject(err);
            } else {
                resolve(size);
            }
        });
    });
};

const resize = (buffer: any, width: number, height: number, imageType: string): Promise<Buffer> => {
    return new Promise((resolve, reject) => {
        im(buffer).resize(width, height).toBuffer(imageType, (err, buffer) => {
            if (err) {
                reject(err);
            } else {
                resolve(buffer);
            }
        });
    });
};

export function handler(event: S3Event, context: Context, callback: Callback) {
    // Read options from the event.
    console.log("Reading options from event:\n", util.inspect(event, { depth: 5 }));
    const srcBucket = event.Records[0].s3.bucket.name;

    // Object key may have spaces or unicode non-ASCII characters.
    const srcKey = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " "));
    const dstBucket = srcBucket + "resized";
    const dstKey = "resized-" + srcKey;

    // Sanity check: validate that source and destination are different buckets.
    if (srcBucket === dstBucket) {
        callback(new Error("Source and destination buckets are the same."));
        return;
    }

    // Infer the image type.
    const typeMatch = srcKey.match(/\.([^.]*)$/);
    if (!typeMatch) {
        callback(new Error("Could not determine the image type."));
        return;
    }
    const imageType = typeMatch[1];
    if (imageType !== "jpg" && imageType !== "png") {
        callback(new Error(`Unsupported image type: ${imageType}`));
        return;
    }

    // Download the image from S3, transform, and upload to a different S3 bucket.
    (async () => {
        const data = await s3.getObject({ Bucket: srcBucket, Key: srcKey }).promise();
        const imgSize = await getSize(data.Body);
        const scalingFactor = Math.min(
            MAX_WIDTH / imgSize.width,
            MAX_HEIGHT / imgSize.height,
        );
        const width = scalingFactor * imgSize.width;
        const height = scalingFactor * imgSize.height;
        const resizedImg = await resize(data.Body, width, height, imageType);
        // Stream the transformed image to a different S3 bucket.
        const params: AWS.S3.PutObjectRequest = {
            Bucket: dstBucket,
            Key: dstKey,
            Body: resizedImg,
            ContentType: data.ContentType,
        };
        await s3.putObject(params).promise();
    })().then((result) => {
        const message = `Successfully resized ${srcBucket}/${srcKey} and uploaded to ${dstBucket}/${dstKey}`;
        console.log(message);
        callback(null, message);
        return;
    }).catch((err) => {
        const message = `Unable to resize ${srcBucket}/${srcKey} and upload to ${dstBucket}/${dstKey} due to an error: ${err}`;
        console.error(message);
        callback(err);
    });
}

コード補完が効きます。 vscode+typescript+apex-2

Quick Infoも表示されます。 vscode+typescript+apex-3

パラメータの誤りや過不足も検知できます。 vscode+typescript+apex-4

ここまででプロジェクトフォルダは以下の状態になります。

ts_example
├── functions
│   └── CreateThumbnail
│       ├── node_modules
│       ├── package.json
│       └── src
│           ├── index.ts
│           └── s3_event.d.ts
├── node_modules
├── package.json
├── project.json
├── tsconfig.json
└── tslint.json

Labmdaファンクションのデプロイ設定1:zipで固めてデプロイ

handlerの設定

「CreateThumbnail」フォルダの下にApexのLambdaファンクション設定ファイル「function.json」を作成し、「built/index.js」のhandler関数がエントリポイントとなるようhandlerの値を"built/index.handler"に設定します。

{
  "description": "create thumbnail",
  "runtime": "nodejs4.3",
  "handler": "built/index.handler",
  "memory": 128,
  "timeout": 5
}

デプロイ対象フォルダの設定

同じく「CreateThumbnail」フォルダの下に「.apexignore」を作成し「built」フォルダ以外をデプロイ対象から除外するよう設定します。「.apexignore」はApexでデプロイ対象外のファイルやフォルダを指定するためのファイルで、「.gitignore」と同様の書式が使えます。

src/
node_modules/
package.json
tsconfig.json
!built/node_modules/
!built/package.json

ビルドフックの設定

ApexではLambdaファンクションの「build(ビルド(zip化)前)」、「deploy(デプロイ前)」、「clean(デプロイ後)」の3つのタイミングで任意のシェルコマンドを実行することが可能です。今回は「build」のタイミングで以下の処理を実行します。

  • TypeScript → JavaScriptへのコンパイル
  • Lambdaファンクションフォルダ(CreateThumbnailフォルダ)の「package.json」を「built」フォルダにコピー
  • 「package.json」のdependenciesにあるパッケージのみを「built/node_modules」フォルダにインストール

これで「built」フォルダにはコンパイル済みのjsファイルと依存パッケージ一式が格納される形になります。

buildフックは複数のLambdaファンクションで共用できるよう、「project.json」に設定します。またcleanフックも設定し、デプロイ後に「built」フォルダを削除するようにします(これは必須ではありません)。

{
  "name": "ts_example",
  "description": "ts_example",
  "memory": 128,
  "timeout": 5,
  "role": "arn:aws:iam::XXXXXXXXXXXX:role/ts_example_lambda_function",
  "environment": {},
  "hooks": {
    "build": "cp -f ../../tsconfig.json ./ && tsc -p ./ && cp -f ./package.json ./built && npm install --only=production --prefix ./built",
    "clean": "rm -fr built"
  }
}

これでようやくLambdaファンクションがデプロイできる状態になりました。プロジェクトフォルダ構成は以下のようになります。

ts_example
├── functions
│   └── CreateThumbnail
│       ├── .apexignore
│       ├── function.json
│       ├── node_modules
│       ├── package.json
│       └── src
│           ├── index.ts
│           └── s3_event.d.ts
├── node_modules
├── package.json
├── project.json
├── tsconfig.json
└── tslint.json

Lambdaファンクションのデプロイ

apex deployコマンドを実行するとTypeScriptのコンパイル〜依存パッケージのインストール〜Lambdaファンクション(zipファイル)のデプロイが実行されます。

$ apex deploy

Labmdaファンクションのデプロイ設定2:Webpackで1ファイルにバンドルしてデプロイ

Webpackとts-loaderのインストール

webpackと、TypeScriptをコンパイルするためにts-loaderをインストールします。

プロジェクトルートで以下を実行します。

$ npm install --save-dev webpack ts-loader

webpackはv2.2.1を使います。

  "devDependencies": {
    "@types/aws-lambda": "0.0.7",
    "@types/node": "^7.0.5",
    "aws-sdk": "^2.20.0",
    "ts-loader": "^2.0.1",
    "webpack": "^2.2.1"
  }

webpackの設定(webpack.config.js)

プロジェクトルートに以下の内容で「webpack.config.js」を作成します(今回のサンプルコードでは「*.tsx」ファイルは登場しませんが、一応コンパイル対象に含めておきます)。

module.exports = {
        entry: "./src/index.ts",
        target: "node",
        output: {
            path: "./built",
            filename: "index.js",
            libraryTarget: "commonjs2"
        },
        externals: {
            "aws-sdk": "aws-sdk",
            "spawn-sync": "spawn-sync"
        },
        resolve: {
            // Add `.ts` and `.tsx` as a resolvable extension.
            extensions: [".ts", ".tsx", ".js"],
        },
        module: {
            // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
            rules: [
                {
                test: /\.tsx?$/,
                use: [
                    "ts-loader"
                ]
            }]
        }
}

今回のサンプルコードですが、webpackでのコンパイル時に以下のWARNINGが発生します。

WARNING in ./~/cross-spawn/index.js
Module not found: Error: Can't resolve 'spawn-sync' in '/Users/yawata.yutaka/Dropbox/Repos/yuyawata/apex/ts_example_webpack/functions/CreateThumbnail/node_modules/cross-spawn'
 @ ./~/cross-spawn/index.js 32:26-47
 @ ./~/gm/lib/compare.js
 @ ./~/gm/index.js
 @ ./src/index.ts

cross-spawnは gmの依存パッケージですが、このcross-spawnのREADMEに以下の記述がありました。

If you are using spawnSync on node 0.10 or older, you will also need to install spawn-sync:

このあたりchild_process.spawnSyncが使えるかをチェックして、なければspawn-syncをrequireしているようです。

Node.jsのランタイムはv4.3.2を利用するのでspawn-syncは不要です。 コンパイルは通りますのでWARNINGは無視しても構いませんが、今回はexternalsでspawn-syncをコンパイルの対象外にする方法をとりました(aws-sdkもAWS Lambdaでは標準で使えるの同じくコンパイルの対象外としています)。

ビルドフックの設定

「build」のタイミングでwebpackを実行するよう、「project.json」のbuildフックを以下のように設定します。

{
  "name": "ts_example",
  "description": "ts_example",
  "memory": 128,
  "timeout": 5,
  "role": "arn:aws:iam::XXXXXXXXXXXX:role/ts_example_lambda_function",
  "environment": {},
  "hooks": {
    "build": "../../node_modules/.bin/webpack --config ../../webpack.config.js --bail",
    "clean": "rm -fr built"
  }
}

プロジェクトフォルダ構成は以下のようになります。「webpack.config.js」を追加した以外は、「zipで固めてデプロイ」の場合と同一です。

ts_example
├── functions
│   └── CreateThumbnail
│       ├── .apexignore
│       ├── function.json
│       ├── node_modules
│       ├── package.json
│       └── src
│           ├── index.ts
│           └── s3_event.d.ts
├── node_modules
├── package.json
├── project.json
├── tsconfig.json
├── tslint.json
└── webpack.config.js

Lambdaファンクションのデプロイ

apex deployコマンドを実行するとwebpackでのコンパイル〜Lambdaファンクション(built/index.js)のデプロイが実行されます。

$ apex deploy

Lambdaファンクションの動作確認

実際のこのLambdaファンクションを動かす場合は、以下のドキュメントを参照しIAMロールのポリシー設定、LambdaファンクションへのS3イベントの追加を行なってください。

チュートリアル: Amazon S3 での AWS Lambda の使用

コンパイルのtargetをes5にする方法

今回のサンプルコードをコンパイルする場合、単純に「tsconfig.json」でtargetes5にしただけでは以下のようなエラーが発生してコンパイルが通りません。これはサンプルコードで利用している「Promise」がES5に存在しないために発生するエラーです。

error TS2468: Cannot find global value 'Promise'.
src/index.ts(16,16): error TS2693: 'Promise' only refers to a type, but is being used as a value here.
src/index.ts(16,25): error TS7006: Parameter 'resolve' implicitly has an 'any' type.
src/index.ts(16,34): error TS7006: Parameter 'reject' implicitly has an 'any' type.
src/index.ts(28,16): error TS2693: 'Promise' only refers to a type, but is being used as a value here.
src/index.ts(28,25): error TS7006: Parameter 'resolve' implicitly has an 'any' type.
src/index.ts(28,34): error TS7006: Parameter 'reject' implicitly has an 'any' type.
src/index.ts(68,6): error TS2705: An async function or method in ES5/ES3 requires the 'Promise' constructor.  Make sure you have a declaration for the '
Promise' constructor or include 'ES2015' in your `--lib` option.
../../node_modules/aws-sdk/lib/config.d.ts(39,37): error TS2693: 'Promise' only refers to a type, but is being used as a value here.

これを回避するためにはTypeScriptのコンパイラーの「lib」オプションに「ES2015.Promise」を指定して、コンパイラーがPromiseの型定義を参照するようにします。具体的には「tsconfg.json」を以下のように設定します。

{
    "compilerOptions": {
        "module": "commonjs",
        "target": "es5",
        "noImplicitAny": true,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,
        "outDir": "./built",
        "allowJs": true,
        "lib": [
            "es5",
            "es2015.promise"
        ]
    },
    "include": [
        "./src/**/*"
    ]
}

targetes5にし、libes5es2015.promiseを指定します。libに何も指定しなかった場合はtargetに合わせて暗黙的にデフォルトのライブラリーの型定義が参照されますが(targetがes5の場合はデフォルトで"dom", "es5", "scripthost"が参照されます)、libを一つでも指定した場合は暗黙の参照は行われないので、必要なライブラリーを明示的に指定する必要があります。

まとめ

サンプルコード程度の規模であればJavascriptでサクッと書いてしまった方が効率的かと思いますが、コード補完が使えるのとQuick Infoが参照できるのはやはり便利だと思いました。実はVisual Studio Codeであれば、型定義ファイルをインストールしておくとJavascriptのコードでもコード補完とQuick Infoが利用できます。これらの機能をお求めの方はとりあえず型定義ファイルだけでもインストールしておくことをお勧めします。

async/awaitも初めて使ってみましたが、こちらも便利ですね。TypeScript、引き続き色々試していきたいと思います。

参考