この記事は 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のコールドスタートがユーザー体験悪いのでは?
- これはre:Invent 2019で解消されました(別途コストは掛かりますが)
- New – Provisioned Concurrency for Lambda Functions | AWS News Blog
- ローカルで開発するときとLambdaで動かす用のコードが異なるのが微妙
- Dockerで全部固めてしまえば動く世界が楽そう
ということでFargateにしました。
GraphQL + typescript
AppSync(GraphQL)のスキーマ定義を活かしたかったのでコード生成する方法をいくつか試したのですが、最終的にgraphql-codegen を使うことにしました。
実際の設定ファイル
// 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も検討したのですが
以下の点で微妙だったので採用を見送り。
- 開発当時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 さんのエントリになります。