Angular でサーバーレスSPA: DynamoDB を使って「さらに読みこむ」パターンを実装しよう

2018.10.03

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

これをつくります。

angular_read_more.gif

構成図はこんな感じ。

arch.png

やりたいこと - DynamoDB を使って一覧表示

DynamoDB を使うときは、データの一覧を取得する というシーンに遭遇した場合、 read capacity unit に注意して実装する必要があります。DynamoDB は パーティションキーとソートキーによりデータをひとつだけ特定して取得することが得意ですが、複数のデータを取得する場合はクエリやスキャンを使うことになり、とり方よっては大量の read capacity unitを消費してしまうことになります。

そこで登場するのが、DynamoDB に対してスキャン・クエリをかける際に指定できる Limit です。うまく取得件数を制限できれば、消費する read capacity unitもおさえられそうです。ただし、 Limit を使うことにより一度ですべてのデータを取得できない状況が発生することになるので、クライアント側では「さらに読み込む」ボタンを設置し、続きのデータを読めるようにしたいです。

全体として、ポイントは「さらに読み込む」を押したときの動作を、サーバーとクライアントそれぞれどうするかという点です。ここに注目して実装を見ていきましょう。

サーバーサイド

構成情報

利用ツール バージョン
Python 3.6.5
aws-cli 1.16.26

DynamoDB User テーブル

ユーザー情報を格納するためのテーブルを作ります。なお、今回は DynamoDB の Query を使いたいと思い、属性のひとつ、organization_id でグローバルセカンダリインデックス(GSI)を作成しています。

capture-dynamo_table.png

GSI設定項目
Name user_organization_id-index
Partition key organization_id (String)
Sort key updated_at (Number)
Attributes ALL

Lambda Function

この DynamoDB に対して organization_id による Query を作っていきます。 Query に対して Limit を指定し、取得する件数を制御するのでした:

ドキュメントを見ると、どうやら Limit とセットで ExclusiveStartKey も考慮する必要がありそうです。これを指定することで、すでに読み込んだ項目はスキップできるみたいですね。必要な素材をまとめると以下です。

  • organization_id: GSI のパーティションキー。パラメータとして外部から受け取る。
  • ExclusiveStartKey: 検索をかけるスタート地点。これもパラメータとして外部から受け取る。
  • Limit: 一度に取得する件数。 read capacity unitとの兼ね合いであることをから、パラメータとして受け取るのではなく、Lambda Functionの環境変数 にセットする方針

これでコードがかけます。以下のようにしましょう。

user_repository.py

import os
import boto3
from boto3.dynamodb.conditions import Key

DYNAMODB_ENDPOINT = os.getenv('DYNAMODB_ENDPOINT')
NOTE_USER_TABLE_NAME = os.getenv('NOTE_USER_TABLE_NAME')
DYNAMO = boto3.resource('dynamodb', endpoint_url=DYNAMODB_ENDPOINT)
DYNAMODB_TABLE = DYNAMO.Table(NOTE_USER_TABLE_NAME)
USER_LIST_LIMIT = int(os.getenv('USER_LIST_LIMIT')) ## 今回は2にしている

def get_users_by_organization_id(organization_id, last_evaluated_key):
    param = {
        'IndexName': 'user_organization_id-index',
        'KeyConditionExpression': Key('organization_id').eq(organization_id),
        'ScanIndexForward': False,
        'Limit': USER_LIST_LIMIT
    }

    if last_evaluated_key is not None:
        param['ExclusiveStartKey'] = last_evaluated_key

    return DYNAMODB_TABLE.query(**param)

ExclusiveStartKey はセットされない場合(初回)とセットされる場合(さらに読み込む)があると予想できるため、両方対応できるようにしています。なお、query を実行すると、「最後に評価したキー」という意味で LastEvaluatedKey が返ってきます。DynamoDB としては、この LastEvaluatedKeyExclusiveStartKey にセットしてやれば、それまでの値はスキップします。ということで、クライアント(SPA)が実際に目にすることになる値は LastEvaluatedKey のほうです。よって、引数名は last_evaluated_key としました。

API Gateway

Lambda Function の実装を考慮すると、organization_idlast_evaluated_key を受け渡しできるような GET 系のAPI を用意できれば良さそうです。/users というパスを切って、クエリパラメータで必要な情報を取得できるようにします。

apigw_parameters.png

