[Node.js] SequelizeでMySQLを利用する - その5「ToDoアプリ作成編」

Node.jsの代表的なO/RMであるSequelizeの第五弾。

これまで以下のような内容を取り上げてきました。

  1. 第1回 インストールから基本的な利用方法
  2. 第2回 SELECT文の使い方
  3. 第3回 トランザクション
  4. 第4回 マイグレーション

今回はここまでの情報の整理も兼ねて、簡単なToDoアプリを作ってみようと思います。


www.youtube.com

最終的なコード

GitHubで公開しています。手っ取り早く確認したい方はこちらからどうぞ。 github.com

準備

MySQL

MySQLをインストールし適当なユーザー、データベースを作成しておきます。第1回目の冒頭に極簡単な説明があります。 blog.katsubemakito.net

Node.js

Node.jsを入れたら、適当なディレクトリを作成し中に移動。

$ mkdir todo; cd todo

npm initでpackage.jsonを生成します。

$ npm init

その後必要なライブラリをnpmでインストールします。今回はNode.jsで簡単にWebサーバを構築できるexpressを利用してAPIサーバを構築します。

$ npm install express sequelize mysql2
$ npm install -D sequelize-cli

初期ファイルを準備

まずはテンプレをコマンドで自動生成します。sequelize-cliで一発です。

$ npx sequelize-cli init

「config/config.json」にMySQLへの接続情報を記入しておきます。デフォルトではdevelopment環境が選択されますので特にこだわりがなければdevelopment内の値を編集します。

{
  "development": {
    "username": "root",
    "password": null,
    "database": "database_development",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "test": {
    "username": "root",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "mysql"
  },
  "production": {
    "username": "root",
    "password": null,
    "database": "database_production",
    "host": "127.0.0.1",
    "dialect": "mysql"
  }
}

あとこのファイルはJavaScriptではなく「JSON」であり文法がかなり厳しくチェックされますのでご注意ください(詳しくは第4回にて)

設計する

API設計

今回はSequelizeでCRUDの練習をするのが目的なので、以下の5種類のAPIを準備することにします。

機能 END POINT 引数 レスポンス
カテゴリ一覧を返却 GET /api/category なし
[
  {
    id: 1,
    name: "カテゴリ名"
  }
]
タスク一覧を返却 GET /api/task なし
[
 {
   id: 1,
   title: "タイトル",
   done: false,
   Category: {
     id: 1,
     name: "仕事"
   }
  }
]
タスクを新規追加 POST /api/task/new
{
  title: "タイトル",
  category: 1
}
{status:true}
タスクを完了 POST /api/task/done
{id:1}
{status:true}
タスクを物理削除 POST /api/task/remove
{id:1}
{status:true}

テーブル設計

今回はシンプルにToDoのタスクを登録する「Tasks」とToDoのカテゴリを管理する「Categories」の2つのテーブルを用意します。

  • Tasks.categoryId = Categories.id が「n:1」の関係性になっています。

コーディング

モデルの準備

モデルのテンプレを生成します。sequelize-cliをテーブルの数だけ実行します。細かいところは直接コードを書きますのでここではカラム名とデータ型を羅列するだけでOKです。

まずはTask用テーブル。

$ npx sequelize-cli model:generate --name Task \
    --attributes title:string,done:boolean

Category用のテーブルです。コマンドを実行する順番はどちらが先でもかまいません。

$ npx sequelize-cli model:generate --name Category \
    --attributes name:string

model/category.jsを編集

まずは簡単な方から。タイムスタンプ機能をOFFにしました。それ以外の処理その物はいじっていません

'use strict';
const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class Category extends Model {
    static associate(models) {
      // 必要があればここにテーブルの関連付けを書く
      // メソッド自体は削除しない
    }
  };
  Category.init({
    name: DataTypes.STRING
  }, {
    sequelize,
    modelName: 'Category',
    timestamps: false
  });
  return Category;
};

model/task.jsを編集

クラス内にメソッドとテーブルの関連付けの設定を追加しました。またコメントの削除と改行位置を変更しています。

