アドベントカレンダーも中盤に差し掛かってきました。
今回は画像投稿アプリのサーバサイド(ユーザー認証・GraphQLによるAPI通信)をAWSのサービスAmplify
・AppSync
を主に活用して実装していきます。
一応、以下記事の続編という形で投稿を行っていますが、途中まではAmplify
とAppSync
の概要説明を比較的シンプルな実装例を用いて行います。
AWS Amplifyとは
AWS Amplify 公式
AWS Amplify は、AWS を使用したスケーラブルなモバイルアプリおよびウェブアプリの作成、設定、実装を容易にします。Amplify はモバイルバックエンドをシームレスにプロビジョニングして管理し、バックエンドを iOS、Android、ウェブ、React Native のフロントエンドと簡単に統合するためのシンプルなフレームワークを提供します。また、Amplify は、フロントエンドとバックエンドの両方のアプリケーションリリースプロセスを自動化し、機能をより迅速に提供することができます。
簡単に言えば、モバイル・Webアプリケーションのサーバサイド(認証・API通信・DB操作・S3操作など)を超手軽に実装できるサービスです。
AWS Amplify CLIのインストール・初期設定
aws amplify cli はnode moduleで提供されているので、グローバルにインストールしておきます。
$ npm install -g @aws-amplify/cli
インストール後に、AWSの初期設定(IAMユーザー作成)していきます。
以下コマンドを打つことで対話形式で設定を行うことができます。
$ amplify configure
$ Sign in to your AWS administrator account:
$ https://console.aws.amazon.com/
// awsのログイン画面に移動するので、ログイン後Enterをクリック
$ Specify the AWS Region
? region: ap-northeast-1
Specify the username of the new IAM user:
? user name: (amplify-XXXXX)
// IAM作成画面に移動するので、IAMユーザーでログイン後Enterをクリック。IAMユーザーを作成します。
// そのあと、上で作成したIAMユーザーのアクセス・シークレットキーを入力
$ Enter the access key of the newly created user:
? accessKeyId: YYYYYYYYYYYYY**********
? secretAccessKey: YYYYYYaaaaaaaaaa********************
This would update/create the AWS Profile in your local machine
? Profile Name: amplify_user
AWSからIAMにアクセスすると「ユーザー」に新規で今回登録したユーザーが登録されているのが確認できます。
とりあえずReactアプリのテンプレートを生成しておきます。
$ npx create-react-app amplify-js-app --typescript && cd amplify-js-app
生成したReactアプリのルート上でamplifyでサーバサイドの初期設定をしていきます。
こちらも以下コマンドを打つことで回答形式で設定を行うことができます。
全ての回答が終わると構成が自動生成され、ルート配下のamplify
フォルダに設定が保存されます。
$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project amplify-js-app
? Enter a name for the environment amplifyenv
? Choose your default editor: None
? Choose the type of app that you're building javascript
Please tell us about your project
? What javascript framework are you using none
? Source Directory Path: src
? Distribution Directory Path: dist
? Build Command: npm run build
? Start Command: npm start
Using default provider awscloudformation
For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html
? Do you want to use an AWS profile? Yes
? Please choose the profile you want to use amplify_user
初期設定を行っただけなので、現状何の機能も登録されていない状態です。以下コマンドで確認します。
$ amplify status
| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | --------------- |
AWS Amplify AuthでCognitoユーザー認証機能を実装してみる
上記までで初期設定は終わりました。しかし、現状では機能が空の状態かつAWS上にもなにも作成されていません。
とりあえずどんなアプリにも必要と思われる「ユーザー認証(Auth)機能」を作成しながら、amplifyの検証を行います。
今回は一番簡単な設定にするため、SNS認証機能などは作成しない前提で進めます。
Amplify Auth による AWS Cognito 設定
amplify add { 導入したい機能 }
で環境構築したい機能をAWSに追加していくことができます。
$ amplify add auth
Using service: Cognito, provided by: awscloudformation
Do you want to use the default authentication and security configuration? Default configuration
Warning: you will not be able to edit these selections.
// E-mailを必須項目にする
How do you want users to be able to sign in? Email
// 追加設定はしない
Do you want to configure advanced settings? No, I am done.
Successfully added resource amplifyjsappxxxxxxx locally
追加されたか確認
$ amplify status
| Category | Resource name | Operation | Provider plugin |
| -------- | -------------------- | --------- | ----------------- |
| Auth | amplifyjsappxxxxxxx | Create | awscloudformation |
ローカルにauthの設定がされましたがまだAWSには反映されていません。以下コマンドでAWSにデプロイします。
(成功すれば裏でCloudFormation
が動き設定が反映されていきます。)
amplify push
デプロイが完了後にAWS Cognito
のユーザープール
にアクセスし、設定どおり新規生成されているのを確認します。
フロントエンドからCognito認証する
フロントエンドのサインアップ・ログイン・パスワード変更機能を実装していきます。とはいっても、aws-amplify-react
というマテリアルデザインのコンポーネントを手軽に使えるのでデザインにこだわりがなければかなり楽に実装できます。
まず、amplifyをreactで使うためのモジュールをインストールします
$ npm i aws-amplify aws-amplify-react
aws-amplify-reactとtypescriptを組み合わせて使うと、型定義でエラーが出てしまうため型を別途作成します。
declare module 'aws-amplify-react';
アプリのルートコンポーネントでCognito
の設定を定義します。
(今回はシンプルに見せるためにコンポーネントファイルに直書きしていますが、実運用ではセキュリティの観点からconfigure
は別ファイルから読み込みかつIDなどは.env
から読み込み外部から見えないようにする必要があります。)
import React from 'react';
import Amplify from 'aws-amplify';
import { Authenticator } from 'aws-amplify-react';
Amplify.configure({
Auth: {
// REQUIRED - Amazon Cognito Identity Pool ID
// 「ユーザープール」ではなく「フェレデーティットアイデンティティ」の「サンプルコード」から確認できます
identityPoolId: 'XX-XXXX-X:XXXXXXXX-XXXX-1234-abcd-1234567890ab',
// REQUIRED - Amazon Cognito Region
// Cognitoを使用しているリージョンです
region: 'XX-XXXX-X',
// OPTIONAL - Amazon Cognito User Pool ID
// 「ユーザープール」の「プールID」です
userPoolId: 'XX-XXXX-X_abcd1234',
// OPTIONAL - Amazon Cognito Web Client ID
// 「ユーザープール」の「アプリクライアント」の「xxxxxx_app_clientWeb」のIDです
userPoolWebClientId: 'XX-XXXX-X_abcd1234',
}
});
const App: React.FC = () => <Authenticator />;
export default App;
たったこれだけのコードで、「サインアップ」・「eメールへの登録認証コードの送信」・「サインイン」・「サインアウト」・「パスワードの変更」機能を実装できてしまいます。AWS恐るべしですね。
ユーザー認証機能単体なら手軽に実装することができました。
しかし、Authコンポーネントをカスタマイズしたり、ReduxのフローにのせようとするとAuthenticator
のコンポーネント・ファンクションを分解して再度組み立てる必要があるため難易度が上がります。
ここでは、説明が長くなってしまうため説明は割愛しますが、近いうちにまとめて記事にする予定です。
AWS AppSyncとは
AWS AppSync 公式
AWS AppSync を使用すると、1 つ以上のデータソースからのデータに安全にアクセス、操作、結合するための柔軟な API を作成でき、アプリケーション開発がシンプルになります。AppSync は、GraphQL を使用してアプリケーションが必要なデータを正確に取得できるようにするマネージド型サービスです。
なぜREST APIではなくGraphQLを使うか
単に話題のGraphQLを使ってみたかったというだけです。
REST APIと比較してもGraphQLの方が圧倒的に優れているということはないようです。
一応参考までにメリット・デメリットをまとめてみました。
メリット
- エンドポイントが一つしかなく、管理がしやすい(REST APIの場合うまく設計しないとエンドポイントが増え続ける)
- フロントエンドからクエリを使用して必要な分だけデータを返すことができる
- 単一のエンドポイントから全てのデータにアクセスできる(簡単にリレーションを構築する)
- 結果的にAPIリクエストの回数を削減することができる
デメリット
- エンドポイントが1つしかないので、細かいログ・キャッシュモニタリングが難しい
- クライアント側でクエリを記述しなければならないため、RESTよりもクライアント側のコードがファットになる
- エラーの場合もHTTPヘッダのレスポンス 200OKが返ってくるのでRESTのエラーハンドリングよりも複雑になる
- 「N+1」問題は緩和されない。設計時に検討する必要がある。
AWS AppSyncでGraphQLサーバを実装してみる
Amplify Api による AWS AppSync 環境構築
$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: amplifyjsapp
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Do you want to configure advanced settings for the GraphQL API No, I am done.
? Do you have an annotated GraphQL schema? No
? Do you want a guided schema creation? No
? Provide a custom type name MyType
Creating a base schema for you...
The following types do not have '@auth' enabled. Consider using @auth with @model
- MyType
追加されたか確認
$ amplify status
| Category | Resource name | Operation | Provider plugin |
| -------- | -------------------- | --------- | ----------------- |
| Api | amplifyjsapp | Create | awscloudformation |
| Auth | amplifyjsappxxxxxxxx | No Change | awscloudformation |
AppSync設定をデプロイ
$ amplify push
✔ Successfully pulled backend environment amplifyenv from the cloud.
Current Environment: amplifyenv
| Category | Resource name | Operation | Provider plugin |
| -------- | -------------------- | --------- | ----------------- |
| Api | amplifyjsapp | Create | awscloudformation |
| Auth | amplifyjsappxxxxxxxx | No Change | awscloudformation |
? Are you sure you want to continue? Yes
The following types do not have '@auth' enabled. Consider using @auth with @model
- MyType
Learn more about @auth here: https://aws-amplify.github.io/docs/cli-toolchain/graphql#auth
GraphQL schema compiled successfully.
Edit your schema at /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/schema.graphql or place .graphql files in a directory at /xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/schema
? Do you want to generate code for your newly created GraphQL API No
ブラウザからAWS AppSync
にアクセスして想定どおり環境が構築されているか確認します。DynamoDBにも同時にデータリソースが生成されています。
ひとまずテストクエリを実行してみる
AWS AppSyncコンソール画面のクエリから簡単にクエリのテストを行うことができます。サンプルでスキーマ&リソースDBが生成されていると思うので、こちらを利用してでひとまず挙動を確認します。今回はAPI認証にAmazon Cognito User Pool
を使用したので、あらかじめAmplify Auth
で作ったテストユーザーでログインしておきましょう。(クエリの実行ボタンの右横にログインボタンがあります)
Mutation
mutation
でデータの作成
・更新
・削除
などいわゆる「更新系」の処理を行います。
DymanoDBにも記録されていることを確認します。
Query
次はquery
を試してみます。リスト表示
などのいわゆる「参照系」の処理を行います。graphQLの強みとして欲しいデータをクエリで指定することができます(id
とtitle
だけ取得など)。
AWS AppSyncで画像投稿webアプリのサーバサイドを実装する
ここからが本番です。
画像投稿アプリの実装を通して、AppSyncを利用したサーバサイド開発について検証します。
アプリ全体構成図
今更感がありますが、サーバサイドの構成も固まってきたところで全体の構成図を載せておきます。
データベースのER図
単純なアプリケーションかつ、ユーザー管理はCognito
にまかせてあるため、ER図はシンプルです。画像投稿アプリのため、1つのアルバムに対して1つ以上の画像が投稿されているという条件にしました。AlbumPicture
のalbumId
を外部キーとしてリレーションシップを構築します。
また、AlbumPicture
のfile
にはjson形式でS3の画像を特定できる情報を登録します。
"file": {
"bucket": "yyyyyyyyyyyyyyyyyyyyyy", // バケット名
"key": "xxxxxxxxxxxxxxxxxxxxxxx", // 画像名(キー)
"region": "ap-northeast-1" //リージョン名
},
AppSyncにスキーマ・リレーション・サブスクリプションの記述を行う
Schema(データベース構造)
上記ER図で示した、DBの構造をスキーマに記載します。
type Album {
id: ID!
title: String!
visible: String!
picture: [AlbumPicture]
note: String
owner: String!
createdAt: String!
}
type AlbumConnection {
items: [Album]
nextToken: String
}
type AlbumPicture {
id: ID!
albumId: ID!
name: String!
file: S3Object!
createdAt: String!
}
type AlbumPictureConnection {
items: [AlbumPicture]
nextToken: String
}
input CreateAlbumInput {
title: String!
visible: String!
note: String
owner: String!
createdAt: String!
}
input CreateAlbumPictureInput {
albumId: ID!
name: String!
file: S3ObjectInput!
createdAt: String!
}
input DeleteAlbumInput {
id: ID!
}
input DeleteAlbumPictureInput {
id: ID!
}
type Mutation {
createAlbumPicture(input: CreateAlbumPictureInput!): AlbumPicture
updateAlbumPicture(input: UpdateAlbumPictureInput!): AlbumPicture
deleteAlbumPicture(input: DeleteAlbumPictureInput!): AlbumPicture
createAlbum(input: CreateAlbumInput!): Album
updateAlbum(input: UpdateAlbumInput!): Album
deleteAlbum(input: DeleteAlbumInput!): Album
}
type Query {
getAlbumPicture(id: ID!): AlbumPicture
listAlbumPictures(filter: TableAlbumPictureFilterInput, limit: Int, nextToken: String): AlbumPictureConnection
getAlbum(id: ID!): Album
listAlbums(filter: TableAlbumFilterInput, limit: Int, nextToken: String): AlbumConnection
}
type S3Object {
bucket: String!
region: String!
key: String!
}
input S3ObjectInput {
bucket: String!
region: String!
key: String!
}
input TableAlbumFilterInput {
id: TableIDFilterInput
title: TableStringFilterInput
visible: TableStringFilterInput
note: TableStringFilterInput
owner: TableStringFilterInput
createdAt: TableStringFilterInput
}
input TableAlbumPictureFilterInput {
id: TableIDFilterInput
albumId: TableIDFilterInput
name: TableStringFilterInput
createdAt: TableStringFilterInput
}
input TableBooleanFilterInput {
ne: Boolean
eq: Boolean
}
input TableFloatFilterInput {
ne: Float
eq: Float
le: Float
lt: Float
ge: Float
gt: Float
contains: Float
notContains: Float
between: [Float]
}
input TableIDFilterInput {
ne: ID
eq: ID
le: ID
lt: ID
ge: ID
gt: ID
contains: ID
notContains: ID
between: [ID]
beginsWith: ID
}
input TableIntFilterInput {
ne: Int
eq: Int
le: Int
lt: Int
ge: Int
gt: Int
contains: Int
notContains: Int
between: [Int]
}
input TableStringFilterInput {
ne: String
eq: String
le: String
lt: String
ge: String
gt: String
contains: String
notContains: String
between: [String]
beginsWith: String
}
input UpdateAlbumInput {
id: ID!
title: String
visible: String
note: String
owner: String
createdAt: String
}
input UpdateAlbumPictureInput {
id: ID!
albumId: ID
name: String
createdAt: String
}
Resolver(データベースマッピング・リレーション)
「アルバムに紐付いた、複数枚の画像をアップロード・表示することができる」機能を実現させるためにはAlbum
テーブルとAlbumPicture
テーブルとのリレーションシップを構成する必要があります。リレーションを実現するためにはリゾルバー
を書き換える必要があります。schema
、resolvers
からAlbumPictureTable
に対して「アタッチ」を行います。記述はVTL
言語指定なので、それに従ってリレーションシップの記述を行います。
{
"version": "2017-02-28",
"operation": "Query",
"index": "albumId-index",
"query": {
"expression": "albumId = :albumId",
"expressionValues": {
":albumId": { "S": "$ctx.source.id" }
}
}
}
$util.toJson($ctx.result.items)
リゾルバーの設定はこれで完了です。しかし、上記記述にもあるとおりDynamoDBAlbumPictureTable
にグローバルセカンダリインデックスを設定しないとalbumId
で抽出することができません。DynamoDBの「インデックス」タブから設定を行います。
Subscription(リアルタイム更新)
「複数デバイスでアクセスした時に、一方のデバイスでアルバムを「投稿」or「削除」or「更新」すると、もう一方のデバイスでリアルタイム更新される」機能を実装するために、クライアントからのAPIリクエストを検知して、サーバサイドから他のデバイスにも結果を返すという処理が必要になります。AppSyncではSubscription
の記述をスキーマにしておくだけで簡単にチャットのようなリアルタイムアップデートが実現できます。
type Subscription {
onCreateAlbumPicture(
id: ID,
albumId: ID,
name: String,
createdAt: String
): AlbumPicture
@aws_subscribe(mutations: ["createAlbumPicture"])
onUpdateAlbumPicture(
id: ID,
albumId: ID,
name: String,
createdAt: String
): AlbumPicture
@aws_subscribe(mutations: ["updateAlbumPicture"])
onDeleteAlbumPicture(
id: ID,
albumId: ID,
name: String,
createdAt: String
): AlbumPicture
@aws_subscribe(mutations: ["deleteAlbumPicture"])
onCreateAlbum(
id: ID,
title: String,
visible: String,
note: String,
owner: String
): Album
@aws_subscribe(mutations: ["createAlbum"])
onUpdateAlbum(
id: ID,
title: String,
visible: String,
note: String,
owner: String
): Album
@aws_subscribe(mutations: ["updateAlbum"])
onDeleteAlbum(
id: ID,
title: String,
visible: String,
note: String,
owner: String
): Album
@aws_subscribe(mutations: ["deleteAlbum"])
}
React Reduxで状態管理を行い、AppSyncに対しクエリを送ってみる
ここまでの実装でサーバサイドの準備は整いました。
いよいよクライアントから、今回構築したAppSync(GraphQL)のエンドポイントに対して、クエリリクエストを送ってみます。
RESTAPIでもGraphQLでもAPI通信には変わらないので、Redexにおける非同期通信のフローは全く同じです。
まずはReduxのstore・reducer設定から
全てを掲載すると長くなってしまうため、今回はアルバム
機能のみに限定しました。
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store/configureStore';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { theme } from './constants/GlobalUITheme';
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<Provider store={store}>
<App />
</Provider>
</MuiThemeProvider>,
document.getElementById('root')
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
import { createStore, applyMiddleware, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import amplifyAuthReducer from './reducers/Auth';
import albumReducer from './reducers/Album';
import { reducer as reduxFormReducer } from 'redux-form';
const enhancer =
process.env.NODE_ENV === 'development' ? composeWithDevTools(applyMiddleware(thunk)) : applyMiddleware(thunk);
const store = createStore(
combineReducers({
auth: amplifyAuthReducer,
form: reduxFormReducer,
album: albumReducer,
}),
enhancer,
);
export default store;
import { Reducer } from 'redux';
import * as AlbumAPIActionType from '../../constants/Album';
export interface AlbumApiState {
albums: any;
isLoading: boolean;
isLoaded: boolean;
error?: any;
}
export const initialState: AlbumApiState = {
albums: [],
isLoading: false,
isLoaded: false,
};
const apiReducer: Reducer<AlbumApiState> = (
state: AlbumApiState = initialState,
action: any
): AlbumApiState => {
switch (action.type) {
case AlbumAPIActionType.LIST_ALBUMS_START:
case AlbumAPIActionType.CREATE_ALBUM_START:
case AlbumAPIActionType.UPDATE_ALBUM_START:
case AlbumAPIActionType.DELETE_ALBUM_START:
return {
...state,
albums: [],
isLoading: true,
isLoaded: false,
};
case AlbumAPIActionType.LIST_ALBUMS_SUCCEED:
case AlbumAPIActionType.CREATE_ALBUM_SUCCEED:
case AlbumAPIActionType.UPDATE_ALBUM_SUCCEED:
case AlbumAPIActionType.DELETE_ALBUM_SUCCEED:
return {
...state,
albums: action.payload.result,
isLoading: false,
isLoaded: true,
};
case AlbumAPIActionType.LIST_ALBUMS_FAIL:
case AlbumAPIActionType.CREATE_ALBUM_FAIL:
case AlbumAPIActionType.UPDATE_ALBUM_FAIL:
case AlbumAPIActionType.DELETE_ALBUM_FAIL:
return {
...state,
isLoading: false,
isLoaded: false,
error: action.payload.error,
};
default: {
return state;
}
}
};
export default apiReducer;
Reduxアクションを発行したタイミングでGraphQLのAPI通信を行う
FSAの型に従ってアクションの処理を行っています。関数の呼び出し方などは若干のアレンジを加えてあります。
export interface Action<Payload> {
type: string;
payload?: Payload;
error?: boolean;
meta?: { [key: string]: any } | null;
}
export interface ActionStart<ActionType, Params> {
type: ActionType;
payload?: Params;
meta?: { [key: string]: any } | null;
}
export interface ActionSucceed<ActionType, Params, Result> {
type: ActionType;
payload: {
params: Params;
result: Result;
};
meta?: { [key: string]: any } | null;
}
export interface ActionFail<ActionType, Params, AnyError> {
type: ActionType;
payload: {
params: Params;
error: AnyError;
};
error: boolean;
meta?: { [key: string]: any } | null;
}
import { Dispatch } from 'redux';
const apiActionStart: Function = (startActionType: string): any => {
return {
type: startActionType,
};
};
const apiActionSuccess: Function = <T extends {}>(successActionType: string, result: T): any => {
return {
type: successActionType,
payload: { result },
};
};
const apiActionFail: Function = <T extends {}>(failActionType: string, error: T): any => {
return {
type: failActionType,
payload: { error },
error: true,
};
};
export const apiRequestFunc = (
startActionType: string,
successActionType: string,
failActionType: string,
uniqueLogicFunc: Function,
) => {
return async (dispatch: Dispatch): Promise<void> => {
dispatch(apiActionStart(startActionType));
try {
dispatch(apiActionSuccess(successActionType, await uniqueLogicFunc()));
} catch (error) {
dispatch(apiActionFail(failActionType, error));
}
};
};
AppSyncApi
・Storage
が使えるようにamplify configure
の設定を更新してから、アクションにアルバム投稿におけるCRUD機能の処理を記述します。
Amplify.configure({
Auth: {
identityPoolId: process.env.REACT_APP_IDENTITY_POOL_ID,
region: process.env.REACT_APP_REGION,
userPoolId: process.env.REACT_APP_USERPOOL_ID,
userPoolWebClientId: process.env.REACT_APP_USERPOOL_WEBCLIENT_ID,
},
Storage: {
AWSS3: {
bucket: process.env.REACT_APP_S3_BUCKET, // アップロードするS3のバケット名
region: process.env.REACT_APP_REGION, // アップロードするS3のリージョン名
},
},
aws_appsync_graphqlEndpoint: process.env.REACT_APP_APPSYNC_GRAPHQL_ENDPOINT, // AppSyncのエンドポイント。AppSyncコンソールの「設定」画面で確認
aws_appsync_region: process.env.REACT_APP_REGION, //.envに設定したAppSyncのリージョン名
aws_appsync_authenticationType: 'AMAZON_COGNITO_USER_POOLS',
aws_appsync_apiKey: process.env.REACT_APP_APPSYNC_APIKEY, // AppSyncのapiキー。AppSyncコンソールの「設定」画面で確認
});
import { Auth, API, graphqlOperation, Storage } from 'aws-amplify';
import _ from 'lodash';
import uuid from 'uuid/v4';
import { apiRequestFunc } from './index';
import {
listAlbums,
createAlbum,
createAlbumPicture,
updateAlbum,
deleteAlbum,
deletePicture,
} from '../graphql/Album';
import * as AlbumAPIActionType from '../constants/Album';
export const getAlbumListFunc = () => {
const uniqueLogicFunc = async () => {
return API.graphql(graphqlOperation(listAlbums)).then(
(response: any) => response.data.listAlbums.items
);
};
return apiRequestFunc(
AlbumAPIActionType.LIST_ALBUMS_START,
AlbumAPIActionType.LIST_ALBUMS_SUCCEED,
AlbumAPIActionType.LIST_ALBUMS_FAIL,
uniqueLogicFunc
);
};
export const createAlbumFunc = (albums: any, values: any) => {
const uniqueLogicFunc = async () => {
const loginInfo = await Auth.currentUserInfo();
const albumInfo = await API.graphql(
graphqlOperation(createAlbum, {
createAlbuminput: {
title: values.title,
visible: values.visible,
note: values.notes,
owner: loginInfo.username,
createdAt: new Date().toISOString(),
},
})
);
if (values.imageToUpload.length !== 0) {
albumInfo.data.createAlbum.picture = await Promise.all(
_.map(values.imageToUpload, async (image: any) => {
const S3image: any = await Storage.put(uuid(), image, {
contentType: image.type,
});
const picture: any = await API.graphql(
graphqlOperation(createAlbumPicture, {
createAlbumPictureinput: {
name: image.name,
albumId: albumInfo.data.createAlbum.id,
file: {
bucket: process.env.REACT_APP_S3_BUCKET,
region: process.env.REACT_APP_REGION,
key: S3image.key,
},
createdAt: new Date().toISOString(),
},
})
);
return picture.data.createAlbumPicture;
})
);
} else {
albumInfo.data.createAlbum.picture = null;
}
return [...albums, albumInfo.data.createAlbum];
};
return apiRequestFunc(
AlbumAPIActionType.CREATE_ALBUM_START,
AlbumAPIActionType.CREATE_ALBUM_SUCCEED,
AlbumAPIActionType.CREATE_ALBUM_FAIL,
uniqueLogicFunc
);
};
export const updateAlbumFunc = (albums: any, album: any): any => {
const uniqueLogicFunc = async () => {
const albumInfo = await API.graphql(
graphqlOperation(updateAlbum, {
updateAlbuminput: {
id: album.id,
title: album.title,
visible: album.visible,
note: album.note,
owner: album.username,
createdAt: new Date().toISOString(),
},
})
).then((response: any) => response.data.updateAlbum);
let updatePicture = album.picture;
const removeAlbums: any[] = _.compact(
await Promise.all(
_.map(albums, async (updateAlbum: any) => {
if (updateAlbum.id === albumInfo.id) {
if (updateAlbum.picture !== album.picture) {
await Promise.all(
_.map(updateAlbum.picture, async (picture: any) => {
await Storage.remove(picture.file.key, { level: 'public' });
return API.graphql(
graphqlOperation(deletePicture, {
deleteAlbumPictureinput: {
id: picture.id,
},
})
);
})
);
if (album.picture.length !== 0) {
updatePicture = await Promise.all(
_.map(album.picture, async (image: any) => {
const S3image: any = await Storage.put(uuid(), image, {
contentType: image.type,
});
const picture: any = await API.graphql(
graphqlOperation(createAlbumPicture, {
createAlbumPictureinput: {
name: image.name,
albumId: albumInfo.id,
file: {
bucket: process.env.REACT_APP_S3_BUCKET,
region: process.env.REACT_APP_REGION,
key: S3image.key,
},
createdAt: new Date().toISOString(),
},
})
);
return picture.data.createAlbumPicture;
})
);
}
}
return '';
} else {
return updateAlbum;
}
})
)
);
albumInfo.picture = updatePicture;
return [...removeAlbums, albumInfo];
};
return apiRequestFunc(
AlbumAPIActionType.UPDATE_ALBUM_START,
AlbumAPIActionType.UPDATE_ALBUM_SUCCEED,
AlbumAPIActionType.UPDATE_ALBUM_FAIL,
uniqueLogicFunc
);
};
export const deleteAlbumFunc = (albums: any, album: any): any => {
const uniqueLogicFunc = async () => {
const deleteId = await API.graphql(
graphqlOperation(deleteAlbum, {
deleteAlbuminput: {
id: album.id,
},
})
);
return _.compact(
await Promise.all(
_.map(albums, async (deleteAlbum: any) => {
if (deleteAlbum.id === deleteId.data.deleteAlbum.id) {
await Promise.all(
_.map(deleteAlbum.picture, async (picture: any) => {
await Storage.remove(picture.file.key, { level: 'public' });
return API.graphql(
graphqlOperation(deletePicture, {
deleteAlbumPictureinput: {
id: picture.id,
},
})
);
})
);
return '';
} else {
return deleteAlbum;
}
})
)
);
};
return apiRequestFunc(
AlbumAPIActionType.DELETE_ALBUM_START,
AlbumAPIActionType.DELETE_ALBUM_SUCCEED,
AlbumAPIActionType.DELETE_ALBUM_FAIL,
uniqueLogicFunc
);
};
export const listAlbums = `query listAlbums {
listAlbums {
items {
id
title
visible
note
owner
createdAt
picture {
id
name
file{
key
}
}
}
}
}`;
export const createAlbum = `mutation createAlbum($createAlbuminput: CreateAlbumInput!) {
createAlbum(input: $createAlbuminput) {
id
title
visible
note
owner
createdAt
}
}`;
export const updateAlbum = `mutation updateAlbum($updateAlbuminput: UpdateAlbumInput!) {
updateAlbum(input: $updateAlbuminput) {
id
title
visible
note
owner
createdAt
}
}`;
export const deleteAlbum = `mutation deleteAlbum($deleteAlbuminput: DeleteAlbumInput!) {
deleteAlbum(input: $deleteAlbuminput) {
id
}
}`;
export const createAlbumPicture = `mutation createAlbumPicture($createAlbumPictureinput: CreateAlbumPictureInput!) {
createAlbumPicture(input: $createAlbumPictureinput) {
id
name
albumId
file {
bucket
region
key
}
createdAt
}
}`;
export const deletePicture = `mutation deleteAlbumPicture($deleteAlbumPictureinput: DeleteAlbumPictureInput!) {
deleteAlbumPicture(input: $deleteAlbumPictureinput) {
id
}
}`;
Subscriptionでリアルタイムに更新を反映する
アルバムコンポーネントが読み込まれたときにサブスクライブ(購読)を開始し、もし他のデバイス(ブラウザ)で「更新系」のリクエストが送られたらサーバサイドから「更新された」というレスポンスが返され、ブラウザをリロードしなくても画面にリアルタイムで反映させることができます。
export const onCreateAlbumPicture = `subscription onCreateAlbumPictureSub {
onCreateAlbumPicture {
__typename
id
albumId
name
createdAt
}
}`;
export const onDeleteAlbum = `subscription onDeleteAlbumSub {
onDeleteAlbum {
__typename
id
}
}`;
useEffect(() => {
(async () => {
await API.graphql(graphqlOperation(onCreateAlbumPicture)).subscribe({
next: async () => {
await albumActions.getAlbumListFunc();
},
});
await API.graphql(graphqlOperation(onDeleteAlbum)).subscribe({
next: async () => {
await albumActions.getAlbumListFunc();
},
});
await albumActions.getAlbumListFunc();
})();
}, []);
まとめ
今回はAmplifyフレームワークを使ってサーバサイド構築をしてみましたが、本当に手軽に環境を整えることができます。
実運用面で使用するのはもう少し調査が必要かなとは思いますが、AWSのIAM、認証系統、ストレージ、データベース、AppSyncなどのプロビジョニングをコマンドと対話形式で自動で行ってくれるので、様々な機能を実験的に試すには一番簡単な方法だと思います。(手動で行うとかなり骨が折れると思います。。)
GraphQLに関しては今回始めて使用してみましたが、クエリとリゾルバを組み合わせれば少ないAPIリクエストで過不足なくデータを取得できるため、負荷軽減のために活用できそうな技術であることは間違いなさそうです。その一方で、クエリの記述をクライアントで記述する分フロントエンドでのコード量が増える印象を持ちました(今回のreduxのactionのコードはリファクタリングをしていないため余計粗が目立つのたもしれませんが...)。
AppSyncはLambdaと連携もできるため、画像投稿系の処理はLambdaに記述したほうがフロントエンドのコードはすっきりとしたかもしれません。また時間がある時にLambdaとの連携を試してみたいです。
さて次回、アドベントカレンダー投稿の最後を締めくくるのは、アプリの品質を保つためにもっとも重要な「テスト自動化
」についてです。testcafe
・cypress
というモダンE2Eテストツールについての投稿を予定しています。もし時間があればGithub Actions
を利用した継続的インテグレーション(CI)についても触れたいなと考えています。