注目の記事

サーバーサイドエンジニアこそAngular2をやるべきかもしれない – Heroデータを AWS Lambda+DynamoDB で取得する

2016.11.08

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

はじめに

私自身は普段サーバサイド(主にWeb API)の開発を行っているのですが、業務外でAngular2を触ってみたところかなり好感触だったのでブログにしました。この記事では、Angular2のTutorialを終えた状況から、データ取得部分をモックではなく外部サーバに置き換える例を示し、Angular2の考え方、サーバサイドとの連携方法について記録したいと思います。

Angular2をはじめたときの筆者の状況

フロントエンドスキルセット

  • jQuery、CSSを利用したウェブページの開発経験あり
  • とはいえ、アニメーションや色彩に明るいわけではなく、ほとんどWebアプリケーションフレームワーク任せ
  • JavaScriptはDOM操作用の言語という考え方が拭いきれずメンテナンス性度外視のコードを書く
  • フレームワークが読み込むjQueryと自分で書いたjQueryのバージョンが衝突したあたりで考えることをやめた

なかなかにひどいですね。 こんな状況も相まって、ここ数年の目覚ましいフロントエンドフレームワークの登場やトレンドの入れ替わりなど、正直言ってほとんどキャッチアップしていませんでした。

なぜ思い立ったか

理由は2つです。

  • やはりビューを構築できるスキルは欲しい
  • S3やDynamoDB、API Gatewayなど、AWSのマネージサービスを理解するにあたり、利用する側の観点も必要だと認識した

なぜAngular2か

react.jsやvue.jsなど選択肢もあったのですが、ライブラリを組み合わせるスキルを持ち合わせておらず、また目的はあくまで「よりサーバサイドのことを知るために使う側にもたってみる」であったため、オールインワンと謳われるAngularを使ってみることにしました。ちょうど2.0.0がリリースされた、というのもあります。ちなみにAngularJSは触ったことがありませんでしたが、まったく問題ありませんでした。

やること

本題です。以下の順で進めます

  1. Angular2のチュートリアルを終えた状況を作り、データ取得をどのようにやっているかを知る
  2. DynamoDBからデータを取得する Lambda Function をScalaで書く
  3. API Gatewayで Lambda Function をAPIとして利用できるようにする
  4. Angular2を修正して外部からデータを取得する

1.Angular2のチュートリアルが完了した状態をつくる

Angular2は公式サイトのチュートリアルがとても充実しています。実践形式で動くコードをベースに話を進めてくれるので、英語が得意でない私でもすんなりと完走できました。チュートリアルは、「ヒーローのデータを取ってきて、リスト表示して、詳細表示して、編集、新規作成、削除ができるようにする」という例で話が進みます。チュートリアルを終えた状態のコードはこちらにおいています:cm-wada-yusuke/angular-tutorial at finish-tutorial

まずは下の図を見てください。Angular2アーキテクチャの概観です。

00_arch

  • Template: ビューのテンプレートです。HTML文書を拡張して、Angular2特有の記述を行うことによりDOM構造の繰り返しや、クリックイベントに反応してデータを更新する、といった芸当が可能になります。この処理は対となるComponentが解釈します。
  • Component: テンプレートと対になるファイルで、ビューで利用するデータを用意したり、逆にビューで発生したイベントを検知して後述のServiceへ処理を委譲します。私はまるでサーバサイドでいうControllerのようだと感じました。
  • Service: データ、より踏み込んで言うとドメインのCRUDを司るファイルです。Angular2ではDIの仕組みを導入することにより、あらゆるコンポーネントからnewすることなくサービスを呼び出すことが可能になります。また、Serviceをビューやコンポーネントの世界から切り離すことにより、ビュー側がデータの生成方法などを知らなくても良いようになっています。私は、Serviceの考え方がとても馴染み深く、惹き込まれました。

他にも構成要素がありますが今回の範囲を超えるため割愛します。

さて、Angular2のチュートリアルを無事終えると、画面上で以下のようなことができるようになります。(公式サイトより)

10
画面遷移概略図

20
実際に画面を動かしている様子

まずダッシュボードがあり、そこからヒーローの一覧画面へ遷移でき、さらにヒーローの詳細プロフィール画面へ遷移できます。詳細画面ではヒーローの名前を編集することができるようです。今回はヒーローたちのIDと名前が並んでいる画面に注目します。ここでは「ヒーローの一覧を取得する」という処理を行っています。Serviceのコードを見てみましょう。

hero.service.ts

import { Injectable } from '@angular/core';
import { Headers, Http, Response } from '@angular/http';

import { Hero } from './hero';
import 'rxjs/add/operator/toPromise';

