botchy hack note

TypeScript、Ruby、Golang とか Macとかの備忘録です.間違いがあるとご指摘くださると嬉しいです.

AWS Lambda のファンクションを TypeScript で作る! 実践編

f:id:bokken31:20170503123927p:plain

こんにちは、TypeScript と VS CodeAWS が好きな 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 に上げているので参照してみてください。

目次

システム全体のイメージ図

全体のイメージから回作成するシステムの概要図は下記のとおりです。

CloudWatch のイベントで定期的に AWS Lambda を呼び出し、Lambda からお天気WebサービスAPIを叩き、その結果を Amazon SNS に送り、メールを飛ばすというものになっています。

f:id:bokken31:20170503140103p:plain

準備

まずは任意のディレクトリを作成し、そのディレクトリ上で下記パッケージをインストールしましょう。(サンプルリポジトリ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");
...
"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.jshandler の場合、 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 を利用して設定します。

f:id:bokken31:20170503135506p:plain

各関数の説明

各関数は大まかには下記の役割を持っています。

  • getWeatherInformation()

  • arrangeInformation(weatherNews: WeatherNewsResponse)

    • お天気情報を Amazon SNS で通知するときに見やすくなるように整形する
  • validateTopicArn(topicArn: string)

    • SNS の TopicARN が有効かどうかチェックする (CloudFormation で SNS_TOPIC_ARN として設定する)
  • publish(params: AWS.SNS.PublishInput)

    • プッシュ通知用の関数 (AWS.SNS.publish()) を Promise でラップした関数

ビルドシステムの設定と利用

今回は 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つのタスクの概要は下記のとおりです。

  1. default タスク
    • *.ts*.js にトランスパイルする
  2. zip タスク
    • index.js./node_modules を zip にまとめる

このままでも、./node_modules/.bin/gulp zip などとすると、 gulp を実行して zip 化できるのですが、 npm run xxx として利用できるようにするのが一般的です。

その際、package.jsonscript に下記を追加します。

...
  "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::IAM::Role
    • Lambda の権限を定義するリソース
  • AWS::Events::Rule
    • 定期実行するイベントの定義するリソース
  • AWS::Lambda::Permission
    • 上記の定期実行するイベントから 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 をデプロイできます。

  1. npm start を実行して Lambda ファンクションを zip 圧縮する
  2. S3 バケットを作成する
  3. S3 バケットに Lambda 関数が圧縮された zip をアップロードする
  4. CloudFormation を実行する
  5. 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 を書き連ねています。概要は下記のとおりです。

  • s3
  • lambda
    • upload
      • Lambda の zip ファイルを S3 へアップロードする
  • cfn
    • create do
      • kumogata で JSON ファイルを生成するだけのタスク
    • execute do
      • kumogata で JSON ファイルを生成し、CloudFormation で環境を構築するためのタスク
    • delete do
      • CloudFormation で作成したタスクを消去するタスク
  • sns
    • subscribe
      • SNS で通知する宛先メールアドレスを追加するためのタスク

Rake の利用

Rake を利用するには下記のように Gemfile に rake を追加する必要があります。

source 'https://rubygems.org'
gem 'kumogata', '0.5.10'
gem 'rake'

それから、bundle install --path vendor/bundle とすることで rake をインストールでき、 bundle exec rake タスク名 で各タスクを実行できます。

デプロイ手順

デプロイは下記の手順でできます。

  1. npm start を実行して Lambda ファンクションを zip 圧縮する
    • npm start
  2. S3 バケットを作成する
    • bundle exec rake s3:create BUCKET={任意のバケット名}
      • ここのバケット名は任意ですが、他のAWSユーザーとの重複ができないようになっているため、重複のエラーが帰ってきた場合他の名前で実行してみてください。(個人用アカウントの場合です)
  3. S3 バケットに Lambda 関数が圧縮された zip をアップロードする
    • bundle exec rake lambda:upload BUCKET={上記のバケット名}
  4. 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」 | 商用利用可能なアイコン素材が無料(フリー)ダウンロードできるサイト