React と Amazon Rekognition を用いたサーバレスウェブアプリケーション構築

14 min read読了の目安(約12700字

ESLint との格闘の末

はじめに

過去に作成した画像分析サーバレス Web アプリのフロントエンドを Vue から React + TypeScript + Material-UI の組み合わせに変更

https://qiita.com/takanassyi/items/9684ce2230bae6683c91

ソースは下記 GitHub に公開

https://github.com/takanassyi/react-and-rekognition

アプリ概要

  1. ブラウザ上で画像を選択、その画像のファイル名表示とプレビュー
  2. API 経由で画像をアップロード
  3. アップロードした画像の識別結果を表示

react-demo.gif

構成図

フロントエンドは React, バックエンドは Chalice で構築

https://github.com/aws/chalice

arch.png

バックエンド

  • Chalice を用いて API Gateway エンドポイントを作成し、Lambda から Rekognition, Translate を使用するバックエンドを構築
  • 詳細はまずやってみる機械学習 ~AWS SAGEMAKER/REKOGNITIONとVUEで作る画像判定WEBアプリケーション[1]を参照

http://www.intellilink.co.jp/article/column/ai-ml02.html

フロントエンド

開発環境の構築からアプリの構築まで

React 開発環境

  • 本アプリは、開発環境のテンプレートとして下記のリンク先の04-advancedを拝借[2]
  • Linter の設定など全部済んでいるため、至れり尽せりで非常にオススメ

https://github.com/oukayuka/Riakuto-StartingReact-ja3.1/tree/bcbef60c13c1f59a1f5507e5cbd743248d233b73/06-lint

Material-UI

  • 今回は v5.0.0 (プレビュー版) を利用
  • インストール方法は公式を参照
package.json
"dependencies": {
  "@material-ui/core": "^5.0.0-alpha.25",
  "@material-ui/icons": "^5.0.0-alpha.26",
  "@material-ui/lab": "5.0.0-alpha.25",
  (略)
}

Atomic Design

  • パーツ・コンポーネント単位で定義していく UI デザイン手法の Atomic Design で設計[3]
  • Template / Pages / Organisms / Molecules / Atoms の構成要素に分けて画面を設計
  • 詳細は 下記参照

https://design.dena.com/design/atomic-design-を分かったつもりになる

Templates

  • 全体のレイアウト、テーマを設定
    • 赤枠 (header) : AppBarコンポーネントでタイトル部を作成
    • 青枠 (main) : アプリのメインで children にバインド
    • 緑枠 (footer) : Copyright用に作成

Screen Shot 2021-03-06 at 22.08.41.png

GenericTemplate.tsx
import React from 'react';
import CssBaseline from '@material-ui/core/CssBaseline';
import {
  createMuiTheme,
  createStyles,
  makeStyles,
  Theme,
  ThemeProvider,
} from '@material-ui/core/styles';
import { AppBar, Link, Typography } from '@material-ui/core';

const muiTheme = createMuiTheme({
  typography: {
    h5: {
      fontWeight: 800,
      fontFamily: ['sans-serif'].join(','),
    },
    fontFamily: [
      'Noto Sans JP',
      'Lato',
      '游ゴシック Medium',
      '游ゴシック体',
      'Yu Gothic Medium',
      'YuGothic',
      'ヒラギノ角ゴ ProN',
      'Hiragino Kaku Gothic ProN',
      'メイリオ',
      'Meiryo',
      'MS Pゴシック',
      'MS PGothic',
      'sans-serif',
    ].join(','),
  },
  palette: {
    mode: 'light', // dark is "dark mode"
  },
});

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    appbar: {
      padding: theme.spacing(2),
      Height: '200px',
    },
  }),
);

// Link => eslint を disable にする必要がある?
// 参考:https://next.material-ui.com/components/links/#main-content
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/anchor-is-valid */
const Copyright = () => (
  <Typography variant="body2" align="center">
    {'Copyright © '}
    {new Date().getFullYear()}
    <Link href="https://github.com/takanassyi/react-and-rekognition">
      {' '}
      takanassyi{' '}
    </Link>
    All rights reserved.
  </Typography>
);

export interface GenericTemplateProps {
  children: React.ReactNode;
}

const GenericTemplate: React.FC<GenericTemplateProps> = ({ children }) => {
  const classes = useStyles();

  return (
    <ThemeProvider theme={muiTheme}>
      <CssBaseline />
      <div>
        <header>
          <AppBar position="static" className={classes.appbar}>
            <Typography variant="h6">
              Image Classification Example (React Frontend App Ver.)
            </Typography>
          </AppBar>
        </header>
      </div>
      <div>
        <main>{children}</main>
      </div>
      <div>
        <footer>
          <Copyright />
        </footer>
      </div>
    </ThemeProvider>
  );
};
export default GenericTemplate;