@Injectable()  // DI可能なクラスであることを示すデコレータ
export class HeroService {
  private heroesUrl = '/app/heroes';    // チュートリアルのURLはAngular2のホストと同一
  private headers = new Headers({ 'Content-Type': 'application/json' });

  constructor(
    private http: Http    // HttpをDI
  ) { }

  getHero(id: number): Promise<Hero> {
    return this.getHeroes()
      .then(heroes => heroes.find(hero => hero.id === id));
  }

  getHeroes(): Promise<Hero[]> {
    return this.http.get(this.heroesUrl)
      .toPromise()
      .then(response => response.json().data as Hero[])  // URLに対してリクエストを行い、結果JSONをオブジェクトに変換します
      .catch(this.handleError);
  }

  private handleError(error: any): Promise<any> {
    console.error('An error occurd', error);
    return Promise.reject(error.message || error);
  }
}

getHeroes()メソッドに注目してください。TypeScriptゆえ、戻り値の型が定義されていますね。Promise<Hero[]>です。私のように静的型付けに慣れた者からするとこの時点でメソッドの役割がほぼ明確になるため非常にありがたいです。Hero型のリストを返していることがわかります。このデータを使ってヒーロー一覧画面ではヒーローリストを表示しているわけですね。

チュートリアルではInMemoryWebAPIというAngular2がチュートリアルのために用意したと思われるライブラリを使ってAPIを呼べるようにしています。Angular2のアプリを立ち上げると同一サービスにAPIも立ち上がります。APIで返すデータは以下のように定義されています。

in-memory-data.service

import { InMemoryDbService } from 'angular-in-memory-web-api';

export class InMemoryDataService implements InMemoryDbService {
  createDb() {
    let heroes = [
      { id: 11, name: 'Mr. Nice' },
      { id: 12, name: 'Narco' },
      { id: 13, name: 'Bombasto' },
      { id: 14, name: 'Celeritas' },
      { id: 15, name: 'Magneta' },
      { id: 16, name: 'RubberMan' },
      { id: 17, name: 'Dynama' },
      { id: 18, name: 'Dr IQ' },
      { id: 19, name: 'Magma' },
      { id: 20, name: 'Tornado' }
    ];
    return {heroes};
  }
}

これを実際のAPIサーバーから呼び出すよう、置き換えていきます。

2. DynamoDBからHeroデータを取得するAWS Lambda FunctionをScalaで書く

Lambda FunctionはJVM言語でも動かすことができるので、Scalaを用いて書いてみます。ストレージにはDynamoDBを選択しました。今回はDynamoDBにHeroのデータを保存して、テーブルスキャンを行いすべてのHeroを取得することをやります。Lambdaのエントリポイントは以下のように実装します。

package lambda.hero

import com.amazonaws.services.lambda.runtime.{ Context, RequestHandler }
import domains.Heroes
import infrastructures.HeroDBClient

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

  val client: HeroDBClient

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

}

class HeroesController extends HeroesComponent {
  override val client: HeroDBClient = new HeroDBClient()
}

RequestHandlerを実装することで、レスポンスの型をPOJOとしこちらで指定することができます。Lambdaが、型に含まれている値を自動でJSON化してくれます。ただし、その実装にはJacksonを利用しているようで、リクエスト/レスポンスに指定した型は漏れなくgetterおよびsetterそ備えている必要があります。上述コードではHeroesを指定していますね。見てみましょう。

package domains

import scala.beans.BeanProperty

case class Heroes(
    @BeanProperty var heroes: java.util.List[Hero]
) {
  def this() = this(
    heroes = new java.util.ArrayList[Hero]()
  )
}
package domains

import scala.beans.BeanProperty

case class Hero(
    @BeanProperty var id: Int,
    @BeanProperty var name: String
) {
  def this() = this(
    id = 0,
    name = ""
  )
}

Scalaにおいては@BeanPropertyアノテーションをフィールドに指定することにより、コンパイル時自動でgettersetterを付与してくれます。加えて、引数なしのコンストラクタを定義してますね。これもやはりJacksonが求める実装です。POJOとして利用する型はこれらの条件を満たしている必要があります。

残るは、Lambdaも何も絡まない、純粋なDynamoDBクライアントの実装です。

package infrastructures

import com.amazonaws.regions.Regions
import com.amazonaws.services.dynamodbv2.document.{ DynamoDB, Item }
import domains.{ Hero, Heroes }
import infrastructures.HeroDBClient.{ AttributeName, HeroConverter }

import scala.collection.JavaConversions._


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))
}

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ファイルを作ります。JARファイル生成はこちらの記事を参考にさせていただきました。

あとは、実際にデータを読み出すためのheroテーブルをDynamoDBで用意し、いくつかデータも作っておきます。

21

Lambda Function の準備は完了です。

3.API Gatewayで Lambda Function を利用できるようにする

Lambda Functionのセットアップ

