Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
2
Help us understand the problem. What is going on with this article?
@a1k4r

ExpressをシンプルなMVCの書き方に変えて、Docker+VSCodeでデバッグもできるようにしてみました

意外と検索したら出てきそうなのに、蓋を開けたら「なんでこんな難しい書き方してるの?」「わかるけど今のNode.jsならもう少し綺麗に書ける気がする」という感じだったので、今回自分で最新の公式ドキュメントだけ見て書いたコードをまとめてみました。

ざっくりやっていることは

  • express-generatorで生成されたファイルをMVCの書き方にする
  • シンプルかつ2021年最新の書き方にしてみる
  • あらゆる環境を仮想化して誰のパソコンでも再現できるようにする

となります。

Expressというフレームワークが非常に最小限の構成となっているので「Laravelのように機能の多いフレームワークだとかえってどこで何やってるかわからなくなりがち」という方には今回の内容でMVCの流し方を実感していただけるのかなと思います。(私は最小構成であれこれ付け足す方がスマートで見やすくて好きです)

環境

  • Windows 10
  • Docker for Windows
  • Node.js 14.15.5 LTS

前提

  • 上記のDocker, Node.jsがインストールされていること
  • MVC(Model-View-Controller)を軽くでも触れていること
  • javascriptの非同期関数(async/await, Promise)やアロー関数、モジュールの使い方を理解していること

まずやること

  • express-generatorを自分のグローバルインストールする
  • Viewファイルをejsで指定して、プロジェクトを自動生成する
  • .envファイルを使用するためにdotenv、MySQLと接続するためにmysql2をインストールする
  • コードを綺麗にしたいのでeslint、デバッグ用にnodemonを開発用パッケージとしてインストールする
$ npm install -g express-generator
$ express --view=ejs node-app(プロジェクト名は何でもOK)
$ cd node-app
$ npm install -S dotenv mysql2
$ npm install -D eslint nodemon

実行環境とデバッグの設定

①まずnpmの設定を軽くいじる

package.jsonのscriptsの中にデバッグ用のコマンドを追加する。
(これでnpm run start:debugが使えるようになる)

/package.json
{
  (略)
  "scripts": {
    "start": "node ./bin/www",
    "start:debug": "nodemon -L --inspect-brk=0.0.0.0:3001 ./bin/www" // これを記述
  },
  (略)
}

②仮想環境を用意する

Dockerfileをルートディレクトリに作成して、以下のコードを書く。
(これでコンテナ起動時に自動でpackage.jsonに書いてあるパッケージをインストールしてくれる)

/Dockerfile
FROM node:14
WORKDIR /src
COPY ./package*.json /src/
RUN npm install

今回使用しそうなコンテナを用意する。

/docker-compose.yml
version: "3.9"

services:
  backend:
    build: .
    command: npm run start
    ports:
      - "3000:3000"
    volumes:
      - .:/src
      - /src/node_modules

  backend-dev:
    build: .
    command: npm run start:debug
    environment:
      - NODE_ENV=development  # devDependenciesのパッケージをインストールするため
    ports:
      - "13000:3000"  # backendサービスと同時に実行してしまった時のための予防
      - "3001:3001"  # VSCodeでのデバッグで使用するため
    volumes:
      - .:/src
      - /src/node_modules

  mysql:
    image: mysql:8.0
    # MySQL8.0をnode.jsで使用する時はmysql_native_password指定をしてください
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-authentication-plugin=mysql_native_password
    volumes:
      - ./.docker/mysql/data:/var/lib/mysql
    expose:
      - "3306"
    environment:
      - MYSQL_ROOT_USER=root
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_ROOT_HOST=%

  phpmyadmin:
    image: phpmyadmin
    ports:
      - "8080:80"
    environment:
      - PMA_ARBITRARY=1
      - PMA_HOST=mysql
      - PMA_PORT-3306
      - PMA_USER=root
      - PMA_PASSWORD=root

③VS Codeのデバッグ構成を設定する

下のファイルを新規作成してください。
ざっくり「コンテナ内のデバッグをlocalhost:3001で繋いで行いますよ」の設定みたいな感じです。

/.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "attach",
            "name": "Docker: Attach to Node",
            "port": 3001,
            "address": "localhost",
            "localRoot": "${workspaceFolder}",
            "remoteRoot": "/src",
            "protocol": "inspector"
        }
    ]
}