Pages

  • Template からデザインを継承
  • Grid でレイアウト
  • Pages をトップに propsOrganisms, Molecules へデータと関数を伝播
  • http://<<YOUR ENDOPOINT URL>> に Chalice で構築したエンドポイントの URL を設定
  • axios で API 実行 (本来は別のソースに切り出すべき?)
Page.tsx
import React, { useState } from 'react';
import { Grid, Theme, createStyles, makeStyles } from '@material-ui/core';

import DisplayResult from 'Components/Organisms/DisplayResult';
import UploadImage from 'Components/Organisms/UploadImage';
import GenericTemplate from 'Components/Templates/GenericTemplate';

import axios from 'axios';
import { Result } from 'utils/utils';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    grid: {
      padding: theme.spacing(2),
    },
  }),
);

const Page: React.FC = () => {
  const classes = useStyles();

  const [image, setImage] = useState<string | null | ArrayBuffer | undefined>(
    null,
  );
  const [fileName, setFileName] = useState<string>('');
  const [pending, setPending] = useState<boolean>(false);
  const [items, setItems] = useState<Result[]>([]);

  const getImage = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files === null) return;

    try {
      const reader = new FileReader();
      reader.readAsDataURL(e.target.files[0]);
      reader.onload = () => {
        setImage(reader.result);
        if (e.target.files === null) return;
        setFileName(e.target.files[0].name);
      };
    } catch {
      console.error('Error');
    }
  };

  // TODO:型を明確にしながら axios を使う
  // https://qiita.com/keyakko/items/ec536545d2faa9cabc84

  const fetchData = async () => {
    const config = {
      headers: {
        'content-type': 'application/octet-stream',
      },
    };
    const resultAxios = await axios.post<string>(
      'http://<<YOUR ENDOPOINT URL>>/api/rekognition',
      image,
      config,
    );

    const results: Result[] = [];
    resultAxios.data.split(',').map((text, id) => results.push({ id, text }));

    setItems(results);
    setPending(false);
  };

  const uploadImage = () => {
    if (typeof image !== 'string') {
      return;
    }
    setPending(true);
    void fetchData();
  };

  return (
    <GenericTemplate>
      <Grid container spacing={2} className={classes.grid}>
        <Grid item sm={6}>
          <UploadImage
            image={image}
            getImage={getImage}
            fileName={fileName}
            uploadImage={uploadImage}
            pending={pending}
          />
        </Grid>

        <Grid item sm={6}>
          <DisplayResult items={items} />
        </Grid>
      </Grid>
    </GenericTemplate>
  );
};
export default Page;

Organisms

  • Page を左右2つの領域に分割
    • 左側 (赤枠) : 画像の選択、アップロードに関する領域
    • 右側 (青枠) : 画像認識の結果を表示する領域

Screen Shot 2021-03-06 at 23.16.14.png

UploadImage.tsx
import React from 'react';
import { Grid, Typography } from '@material-ui/core';

import DisplayImage from 'Components/Molecules/DisplayImage';
import SelectImage from 'Components/Molecules/SelectImage';

type UploadImageProps = {
  image: string | null | ArrayBuffer | undefined;
  fileName: string;
  getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
  uploadImage: () => void;
  pending: boolean;
};
// ブレークポイントとGrid item/containerの解説
// https://blog.katsubemakito.net/react/react1st-28-materialui
// 12を超えると次の行に送られる
const UploadImage: React.FC<UploadImageProps> = (props: UploadImageProps) => {
  const { image, fileName, getImage, uploadImage, pending } = props;

  return (
    <Grid container spacing={2}>
      <Grid item sm={12}>
        <Typography variant="h5">Select Image file</Typography>
      </Grid>

      <Grid item sm={12}>
        <Grid container spacing={2} alignItems="flex-end">
          <SelectImage fileName={fileName} getImage={getImage} />
        </Grid>
      </Grid>

      <Grid item sm={12}>
        <Grid container spacing={2} alignItems="flex-end">
          <DisplayImage
            image={image}
            pending={pending}
            uploadImage={uploadImage}
          />
        </Grid>
      </Grid>
    </Grid>
  );
};

export default UploadImage;
DisplayResult.tsx
import React from 'react';
import {
  Grid,
  Paper,
  Typography,
  createStyles,
  makeStyles,
  Theme,
  // colors,
} from '@material-ui/core';
import { Result, ResultProps } from 'utils/utils';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    paper: {
      width: '100%',
      padding: theme.spacing(1),
    },
  }),
);