'use strict';
const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class Task extends Model {
    // テーブルの関連付け
    static associate(models) {
      Task.belongsTo(models.Category, {
        foreignKey: 'categoryId',  // デフォルト値なので未指定でも可
        targetKey: 'id'            // 〃
      })
    }

    /**
     * タスク一覧を返却 (C"R"UD)
     */
    static async getAll(){
      try{
        const tasks = await this.findAll({
          include: 'Category',
          attributes: ['id', 'title', 'done', 'Category.name'],
          order: [
            ['id', 'ASC']
          ]
        })
        return(tasks)
      }
      catch(e){
        console.error(e)
        return(false)
      }
    }

    /**
     * タスクを追加 ("C"RUD)
     */
    static async add(value){
      try{
        const task = await this.create({
          title: value.title,
          categoryId: value.category,
        })
        return(task)
      }
      catch(e){
        console.error(e)
        return(false)
      }
    }

    /**
     * ステータスを変更する (CR"U"D)
     */
    static async done(id, flag=true){
      try{
        await this.update({done: flag}, {
          where:{
            id
          }
        })
        return(true)
      }
      catch(e){
        console.error(e)
        return(false)
      }
    }

    /**
     * タスクを物理削除 (CRU"D")
     */
    static async remove(id){
      try{
        await this.destroy({
          where:{
            id
          }
        })
        return(true)
      }
      catch(e){
        console.error(e)
        return(false)
      }
    }
  }

  Task.init({
    title: DataTypes.STRING,
    done: {
      type: DataTypes.BOOLEAN,
      defaultValue: false
    }
  }, {
    sequelize,
    modelName: 'Task',
  });
  return Task;
}

express側の準備

先ほど作成したモデルをexpress側から利用してAPIサーバを構築します。本題とずれるのでexpressについての説明は端折りますが、詳しくは過去記事をご覧ください。 blog.katsubemakito.net

serve.jsを編集

APIサーバを構築します。package.jsonと同じ階層にserve.jsというファイル名でコードを書いていきます。

個別にモデルをrequireしなくても、models/index.jsをrequireするだけで好きなタイミングで好きなモデルを利用できるのがわかります。Validationや厳密なエラー処理は行っていないので本番サービスでは適宜追加してください。

/**
 * ToDoサーバ
 */

//---------------------------------------------------------
// modules
//---------------------------------------------------------
const models = require('./models')
const path = require('path')

//---------------------------------------------------------
// define
//---------------------------------------------------------
const PORT = 3000
const DOCUMENT_ROOT = path.join(__dirname, 'public')

//---------------------------------------------------------
// express
//---------------------------------------------------------
//-----------------
// サーバー設定
//-----------------
const express = require('express')
const app  = express()

app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(express.static(DOCUMENT_ROOT))

//-----------------
// API準備
//-----------------
/**
 * カテゴリー一覧を返却
 */
app.get('/api/category', async (req, res) =>{
  const category = await models.Category.findAll()
  res.json(category)
})

/**
 * タスク一覧を返却
 */
app.get('/api/task', async (req, res) =>{
  res.json( await models.Task.getAll() )
})

/**
 * タスクを新規追加
 */
app.post('/api/task/new', async (req, res) =>{
  const result = await models.Task.add({
    title: req.body.title,
    category: req.body.category
  })
  res.json( {status: result !== false })
})

/**
 * タスクを完了
 */
app.post('/api/task/done', async (req, res) =>{
  const result = await models.Task.done(req.body.id)
  res.json( {status: result !== false })
})

/**
 * タスクを物理削除
 */
app.post('/api/task/remove', async (req, res) =>{
  const result = await models.Task.remove(req.body.id)
  res.json( {status: result !== false })
})

//-----------------
// サーバー起動
//-----------------
app.listen(PORT, () => {
  console.log(`listening at http://localhost:${PORT}`);
});

public/index.htmlを編集

APIを利用するクライアントは今回はWebページとして用意します。本題とずれるので詳しくはGitHub上のソースをご覧ください。普通にFetch APIで呼んでるだけです。 github.com

環境構築

マイグレーション

MySQLへ反映する内容を準備します。

migrations/20210810xxxxxx-create-task.jsを編集

Tasksテーブルの内容を定義します。ほぼそのままですが外部キー制約を付けているところが一番大きな変更でしょうか。

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Tasks', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      title: {
        type: Sequelize.STRING,
        allowNull: false
      },
      done: {
        type: Sequelize.BOOLEAN,
        allowNull: false,
        default: false
      },
      categoryId:{
        type: Sequelize.INTEGER,
        references:{
          model: {
            tableName: 'Categories',
            key: 'id'
          }
        }
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Tasks');
  }
};

