AWS Amplify
を利用して、Gatsby+Contentfulを利用して、サロンの予約アプリを作成しました。
本記事とリンク記事にて、作成した過程で考慮した点などをご紹介します。
必要に応じ、随時アップデートしていきます。
目次
- アプリ概要
- 構築手順
- 共通機能
- 個別機能
- DevOps
- Tips
- 最後に
1. アプリ概要
作成したアプリの情報は以下のとおりです。
SEOにも強いパフォーマンスに優れたサイトとしたかったため、Gatsbyを採用しました。
また、無駄な管理機能の実装を避けるため、ヘッドレスCMSのContentful(サイト管理者のマスタデータ更新用)やGoogle Calender
(サイト管理者のスケジュール閲覧用)を利用しています。
Amplifyを採用することで、Infrastructure as Code
(IaC) やCI/CD (Amplify Console
) 、環境分離 (開発環境 / 検証環境 / 本番環境) を容易に実現でき、かつバックエンドのコードも一元管理できるため、開発効率はとても良かったと感じています。
なお、トランザクションデータ(予約データ)を管理する関係上、全てSSG (Static Site Generator
) とするのは現実的ではないと判断し、一部は画面側でレンダリングしています。
機能一覧
- 予約管理
- 問い合わせ管理
デモサイト
name | password |
---|---|
demo | Password1? |
※デモサイトでは画面の動きのみ確認できます。
※バックエンドも構築し、Amplify Console
に接続すれば、予約や問い合わせの受け付けも可能となりますが、上記はデモ用のため、バックエンドには接続していません(=予約や問い合わせするとエラーになります)。
GitHub
https://github.com/Thirosue/booking
アーキテクチャ
- アーキテクチャ概要
サーバレスアーキテクチャを採用しています。
トラフィックが少ない期間は、AWS無料枠でほぼ賄える(Google Calender
、Contentfulも無料枠で構築可)ため、月額のランニングコストは最低限ですみます。
- アーキテクチャ一覧
大分類 | 小分類 | 名前 | 補足 |
---|---|---|---|
開発プラットフォーム | - | AWS Amplify | 高速開発プラットフォーム。DevOpsを支援する |
フレームワーク | フロントエンド | React | - |
フレームワーク | フロントエンド | Gatsby | Static Site Generator(SSG) |
フレームワーク | フロントエンド | Material-UI | UI Framework |
フレームワーク | フロントエンド | React Hook Form | フォームライブラリ |
認証・認可 | - | Amazon Cognito | 認可は実装なし |
データ管理 | マスタデータ | Contentful | 予約メニューや定休日などのデータを管理する。サイト管理者がデータを直接更新する。 |
データ管理 | トランザクションデータ | Amazon DynamoDB | 予約や問い合わせなどのトランザクションデータを管理する |
データ管理 | その他 | Amazon S3 | サロンのスケジュール(シフトスケジュール)などをファイル(json)で管理する |
API | REST | Amazon API Gateway | 予約などで利用 |
API | GraphQL | AWS AppSync | 問い合わせなどで利用 |
バックエンド | - | AWS Lambda | API・バッチ・非同期処理などで利用 |
エラー管理 | - | Sentry | フロントエンド・バックエンド・バッチ処理のエラーを一元管理する |
その他 | カレンダーアプリ | Google Calender | サイト管理者が予約状況を確認する。APIで予約状況を非同期連携する。 |
構築に必要なもの
-
node.js & yarn
(※必須) - Contentful(※必須)
- AWSアカウント(※バックエンドまで作成する場合は必要)
2. 構築手順
本アプリは、予約メニューなどのデータをContentfulから取得しているため、Contentfulのセットアップは必須です。
記事だけ参照したい方は、スキップしてください。
フロント構築手順
git clone
- Contentfulにデータをインポート
- 環境変数ファイル作成(.env.development)
- ローカルサーバ起動
1. git clone
$ git clone git@github.com:Thirosue/booking.git
2. Contentfulにデータをインポート
Contentfulアカウント作成 & パーソナルアクセストークン作成
以下を参照し、Contentfulのアカウントを作成のうえ、パーソナルアクセストークンを取得してください。
デフォルトロケール設定
次に、Contentfulのデフォルトロケールを日本語に設定しましょう。
初期データ投入用の設定ファイルを作成
次に、初期データ投入用の設定ファイルを作成しましょう。
$ cd /path/to/directory/booking/contentful-data
$ pwd
/path/to/directory/booking/contentful-data
$ cat <<EOF > example-config.json
{
"spaceId": "<< 作成したContentfulのスペースIDを記載 >>",
"managementToken": "<< 上で取得したパーソナルアクセストークンを記載 >>",
"contentFile": "contentful-export.json"
}
EOF
初期データ投入
以下コマンドで初期データを投入します。
$ pwd
/path/to/directory/booking/contentful-data
$ npx contentful-cli login --management-token <<Contentfulのパーソナルアクセストークンを記載>>
$ npx contentful-cli space import --config example-config.json
Contentfulでexport / import(バックアップ/リストア[復元])する
3. 環境変数ファイル作成(.env.development)
コンテンツデリバリトークン確認
- 「Settings」 - 「API Keys」 - 「Content delivery / preview tokens」 タブ
環境変数ファイル生成
$ yarn install
$ yarn setup
yarn run v1.22.4
$ node ./bin/setup.js
? Your Contentful Space ID <<作成したContentfulのスペースIDを記載>>
? Your Contentful Content Delivery API access token <<コンテンツデリバリトークンを記載 ※パーソナルアクセストークンではないです>>
Writing config file...
Config file /path/to/directory/booking/.env.development written
Config file /path/to/directory/booking/.env.production written
All set! You can now run yarn develop to see it in action.
✨ Done in 9.03s.
4. ローカルサーバ起動
$ yarn develop
yarn run v1.22.4
$ gatsby develop
...(中略)...
Note that the development build is not optimized.
To create a production build, use gatsby build
⠀
success Building development bundle - 21.326s
問題なく設定できていれば、以下URLにブラウザで接続すると、トップ画面に接続できるはずです。
ここまでやっていただいて動かなかった方は、GitHub issueかコメントいただけると幸いです。
ローカル起動後は、以下のデモユーザでログインできます。
- デモユーザ
name | password |
---|---|
demo | Password1? |
3. 共通機能
ステート管理
Reactでのグローバルな状態管理には、Reduxがよく用いられていましたが、Context APIで状態管理できるようになったので、Context APIを採用しました。
Reduxを見送った理由は、学習コストの高さを感じたのと、実装を見比べた結果、採用のメリットを感じなかったからです。
なお、以下などの情報を参考にしました。
React Context vs Redux - Who wins?
グローバルステートで管理している情報は、以下の3点です。
- ログインしているか否か
- 処理中か否か
- ログインセッション情報
const initialState = {
signedIn: false, //ログイン中を判定
processing: false, //処理中を判定 ※APIコールなどの非同期処理で利用
session: {} //ログインセッション情報
};
ダイアログ表示
確認ダイアログ(Yes / Noオプション)やエラー時の通知ダイアログ(Yesオプションのみ)の表示は、templateの記述なしでサクッと実装したかったので、
以下ライブラリを少しカスタマイズして利用しました。
https://github.com/jonatanklosko/material-ui-confirm
カスタマイズ内容
カスタマイズ箇所は、以下です。
- 表示内容にtemplate(HTML)を記載できるようにする
- 閉じるボタンのみ表示できるようにする
const ConfirmationDialog = ({ open, options, onCancel, onConfirm, onClose }) => {
const {
html,
alert,
title,
description,
confirmationText,
cancellationText,
dialogProps,
confirmationButtonProps,
cancellationButtonProps,
} = options;
return (
<Dialog fullWidth {...dialogProps} open={open} onClose={onClose}>
{title && (
<DialogTitle>{title}</DialogTitle>
)}
{description && (
<DialogContent>
{/* htmlオプション指定の場合は、そのまま表示する */}
{!html && (<DialogContentText>{description}</DialogContentText>)}
{!!html && (<>{description}</>)}
</DialogContent>
)}
<DialogActions>
{/* alertのみの場合はcloseボタンのみ表示 */}
{!!alert && (
<Button color="primary" {...confirmationButtonProps} onClick={onConfirm}>
Close
</Button>)}
{!alert && (
<>
<Button {...cancellationButtonProps} onClick={onCancel}>
{cancellationText}
</Button>
<Button color="primary" {...confirmationButtonProps} onClick={onConfirm}>
{confirmationText}
</Button>
</>)}
</DialogActions>
</Dialog>
);
};
なお、上記カスタマイズに併せて、src/context/confirmProvider.js
も修正しています。
組み込み
確認ダイアログ
if (ids.filter(id => id && id.indexOf('hand') !== -1).length && ids.filter(id => id && id.indexOf('foot') !== -1).length) {
//文言にtemplateを設定
await confirm({ html: true, description: (<><strong>ハンドメニュー</strong>と<strong>フットメニュー</strong>を選択しています。<br />間違いないですか?</>) })
.catch(() => stopFlg = true);
}
通知ダイアログ(=確認アクションを挟む通知を表示する場合に利用)
const signOut = async () => {
await AuthService.signOut()
context.updateState({
signedIn: false,
session: {
username: null
}
})
//alertオプション指定で、閉じるのみ表示
confirm({ alert: true, description: 'ログアウトしました' })
.then(() => {
navigate('/')
})
};
通知
次は、確認アクションを必要としない通知です。
こちらは、以下のライブラリが要件を満たしていたので、そのまま採用しました。
https://github.com/iamhosseindhv/notistack
enqueueSnackbar('ログインしました', { variant: 'success' })
タイトル設定
画面のタイトル設定は、hookを用いて実現しています。
URLの変更を検出し、タイトルを設定するように実装しています。
- Layoutコンポーネントにlocationを渡す
// Wraps every page in a component
export const wrapPageElement = ({ element, props }) => {
if (props.location.pathname.startsWith('/mypage'))
return <Mypage {...props}>{element}</Mypage>; // props.locationをlayoutに渡す
- Layoutコンポーネントでhookを利用
export default ({ location, children }) => {
const classes = useStyles();
const title = useDocumentTitle(location); // locationをhookに渡す
- locationをフックし、タイトル (
サイト名 | ページタイトル
) を設定する
// パスに応じたタイトルを定義
const titleMapping = [
{ pathname: "/about", title: 'About' },
{ pathname: "/error", title: 'Error' },
{ pathname: "/404", title: 'Not Found' },
{ pathname: "/", title: 'Top' },
]
export default (location) => {
const [title, setTitle] = useState('Top');
useEffect(() => {
const pathname = location.pathname;
const title = _.head(titleMapping.filter(mapping => _.startsWith(pathname, mapping.pathname)))?.title // パスに応じたページタイトルを取得
setTitle(`${process.env.GATSBY_SALON_NAME} | ${title}`)
}, [location]);
return title
}
処理中
処理中のアニメーション実装を容易にするため、
処理中か否かの状態をグローバルステートに保存させ、状態を処理中に更新するとアニメーションを表示するようにしています。
- 処理イメージ
- 処理中アニメーションのコンポーネント作成
const useStyles = makeStyles((theme) => ({
backdrop: {
zIndex: theme.zIndex.tooltip + 1, // 一番上に被せるように調整 https://material-ui.com/customization/z-index/
color: '#fff',
},
}));
// 処理中の場合、アニメーションを表示させるようにする
export default ({ processing }) => {
const classes = useStyles()
return (
<>
{!!processing && (
<Backdrop className={classes.backdrop} open={true}>
<CircularProgress color="inherit" />
</Backdrop>
)}
</>);
}
- グローバルステートのテンプレートに上記コンポーネントを配置
return (
<GlobalContext.Provider
value={global}
>
{/* processing start */}
<Progress processing={state.processing} />
{/* processing end */}
{children}
</GlobalContext.Provider>
);
- 各画面での組み込み
const context = React.useContext(GlobalContext);
...(中略)...
const doSubmit = async data => {
context.startProcess() // 処理開始 ---> 終了までの間、処理中アニメーションが表示される
const response = await AuthService.signIn(data.username, data.password)
.finally(() => context.endProcess()) // 処理終了
2重送信防止
submitボタン(送信・更新ボタン)押下時に、APIを2重コールさせないようにする対応です。
本対応はバックエンドAPI側でも対応が必要ですが、画面側でもボタンをdisableにするなどの対応はやっておくべきです。
Material-UI
のボタンコンポーネントをちょっとラップ実装することで、上記対応を実現します。
- Submitボタンコンポーネント作成
グローバルステートの処理中状態の場合、ボタンをdisable
にし、return
するようにしています。
export default ({ children, fullWidth = false, color = 'primary', className = '', onClick }) => {
const context = React.useContext(GlobalContext); // グローバルステートを利用
const handleSubmit = () => {
if (context.processing) return // 処理中の場合は、イベントをスキップ
onClick()
}
return (
<Button
fullWidth={fullWidth}
disabled={context.processing} {/* 処理中の場合は、disableにする */}
variant='contained'
color={color}
className={className}
onClick={handleSubmit}
>
{!!children && (<>{children}</>)}
{!children && (<>Confirm</>)}
</Button>
)
}
- 各画面での組み込み
フォーム画面(=サブミット処理がある画面)では、上記コンポーネントを利用し、クリックハンドラをコンポーネントに渡します。
<Submit
onClick={handleSubmit(handleNext)}
className={classes.button}
>
{activeStep === steps.length - 1 ? 'Confirm' : 'Next'}
</Submit>
- 非同期アクションの開始→終了の際に、グローバルステートを更新します。
context.startProcess() // 処理開始 ---> 終了までの間、API送信処理をブロック
const results = await submitAction({ ...form, ...data }, confirm, doHandleClose)
.catch(() => doHandleClose())
.finally(() => context.endProcess()) // 処理終了
デバイス判定
Material-UI
にはレスポンシブ対応が容易になるさまざまな機能が実装されています。
Material-UI ブレークポイント
Material-UI useMediaQuery
しかし、以下2点の対応を実現にするために、追加対応を行いました。
- テンプレート内での条件分岐を容易にしたい
- iPhnoeの各デバイスに最適化した細かい制御を実施したい
テンプレート内での条件分岐
本要件には、react-responsiveを用いました。
https://github.com/contra/react-responsive
このライブラリを使うと、iPhnoeX
とiPhoneX Plus
などの境界の条件分岐を直感的に実装できます。
{/* iPhoneX以下 */}
<MediaQuery maxDeviceWidth={413}>
<Stepper alternativeLabel activeStep={activeStep} className={classes.stepper}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</MediaQuery>
{/* iPhoneX Plus以上 */}
<MediaQuery minDeviceWidth={413}>
<Stepper activeStep={activeStep} className={classes.stepper}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
</MediaQuery>
iPhnoeの各デバイス幅に最適化した細かい制御
こちらは、予約時の表組をXPlusデバイス幅に最適化するといった対応です。
iPhnoe8
iPhnoe8 Plus
本要件を実現するために、Hookで各デバイス情報を判定し、各コンポーネントで利用できるようにしました。
- デバイス判定するHook
export default () => {
const [device, setDevice] = React.useState('iPhoneX');
const overSE = useMediaQuery('(min-width:374px)'); // over iPhoneSE
const overX = useMediaQuery('(min-width:413px)'); // over iPhoneX
const overSmartPhone = useMediaQuery('(min-width:600px)'); // over iPhoneXPlus
const overiPad = useMediaQuery('(min-width:769px)'); // over iPad
React.useEffect(() => {
const device = getDevice(overSE, overX, overSmartPhone, overiPad) //デバイスを設定する
setDevice(device)
}, [overSE, overX, overSmartPhone, overiPad, device]); //画面幅の変更を検出
return device;
}
- 予約表組出力コンポーネントで利用
const device = useDevice();; // デバイス情報を利用
...(中略)...
// デバイスの変更を検出して、予約の表組の表示を変更
React.useEffect(() => {
...(ロジックがわかりにくいので割愛)...
}, [reservationTable, device, page]); // デバイス変更を検出
エラートラッキング
画面側(Webブラウザ)でのエラーは、エラー情報をバックエンドに連携しないと、エラーに気づくことも、エラーの詳細情報を知ることもできません。
エラーの発生状況は可能な限りトレースできるようにし、エラーにはプロアクティブに対応できる仕組みが必要です。
この仕組みを自前で実装することも可能ですが、特段の要件がない場合は、Sentryを利用することで、エラー発生状況を容易に管理・トレースすることができます。
import * as Sentry from "@sentry/react";
...(中略)...
Sentry.init({
dsn: process.env.GATSBY_SENTRY_DSN, // DSNは環境変数から取得する
integrations: [
new Integrations.BrowserTracing(),
],
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: Number(process.env.GATSBY_TRACE),
});
・公式リファレンス
https://docs.sentry.io/platforms/javascript/guides/react/
4. 個別機能
認証
認証カスタマイズ
Amplify UI Components
を利用すれば、瞬殺でUIを含む認証機能の実装ができますが、見た目を変更したかったため、カスタマイズ対応しました。
Amplify JavaScript
ライブラリを用いると、AWS SDK
より容易に基本機能(認証 / 認証付きAPIコール / S3連携など)の実装できます。
- 認証サービスクラス
import { Auth } from 'aws-amplify';
...(中略)...
async function signIn(username, password) {
try {
const user = await Auth.signIn(username, password);
return {
user
}
} catch (error) {
console.log('error signing in', error);
return {
errorMessage: messageMappings[getErrorCode(error.code)]
}
}
}
- 認証画面 (ログイン実装)
const doSubmit = async data => {
context.startProcess()
const response = await AuthService.signIn(data.username, data.password)
.finally(() => context.endProcess())
if (response && response.errorMessage) {
await confirm({ alert: true, description: response.errorMessage })
return
}
...(中略)...
};
SNS連動(Googleソーシャルログイン)
Amplify+Congnitoを用いると、Googleソーシャルログインが容易に実現できます。
AWS Amplify(Cognito)でGoogleソーシャルログインする
SNS連動(LINEソーシャルログイン)
LINEとのログイン連携も容易です。
AWS Amplify(Cognito)でLINEへソーシャルログインする
5. DevOps
CI/CD(Amplify Console
)
Amplifyを使うとCI/CDが驚くほど簡単に実現できます。
Amplifyセットアップ(amplify init
)後に、Amplify Console
を対象のブランチに接続するだけで、git push
時に、CloudFrontにデプロイできます。
ほぼほぼGitHub Pages
やNetlifyなどへデプロイするのと同等の手間でCI/CDが実現できるため、AWSを用いたサーバレス構成だと使わない手はないと感じています。
AWS AMPLIFY と AWS CODECOMMIT で CI/CD 環境の構築
ブランチプレビュー
Amplify Console
は、ブランチのプレビュー機能も容易に実現できます。
本番同等のフローでデプロイされた環境で確認できるのは、レビュアーの負担を軽減でき、手戻りも少なくなります。
気になる料金もサーバレス構成(CloudFront+S3)のため、ほとんど掛かりません。
ブランチプレビュー - Amplifyで自動でブランチごとにドメインを切ってデプロイ出来るようになりました!
アクセス制限
開発中のサイトには、ベーシック認証などのアクセス制限をかけたいことはよくあります。
Amplify Cosole
を使えば一瞬で対応できてしまいます。
超簡単!AWS Amplify ConsoleでWebサイトにアクセス制限(Basic認証)を設定する #reinvent
デプロイ通知
Amplify Console
は、標準でメールにビルド状況を通知することができ、
少しカスタマイズ(CloudWatchEventsなど)することで、Slackなどにも通知することができます。
Amplify Console のビルド通知をSlackで受け取るためにやったこと
マスタデータ更新時に自動ビルド(ContentFul->Amplify Console
連携)
Webhookを使って簡単に連携できます。
本設定をすると、サイト管理者がメニューなどの情報を更新すると、自動ビルドされ画面コンテンツが更新されます。
Contentfulのコンテンツ更新時にAmplify Console(Gatsuby)を自動ビルドする(WebHook)
6. Tips
リロード時に状態をxStorageから引き戻す
ブラウザでリロードした場合(WindowsのF5)など、特段の対応をしない場合、メモリにのみ状態が保存されているため、
グローバルステートの情報はクリアされてしまいます。
xStorageにステートを同期し、リロード時は値を引き戻すことでブラウザのリロード時にもセッション状態は保持されるようになります。
上記は、hookで以下のように対応しました。
- リロード時の状態引き戻し
const initState = () => {
let state = _.attempt(JSON.parse.bind(null, localStorage.getItem('state'))); // localStrageから状態を引き戻す
console.log(state)
if (_.isError(state) || !state) {
state = {
...{},
...initialState
};
}
return {
...state,
processing: false, // 「処理中」状態はリロード時は初期化する
}
}
const GlobalStateProvider = ({ children }) => {
const [state, setState] = React.useState(initState); // フックの初期化時に上記関数を利用
- ステート更新時の同期処理
React.useEffect(() => {
localStorage.setItem("state", JSON.stringify(state))
}, [state]); //状態変更をフック
APIレスポンスのキャッシュを効かなくする
リクエストURLを一意にすることで、APIレスポンスのキャッシュが効かないようにします。
具体的には、UNIXタイムスタンプをクエリに付与することでAPIのキャッシュが効かないようにしました。
const getAll = async () => {
const time = new Date().getTime()
const status = fetch(`${ENDPOINT}/public/0.min.json?_=${time}`, { //UNIXタイムスタンプを末尾に付与
mode: 'cors'
}).then(response => response.json())
return status
};
7. 最後に
Amplifyで対応できる領域はどんどん広がっており、小規模から大規模まで対応できる開発プラットフォームになってきています。
コード(CloudFormation)に基づいた環境管理だったり、CI/CD環境を爆速で構築できたり、ブランチのプレビュー機能だったり、、、、DevOpsであったら嬉しい機能がどんどん実装・追加されており、AWSを利用した開発者体験を劇的に変化させてくれました。
AWSを利用したサーバレスアプリを構築する場合は、使わない手はないですね。
コンテナサポート - Amplify CLIでFargateを利用したサーバーレスコンテナのデプロイが可能になりました #reinvent #amplify
ブランチプレビュー - Amplifyで自動でブランチごとにドメインを切ってデプロイ出来るようになりました!
Headless CMS領域へ - Amplify Admin UIを試してみる(ブログ構築)