const DisplayResult: React.FC<ResultProps> = (props: ResultProps) => {
  const classes = useStyles();

  const { items } = props;

  return (
    <Grid container spacing={2}>
      <Grid item sm={12}>
        <Typography variant="h5">Result - Rekognition</Typography>
      </Grid>
      <Grid item sm={12}>
        <Grid container>
          {items.map((result: Result) => (
            <Paper
              className={classes.paper}
              key={result.id}
              square
              variant="outlined"
            >
              <Typography>{result.text}</Typography>
            </Paper>
          ))}
        </Grid>
      </Grid>
    </Grid>
  );
};

export default DisplayResult;

Molecules

  • UploadImage を更に上下に分割
    • 上側(黄枠) : 画像の選択、ファイル名表示(SelectImage.tsx)
    • 下側(緑枠) : 画像のプレビュー、アップロードボタン表示(DisplayImage.tsx)
SelectImage.tsx
import React from 'react';
import { Button, Grid, Typography } from '@material-ui/core';

import { Image } from '@material-ui/icons';

type SelectImageProps = {
  fileName: string;
  getImage: (event: React.ChangeEvent<HTMLInputElement>) => void;
};

const SelectImage: React.FC<SelectImageProps> = (props: SelectImageProps) => {
  const { fileName, getImage } = props;

  return (
    <>
      <Grid item>
        <Button
          startIcon={<Image />}
          color="primary"
          variant="contained"
          component="label"
        >
          Select
          <input
            id="img"
            type="file"
            accept="image/*,.png,.jpg,.jpeg,.gif"
            hidden
            onChange={(e: React.ChangeEvent<HTMLInputElement>) => getImage(e)}
          />
        </Button>
      </Grid>
      <Grid item>
        <Typography>{fileName}</Typography>
      </Grid>
    </>
  );
};
export default SelectImage;
DisplayImage.tsx
import React from 'react';
import { createStyles, makeStyles, Grid } from '@material-ui/core';
import { Cloud } from '@material-ui/icons';
import LoadingButton from '@material-ui/lab/LoadingButton';

const useStyles = makeStyles(() =>
  createStyles({
    img: {
      maxWidth: '100%',
      height: 'auto',
    },
  }),
);

type DisplayImageProps = {
  image: string | null | ArrayBuffer | undefined;
  uploadImage: () => void;
  pending: boolean;
};

const DisplayImage: React.FC<DisplayImageProps> = (
  props: DisplayImageProps,
) => {
  const classes = useStyles();
  const { image, uploadImage, pending } = props;

  return (
    <>
      {typeof image === 'string' ? (
        <>
          <Grid item sm={12}>
            <img alt="detectimage" src={image} className={classes.img} />
          </Grid>
          <Grid item sm={12}>
            <LoadingButton
              pending={pending}
              variant="contained"
              startIcon={<Cloud />}
              onClick={uploadImage}
            >
              Upload
            </LoadingButton>
          </Grid>
        </>
      ) : (
        <></>
      )}
    </>
  );
};
export default DisplayImage;

Atoms

  • ラベル、ボタンなど最小単位を集めたもの
  • 今回は Material-UI のコンポーネントそのまま使用

utils

  • Rekognition の結果を格納するための型を定義
  • idDisplayResult.tsxmap で複数の要素を並べるときにユニークな key 指定をするために付与[4]

https://h3poteto.hatenablog.com/entry/2016/01/03/013921
utils.ts
export type Result = {
  id: number;
  text: string;
};

export type ResultProps = {
  items: Result[];
};

おわりに

  • シンプルなアプリだが、フロントエンドとバックエンドを統合することで、それぞれの技術要素の理解につながった
  • シンプルが故、Atomic Design の適用は大袈裟だったかも
  • useState のみで実装しているため、無駄な再描画が多い懸念
    • 他のフックを利用して効率的な再描画が必要
  • axios の処理について型付けができていない
    • axios の Post 処理は切り出して utils へ切り出したほうが良い[5]
脚注
  1. 上記はSageMakerで推論モデルを生成して、そのエンドポイントも利用しているが、本 Web アプリでは Rekognition のみ利用 ↩︎

  2. 『りあクト!』で有名な大岡さんのリポジトリ ↩︎

  3. React と相性が良いとされる ↩︎

  4. ここが ES Lint と格闘した部分 ↩︎

  5. JavaScript の Promise がよくわかってないと切り出せない ↩︎