Next.jsとSequelizeを使ったアプリ開発に取り組んでみた

こんにちは、次世代デジタル基盤開発事業部の渡邊です。
今回はReactのフレームワークであるNext.jsとNode.js用のORMであるSequelizeを使って自己学習していた時の内容を備忘録として残します。

題材は過去の記事と同じく「一覧表示+検索機能(簡易版)を機能として持つ管理アプリ」になります。 過去記事では、題材が同じでも使っている技術の組み合わせを変えているので、もし気になる方がいればそれらの記事もご参照ください。

[過去リンク]
React+Node.js(express)+MySQLの環境を整えてReactとNode.jsの自己学習に取り組んでみた - テコテック開発者ブログ
ReactのRedux(@reduxjs/toolkit)とFirebaseを使ったアプリ開発に取り組んでみた

[目次]

完成イメージ

この後で実装工程を載せていきますが、先に完成イメージをいくつかご紹介します。

[初期表示時]

[検索時(該当データあり)]

[検索時(該当データなし)]

開発環境

今回の自己学習を進めるにあたって、以下のような開発環境を用意しました。

[環境]

  • WSL2
  • Ubuntu 20.04.1 LTS
  • Node.js 16.13.0
  • MySQL 8.0.27
  • npm 8.19.1
  • Next.js 13.0.6
  • React 18.0.2
  • typescript 4.9.3
  • VS Code 1.74.3

[パッケージ(※)]※主要なものだけ掲載

  • @mui/material 5.11.3
  • sequelize 6.26.0
  • sequelize-cli 6.5.2
  • lodash 4.17.21
  • mysql2 2.3.3

環境構築

この章では開発を行うための環境構築についてご紹介していきます。
ただ、WSL2,Ubuntu,Node.js,npm,VS Codeの導入手順に関しては割愛させていただきます。 詳しくはこちらに手順が載っていますので参考にしてください。

[参考リンク]
WSL 2 上で Node.js を設定する | Microsoft Learn

ここでは大きく分けて以下の3つの作業を実施します。

  • create-next-appを使ってプロジェクトを作成
  • MySQLの設定
  • Sequelizeのインストール,設定

create-next-appを使ってプロジェクトを作成

Next.js使った開発でプロジェクトを作成するにあたり、今回はcreate-next-appを活用しました。VS CodeのターミナルでUbuntu環境下の任意フォルダにて以下のコマンドを実行します。

npx create-next-app@lastest --typescript b_next_mysql

オプションとしてJavaScript/TypeScriptのどちらで開発するか指定できるのですが、今回はTypeScriptの理解を深めるためにTypeScriptを指定しました。

一番後ろについている[b_next_mysql]は作りたいプロジェクトの名称になります。create-next-appを使ってプロジェクト作成したときのスクショが以下になります(※1)。
※1 ESLintを使うかどうかの質問にはYes or Noで答えますが、選択肢の切り替えはTabキーで行います。

実行が正常するとプロジェクトが作成されます。
VS Codeのターミナル上で作成されたプロジェクトをカレントディレクトリにして、以下のコマンドを実行してプロジェクトを起動してください。

npm run dev

そうすると、ターミナル上でブラウザで開くためのURLが表示されます。
これを直接クリックするかブラウザでリンクを貼り付けて開いてみてください。以下のような画面が表示されたらプロジェクト作成は完了となります。

MySQLの準備

次にMySQLの準備を行っていきますが、過去記事で紹介したMySQLの環境を流用しているため細かい設定などは割愛します。詳細については過去記事をご確認ください。

[参考リンク]
2.MySQLのインストール

今回は新しいDBの作成と既存ユーザーへの権限付与を実施します。ユーザーについても過去記事で作ったものを流用します。
これらの作業を実施するために以下のクエリを実行しました。

やること クエリ
DB作成 create database food;
ユーザー作成(今回は未実施) create user 'testuser001'@'localhost' IDENTIFIED BY 'パスワード';
ユーザー権限付与 grant all privileges on food.* to 'testuser001'@'localhost';

DB関連だと他にテーブル作成とデータ投入が残っていますが、これらは後ほどご紹介するSequelizeの中で実施します。

これでMySQLの準備は以上となります。

Sequelizeのインストール、設定

