Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

AppSync + Nuxt.js(SSR)によるリアルタイム野球詳細ページについて

この記事は Gunosy Advent Calendar 2019 の8日目の記事です。なお、昨日は id: tomoko_tsubasa さんの 新米Gopherとしてこの1年やってきたこと - Gunosy Tech Blogでした。

はじめに

こんにちは。グノシー事業部でサーバーサイドを担当している吉澤(azihsoyn)です。好きなawsのサービスはAppSyncです。

久しぶりのグノスポ記事ですが、この秋(もう冬ですが)グノスポに野球詳細がリリースされました🎉🎉🎉

(この試合を選んだ意図は特にありません)

めちゃくちゃかっこいいですね!!!

実際にアクセスして触っていただく方がわかりやすいかもしれません(そしてフィードバックをください🙏)

グノスポといえばAppSyncを利用しているサービスとして有名ですが、今回初めてWebからAppSyncを利用しました。

先日の re:Invent でAppSync周りのアップデートが多数あったのでグノスポに導入したい気持ちでいっぱいです。

今回野球詳細webをリリースするにあたっていくつか工夫・検討したことを備忘録として書き記しておこうと思います。

Nuxt.js選定理由

強い意志で採用したわけではなく、Nuxt.jsがSSRやりやすいぐらいの認識で選びました。

会社のフロントエンド開発の知見としてはReact.jsもVue.jsも半々ぐらいだったので、とりあえずNuxt.jsでいってみてだめならNext.jsに切り替えるでいいのではという感じでした。

今回デザインは別の方にお願いすることになっていたのでReact.jsよりとっつきやすいのでは?という気持ちは多少あったかもしれません。

最終的にNuxt.jsのままリリースしたので他のフレームワークとの比較はできないですが特に困ったことはなかったです。(強いていえばTypeScript化が面倒だったぐらい)

SSRするためのインフラの検討

コストの面から最初はLambdaのNode.jsランタイムでSSRに取り組んでいたんですが、ハマりにハマって諦めました。ECS Fargateで実現しています。

Lambdaで動くまで頑張っても良かったのですが、

  • Lambdaのコールドスタートがユーザー体験悪いのでは?
  • ローカルで開発するときとLambdaで動かす用のコードが異なるのが微妙
    • Dockerで全部固めてしまえば動く世界が楽そう

ということでFargateにしました。

GraphQL + typescript

AppSync(GraphQL)のスキーマ定義を活かしたかったのでコード生成する方法をいくつか試したのですが、最終的にgraphql-codegen を使うことにしました。

graphql-code-generator.com

実際の設定ファイル

// codegen.yml
overwrite: true
schema: "schema.json"
documents: "queries/*.graphql"
generates:
  src/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
  graphqltype.d.ts:
    plugins:
      - "typescript-graphql-files-modules"

package.jsonにscriptを定義しておくと便利です

// package.json
"scripts": {
  "generate": "graphql-codegen --config codegen.yml",
  "downloadschema": "aws appsync get-introspection-schema --api-id <apiID> --format JSON schema.json && aws appsync get-introspection-schema --api-id <apiID> --format SDL schema.graphql"
}
$ yarn downloadschema
$ yarn generate

aws amplifyのcodegenも検討したのですが

aws-amplify.github.io

以下の点で微妙だったので採用を見送り。

  • 開発当時AWSDateなどの独自型に対応していなかった(今も?)
  • 開発当時amplifyで既存のリソースを使う方法が当時なかった(今も?)
    • AppSyncを使おうとするとリソースも勝手に作られてしまう(既存のリソースを使えない)
    • インフラはcloudformationで管理しているので別管理するのは厳しい

amplifyはGraphQLのclientとして使ってます(ほぼ素のapollo client)

import Amplify from 'aws-amplify'
import 'cross-fetch/polyfill'

export default ctx => {
  const AppSyncConfig = {
    aws_AppSync_graphqlEndpoint: ctx.app.$env.AppSync_URL,
    aws_AppSync_region: 'ap-northeast-1',
    aws_AppSync_authenticationType: 'API_KEY',
    aws_AppSync_apiKey: ctx.app.$env.AppSync_API_KEY
  }

  Amplify.configure({
    ...AppSyncConfig
  })
}
// nuxt.config.ts
plugins: [
  { src: '~/plugins/injectEnv.js' },
  { src: '~/plugins/appsync' }, // ←これを追加
  { src: '~/plugins/day.js' },
],

asyncDataでAPIを呼び出しています。

  asyncData: async function({ params, query, error }) {
    try {
      // 今日の試合を取得
      const result = await API.graphql(
        graphqlOperation(queries.GetGameByGameId, { gameId: params.id })
      )
      const game: BaseballGameV1 = result.data.getGameByGameId

      return {
        todayGame: game
      }
    } catch (e) {
      error({
        statusCode: 500
      })
    }
  }

Webからのリクエスト

グノスポアプリのリクエストではCognito+IAMの認証を使っているのですが、ブラウザからだとアプリのユーザーと紐付けることができません。(グノスポアプリ内のWebViewならid渡してゴニョゴニョすればできるかもしれませんが、グノスポ以外のアプリからのアクセス時には使えない)

幸いAppSyncには認証方式が複数サポートされており、WebからはAPI_KEYで認証を行っています。

