Visual Studio Code + TypeScript + ApexでAWS Lambdaファンクションを開発する
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 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つを利用しました。
- AWS Lambda : @types/aws-lambda
- Node.js : @types/node
後者の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用のエクステンションをインストールします。
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
コンパイルのtarget
はes2015
に設定しました。今回作成したサンプルコードは問題なく動作しましたが、AWS LambdaのNode.jsはv4.3.2であるためES2015対応は限定的です。
AWS Lambdaで確実に動作するコードを生成するためにはtarget
をes5
にする必要がありますが、この方法は後ほど紹介します。
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); }); }
ここまででプロジェクトフォルダは以下の状態になります。
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」でtarget
をes5
にしただけでは以下のようなエラーが発生してコンパイルが通りません。これはサンプルコードで利用している「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/**/*" ] }
target
をes5
にし、lib
にes5
とes2015.promise
を指定します。libに何も指定しなかった場合はtargetに合わせて暗黙的にデフォルトのライブラリーの型定義が参照されますが(targetがes5の場合はデフォルトで"dom", "es5", "scripthost"が参照されます)、libを一つでも指定した場合は暗黙の参照は行われないので、必要なライブラリーを明示的に指定する必要があります。
まとめ
サンプルコード程度の規模であればJavascriptでサクッと書いてしまった方が効率的かと思いますが、コード補完が使えるのとQuick Infoが参照できるのはやはり便利だと思いました。実はVisual Studio Codeであれば、型定義ファイルをインストールしておくとJavascriptのコードでもコード補完とQuick Infoが利用できます。これらの機能をお求めの方はとりあえず型定義ファイルだけでもインストールしておくことをお勧めします。
async/awaitも初めて使ってみましたが、こちらも便利ですね。TypeScript、引き続き色々試していきたいと思います。