この章ではSequelizeを使ったテーブル作成とデータ投入を実施していきます。
その作業する際に、今回はsequelize-cliというパッケージを使いました。これは、Sequelizeの機能をコマンドラインを使って操作できるパッケージとなります。

sequelize-cliのインストール、プロジェクト作成

まずはnpmを使ってsequelize-cliパッケージをインストールします。

sequelize-cliをインストールしたら、実際にこれを使って操作できるようにするためプロジェクトを作成します。以下のコマンドを実行してください。実行時のスクショも一緒に載せておきます。

npx sequelize init

これを実行すると、コマンドを実行したカレントディレクトリ内にいくつかのフォルダ/ファイルが生成されます。

フォルダ、ファイルが生成されたら、その中に含まれているconfig.jsonの定義を変更します。

[補足]
この後にご説明する機能の中でSequelizeを使った検索処理を実装します。その際にsequelizeパッケージが必要になりますが、sequelize-cliをインストールすると一緒にsequelizeパッケージもインストールされるため後から個別でsequelizeパッケージをインストールする必要はありません。

モデルファイル、マイグレーションファイルの生成

次に、sequelize-cliを使ってテーブル生成するのに必要となるモデルファイル、マイグレーションファイルを生成します。

先ほどの手順でinitを実行したカレントディレクトリでモデルファイル、マイグレーションファイルの生成コマンドを実行します。実行時のスクショも一緒に載せておきます。

npx sequelize-cli model:generate --name restaurants --underscored --attributes store_name:string,category:string,location:string,evaluation:integer

そうすると、models,migrationsフォルダのそれぞれにファイルが生成されます。
因みに、このコマンドを実行する際に細かい型の指定ができないようなのでご留意ください。
型定義を調整したい場合は、生成されたマイグレーションファイルを直接編集することになります。

最初は型定義の調整はどうやるのかわからず困っていたのですが、こちらの記事が参考になりました。

[参考リンク]
Node.jsのSequelizeでDBのmigrationを実行する

マイグレーションファイルの修正が完了したら、最後にマイグレーションを実行してテーブルを作成します(※2)。実行時のスクショも一緒に載せておきます。

--envオプションの後ろに設定している[development]は、先ほどconfig.jsonで修正した定義を参照していることを意味しています。

正常に行けばマイグレーションが成功して、以下のようにテーブルが生成されていることを確認できます。

※2
このコマンドを実行する際にmysql2パッケージが必要になるため、忘れずにインストールする必要があります。 インストールされていないと以下のようなエラーメッセージがターミナル上に表示されます。

ERROR: Please install mysql2 package manually    
Cannot read properties of undefined (reading 'detail')  
    sequelize-cli db:migrate
    Run pending migrations
TypeError: Cannot read properties of undefined (reading 'detail')   
    at Object.error (/mnt/c/temp/b_next_mysql/node_modules/sequelize-cli/lib/helpers/view-helper.js:31:24)  
    at /mnt/c/temp/b_next_mysql/node_modules/sequelize-cli/lib/commands/migrate.js:68:39    
    at processTicksAndRejections (node:internal/process/task_queues:96:5)   
    at async Object.exports.handler (/mnt/c/temp/b_next_mysql/node_modules/sequelize-cli/lib/commands/migrate.js:27:7)
Seederによるテストデータの用意

sequelize-cliを使った準備はこれで最後になります。
ここではSeederという機能を用いてテストデータを先ほど作成したテーブルへ投入していきます。 そのためにはSeederファイルが必要になるので、まずは以下のコマンドを実行してSeederファイルを生成します。実行時のスクショも一緒に載せておきます。

npx sequelize-cli seed:generate --name restaurants

そうするとSeederフォルダの中にファイルが生成されます。[実行日時]-[--nameオプションで指定した文言]というファイル名になります。

生成された直後は、以下のように何も定義されていないファイルとなります。

その状態に対して、今回は以下のように実装しました。
これで以下のコマンドを実行することにより、対象のテーブルへのデータ投入が成功します。実行時のスクショも一緒に載せておきます。

npx sequelize-cli db:seed:all

実行後のテーブルは、以下のような中身になります。

これで環境構築は以上となります。

クライアント側の実装

この章ではクライアント側の実装について説明します。最初の方でも完成イメージは載せていますが、ここでも改めてご紹介します。

クライアント側の実装を説明する前に完成イメージと作成したコンポーネントの関係性を載せておきます。