migrations/20210810xxxxxx-create-category.js

こちらはいじっていません。

'use strict';
module.exports = {
  up: async (queryInterface, Sequelize) => {
    await queryInterface.createTable('Categories', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      name: {
        type: Sequelize.STRING
      }
    });
  },
  down: async (queryInterface, Sequelize) => {
    await queryInterface.dropTable('Categories');
  }
};

実行する

では先ほど作成したファイルを元にMySQLへ反映しちゃいます。これもコマンド一発です。development環境へ2つのファイルが実行されたのが分かります。

$ npx sequelize-cli db:migrate

Loaded configuration file "config/config.json".
Using environment "development".
== 20210810093257-create-category: migrating =======
== 20210810093257-create-category: migrated (0.049s)

== 20210810095805-create-task: migrating =======
== 20210810095805-create-task: migrated (0.040s)

実際にMySQLへログインするとテーブルが3つ作成されています。SequelizeMetaテーブルには実行したマイグレーションファイルのファイル名が記録されます。

mysql> show tables;
+--------------------------------+
| Tables_in_database_development |
+--------------------------------+
| Categories                     |
| SequelizeMeta                  |
| Tasks                          |
+--------------------------------+

DESCでテーブルの構造も確認しておきます。Tasksの方にはid、タイムスタンプ機能用のカラム(createdAt,updatedAt)、CategoriesとリレーションするためのcategoryIdが自動的に生成されているのが分かりますね。

mysql> desc Categories;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

mysql> desc Tasks;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| title      | varchar(255) | NO   |     | NULL    |                |
| done       | tinyint(1)   | NO   |     | NULL    |                |
| categoryId | int(11)      | YES  | MUL | NULL    |                |
| createdAt  | datetime     | NO   |     | NULL    |                |
| updatedAt  | datetime     | NO   |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

DESCだけではなく、SHOW CREATE TABLEでCREATE TABLE文を直接確認しておくのも良いと思います。

mysql> show create table Tasks;
CREATE TABLE `Tasks` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `done` tinyint(1) NOT NULL,
  `categoryId` int(11) DEFAULT NULL,
  `createdAt` datetime NOT NULL,
  `updatedAt` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `categoryId` (`categoryId`),
  CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`categoryId`) REFERENCES `Categories` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

初期データを作成

このままだとカテゴリが空っぽなので、初期データを突っ込んであげます。初期データはSequelizeでは「seed」と呼びます.

テンプレを生成

初期ファイルをsequelize-cliで生成します。

$ npx sequelize-cli seed:generate --name category

seeders/20210810xxxxxx-category.jsを編集

生成されたファイルに具体的にどういったデータを挿入したいか定義します。マイグレーションのときとほぼ同じ仕組みっぽいですが、こちらはデータの挿入に特化する感じです。

'use strict';

module.exports = {
  up: async (queryInterface, Sequelize) => {
    return queryInterface.bulkInsert('Categories', [
      {name: '仕事'},
      {name: 'プライベート'},
      {name: '町内会'}
    ]);
  },

  down: async (queryInterface, Sequelize) => {
    return queryInterface.bulkDelete('Categories', null, {truncate:true});
  }
};

実行する

先ほど作成したseedファイルをMySQLへ反映します。

$ npx sequelize-cli db:seed:all

MySQLへログインし本当に挿入されているか確認しておきます。大丈夫そうですね。

mysql> select * from Categories;
+----+--------------------+
| id | name               |
+----+--------------------+
|  1 | 仕事               |
|  2 | プライベート       |
|  3 | 町内会             |
+----+--------------------+

アプリを使ってみる

APIサーバを起動

serve.jsをnodeコマンドで実行するだけです。3000番ポートを監視しリクエストが来ると反応します。

$ node serve.js
listening at http://localhost:3000

Sequelize経由でMySQLへクエリが投げられると、その際のSQLがTerminalに表示されるので、意図したSQLが実行されているか確認することもできます。

コードを修正した場合は一度Ctrl+Cで終了し、再度起動する必要があります。開発中など頻繁に再起動する必要がある場合はnodemonをインストールしておくと、ファイルが更新されると自動的に再起動してくれるので楽ちんです。

Webブラウザから利用する

お好みのWebブラウザを起動しhttp://localhost:3000へアクセスすると利用できます。


www.youtube.com

参考ページ