LoginSignup
16
19

More than 3 years have passed since last update.

Amplify+Gatsby+Contentfulで予約アプリを作成する(フロントエンド編)

Last updated at Posted at 2021-01-30

AWS Amplifyを利用して、Gatsby+Contentfulを利用して、サロンの予約アプリを作成しました。
本記事とリンク記事にて、作成した過程で考慮した点などをご紹介します。
必要に応じ、随時アップデートしていきます。

目次

  1. アプリ概要
  2. 構築手順
  3. 共通機能
  4. 個別機能
  5. DevOps
  6. Tips
  7. 最後に

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も無料枠で構築可)ため、月額のランニングコストは最低限ですみます。

スクリーンショット 2021-01-30 15.59.09.png

  • アーキテクチャ一覧
大分類 小分類 名前 補足
開発プラットフォーム - 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のセットアップは必須です。
記事だけ参照したい方は、スキップしてください。

フロント構築手順

  1. git clone
  2. Contentfulにデータをインポート
  3. 環境変数ファイル作成(.env.development)
  4. ローカルサーバ起動

1. git clone

$ git clone git@github.com:Thirosue/booking.git

2. Contentfulにデータをインポート

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」 タブ

スクリーンショット 2021-01-24 20.49.29.jpg

スクリーンショット 2021-01-24 20.42.14.jpg

環境変数ファイル生成

$ 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点です。

  • ログインしているか否か
  • 処理中か否か
  • ログインセッション情報
src/context/globalState.js
const initialState = {
    signedIn: false, //ログイン中を判定
    processing: false, //処理中を判定 ※APIコールなどの非同期処理で利用
    session: {} //ログインセッション情報
};

ダイアログ表示

確認ダイアログ(Yes / Noオプション)やエラー時の通知ダイアログ(Yesオプションのみ)の表示は、templateの記述なしでサクッと実装したかったので、
以下ライブラリを少しカスタマイズして利用しました。

https://github.com/jonatanklosko/material-ui-confirm

カスタマイズ内容

カスタマイズ箇所は、以下です。

  • 表示内容にtemplate(HTML)を記載できるようにする
  • 閉じるボタンのみ表示できるようにする
src/templates/dialog/confirm.js
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も修正しています。

組み込み

確認ダイアログ

スクリーンショット 2021-01-24 21.08.23.png

src/components/pages/booking/index.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);
    }
通知ダイアログ(=確認アクションを挟む通知を表示する場合に利用)

スクリーンショット 2021-01-24 21.10.59.png

src/templates/layout/mypage/navbar/index.js
  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

スクリーンショット 2021-01-26 21.00.54.png

src/components/pages/auth/signin.js
enqueueSnackbar('ログインしました', { variant: 'success' })

タイトル設定

画面のタイトル設定は、hookを用いて実現しています。
URLの変更を検出し、タイトルを設定するように実装しています。

  • Layoutコンポーネントにlocationを渡す
gatsby-browser.js
// 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を利用
src/templates/layout/mypage/index.js
export default ({ location, children }) => {
    const classes = useStyles();
    const title = useDocumentTitle(location); // locationをhookに渡す
  • locationをフックし、タイトル (サイト名 | ページタイトル) を設定する
src/hooks/useDocumentTitle.js
// パスに応じたタイトルを定義
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
}

処理中

処理中のアニメーション実装を容易にするため、
処理中か否かの状態をグローバルステートに保存させ、状態を処理中に更新するとアニメーションを表示するようにしています。

  • 処理イメージ

ezgif.com-gif-maker.gif

  • 処理中アニメーションのコンポーネント作成
src/components/Progress.js
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>
            )}
        </>);
}
  • グローバルステートのテンプレートに上記コンポーネントを配置
src/context/globalState.js
    return (
        <GlobalContext.Provider
            value={global}
        >
            {/*  processing start */}
            <Progress processing={state.processing} />
            {/*  processing end */}
            {children}
        </GlobalContext.Provider>
    );
  • 各画面での組み込み
src/components/pages/auth/signin.js
    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するようにしています。

src/components/atoms/submit.js
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>
    )
}
  • 各画面での組み込み

フォーム画面(=サブミット処理がある画面)では、上記コンポーネントを利用し、クリックハンドラをコンポーネントに渡します。

src/components/pages/booking/index.js
    <Submit
        onClick={handleSubmit(handleNext)}
        className={classes.button}
    >
        {activeStep === steps.length - 1 ? 'Confirm' : 'Next'}
    </Submit>
  • 非同期アクションの開始→終了の際に、グローバルステートを更新します。
src/components/pages/booking/index.js
            context.startProcess() // 処理開始 ---> 終了までの間、API送信処理をブロック
            const results = await submitAction({ ...form, ...data }, confirm, doHandleClose)
                .catch(() => doHandleClose())
                .finally(() => context.endProcess()) // 処理終了

デバイス判定

Material-UIにはレスポンシブ対応が容易になるさまざまな機能が実装されています。

Material-UI ブレークポイント
Material-UI useMediaQuery

しかし、以下2点の対応を実現にするために、追加対応を行いました。

  1. テンプレート内での条件分岐を容易にしたい
  2. iPhnoeの各デバイスに最適化した細かい制御を実施したい

テンプレート内での条件分岐

本要件には、react-responsiveを用いました。
https://github.com/contra/react-responsive
このライブラリを使うと、iPhnoeXiPhoneX Plusなどの境界の条件分岐を直感的に実装できます。

src/components/pages/booking/index.js
    {/* 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

スクリーンショット 2021-01-27 9.27.25.png

  • iPhnoe8 Plus

スクリーンショット 2021-01-27 9.27.35.png

本要件を実現するために、Hookで各デバイス情報を判定し、各コンポーネントで利用できるようにしました。

  • デバイス判定するHook
src/hooks/useDevice.js
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;
}
  • 予約表組出力コンポーネントで利用
src/components/pages/booking/status.js
    const device = useDevice();; // デバイス情報を利用

...(中略)...

    // デバイスの変更を検出して、予約の表組の表示を変更
    React.useEffect(() => {

...(ロジックがわかりにくいので割愛)...

    }, [reservationTable, device, page]); // デバイス変更を検出

エラートラッキング

画面側(Webブラウザ)でのエラーは、エラー情報をバックエンドに連携しないと、エラーに気づくことも、エラーの詳細情報を知ることもできません。
エラーの発生状況は可能な限りトレースできるようにし、エラーにはプロアクティブに対応できる仕組みが必要です。
この仕組みを自前で実装することも可能ですが、特段の要件がない場合は、Sentryを利用することで、エラー発生状況を容易に管理・トレースすることができます。

gatsby-browser.js
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連携など)の実装できます。

  • 認証サービスクラス
src/services/auth.js
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)]
        }
    }
}
  • 認証画面 (ログイン実装)
src/components/pages/auth/signin.js
    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で以下のように対応しました。

  • リロード時の状態引き戻し
src/context/globalState.js
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); // フックの初期化時に上記関数を利用
  • ステート更新時の同期処理
src/context/globalState.js
    React.useEffect(() => {
        localStorage.setItem("state", JSON.stringify(state))
    }, [state]); //状態変更をフック

APIレスポンスのキャッシュを効かなくする

リクエストURLを一意にすることで、APIレスポンスのキャッシュが効かないようにします。
具体的には、UNIXタイムスタンプをクエリに付与することでAPIのキャッシュが効かないようにしました。

src/services/reservationTable.js
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を試してみる(ブログ構築)

16
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
19