AWS Lambda のファンクションを TypeScript で作る! 実践編
こんにちは、TypeScript と VS Code と AWS が好きな bokken (@bokken_) です。
以前、簡単に AWS Lambda のファンクションを TypeScript で作る方法を紹介しましたが、 今回は少し発展して、TypeScript でインターフェースをしっかりと定義しつつ、AWS については Amazon SNS と CloudFormation を使います。
実装する内容は前回と同様、WEB API 経由で天気の情報を取得するものです。ただし今回は毎朝 7:00 に天気を Amazon SNS を使ってメールで通知してくれる Lambda ファンクションを開発しましょう。
さらに CloudFormation を使って AWS Lambda や Amazon SNS などのリソースの設定を自動化するところまでが目標です。 (AWS CLI で Cloudformation を使う予定なので、前回の記事を参考に自分のアカウントに CloudFormation 実行権限を付けておいてください)
サンプルコードは Github に上げているので参照してみてください。
目次
- 目次
- システム全体のイメージ図
- 準備
- tsconfig.json
- TypeScript で Lambda の実装
- ビルドシステムの設定と利用
- CloudFormation
- デプロイ
- まとめ
- アイコン利用
システム全体のイメージ図
全体のイメージから回作成するシステムの概要図は下記のとおりです。
CloudWatch のイベントで定期的に AWS Lambda を呼び出し、Lambda からお天気WebサービスAPIを叩き、その結果を Amazon SNS に送り、メールを飛ばすというものになっています。
準備
まずは任意のディレクトリを作成し、そのディレクトリ上で下記パッケージをインストールしましょう。(サンプルリポジトリの package.json をダウンロードしてきて npm install でも OK です。)
- package.json の作成
npm init -y
- dependencies
npm install -S aws-sdk request
- devDependencies
npm install -D @types/aws-sdk @types/node @types/request gulp gulp-cli gulp-tslint gulp-typescript gulp-zip tslint typescript`
tsconfig.json
tsconfig.json は下記のように設定します。
{ "compilerOptions": { "noImplicitAny": true, "strictNullChecks": true, "target": "es6", "module": "commonjs", "lib": [ "es6", "es2015.promise" ] } }
今回 ES6 にするので "target": "es6"
とします。また、aws-sdk を利用するので "lib": [ "es6", "es2015.promise" ]
を忘れずに記述します。
TypeScript で Lambda の実装
まず全体の実装 (index.ts
) は下記の通り。順番にポイントを紹介していきます。
import AWS = require("aws-sdk"); import * as request from "request"; function getWeatherInformation(): Promise<any> { return new Promise((resolve: Function, reject: Function): void => { let url: string = process.env.URL; if (!url) { reject("[Error] Environment variable 'URL' is not specified."); } let param: request.OptionsWithUrl = { url: process.env.URL, }; request(param, (err: any, response: request.RequestResponse, body: any) => { let weatherInformation: WeatherNewsResponse = JSON.parse(body); err ? reject(err) : resolve(weatherInformation); }); }); } function arrangeInformation(weatherNews: WeatherNewsResponse): Promise<AWS.SNS.PublishInput> { return new Promise((resolve: Function, reject: Function) => { let news: string = weatherNews.forecasts.map((day) => { return `${day.dateLabel}:${day.telop}\n`; }).join(""); const params: AWS.SNS.PublishInput = { Subject: `${weatherNews.location.prefecture}:${weatherNews.location.city}`, Message: news }; resolve(params); }); } function validateTopicArn(topicArn: string): string|undefined { let splitedTopicArn: string[] = topicArn.split(":"); if (splitedTopicArn.length < 7) { return undefined; } return splitedTopicArn[3]; } function publish(params: AWS.SNS.PublishInput): Promise<any> { return new Promise((resolve: Function, reject: Function) => { let topicArn: string = process.env.SNS_TOPIC_ARN; if (!topicArn) { reject("[Error] No Topic ARN specified"); } const region: string|undefined = validateTopicArn(topicArn); if (region) { reject("[Error] Invalid Topic ARN"); } // Specified region because unable to send if the region is different from AWS Lambda const sns: AWS.SNS = new AWS.SNS({region: region}); let snsParams: AWS.SNS.PublishInput = Object.assign(params, {TopicArn: topicArn}); sns.publish(snsParams, (err: AWS.AWSError, data: AWS.SNS.PublishResponse) => { err ? reject(err) : resolve(data); }); }); } export function handler(event: any, context: any, callback: any): void { getWeatherInformation() .then(arrangeInformation) .then(publish) .then(() => { callback(null, "success"); }).catch(callback); };
型情報
型情報は types.ts に下記のように定義しています。
interface PinpointLocations { link: string; name: string; } interface Forecast { dateLabel: string; telop: string; date: string; temperature: Object; image: Object; } interface Description { tet: string; publicTime: string; } interface Image { width: number; link: string; url: string; title: string; height: number; } interface Copyrigh { provider: Object[]; link: string; title: string; image: Image; } interface Location { city: string; area: string; prefecture: string; } interface WeatherNewsResponse { pinpointLocations: PinpointLocations[]; link: string; forecasts: Forecast[]; location: Location; publicTime: string; copyright: string; title: string; description: Description; }
import 時の注意
aws-sdk-js のREADME によると、aws-sdk を TypeScript で利用するためには下記が必要なので利用する際は注意しましょう。
- 必要なパッケージ
npm install --save-dev @types/node
- TypeScript
import AWS = require("aws-sdk");
- tsconfig.json
... "lib": [ "es6", "es2015.promise" ] ...
上記を設定すると、 aws-sdk
の型情報を利用することができます。各変数に型情報を設定すると VS Code でコーディング中に引数、戻り値などが参照できるようになるので、より気分良く TypeScript を書けるのでぜひ設定しましょう。
また、aws-sdk
の型定義は ./node_modules/aws-sdk/**/*.d.ts
(./node_modules/@types
以下ではない点に注意) にあるので適宜利用します。 たとえば、sns.publish の第一引数の型 PublishInput
は ./node_modules/aws-sdk/clients/sns.d.ts
にあります。
このように、たいてい AWS.サービス名.XXX
という名前で型が提供されています。
handler について
AWS Lambda におけるハンドラとは一番初めに実行される関数のことで、 AWS Lambda で利用するにはハンドラとなる関数を定義し、 export
する必要があります。
後々、CloudFormation でハンドラを設定するのですが、 index.js
の handler
の場合、
index.handler
を指定します。
export function handler(event: any, context: any, callback: any): void { ... };
環境変数の利用について
AWS Lambda の環境変数を利用するのはとても簡単で process.env.環境変数名
とするだけで利用することができます。
let url: string = process.env.URL;
環境変数は下記のように AWS Lambda のコンソールや、CloudFormation を利用して設定します。
各関数の説明
各関数は大まかには下記の役割を持っています。
getWeatherInformation()
- Lambda の環境変数で設定したURLから情報をGETして返す関数 (このあと CloudFormation で お天気Webサービス仕様 - Weather Hacks - livedoor 天気情報の URL を環境変数に設定する)
arrangeInformation(weatherNews: WeatherNewsResponse)
validateTopicArn(topicArn: string)
publish(params: AWS.SNS.PublishInput)
ビルドシステムの設定と利用
今回は tsc
を使ったり、Lambda 用に zip にまとるのは、ビルドシステムの gulp を使用します。
今回使用した gulpfile.js
は下記のとおりです。
const gulp = require('gulp'); const ts = require('gulp-typescript'); const zip = require('gulp-zip'); gulp.task('default', () => { 'use strict'; let tsProject = ts.createProject('tsconfig.json'); const tsResult = gulp.src(['**/*.ts', '!./node_modules/**']) .pipe(tsProject()) return tsResult.js.pipe(gulp.dest('')) }); gulp.task('zip', ['default'], () => { 'use strict'; return gulp.src(['./index.js', './node_modules/**/*'], {base: '.'}) .pipe(zip('weather-lambda.zip')) .pipe(gulp.dest('.')); });
2つのタスクの概要は下記のとおりです。
- default タスク
*.ts
を*.js
にトランスパイルする
- zip タスク
index.js
と./node_modules
を zip にまとめる
このままでも、./node_modules/.bin/gulp zip
などとすると、 gulp を実行して zip 化できるのですが、
npm run xxx
として利用できるようにするのが一般的です。
その際、package.json の script
に下記を追加します。
... "scripts": { "build": "gulp", "start": "gulp zip" } ...
上記のようにすると、npm run build
とすると gulp のデフォルト(default)タスクが実行され、
npm start
とすると gulp zip
が実行されるようになります。
下記のように gulp.task('zip', ['default']...
とすることで gulp zip を実行すると gulp default
も実行されるようになっています。そのため、 Lambda ファンクションを作るときには npm start
とすればトランスパイルして、zip も作成してくれます。
gulp.task('zip', ['default'], () => {
ここまでで、ひとまず Lambda のファンクションを作成するところまでは完成です。
CloudFormation
ここからは CloudFormation を使って AWS Lambda と Amazon SNS を自動で構成できるようにしていきます。
CloudFormation のテンプレートの生成には kumogata を利用します。
kumogata は Ruby で CloudFormation のテンプレートを記載できるツールで、JSON だとできなかった、テンプレートの分割や、コメントの記載などができます。
今回の CloudFormation のテンプレート (template.rb
) の全容は下記のとおりです。
AWSTemplateFormatVersion '2010-09-09' Parameters do BucketName do Description 'Bucket Name' Type 'String' end end Resources do WeatherLambdaFunction do Type 'AWS::Lambda::Function' Properties do FunctionName 'weather-news-lambda' Code do S3Bucket { Ref 'BucketName' } S3Key 'weather-lambda.zip' end Environment do Variables do SNS_TOPIC_ARN { Ref 'SNSTopic' } URL 'http://weather.livedoor.com/forecast/webservice/json/v1?city=400040' end end Handler 'index.handler' Runtime 'nodejs4.3' Timeout 30 Role { Fn__GetAtt %w[WeatherLambdaRole Arn] } end end WeatherLambdaRole do Type 'AWS::IAM::Role' Properties do RoleName 'WeatherLambdaRole' Path '/' AssumeRolePolicyDocument do Version '2012-10-17' Statement [ _ { Effect 'Allow' Principal do Service ['lambda.amazonaws.com'] end Action ['sts:AssumeRole'] }, ] end Policies [ _ { PolicyName 'WeatherLambdaPolicy' PolicyDocument do Version '2012-10-17' Statement [ _ { Effect 'Allow' Action '*' Resource '*' }, ] end }, ] end end LambdaPermission do Type 'AWS::Lambda::Permission' Properties do Action 'lambda:InvokeFunction' FunctionName 'weather-news-lambda' SourceArn { Fn__GetAtt %w[ScheduleEvent Arn] } Principal 'events.amazonaws.com' end end ScheduleEvent do Type 'AWS::Events::Rule' Properties do ScheduleExpression 'cron(0 22 * * ? *)' Targets [ _ { Id 'WeatherNewsScheduleEvent' Arn { Fn__GetAtt %w[WeatherLambdaFunction Arn] } }, ] end end SNSTopic do Type 'AWS::SNS::Topic' Properties do DisplayName 'weather news' TopicName 'weather-news' end end end
テンプレートの各要素の説明
AWSTemplateFormatVersion にはテンプレートのバージョンを指定します。また、Parameters には可変の値として管理する値を設定できます。
Resources には AWS のリソースを宣言します。リソースの一覧は AWS のこのページから参照できます
Resources 以下にあるもので大事なのは Type
にかかれてある部分で、今回は下記のリソースを使用しました。
- AWS::Lambda::Function
- AWS Lambda の関数を定義するリソース
- ここの Environments の URL の city 以下を自分の住んでいる場所の郵便番号にすると自分の地域の天気を取得できます
- AWS Lambda の関数を定義するリソース
- AWS::IAM::Role
- Lambda の権限を定義するリソース
- AWS::Events::Rule
- 定期実行するイベントの定義するリソース
- AWS::Lambda::Permission
- 上記の定期実行するイベントから Lambda を実行できるようにするリソース
- ScheduleExpression の部分には cron 式が指定できる(ルールのスケジュール式)
- 上記の定期実行するイベントから Lambda を実行できるようにするリソース
- AWS::SNS::Topic
- SNS のトピックのリソース
kumogata の利用
Gemfile
に下記を書いて bundle install --path vendor/bundle
を実行します。
source 'https://rubygems.org' gem 'kumogata', '0.5.10'
すると、bundle exec kumogata convert template.rb
で template.rb に記載したテンプレートが JSON 形式に変換できます。
デプロイ
ここまでできると、下記手順で Lambda をデプロイできます。
- npm start を実行して Lambda ファンクションを zip 圧縮する
- S3 バケットを作成する
- S3 バケットに Lambda 関数が圧縮された zip をアップロードする
- CloudFormation を実行する
- SNS のトピックに自分のメールアドレスを登録する
地道にデプロイしても良いのですが、どうせなら Rake ファイルでタスクとして定義をして、いつでも簡単にデプロイできるようにすることとします。
Rake タスクの定義
今回、Rakefile は下記のように定義しています。
# encoding: utf-8 bucket = ENV['BUCKET'] topic_arn = ENV['TOPIC_ARN'] endpoint = ENV['ENDPOINT'] namespace :s3 do desc 'tasks for s3 create and delete' task :create do desc 'create s3 bucket' abort 'bundle exec rake s3:create BUCKET=<BUCKET_NAME>' if bucket.nil? puts `aws s3api create-bucket --bucket #{bucket} --region us-west-2` end task :delete do desc 'delete s3 bucket' abort 'bundle exec rake s3:delete BUCKET=<BUCKET_NAME>' if bucket.nil? puts `aws s3api delete-bucket --bucket #{bucket}` puts `aws s3api delete-bucket --bucket #{bucket}` end end namespace :lambda do desc 'tasks for upload lambda zip code' task :upload do abort 'bundle exec rake cfn:execute BUCKET=<BUCKET_NAME>' if bucket.nil? abort 'Execute npm start before execute this task' unless File.exist? './weather-lambda.zip' puts `aws s3 cp weather-lambda.zip s3://#{bucket}/` end end namespace :cfn do desc 'tasks for CloudFormation create and delete' task :create do desc 'create CloudFormation template' puts `bundle exec kumogata convert template.rb > template.json` end task :execute do desc 'execute CloudFormation template' abort 'bundle exec rake cfn:execute BUCKET=<BUCKET_NAME>' if bucket.nil? puts `bundle exec kumogata convert template.rb > template.json` system 'aws cloudformation create-stack --stack-name weather-lambda --template-body file://template.json ' \ '--capabilities "CAPABILITY_NAMED_IAM" --region us-west-2 ' \ "--parameters ParameterKey=BucketName,ParameterValue=#{bucket}" end task :delete do desc 'delete CloudFormation template' puts `aws cloudformation delete-stack --stack-name weather-lambda` end end namespace :sns do desc 'tasks for SNS' task :subscribe do desc 'register endpoint' abort 'bundle exec rake s3:create TOPIC_ARN=<Topic_ARN> ENDPOINT=<Mail_Address>' if topic_arn.nil? || endpoint.nil? puts `aws sns subscribe --topic-arn #{topic_arn} --protocol email --notification-endpoint #{endpoint}` end end
それぞれのタスクの概要
各タスクでは基本的に AWS CLI を書き連ねています。概要は下記のとおりです。
Rake の利用
Rake を利用するには下記のように Gemfile に rake
を追加する必要があります。
source 'https://rubygems.org' gem 'kumogata', '0.5.10' gem 'rake'
それから、bundle install --path vendor/bundle
とすることで rake をインストールでき、
bundle exec rake タスク名
で各タスクを実行できます。
デプロイ手順
デプロイは下記の手順でできます。
- npm start を実行して Lambda ファンクションを zip 圧縮する
npm start
- S3 バケットを作成する
- S3 バケットに Lambda 関数が圧縮された zip をアップロードする
bundle exec rake lambda:upload BUCKET={上記のバケット名}
- CloudFormation を実行する
bundle exec rake cfn:execute BUCKET={上記のバケット名}
SNS のトピックに自分のメールアドレスを登録する
AWS Console から SNS の TopicARN を確認して自分のメールアドレスを登録します。
トピックARNの一覧は$ aws sns list-topics
を実行すると下記のように取得できます。
{ "Topics": [ { "TopicArn": "arn:aws:sns:us-west-2:000000000000:weather-news" } ] }
ここから、末尾に weather-news と書いてある ARN を覚えておいて下記コマンドを実行します。
bundle exec rake sns:subscribe TOPIC_ARN={トピックARN} ENDPOINT={メールアドレス}
すると、登録したメールアドレスに AWS からメールが届いているので、Confirm Subscription
をクリックします。
以上で午前7時にメールが届くようになります。
動作を確認する
このままでは 7時になるまで結果が確認できませんが、ひとまず関数が正しく動くかどうかは下記コマンドで確認できます。
aws lambda invoke --function-name weather-news-lambda
では確認できたところで7時に届くかどうかを楽しみにしていてください。
まとめ
今回は AWS Lambda を TypeScript で作る!実践編と題して、より実践でも利用できるように SNS と連携したり、CloudFormation を使ってみました。
筆者としては少しでも TypeScript や AWS の役に立つ情報を提供できていたら嬉しいです。
良かったら TypeScript 関連や AWS 関連のつぶやきをしているので @bokken_ をフォローしてみてください。
アイコン利用
天気晴れのち曇りのアイコン素材 | アイコン素材ダウンロードサイト「icooon-mono」 | 商用利用可能なアイコン素材が無料(フリー)ダウンロードできるサイト