コードを編集していく

①Eslintの導入

ざっくり言うと、コード記述のルールを定めるための設定です。
eslintの使い方をもうわかってるという方でしたら下のコマンドでいろいろやってください。

$ ./node_modules/.bin/eslint --init

難しそうという方であれば下のファイルを新規作成してもらえば動くかと思います。

/.eslintrc.json
{
    "env": {
        "browser": true,
        "es2021": true
    },
    "extends": "eslint:recommended",
    "parserOptions": {
        "ecmaVersion": 12
    },
    "rules": {
        "no-undef":"off",
        "no-var": "error",
        "no-unused-vars": 0
    }
}

上の設定をすると、varがことごとく弾かれるはずなので全ファイルconstletに変更してください。基本的に上書きが想定されない変数はconstを使用した方が良いと思います。

②dotenvの導入

app.jsに下のように1行だけ追加してください。
これで.envファイル内の変数をprocess.env.(変数名)として使用できるようになります。

/app.js
const createError = require('http-errors');
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const logger = require('morgan');

require('dotenv').config();  // なるべく上の方で読ませたかったのでここに記述しました。

const indexRouter = require('./routes/index');
const usersRouter = require('./routes/users');

あとでデータベースの設定に使うので、これも追加。

/.env
MYSQL_HOST=mysql
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=root
MYSQL_DATABASE=node-app

MVCの書き方に変えていく

今回は自動生成されたusersの部分だけやってます。routesに大量のコードを書いてやってしまうこともできますが、今回はMVCの書き方に沿ってやります。

一応MVCをわかりやすくするために登場人物を使っていきます。

  • ルートさん(元何でも屋。今はリクエストが来た時の交通整理屋へ転身)
  • コントローラさん(ディレクター。いろいろ束ねて最後にレスポンスを返している)
  • モデルさん(法人営業。主要顧客はMySQL)
  • ビューさん(Webサーバの看板娘。CSSでメイクしてたり、たまにjsで小細工したり)

①ルートさん(routesディレクトリ)

自動生成されたコードを全削除して、以下のコードを記述していきます。

ルートさんの「このパス来たぞ、んじゃMySQL接続して、あのデータ取ってきて、MySQL切断して、ビューさん引っ張ってきて、データ突っ込んで、よし、レス返そ。」という何でも屋さんの状況を改善してあげましょう。

/routes/users.js
const router = require('express').Router();
const userController = require('../controllers/userController');

router.get('/', userController.index);
router.get('/create', userController.create);
router.post('/create', userController.store);
router.get('/edit/:id(\\d+)', userController.edit);
router.post('/edit/:id(\\d+)', userController.update);
router.post('/delete', userController.destroy);

module.exports = router;

なんということでしょう。ついさっきまでヒィヒィ言っていた「何でも屋さん」が「適切なコントローラさんにリクエストを流す交通整理のおっちゃん」に生まれ変わりました。

②コントローラさん(controllersディレクトリ)

役割としては、MySQL担当の営業さんに「あのデータ欲しいです!」と依頼して、看板娘のビューさんにデータを突っ込んでレスを返すというディレクターさん的立ち位置です。

モデルさんも忙しいと思うので、非同期メソッドを実装してあげて「待つ」ことを覚えさせましょう。ここでは非同期メソッドを省略した書き方としています。

js/controller/userController.js
const users = require('../models/users');
const redirectPath = '/users';

const userController = {
    async index(req, res) {
        const results = await users.all();
        res.render('users/index.ejs', {
            title: '一覧画面',
            datas: results
        });
    },

    create(req, res) {
        res.render('users/create.ejs', {
            title: '登録画面'
        });
    },

    async store(req, res) {
        const formData = req.body;
        await users.create(formData);
        res.redirect(redirectPath);
    },

    async edit(req, res) {
        const id = req.params.id;
        const result = await users.selectById(id);
        res.render('users/edit.ejs', {
            title: '編集画面',
            data: result
        });
    },

    async update(req, res) {
        const id = req.params.id;
        const formData = req.body;
        await users.update(id, formData);
        res.redirect(redirectPath);
    },

    async destroy(req, res) {
        const id = req.body.id;
        await users.delete(id);
        res.redirect(redirectPath);
    }
};

module.exports = userController;

③モデルさん(modelsディレクトリ)

コントローラさんからデータ欲しいって言われたら、mysql2という道具を操ってMySQLに営業しに行きます。