記事の冒頭でもお伝えしていますが、過去記事で紹介したアプリと全く同じものになります。 そのため、実装するコードや説明が前回と似てしまうところもございますので予めご了承ください。

それでは、コンポーネントごとの処理についての説明していきます。

全体

Homeコンポーネント

import { useState } from 'react'
import Head from 'next/head'
import Header from './components/Header'
import Form from './components/Form'
import List from './components/List'
import { GetServerSideProps } from 'next'

type PropsData = {
  viewData: Array<Object>
}

export default function Home(props: PropsData) {
  const[errorMessage, setErrorMessage] = useState<string>('');
  const[searchResult, setSearchResult] = useState<Array<Object>>([]);
  const search = (data: Array<Object>) => {
    setSearchResult(data);
  }

  const error = (errorMessage: string) => {
    setErrorMessage(errorMessage);
  }

  return (
    <div>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main>
        <Header />
        <Form search={search} error={error}/>
        <List data={props.viewData} searchResult={searchResult} errorMessage={errorMessage}/>
      </main>
    </div>
  )
}

type ViewData = {
  id: number,
  store_name: string,
  category: string,
  location: string,
  evaluation: number,
}

export const getServerSideProps: GetServerSideProps<{ viewData: ViewData }> = async (context) => {
  const result = await fetch('http://localhost:3000/api/getList');
      const data = await result.json();
      return {
        props: {
          viewData: data.result
        }
      }
}

ブラウザ表示されるコンポーネント群のTOPコンポーネントになります。
まずはNext.jsのSSR機能になるgetServerSidePropsを使ってサーバ側で一覧取得API(api/getList)を呼び出しています。その呼び出しにより取得した一覧データをpropsの値としてreturnしています。

そして、その値をHomeコンポーネントでprops値として受け取ります。この中では、Header,Form,Listコンポーネントを呼び出しています。
FormコンポーネントではReact Hookの1種で状態管理を行えるuseStateをprops値として2つ渡しています。

ここではFormコンポーネントにuseStateで宣言した関数をpropsとして渡すことで、Formコンポーネント内でそれらの関数を呼び出して状態を保持する変数(searchResult,errorMessage)を更新します。そうすることで親コンポーネントへ状態を渡すことが可能となります。

そして、その結果をListコンポーネントへ渡しています。

ヘッダー

Headerコンポーネント

import React from 'react'
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';

const Header  = () => {
    return (
        <Box sx={{ flexGrow: 1 }}>
          <AppBar position="static">
            <Toolbar>
              <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
                店舗管理アプリ
              </Typography>
            </Toolbar>
          </AppBar>
        </Box>
      );
}

export default Header

Material UIの公式ドキュメントに載っているAppBarコンポーネントを活用して、不要な部分は取り除いて実装しています。

フォーム

Formコンポーネント(フォーム全体)

import React, { useState } from 'react'
import FormControl from '@mui/material/FormControl';
import ComButton from './common/ComButton'
import PullDown from './common/PullDown'
import TextInput from './common/TextInput'

const Form = (props: {search: (data: Array<Object>) => void, error: (errorMessage: string) => void}) => {
    const [title, setTitle] = useState("")
    const [categories, setCategories] = useState([])

    const textInputSet = (event: any) => {
        setTitle(event.target.value)
    }

    const search = async () => {
        const result = await fetch('api/search',{method: "POST", body: JSON.stringify([title, categories])});
        const data = await result.json();
        if (result.status === 200 && data.result.length === 0) {
            props.error('該当データなし');
        }
        props.search(data.result);
    }

    return (
        <FormControl sx={{ py: 2 }}>
            <TextInput value={title} onChange={textInputSet} />
            <PullDown value={categories} onSelect={setCategories} />
            <ComButton onClick={search} />
        </FormControl>
    );
}

export default Form

この中でTextInput,PullDown,ComButtonコンポーネントを呼び出しています。
また、検索ボタン押下時にfetch関数を使って検索API(api/search)を呼び出してサーバ側からデータを取得します。

TextInputコンポーネント(テキストボックス)

import React from 'react'
import TextField from '@mui/material/TextField';

const TextInput = (props: any) => {
    return (
        <TextField
            id="standard-basic"
            label="タイトル"
            value={props.value}
            onChange={props.onChange}
            sx={{ width: 300 }}
        />
    )
}