AWS AppSync、GraphQL API の複数の認証タイプ設定のサポートを開始

複数の認証方式をサポートするとスキーマで以下のようにアノテーションが必要になるのですが、アクセスするtypeやquery全てにアノテーションを付ける必要があるのが少し不便です。(アノテーションをつけ忘れたリソースにアクセスしようとするとエラーになります。)

type PitcherStatsV1  @aws_api_key @aws_iam{
    playerId: ID!
    playerName: String!
    positionName: String
    # 勝/負/S/H 当試合成績の場合
    pitchingResult: String
}

type Query {
    getBaseballGameDetails(gameId: ID!): [BaseballDetailInfoV1!]! @aws_api_key @aws_iam
}

type Subscription {
    subscribeGameDetailUpdates(gameId: ID!): BaseballDetailInfoV1 // api_key, iam両方でアクセスできる
    @aws_subscribe(mutations: ["notifyBaseballGameDetailUpdate"])
    @aws_api_key @aws_iam
    subscribeGameLiveUpdateInfo(gameId: ID!): GameLiveUpdateInfo
    @aws_subscribe(mutations: ["notifyGameLiveTextUpdate"]) // iamのみアクセス可
}

AppSyncでDynamoDBの大量のデータを取得

野球詳細を作る上で野球のデータ構造が一番難しかったかもしれません。 野球というスポーツはとても情報量が多く、グノスポで一球速報のリアルタイム更新を実現するためにデータを保持するDynamoDBの構造を何度も変えました。

例として、速報テキストは1試合に600件ぐらいあるのですが、gameテーブルにそのまま格納するとDynamoDBの1レコードの上限を超えかねないので、gameテーブルにはidのリストだけ保持しておき、別のテキストテーブルに実体を保存するようにしてあります。

テーブルを分割すると複数のテーブルに問い合わせる必要があるため、AppSyncのpipeline resolverを使っています。

パイプラインリゾルバー (VTL) - AWS AppSync

以下はcloudformationのresolverの定義です

BatchGetGameTextFunctionConfig:
  Type: AWS::AppSync::FunctionConfiguration
  DependsOn: AppSyncSchema
  Properties:
    ApiId: !Sub ${GraphQLApi.ApiId}
    Name: BatchGetGameText
    Description: テキストの一覧を取得
    DataSourceName: !Sub ${GameLiveTextsDynamoDBTableDataSource.Name}
    FunctionVersion: 2018-05-29
    ResponseMappingTemplate: !Sub |
      #set($res = [])
      #if($util.isList($ctx.stash.res))
        #set($res = $ctx.stash.res)
      #end

      #foreach($event in $context.result.data.get("${GameLiveTextsTableName}"))
        #if( !$util.isNull($event) )
          $util.qr($res.add($event))
        #end
      #end
      $util.qr($ctx.stash.put("res", $res))
      $util.toJson($ctx.stash.res)

    RequestMappingTemplate: !Sub |
      #set($ids = [])
      #set($restIds = [])
      #set($arg = [])

      #if($util.isList($ctx.stash.restIds))
        #set($arg = $ctx.stash.restIds)
      #elseif($util.isList($ctx.source.gameTextPbpIdList))
        #set($arg = $ctx.source.gameTextPbpIdList)
      #else
        #return( [] )
      #end

      #foreach($id in $arg)
          #if($foreach.count <= 100)
              #set($map = {})
              $util.qr($map.put("id", $util.dynamodb.toString($id)))
              $util.qr($ids.add($map))
          #else
              $util.qr($restIds.add($id))
          #end
      #end

      #if( $ids.isEmpty() )
          #if( !$util.isNull($ctx.prev.result) && $util.isList($ctx.prev.result))
            #return( $ctx.prev.result )
          #else
            #return( [] )
          #end
      #end

      $util.qr($ctx.stash.put("restIds", $restIds))

      {
          "version" : "2018-05-29",
          "operation" : "BatchGetItem",
          "tables": {
            "${GameLiveTextsTableName}": {
              "keys": $util.toJson($ids)
            }
          }
      }

FieldGameTextListResolver:
  Type: AWS::AppSync::Resolver
  DependsOn: AppSyncSchema
  Properties:
    ApiId: !Sub ${GraphQLApi.ApiId}
    TypeName: BaseballGameV1
    Kind: PIPELINE
    FieldName: gameTextList
    PipelineConfig:
      Functions:
        # コメントを100件ずつ分割してBatchGetItemしてる
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
        - !Sub ${BatchGetGameTextPbpFunctionConfig.FunctionId}
    ResponseMappingTemplate: !Sub |
      $util.toJson($ctx.result)
    RequestMappingTemplate: !Sub |
      {}

pipeline resolverにはfunction間でテータを引き継ぐためのフィールドとしてstashというものがあります。 これにBatchGetItemの値を追加していって最後に返すことで、テーブル分割した大量のテキストを1つのクエリで返しています。

まとめ

以上、グノスポ野球詳細の舞台裏でした。

開発当時AppSyncをWebから使う知見があまりなかったのでとても苦労しました。何かの参考になれば幸いです。 来年もグノスポはアップデートしていく予定なので楽しみにしていてください!

明日は id:akink さんのエントリになります。