補足で、毎回MySQLに接続して切断してみたいなことをするのですが、基本的にUsersモデルでもこの先別のモデルができてもやることは一緒なので、別ファイルに非同期関数を作成してexecuteQueryとして読み込みます。
また、クエリ内の?は「プレースホルダー」と呼ばれています。利点はあちこち文献があるので参照してください。

/models/users.js
const executeQuery = require('./executeQuery');
const table = 'users'

const users = {
    async all() {
        const query = `SELECT * FROM ${table}`;
        const result = await executeQuery(query);
        return result;
    },

    async selectById(id) {
        const query = `SELECT * FROM ${table} WHERE id = ?`;
        const values = [id];
        const result = await executeQuery(query, values);
        return result[0];
    },

    async create(form) {
        const name = form.name;
        const address = form.address;

        const query = `INSERT INTO ${table} (name, address) VALUES (?, ?)`;
        const values = [name, address];
        await executeQuery(query, values);
    },

    async update(id, form) {
        const name = form.name;
        const address = form.address;

        const query = `UPDATE ${table} SET name = ?, address = ? WHERE id = ?`;
        const values = [name, address, id];
        await executeQuery(query, values);
    },

    async delete(id) {
        const query = `DELETE FROM ${table} WHERE id = ?`;
        const values = [id];
        await executeQuery(query, values);
    }
};

module.exports = users;
/models/executeQuery.js
const mysql = require('mysql2/promise');

const config = {
    host: process.env.MYSQL_HOST || 'mysql',
    port: process.env.MYSQL_PORT || '3306',
    user: process.env.MYSQL_USER || 'root',
    password: process.env.MYSQL_PASSWORD || 'root',
    database: process.env.MYSQL_DATABASE || 'node-app'
}

const executeQuery = async (query, values = []) => {
    try {
        const conn = await mysql.createConnection(config);
        const [rows, fields] = await conn.execute(query, values);
        conn.end();
        return rows;
    } catch (err) {
        console.log(err);
    }
}

module.exports = executeQuery;

④ビューさん(viewsディレクトリ)

WebAPIであればjsonで返すだけなのでお役御免ですが、今回はejsを使っているのでビューさんが登場します。

役割は、Node.jsで作ったり探したデータを突っ込んで表示させるためのテンプレとでも言いましょうか。実際に突っ込むのはコントローラさんなので、ビューさんはいつも受け身です。

こちらは探せばいくらでも参考情報が出てくるので、ejsの書き方を検索してみてください。

最後にディレクトリ構成

node-app/
  ├ .docker/mysql/data/...
  ├ .vscode/launch.json
  ├ bin/www
  ├ controllers/
  │   └ userController.js
  ├ models/
  │   ├ executeQuery.js
  │   └ users.js
  ├ node_modules/...
  ├ public/...
  ├ routes/
  │   ├ index.js
  │   └ users.js
  ├ views/...
  ├ .env
  ├ .eslintrc.json
  ├ app.js
  ├ docker-compose.yml
  ├ Dockerfile
  ├ package-lock.json
  ├ package.json
  └ Readme.md

実際に動かす

デバッグする時のやつのみ説明します。探せばいくらでも出てくる説明に関しては省いています。

①dockerコンテナ起動

$ docker-compose up -d backend-dev mysql phpmyadmin

②Chromeとかで「127.0.0.1:8080」でphpMyAdminにアクセスして、データベースを作成(以下SQLの例)

CREATE DATABASE IF NOT EXISTS `node-app`;

CREATE TABLE IF NOT EXISTS `node-app`.`users` (
  `id` INT PRIMARY KEY AUTO_INCREMENT,
  `name` VARCHAR(100) NOT NULL,
  `address` VARCHAR(100) NOT NULL
);

INSERT INTO
  `node-app`.`users` (`name`, `address`)
VALUES
  ('Abe', 'JPN'),
  ('Trump', 'USA');

③VSCodeでプロジェクトを開いたら「F5」を押す。

これでVSCodeの下の部分がオレンジになったらデバッグモードに入るので、ブレークポイントが使えます。

参考

2
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
a1k4r
南の国のWebエンジニアが詰まった時の備忘録的な感じで書き残しています。頭の中はまだ消費税8%。コロナでしばらく帰国できていません。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
2
Help us understand the problem. What is going on with this article?