Scala on AWS Lambda – ヒーローを作成・取得・更新・削除する

2016.11.27

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

以前の記事では、Angular2のヒーローデータ取得部分を API Gateway + AWS Lambda のAPIで置き換える例を示しました。今回はサーバーサイドに注目し、ヒーローの取得だけでなく新規作成、更新、削除を実装してみます。

やってみた感想としては、API GatewayやLambdaが定める作法の理解に時間を要すものの、一度わかってしまえば Lambda Function のコード量も少なく完結でき、サーバをたてる必要もないため有用性は高いと感じました。モバイルアプリやウェブからDynamoDBのデータを読み書きできるAPIをつくりたいのだけども、具体的にどのようにすればよいかイメージがわかない、という方への助けになればと思います。正直なところ、私も取り組み前までは「みんな当たり前のように使ってるけど実際どうやればいいの」「マッピングテンプレートがわかりません」状態でした。

また、Lambda Function の実装にはプログラミング言語Scalaを用いることで、JavaだけでなくScalaでも実装可能であることを示します。とはいえ本記事を読むのにScala特有の知識は特に必要なく、Lambda Function は別の言語で実装する、という場合でも応用できると思います。

やること

  • Scala プロジェクトを準備する
  • DynamoDBを準備する
  • GET /heroes を実装する
  • POST /heroes を実装する
  • DELETE /heroes/{id} を実装する
  • PUT /heroes/{id} を実装する
  • クロスオリジンヘッダを設定する
  • Angular2から呼ぶ

Scala プロジェクトを準備する

以下のような構成にします。

lambda-hero/
├── conf
├── external
│   ├── app
│   └── target
└── library
    ├── app
    └── target
  • external: Lambda Function に設定するコードをこちらのプロジェクトに格納します。
  • library: Lambda とは無関係の共通コードを格納します。

作成したプロジェクトに Lambda Function として使うコードを追加していきます。本記事を完了させると以下のGitHubコードになりますので、必要に応じて取得してください。

DynamoDB を準備する

ヒーローデータを保存するテーブル と、ヒーロー新規作成時にIDを採番するためのテーブル を用意します。

00

GET /heroes を実装する

ヒーローの一覧をDynamoDBから取得します。

ポイント

  • 実装する中では一番シンプルで、DynamoDBからscanしたすべてのヒーローデータをリクエスト元に返す
  • Lambda Function としてRequestHandlarを実装しPOJOで入出力を制御する方法を知る

Lambda Function: リクエスト・レスポンス部分

ヒーロー一覧取得においては、入力パラメータはありません。リクエストを受けたら、DyanmoDBのクライアントに一覧を取得させ、そのデータをクライアントに返すということをやります。データ加工処理がほとんどないため、リクエスト・レスポンス部分の処理はほとんどありません。

external

trait GetHeroesComponent extends RequestHandler[java.lang.Object, Heroes] {

  val client: HeroDBClient

  override def handleRequest(input: Object, context: Context): Heroes = client.findAll

}

class GetHeroesController extends GetHeroesComponent {
  override val client: HeroDBClient = new HeroDBClient()
}

なお、hadleRequestの戻り値の型として指定しているHeoresですが、単純なPOJOとして実装しています。RequestHandler を実装したクラスを Lambda Function のエントリポイントとして指定することで、LambdaがJacksonの仕組みを使って自動でシリアライズ・デシリアライズしてくれます。Jacksonを使っていることから、入力値・出力値として指定するクラスは、以下を満たしていなければなりません。

  • シリアライズ・デシリアライズ対象のフィールドはgetterとsetterを備えていること
  • 引数なしのコンストラクタでインスタンスが生成できること

library

case class Heroes(
    @BeanProperty var heroes: java.util.List[Hero]
) {
  def this() = this(
    heroes = new java.util.ArrayList[Hero]()
  )
  def sort: Heroes = Heroes(this.heroes.asScala.sortWith(_.id < _.id).asJava)
}

Scalaにおいては@BeanPropertyを指定することでコンパイル時に自動でgetterとsetterを付与してくれます。以降の作成・更新・削除においても同様の考え方でPOJOによる入力受付と出力を行う方針とします。

Lambda Function: データ取得部分

DynamoDBのheroテーブルをscanし、結果ItemをHeroesオブジェクトにマッピングして返すのが仕事です。heroテーブルにアクセスするクラスということでHeroDBClientを定義します。

library

class HeroDBClient {

  private val db = new DynamoDB(Regions.AP_NORTHEAST_1)
  private val table = db.getTable("hero")

  def findAll: Heroes =
    Heroes(table.scan().toList.map(HeroConverter.toHero)).sort

}

object HeroDBClient {

  object AttributeName {
    val Id = "id"
    val Name = "name"
  }

  object HeroConverter {
    def toHero(item: Item): Hero =
      Hero(id = item.getInt(AttributeName.Id),
        name = item.getString(AttributeName.Name)
      )
  }
}

後ほどここに作成や更新といったメソッドも追加していきます。

JARアップロード

今回はビルドツールとしてSBTを採用しました。SBTのプラグインでJARファイルを生成できるものがありますで、まずはそのプラグインを利用できるようにします。