export default TextInput

呼ばれた際にMaterial-UIのTextFieldコンポーネントを返すように実装しています。

PullDownコンポーネント(プルダウン)

import React from 'react';
import { useTheme } from '@mui/material/styles';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';

const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
  PaperProps: {
    style: {
      maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
      width: 250,
    },
  },
};

const names = [
  '日本食',
  '洋食',
  '中華料理',
  '韓国料理',
  'イタリアン',
];

function getStyles(name: any, personName: any, theme: any) {
  return {
    fontWeight:
      personName.indexOf(name) === -1
        ? theme.typography.fontWeightRegular
        : theme.typography.fontWeightMedium,
  };
}

export default function MultipleSelect(props: { value: Array<String>, onSelect:React.Dispatch<React.SetStateAction<never[]>> }) {
  const theme = useTheme();

  const handleChange = (event: any) => {
    props.onSelect(event.target.value)
  };

  return (
    <div>
      <FormControl sx={{ my: 1, width: 300 }}>
        <InputLabel id="demo-multiple-name-label">カテゴリ</InputLabel>
        <Select
          labelId="demo-multiple-name-label"
          id="demo-multiple-name"
          multiple
          value={props.value}
          onChange={handleChange}
          input={<OutlinedInput label="カテゴリ" />}
          MenuProps={MenuProps}
        >
          {names.map((name) => (
            <MenuItem
              key={name}
              value={name}
              style={getStyles(name, props.value, theme)}
            >
              {name}
            </MenuItem>
          ))}
        </Select>
      </FormControl>
    </div>
  );
}

Material-UIの公式ドキュメントに載っているSelectコンポーネントの実装例を少しカスタマイズして使用しています。

ComButtonコンポーネント(ボタン)

import React from 'react'
import Button from '@mui/material/Button';

const ComButton = (prop: any) => {
    return (
        <Button
            sx={{ width: 75 }} 
            variant="contained"
            onClick={() => prop.onClick()}
        >
            検索
        </Button>
    )
}

export default ComButton

呼ばれた際にMaterial-UIのButtonコンポーネントを返すように実装しています。

一覧

Listコンポーネント

import React, { useId } from 'react'
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';

type ViewData = {
  id?: number,
  store_name?: string,
  category?: string,
  location?: string,
  evaluation?: number,
}

function List(props: { data: Array<Object>, searchResult: Array<Object>, errorMessage: string }) {
  const id = useId()
  const viewData: Array<Object> = props.searchResult.length === 0 ? props.data : props.searchResult;

  return (
    <>
      <TableContainer component={Paper}>
        <Table sx={{ minWidth: 650 }} size="small" aria-label="a dense table">
          <TableHead>
            <TableRow>
              <TableCell>店舗名</TableCell>
              <TableCell>カテゴリ</TableCell>
              <TableCell>場所</TableCell>
              <TableCell>評価</TableCell>
            </TableRow>
          </TableHead>
          {props.errorMessage === '' && (
            <TableBody>
              {viewData.map((data: ViewData) => (
                <TableRow
                  key={`index${id}_${data.id}`}
                  sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
                >
                  <TableCell>{data.store_name}</TableCell>
                  <TableCell>{data.category}</TableCell>
                  <TableCell>{data.location}</TableCell>
                  <TableCell>{data.evaluation}</TableCell>
                </TableRow>
              ))
              }
            </TableBody>
          )}
        </Table>
      </TableContainer>
      {props.errorMessage !== '' && (
          <div>{props.errorMessage}</div>
      )}
    </>
  );
}

export default List

親コンポーネントから渡ってきたprops値を一覧情報として表示します。 Material-UIの公式ドキュメントに載っているTableコンポーネントの実装例をカスタマイズして実装しました(※3)。

表示する情報としては大きく2つの違いがあります。
[初期表示時]
このときはNext.jsのSSR機能であるgetServerSidePropsを使って一覧取得API(api/getList)を呼び出して取得した一覧情報を表示します。

[検索時]
Formコンポーネントから検索API(api/search)を呼び出して取得した検索結果を表示します。

※3
当初はMaterial-UIのTableコンポーネントではなく、Material-UIをベースに作られたmaterial-tableというコンポーネントを使おうと考えていました。