これらのパラメータを Lambda Function にわたす方法はいろいろありますが、今後パラメータがどんどん増えていくことを考慮して、マッピングテンプレートを定義してみました。

{
  #if( "$input.params('organization_id')" != "" )
    "organization_id": "$input.params('organization_id')"
  #end
  #if( "$input.params('last_evaluated_key')" != "" )
    ,"last_evaluated_key": $util.urlDecode($input.params('last_evaluated_key'))
  #end
}

これでサーバーサイドの材料は揃いました。テストしてみましょう。

apigw_test_first.png

2件取得できたことと、LastEvaluatedKey も得られたことが確認できます。私、最初ここで得られる値は独自のID文字列のようなものをイメージしていたんですが、まさかのJSONですね。これをパラメータで渡すために、怖がらずにまるっとエンコードして指定してみます。

apigw_test_next.png

続きのデータが得られていることがわかります!これでサーバーサイドは準備OKです。

クライアントサイド

サーバーサイドの実装を経て SPA が意識することが明らかになりました。

  • DynamoDB レスポンスの LastEvaluatedKey を保持しておく
  • 「さらに読み込む」が押されたら、保持した Key を last_evaluated_key として API にわたす

これらを画面側で実現していきます。

構成情報

Angular CLI: 6.1.4
Node: 8.11.2
OS: darwin x64
Angular:
...

Package                      Version
------------------------------------------------------
@angular-devkit/architect    0.7.4
@angular-devkit/core         0.7.4
@angular-devkit/schematics   0.7.4
@schematics/angular          0.7.4
@schematics/update           0.7.4
rxjs                         6.2.2
typescript                   2.9.2

APIクライアント部分

APIクライアントは画面コンポーネントからパラメータを受け取って、API Gateway と通信を行うことが仕事です。大まかな流れは以下です:

  1. organizationIdlastEvaluatedKey を受け取る
  2. HttpParams クラスを作ってクエリパラメータを構築する(エンコードはここでやってくれる)
  3. 取得したレスポンスをクラスに変換する
    • ユーザー情報のリスト
    • LastEvaluatedKey
  4. 画面に返す

user-client.service.ts

import {Injectable} from '@angular/core';
import {HttpClient, HttpParams} from '@angular/common/http';
import {environment} from '../../../environments/environment';
import {NoteUser, NoteUsers} from '../../domains/user/note-users';
import { DateUtilsService } from '../../modules/utils/date-utils.service';

@Injectable({
  providedIn: 'root'
})
export class UserClientService {

  private resourcePath =
    environment.noteApi.baseUrl + '/users';

  constructor(
    private http: HttpClient,
    private dateUtils: DateUtilsService
  ) {
  }

  getUserByOrganizationId(
    organizationId: string,
    lastEvaluatedKey: string
  ): Promise<NoteUsers> {
    const httpParams = new HttpParams()
      .set('organization_id', organizationId)
      .set('last_evaluated_key', lastEvaluatedKey);
    return this.http
      .get(this.resourcePath, {
        params: httpParams
      })
      .toPromise()
      .then(res => this.searchResponseToNoteUsers(res));
  }

  searchResponseToNoteUsers(response): NoteUsers {
    console.log(response);
    const lastEvaluatedKeyJson = response['LastEvaluatedKey'];
    const lastEvaluatedKey = lastEvaluatedKeyJson
      ? JSON.stringify(lastEvaluatedKeyJson)
      : null;
    const itemArray: Array<any> = response['Items'];
    const usersArray: Array<NoteUser> = itemArray.map(userJson => {
      const result = new NoteUser();
      result.userUuid = userJson['user_uuid'];
      result.organizationId = userJson['organization_id'];
      result.email = userJson['email'];
      result.company = userJson['company'];
      result.displayUserName = userJson['display_user_name'];
      result.createdAt = this.dateUtils.fromUnixTimeSec(userJson['created_at']);
      result.updatedAt = this.dateUtils.fromUnixTimeSec(userJson['updated_at']);
      return result;
    });
    return new NoteUsers(lastEvaluatedKey, usersArray);
  }
}

画面部分

まずはHTMLです。「さらに読み込む」ボタンとそのクリックイベントに注目してください。

list-users.component.html

