AWSで開発するサーバレスAPIバックエンド

Serverless Meetup Tokyo #14で発表しました。

www.slideshare.net


AWSで開発するサーバレスAPIバックエンド

Serverless Meetup Tokyo #14

2019.09.19. 三宅 暁


自己紹介

  • 三宅 暁 (ミヤケ アキラ)
  • フリーランス(8月から)
  • フロントエンド
    • SPAをJS / HTML / CSSまるごと設計・実装できる程度の能力
    • Reactが得意、あとVueとAngular
  • バックエンド
    • サーバレス中心のAWS
    • Node.js
    • API設計
    • OpenIDまわり

自己紹介


主なサーバレス関連のライブラリ

  • serverless-import-swagger
    • OpenAPI形式のAPI定義からserverless.ymlのhandlerの定義を生成するツール
    • 最近のバージョンアップで設定まわりが洗練されました
  • node-lambda-utilities
    • lambdaのhandlerのテストをしやすくするツール
    • contextやcallbackのモックの生成とhandlerの実行

今日話すこと

  • AppSync + DynamoDB + Cognitoを使って、GraphQLのAPIバックエンドを作る
    • ノンコーディング
    • デプロイが簡単にできて
    • なるべくベンダーロックインしないように

GraphQL or RESTful ?


プロジェクトのサンプル

https://github.com/AKIRA-MIYAKE/aws-serverless-sample


ベンダーロックインしないために

  • Amplifyは使わない
    • フロントエンドがAWSにロックインしないように
    • Apollo Clientを利用
  • Cognitoを用いるが、OpenID Providerとして利用する

APIの仕様

  • メッセージの作成・取得・一覧・削除
  • 取得・一覧はログインしなくても呼び出せる
  • 一覧は作成日での降順
  • 作成・削除はログインが必要
  • 自分が作成したメッセージのみ削除できる

schema.graphql

type Query @aws_api_key @aws_oidc {
    readMessage(id: ID!): Message
    listMessage(limit: Int, nextToken: String): MessageCollection!
}

type Mutation @aws_oidc {
    createMessage(input: CreateMessageInput!): Message!
    deleteMessage(input: DeleteMessageInput!): Message
}
  • 基本の承認モードをAPI_KEYに指定し、OPENID_CONNECTを追加
  • mutationはOPENID_CONNECTのみ許可し、認証状態を必須に

schema.graphql

type Message @aws_api_key @aws_oidc {
    id: ID!
    value: String!
    createdBy: String!
    createdAt: String!
}
  • createdByはCognitoが発行するsubを用いる

schema.graphql

type MessageKeyValue @aws_api_key @aws_oidc {
    id: ID!
    key: String!
    value: String!
    message: Message
}

type MessageCollection @aws_api_key @aws_oidc {
    items: [MessageKeyValue!]!
    nextToken: String
    scannedCount: Int
}

Mutation.createMessage.request.vtl

#set($id = $util.autoId())
#set($now = $util.time.nowISO8601())
#set($sub = $context.identity.sub)

#set($items = [...])

{
  "version" : "2018-05-29",
  "operation" : "BatchPutItem",
  "tables" : {
    "${DynamoDB}": $utils.toJson($items)
  }
}
  • BatchPutItemを用いてクエリ用のレコードを同時に登録

Mutation.createMessage.request.vtl

#set($items = [
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message"),
    "value": $util.dynamodb.toString("${context.arguments.input.value}"),
    "createdBy": $util.dynamodb.toString("${sub}"),
    "createdAt": $util.dynamodb.toString("${now}")
  },
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message/createdBy"),
    "value": $util.dynamodb.toString("${sub}")
  },
  {
    "id": $util.dynamodb.toString("${id}"),
    "key": $util.dynamodb.toString("message/createdAt"),
    "value": $util.dynamodb.toString("${now}")
  }
])

Query.listMessage.request.vtl

{
  "version": "2017-02-28",
  "operation": "Query",
  "query": {
    "expression": "#key = :key",
    "expressionValues": {
      ":key": $util.dynamodb.toStringJson("message/createdAt")
    },
    "expressionNames": {
      "#key": "key"
    }
  },
  "index": "key-value-index",
  "limit": #if($context.arguments.limit) $context.arguments.limit #else 10 #end,
  "nextToken": #if($context.arguments.nextToken) "${context.arguments.nextToken}" #else null #end,
  "scanIndexForward": false
}
  • key-value-indexをスキャンし、valueに入っているcreatedAtの値でのソートを実現
  • limitとnextTokenにデフォルト値をセット

MessageKeyValue.message.request.vtl

{
  "version" : "2017-02-28",
  "operation" : "GetItem",
  "key" : {
    "id": $util.dynamodb.toStringJson("${context.source.id}"),
    "key": $util.dynamodb.toStringJson("message")
  }
}
  • listMessageが返す、MessageKeyValueのmessageフィールドのマッピング
  • 1件ごとにGetItemが実行されるため、1+Nに

Mutation.deleteMessage.request.vtl

{
  "version": "2017-02-28",
  "operation": "DeleteItem",
  "key": {
    "id": $util.dynamodb.toStringJson("${context.arguments.input.id}"),
    "key": $util.dynamodb.toStringJson("message")
  },
  "condition": {
    "expression": "createdBy = :createdBy",
    "expressionValues": {
      ":createdBy": $util.dynamodb.toStringJson("${context.identity.sub}")
    }
  }
}
  • BatchDeleteではconditionが使えないため、メインのレコードのみの削除
  • パイプラインリゾルバの利用がおそらく適切