ただ、実際に導入してみようとインストール等を試したところ自己学習を行っていた2022年12月時点ではどうやらNext.jsのv13系(というよりReactのv18系)にはまだ対応されていなかったため断念しました。。。

これでクライアント側の実装は以上となります。

サーバ側の実装

この章ではサーバ側の実装についてご紹介していきます。
内容としては大きく分けて以下の2つになります。

  • 初期表示時
  • 検索時

なお、繰り返しになりますがサーバ側でMySQLからデータを取得するのにsequelizeパッケージの機能を使って実現しています。
それぞれ詳しく説明していく前に、ブラウザで見たときにどのような違いがあるか先に載せておきます。
初期表示時 検索時(該当データあり)
検索時(該当データなし)

それでは、それぞれについて詳しく書いていきます。

初期表示時

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'

type Data = {
  result: Array<object>
}

import { Restaurants } from '../../sequelize/models/restaurants';

const _ = require("lodash");

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const result:Array<Object> = await Restaurants.findAll();
  let resultData:Array<Object> = [];
  for (const {value} of result.map((value) => ({ value }))) {
    resultData.push(_.get(value, 'dataValues', 'non_value'));
  }
  res.status(200).json({ result: resultData });
}

初期表示時はSSRの機能であるgetServerSidePropsを使って検索結果を取得しています。

これを使うことでサーバ側でレンダリングが行われることになるため、検索結果を取得した状態でブラウザ表示が可能となります。 このときはrestaurantsテーブルに登録されているデータの全量が必要となるため、引数なしでfindAllを呼び出してデータを取得します。

取得した中身はArray型となっており、その中に含まれるdataValuesという変数内に検索結果が格納されています。 そのため、そこからデータを取得するために今回はlodashパッケージの機能(get関数)を使いました。

そして、dataValuesから取得したデータをresultDataにセットしてレスポンス情報として渡しています。

検索時

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
const { Op } = require('sequelize');

type Data = {
  result: Array<object>
}
type Query = {
  where?: any
}

import { Restaurants } from '../../sequelize/models/restaurants';

const _ = require("lodash");

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  const requestData = JSON.parse(req.body);
  const query: Query = {}
  if (requestData[0] && requestData[1].length !== 0) {
    query.where = {
      [Op.and]: [
        { store_name: { [Op.like]: `%${requestData[0]}%` } },
        { category: requestData[1] }
      ]
    }
  } else if (requestData[0]) {
    query.where = {
        store_name: { [Op.like]: `%${requestData[0]}%` }
      };
  } else if (requestData[1].length !== 0) {
    query.where = { category: requestData[1] };
  } else {
    query.where = {};
  }
  let resultData: Array<Object> = [];
  const result = await Restaurants.findAll(query);
  if (result.length === 0) {
    res.status(200).json({ result: [] });
  } else {
    for (const { value } of result.map((value) => ({ value }))) {
      resultData.push(_.get(value, 'dataValues', 'non_value'));
    }
    res.status(200).json({ result: resultData });
  }
}

検索時はFormコンポーネントに含まれる検索ボタンを押下することで処理されます。 その際に検索条件をパラメータとして含めて検索API(api/search)を呼び出しています。

サーバ側の処理としては、検索時のwhere句を検索条件に応じてパターン分けして生成しています。
生成されたwhere句をfindAll関数の引数として渡してデータ取得を実施。その結果をresultDataにセットしてレスポンス情報として渡しています。

これでサーバ側の実装は以上となります。

まとめ

今回は自己学習の技術選定として、初めてNext.jsとSequelizeを取り込んでみました。 最後に、これらを実際に使って実装してみた感想を振り返っています。

Next.js
SSR機能であるgetServerSidePropsをとりあえず使ってみてReactのCSRとの違いを知ることはできたけど、それでも十分にNext.jsの機能をフル活用できたわけではないのでまだ十分には出来ていない。

Sequelize
クエリを直書きせずオプションを用いることでクエリを生成できるメリットはあるけど、 複雑なクエリを実装したくなった場合はコードの可読性が低下しそうだしどのように実装すればいいのか悩みそう。

Next.jsはReactを使った開発を考える際にワードとして登場する機会が多いと思うので、今後も継続して学習していきたいと思っています。

最後まで読んでくださりありがとうございました!

www.tecotec.co.jp