<mat-sidenav-container class="sidenav-container">
  <mat-sidenav-content>
    <mat-toolbar color="primary">
          <span>ユーザー管理</span>
    </mat-toolbar>
    <!-- Page Content -->
    <div class="note-master-content">
      <!-- search and result-->
      <div fxLayout="column" fxLayoutAlign="space-around space-around" fxLayoutGap="5px">
        <div fxFlex class="result-table-container">
          <div>
            <mat-progress-bar mode="indeterminate" *ngIf="loading"></mat-progress-bar>
          </div>
          <div fxFlexFill class="mat-elevation-z8">
            <table fxFlexFill mat-table [dataSource]="displayUsers">

              <ng-container matColumnDef="userUuid">
                <th mat-header-cell *matHeaderCellDef>ユーザーID</th>
                <td mat-cell *matCellDef="let element"> {{element.userUuid || '-'}}</td>
              </ng-container>

            <!-- ...テーブル定義記述は省略... -->

              <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
              <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
            </table>
            <div *ngIf="lastEvaluatedKey" fxFlexFill fxLayoutAlign="center center">
              <button fxFlexFill mat-button color="basic" (click)="getUsers(lastEvaluatedKey)">
                <mat-icon>expand_more</mat-icon>
                さらに読み込む
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
    <!-- Page Content -->
  </mat-sidenav-content>
</mat-sidenav-container>

結果表示テーブルの下部に「さらに読み込む」ボタンが配置されているのと、それをクリックすると (click)="getUsers(lastEvaluatedKey)" が発火されるようですね。どうやら状態として持っている lastEvaluatedKey を使って続きのデータを取得しているようです。 Angular のお作法で、このHTMLの対になる画面ロジックがありますのでそれも見てみましょう。

list-users.component.ts

import { Component } from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { NoteUser } from '../../../domains/user/note-users';
import { UserComponentService } from '../../user-component.service';
import { DateUtilsService } from '../../../modules/utils/date-utils.service';
import { Moment } from 'moment';

@Component({
  selector: 'app-list-users',
  templateUrl: './list-users.component.html',
  styleUrls: ['./list-users.component.scss']
})
export class ListUsersComponent {


  displayUsers: NoteUser[];
  lastEvaluatedKey: string;

  // Obtain from login user information, for example.
  organizationId = '1ea61e76-c2aa-433d-ad00-297ccdd32149';

  displayedColumns: string[] = [
    'email',
    'displayUserName',
    'company',
    'createdAt',
    'updatedAt'
  ];
  loading = false;

  constructor(
    private breakpointObserver: BreakpointObserver,
    private service: UserComponentService,
    private dateUtil: DateUtilsService) {
    this.init();
  }

  refresh() {
    this.lastEvaluatedKey = null;
    this.displayUsers = [];
  }

  init() {
    this.refresh();
    this.getUsers(null);
  }

  getUsers(lastEvaluatedKey: string) {
    this.loading = true;
    this.service.getOrganizationUsers(this.organizationId, lastEvaluatedKey)
      .then(users => {
        this.displayUsers = this.displayUsers.concat(users.noteUsers);
        this.lastEvaluatedKey = users.lastEvaluatedKey;
        this.loading = false;
      });
  }

  toJst(moment: Moment): string {
    return this.dateUtil.formatJstDateTime(moment);
  }
}

getUsers で、

  • organizationIdlastEvaluatedKey を使ってAPIクライアントのメソッド呼び出し
  • 得られた結果のうち、
    • ユーザーリストは 画面表示用のリストに連結する
    • lastEvaluatedKey は、そのまま画面の状態として保持しておく(上書き)

このようにすることで、「さらに読み込む」ボタンが押されたとき、パラメータの organizationId と 画面の状態として持っている lastEvaluatedKey をAPIに渡して、次のデータを画面に表示させることができるという仕掛けです。実装完了です。冒頭のGIFが実際に使えるようになりました。

まとめ

Angular と サーバーレスの組み合わせは、本当にいろいろなことができます。今回示した DynamoDB のデータとの連携は、その一部です。SPAもサーバーレスもない時代は、画面とサーバーサイドを構築するのに環境の用意含め一人で進めるのはかなり大変な作業でした。サーバーレスアプリケーションの場合はデプロイのハードルが低く、少しずつ機能を実装し完成を目指す方針がとりやすいです。複雑なECサイトなどは難しいかもしれませんが、ユーザーやコンテンツをCRUDする管理画面 であったり、 データをフェッチして表示するダッシュボード が得意だと思います。

他にもたくさんパターンがあるのでブログで紹介していきます。ソースコードは GitHub ですべて公開していますので、参考にしてください。

参考

ソースコード