plugins.sbt

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3")

その上で以下のコマンドを実行。external/target/scala-2.11にJARが生成されます。

$ sbt assembly

生成したJARを Lambda Functionとしてアップロードします。関数名はgetHeroesとしました。

10

API Gateway

API Gatewayでは、外部から受け付けたGET /heroesのHTTPリクエストを Lambda Function へ送り、結果をクライアントに返す役割を担います。ここで、クライアントから受け取ったクエリパラメータやリクエストボディを Lambda Funtion へどう渡すか決める Mapping Template という仕組みを使うのですが、最初に決めた方針通り、 Lambda Function の仕事が最小限になるよう接合点を設計します。すなわち、以下のようにします。

  • 採用しない: 入力パラメータを汎用的なJSONにマッピングし、それを Lambda Function 側で解釈する
  • 採用する: Lambda Function が必要とする最小限のデータを渡す

さて、設定しましょう。まずはAPIを作成します。名前はなんでもいいです。最終的にAngular2から使われるのでAngularとしました。次にリソースを作成します。heroesとしました。作成する際、CORS は有効にしてください。

そして下図のようにGETメソッドを定義します。先程作成した Lambda Function が補完できるようになっているはずですので、getHeroesを割り当てます。

20

これで GET /heroesの設定は終わりです。

POST /heroes を実装する

ヒーローを新しく作成します。

ポイント

  • DynamoDBの別のテーブルを用意して、シーケンスを採番する
  • API GatewayでクライアントからのリクエストJSONを Lambda Function の入力へマッピングする

Lambda Function: リクエスト・レスポンス部分

入力としてヒーローの名前を受取り、IDを採番して、IDと名前を使って新しいItemをDynamoDBへ作成します。まずは Lambda Function のエントリポイントからです。

external

trait AddHeroComponent extends RequestHandler[AddHeroRequest, Hero] {
  val sequenceClient: SequenceDBClient
  val heroClient: HeroDBClient

  override def handleRequest(input: AddHeroRequest, context: Context): Hero = {
    val seq = sequenceClient.sequence("heroId")
    val newHero = Hero(seq, input.name)
    heroClient.create(newHero)
    newHero
  }
}

class AddHeroController extends AddHeroComponent {
  override val sequenceClient: SequenceDBClient = new SequenceDBClient()
  override val heroClient: HeroDBClient = new HeroDBClient()
}

DynamoDBへのアクセスクライアントを2つ作っていることがわかります。

Lambda Function: 採番部分

DynamoDBのConditional Updateを使ってシーケンスを実装する例はいくつかあります。

Java、Scalaのサンプルが少なくて試行錯誤しましたが、以下のようにしました。

library:SequenceDBClient

import scala.collection.JavaConverters._

def sequence(keyName: String): Long = {
  val updateItemSpec: UpdateItemSpec = new UpdateItemSpec()
      .withPrimaryKey(SequenceName, keyName)
      .withReturnValues(ReturnValue.UPDATED_NEW)
      .withUpdateExpression("SET #count = if_not_exists(#count, :zero) + :i")
      .withNameMap(new NameMap().`with`("#count", "count"))
      .withValueMap(Map[String, AnyRef](":i" -> int2Integer(1), ":zero" -> int2Integer(0)).asJava)

  table.updateItem(updateItemSpec).getItem.getLong("count")
}

sequenceメソッドを呼び出せば、現在Itemで持っている値を+1し、その結果を返してくれます。これを新しいヒーローのIDとしていきます。

Lambda Function: Item作成部分

HeroDBClientへメソッドを追加します。ここではTable.putItemを利用してDynamoDBへItemを追加します。

def create(hero: Hero): Unit =
  table.putItem(
    new Item()
        .withPrimaryKey(AttributeName.Id, hero.id)
        .withString(AttributeName.Name, hero.name)
  )

Lambda Function作成 〜 API Gateway設定

ヒーロー取得のときと同じようにJARをアップロードし 、 addHeroとして Lambda Functionを作成したら、API Gatewayで/heroesリソースに対してPOSTメソッドを作成します。

30

GETと異なるのはここからです。クライアントからは、POSTメソッドとしてリクエストボディと一緒にアクセスが来ます。API Gatewayとしては、このリクエストを Lambda Function へ渡さねばなりません。また、 Lambda Function はHeroを返すことがわかっています。これも、リクエスト元のクライアントへ橋渡しをしてやる必要があります。

↓①と②で Mapping Templateを定義します。 40

入出力のマッピング

入出力いずれについてもAPI Gatewayで内容を操作する必要はありません。①統合リクエスト ②統合レスポンス ともに以下のように Mapping Template を指定します。API Gatewayに入力として渡ってきたJSONをそそのまま次に伝えるという意味です。

$input.json('$')

これで、リクエストボディにnameを指定してPOSTリクエストを投げると、新しいヒーローが生成できるようになります。

DELETE /heroes/{id} を実装する

指定されたヒーローをDynamoDBから削除します。

ポイント

  • パスパラメータを Lambda Functionに渡すよう Mapping Template を定義する