ここは、 API Gateway + Lambdaの世界です。まずは作成したJARファイルをLambda Functionとしてアップロードします。Lambda Functionの設定は以下のようにしました。

  • Runtime: Java 8
  • Handler: lambda.hero.HeroesController::handleRequest
  • Role: Choose an existing role : service-role/microservice

API Gatawayのセットアップ

API Gatewayの画面へ行き、リソースを作成します。/heroesリソースを作成しました。

30

CORSを有効にし、デプロイします。ステージ名はv1としました。

40

エンドポイントにたいしてリクエストを送り、DynamoDBのデータが返ってくることを確認します。

50

サーバサイドの準備が整いました。

4.Angular2を修正してAPI Gatewayからデータを取得する

修正箇所は3点です。

app.module.ts

アプリケーションで利用するモジュールやDI用のサービス宣言など、包括的な設定情報を記述するファイルがあり、ここを修正します。といってもコメントアウトだけです。

@NgModule({
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    // InMemoryWebApiModule.forRoot(InMemoryDataService),   !ココ!
    AppRoutingModule
   ],
   providers: [
     HeroService
   ],
  declarations: [
    AppComponent,
    HeroesComponent,
    HeroDetailComponent,
    DashboardComponent,
    HeroSearchComponent
   ],
  bootstrap: [ AppComponent ]
})
export class AppModule { }

チュートリアルのAPIはAngular2と一緒に立ち上がる同梱版を利用しているのですが、そのモジュールを利用しないよう宣言しました。これを行わないと、Angular2から発せられるHTTPリクエストはすべてホストがlocalhostと解釈され、正しくリクエストを外部へ送ることができません。

hero.service.ts

HeroのデータCRUDを司るファイルの修正です。URLの修正 と、JSONパース の修正が必要そうです。

hero.service.ts

import { Injectable } from '@angular/core';
import { Headers, Http, Response } from '@angular/http';

import { Hero } from './hero';
import 'rxjs/add/operator/toPromise';

@Injectable()
export class HeroService {
  private heroesUrl = 'https://xxxxxxxxx.ap-northeast-1.amazonaws.com/v1/heroes'; // -- ①
  private headers = new Headers({ 'Content-Type': 'application/json' });

  constructor(
    private http: Http
  ) { }

  getHero(id: number): Promise<Hero> {
    return this.getHeroes()
      .then(heroes => heroes.find(hero => hero.id === id));
  }

  getHeroes(): Promise<Hero[]> {
    return this.http.get(this.heroesUrl)
      .toPromise()
      .then(response => response.json().heroes as Hero[]) // -- ②
      .catch(this.handleError);
  }

  private handleError(error: any): Promise<any> {
    console.error('An error occurred', error);
    return Promise.reject(error.message || error);
  }
}
  • ①: URLを外部のAPIに修正します。
  • ②: Lambdaが返すJSONはheroesキーに配列が連なる形としたので、dataプロパティで全オブジェクト取得するのではなく、heroesプロパティを指定して配列部分のみ取得するよう修正します。

修正はこれだけです。すげえ!

実行結果

60

API Gateway + Lambda + DynamoDB で取得したデータを表示することができました。

おわりに

Angular2はServiceがデータのCRUDの役割をもち、ビューコンポーネントから完全に分離されているため、データ取得先とその形式が変わっても、少量のコード修正で完結できます。Serviceとしては要求された型のデータ(ここではHeroの配列)を返してやればよいことがわかっているので、ロジックの意図も明確ですし、Serviceを利用する側としても安心感があります。サーバサイドの実装もそうですが、これは静的型付け言語とDIの仕組みを導入したフレームワークならではのメリットかと思いました。

また、書いたコードをみると、Angular2側とLambda側で共通インターフェースが定義されていることがわかります。ドメイン、すなわちHero型です。ビューはHeroに含まれるデータを使って画面を表示していますし、Lambda側も同じ型を定義してDynamoDBからデータを取り出しています。つまり、「Heroの中身が変わらない限りは、データ表示・データ保存部分にさほど大きな影響はない」といえます。ドメインを共通言語とすることで、開発がスムースに進むことが想像できます。

Angular2はコンセプトや考え方がかなりサーバサイド開発に近く、APIやWebサーバの「コンポーネント/リソース」開発に従事してきたエンジニアにとってはとてもフレンドリーだと感じました。逆に、jQueryに強くPHPなどで「ページ」を実装してきた方にとっては、若干とっつきにくいかもしれません。 フレームワークは各々誕生した背景やコンセプトがあり、それにうまく乗っかる形で実装できると非常に楽ができます。今後もヒーローをいじくり倒して、Angular2の世界を楽しみたいと思います。今回できなかったデータの作成、更新、削除にもアタックします。

参考