serverless-appsync-plugin

https://github.com/sid88in/serverless-appsync-plugin

  • AppSyncの設定を簡潔に定義してデプロイできるServerless Frameworkのプラグイン
  • mappingTemplates(リゾルバ)は {type}.{field}.request.vtl{type}.{field}.respose.vtl のファイルをデフォルトで認識
  • vtlファイルで環境変数を設定できる
  • CloudFormationの出力を利用可能

serverless.yml

custom:
  appSync:
    substitutions:
      DynamoDB: sls-sample-${env:ENV}
  • Batch系処理ではvtlで直接テーブル名の指定が必要
  • ステージごとにテーブル名が異なるため、環境変数として定義 
  • vtlでは $DynamoDB のように、変数としてアクセス可能

serverless.yml

custom:
  appSync:
    additionalAuthenticationProviders:
      - authenticationType: OPENID_CONNECT
        openIdConnectConfig:
          issuer: !Join
            - ''
            - - https://cognito-idp.ap-northeast-1.amazonaws.com/
              - !Ref UserPool
  • CognitoをOPENID_CONNECT承認モードとして利用するための設定
  • issuerのURLがUserPoolのIDを含んだものとなるので、resourcesで出力された値を参照

Cognito UserPoolのアプリの統合の設定

  • アプリクライアントはCloudFormationで作れるが、アプリの統合は未サポート
  • この箇所だけコンソールかCLIからの設定が必要

アプリの統合 / アプリクライアントの設定

  • 「有効な ID プロバイダ」
    • 「Cognito User Pool」を選択
  • 「サインインとサインアウトの URL」
    • 「コールバック URL」に http://localhost:3000/authentication/callback を設定
      • @axa-fr/react-oidc-contextのデフォルト値
    • 「サインアウト URL」に http://localhost:3000 を設定
    • 「OAuth 2.0」
      • 「許可されている OAuth フロー」の「Authorization code grant」を選択
      • 「許可されている OAuth スコープ」の「phone」「email」「openid」「profile」を選択
        • openid」があれば動作はするはず

アプリの統合 / ドメイン


フロントエンド

  • 動作確認のためのシンプルなReactアプリ
    • ログイン / ログアウト
    • listMessageの結果の表示
    • createMessageの実行
    • deleteMessageの実行

ログイン / ログアウト (AppHeader)

const AppHeader = () => {
  const { login, logout, oidcUser } = useReactOidc();

  return (
    <h1>AWS SLS Sample</h1>
      {
        oidcUser
          ? <div><button onClick={logout}>Logout</button></div>
          : <div><button onClick={login}>Login</button></div>
      }
      {oidcUser && (
        <span>sub(generated by cognito): {oidcUser.profile.sub}</span>
      )}
  );
};
  • @axa-fr/react-oidc-contextのフックを利用
  • Cognitoのページにリダイレクトされ、ユーザ登録まで処理してくれる

認証方式の切り替え (ApolloContext)

const client = useMemo(
    () => {
      if (oidcUser && oidcUser.id_token) {
        return new ApolloClient({
          uri: process.env.REACT_APP_GRAPHQL_URL,
          request: operation => {
            operation.setContext({ headers: { Authorization: `${oidcUser.id_token}` },
            });
          },
        });
      } else {
        return new ApolloClient({
          uri: process.env.REACT_APP_GRAPHQL_URL,
          request: operation => {}
            operation.setContext({ headers: { 'x-api-key': process.env.REACT_APP_GRAPHQL_API_KEY },
            });
          },
        });
      }
    },
    [oidcUser]
  );
  • ログイン状態に応じて、クライアントを切り替えるコンテキストを定義

フロントエンドのベンダーロックイン

  • GraphQL、OpenID共にAWSに依存しない仕組みにしているため、最低限の修正でエンドポイントの変更が可能
    • OpenIDの設定値の変更
    • ApolloContextのヘッダーの変更

実サービスで利用するために検討が必要な箇所

  • AppSyncのAPI_KEY承認モードの利用
    • 定期的な再発行を行い、キーを返すAPIを実装する
    • Cognitoに匿名ユーザを登録し、sub値で判断する
    • Client Credentials Grantフローで取得したトークンを返すAPIを実装する(そのトークンが利用可能か未検証)
  • 厳密なログアウト
    • @axa-fr/react-oidc-contextはトークンを削除することでログアウト
    • 厳密な制御を行うなら、ログアウトエンドポイントを用いてCognito側のログアウトも必要
  • ホストされたサインアップ、サインインページとリダイレクト
    • ADMIN_NO_SRP_AUTHを用いてそれぞれの処理を行い、取得されたトークンを返すAPIを実装する

まとめ

  • AppSync + DynamoDB + Cognitoを使えば、ノンコーディングでGraphQLのAPIバックエンドを作ることができる
    • パイプラインリゾルバを利用すれば、実サービスでもある程度までは対応できそう
  • serverless-appsync-pluginを利用することで(ほぼ)自動でデプロイが可能
  • AWSにほとんど依存しないフロントエンドの開発も可能
  • 非ログイン状態でのAppSyncへのアクセス制御は検証が必要

Marpでスライドを作ると、そのまま貼り付けれるから便利。