Lambda Function

実装はシンプルです。削除したいヒーローのidを受け取り、DynamoDBからそのIDのItemを削除するというもの。

external

trait DeleteHeroComponent extends RequestHandler[DeleteHeroRequest, Unit] {
  val client: HeroDBClient

  override def handleRequest(input: DeleteHeroRequest, context: Context): Unit =
    client.delete(input.id.toInt)
}

class DeleteHeroController extends DeleteHeroComponent {
  override val client: HeroDBClient = new HeroDBClient
}

library:HeroDBClient

def delete(id: Int): Unit =
  table.deleteItem(new PrimaryKey("id", Int.box(id)))

Lambda Function作成 〜 API Gateway設定

JARをアップロードし、deleteHeroとして Lambda Functionを作成したら、API Gatewayで新しいリソースを作成します。/heroes/{id}リソースです。特定のヒーローに対して何か操作を行う場合はこちらのリソースにメソッドを定義します。

50

DELETEメソッドを作成したら、リクエスト側の Mapping Templateを編集します。

60

API Gatewayのドキュメントによると、Mpping Template 内では$input.params(x)によってパスパラメータ、クエリパラメータ、ヘッダ値を取り出すことができるようです。今回はパスパラメータidから削除対象のヒーローIDを取り出し、それをJSONとして Lammbda Functionへ送るようにしています。これで、ヒーローの削除もできるようになりました。

PUT /heroes/{id} を実装する

指定されたヒーローを更新します。

ポイント

  • パスパラメータとリクエストボディのあわせ技で Mapping Templateを定義する

ここまでくれば予想がつくかもしれませんが、Mapping Template ではパスパラメータやリクエストボディを抽出して Lambda Function へのJSONを再構築できるのでした。これまで出てきた要素を上手く使えば更新処理も実装できそうです。まずはScalaのコードから見ていきます。

Lambda Function: リクエスト・レスポンス部分

external

trait UpdateHeroComponent extends RequestHandler[UpdateHeroRequest, Unit] {
  val client: HeroDBClient

  override def handleRequest(input: UpdateHeroRequest, context: Context): Unit =
    client.update(Hero(input.id.toLong, input.name))
}

class UpdateHeroController extends UpdateHeroComponent {
  override val client: HeroDBClient = new HeroDBClient()
}

external

class UpdateHeroRequest(
    @BeanProperty var id: String,
    @BeanProperty var name: String
) {
  def this() = this(
    id = "", name = ""
  )
}

Lambda Function としては、更新対象のヒーローIDと、上書き用の名前がJSONで渡ってくることを期待しているようです。

Lambda Function作成 〜 API Gateway設定

JARをアップロードし、updateHeroとして Lambda Functionを作成したら、API Gatewayで/heroes/{id}リソースに対してPUTメソッドを作成します。そして、リクエスト側のマッピングテンプレートを以下のように設定します。

70

これで、Lambda Function が期待するリクエストが渡せるようになり、パスパラメータで指定したIDのヒーローの更新が行えるうようになります。

クロスオリジンヘッダの設定

作成したメソッドに共通してやらなければならない設定として、クロスオリジンアクセスの許可 があります。「①メソッドレスポンス」でヘッダを定義した上で、「②統合レスポンス」でヘッダに値を設定します。

80

↓①の設定
90

↓②の設定
100

ここまで終わったらAPI GatewayでAPIをデプロイします。これで作成したAPIが公開され、外部から利用可能となりました。

Angular2から呼ぶ

視点はクライアント側に移ります。せっかく作ったので呼んでみましょう(これがやりたかった)。

100

すべてのメソッドを使うよう操作してみました。DynamoDBが更新されているのでちゃんと動いてそうですね。

おわりに

API GatewayとLambdaを使って、データ操作を行うAPIを構築してみました。また、Lambda Functionの実装言語としてScalaを使いました。冒頭でも述べたようにMapping TemplateなどAPI Gatewayの作法に慣れる必要はありましたが、その分、ふつうであればアプリケーションフレームワーク側で実装するべきルーティングやレスポンスヘッダの設定など受け持ってくれるので、適切に設計すればまさにFunctionレベルの実装コードでサーバサイドの機能が実現できます。このパターンは以下のようなシーンで向いているでしょう:

  • ストレージやデータベースがAWSのマネージサービスであり、単純なデータ取り出し、書き込み用途でのAPIを実装したい
  • APIは不特定多数から不定期でアクセスされるものというよりは、どちらかというと何かの契機で動作するバッチプログラムから呼ばれる
  • 開発段階のモックAPIが欲しい

逆に、APIの受け持つ仕事が単純なCRUDだけでなく、例えば

  • 複数の外部APIへリクエストを行い、その結果を統合してクライアントへ返す
  • 秒間100万アクセスに耐えうる非機能要件を満たす必要がある

といった背景がある場合、やはりまだアプリケーションサーバを構築する手段が俎上に上がることになりそうです。あくまで"Function"として切り出せる仕事をサーバー運用なしで動作させる選択肢だ、という見方をすれば、頼もしいサービスになると感じました。

参考