意外と検索したら出てきそうなのに、蓋を開けたら「なんでこんな難しい書き方してるの?」「わかるけど今の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
が使えるようになる)
{(略)"scripts":{"start":"node ./bin/www","start:debug":"nodemon -L --inspect-brk=0.0.0.0:3001 ./bin/www"//これを記述},(略)}
②仮想環境を用意する
Dockerfileをルートディレクトリに作成して、以下のコードを書く。
(これでコンテナ起動時に自動でpackage.jsonに書いてあるパッケージをインストールしてくれる)
FROM node:14WORKDIR /srcCOPY ./package*.json /src/RUN npm install
今回使用しそうなコンテナを用意する。
version:"3.9"services:backend:build:.command:npm run startports:-"3000:3000"volumes:-.:/src-/src/node_modulesbackend-dev:build:.command:npm run start:debugenvironment:-NODE_ENV=development# devDependenciesのパッケージをインストールするためports:-"13000:3000"# backendサービスと同時に実行してしまった時のための予防-"3001:3001"# VSCodeでのデバッグで使用するためvolumes:-.:/src-/src/node_modulesmysql: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_passwordvolumes:-./.docker/mysql/data:/var/lib/mysqlexpose:-"3306"environment:-MYSQL_ROOT_USER=root-MYSQL_ROOT_PASSWORD=root-MYSQL_ROOT_HOST=%phpmyadmin:image:phpmyadminports:-"8080:80"environment:-PMA_ARBITRARY=1-PMA_HOST=mysql-PMA_PORT-3306-PMA_USER=root-PMA_PASSWORD=root
③VS Codeのデバッグ構成を設定する
下のファイルを新規作成してください。
ざっくり「コンテナ内のデバッグをlocalhost:3001で繋いで行いますよ」の設定みたいな感じです。
{"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
難しそうという方であれば下のファイルを新規作成してもらえば動くかと思います。
{"env":{"browser":true,"es2021":true},"extends":"eslint:recommended","parserOptions":{"ecmaVersion":12},"rules":{"no-undef":"off","no-var":"error","no-unused-vars":0}}
上の設定をすると、var
がことごとく弾かれるはずなので全ファイルconst
かlet
に変更してください。基本的に上書きが想定されない変数はconst
を使用した方が良いと思います。
②dotenvの導入
app.jsに下のように1行だけ追加してください。
これで.envファイル内の変数をprocess.env.(変数名)
として使用できるようになります。
constcreateError=require('http-errors');constexpress=require('express');constpath=require('path');constcookieParser=require('cookie-parser');constlogger=require('morgan');require('dotenv').config();// なるべく上の方で読ませたかったのでここに記述しました。constindexRouter=require('./routes/index');constusersRouter=require('./routes/users');
あとでデータベースの設定に使うので、これも追加。
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切断して、ビューさん引っ張ってきて、データ突っ込んで、よし、レス返そ。」という何でも屋さんの状況を改善してあげましょう。
constrouter=require('express').Router();constuserController=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担当の営業さんに「あのデータ欲しいです!」と依頼して、看板娘のビューさんにデータを突っ込んでレスを返すというディレクターさん的立ち位置です。
モデルさんも忙しいと思うので、非同期関数を実装してあげて「待つ」ことを覚えさせましょう。(async/awaitのことです。)
constusers=require('../models/users');constredirectPath='/users';constuserController={index:async(req,res)=>{constresults=awaitusers.all();res.render('users/index.ejs',{title:'一覧画面',datas:results});},create:(req,res)=>{res.render('users/create.ejs',{title:'登録画面'});},store:async(req,res)=>{constformData=req.body;awaitusers.create(formData);res.redirect(redirectPath);},edit:async(req,res)=>{constid=req.params.id;constresult=awaitusers.selectById(id);res.render('users/edit.ejs',{title:'編集画面',data:result});},update:async(req,res)=>{constid=req.params.id;constformData=req.body;awaitusers.update(id,formData);res.redirect(redirectPath);},destroy:async(req,res)=>{constid=req.body.id;awaitusers.delete(id);res.redirect(redirectPath);}};module.exports=userController;
③モデルさん(modelsディレクトリ)
コントローラさんからデータ欲しいって言われたら、mysql2という道具を操ってMySQLに営業しに行きます。
補足で、毎回MySQLに接続して切断してみたいなことをするのですが、基本的にUsersモデルでもこの先別のモデルができてもやることは一緒なので、別ファイルに非同期関数を作成してexecuteQuery
として読み込みます。
また、クエリ内の?
は「プレースホルダー」と呼ばれています。利点はあちこち文献があるので参照してください。
constexecuteQuery=require('./executeQuery');consttable='users'constusers={all:async()=>{constquery=`SELECT * FROM ${table}`;constresult=awaitexecuteQuery(query);returnresult;},selectById:async(id)=>{constquery=`SELECT * FROM ${table} WHERE id = ?`;constvalues=[id];constresult=awaitexecuteQuery(query,values);returnresult[0];},create:async(form)=>{constname=form.name;constaddress=form.address;constquery=`INSERT INTO ${table} (name, address) VALUES (?, ?)`;constvalues=[name,address];awaitexecuteQuery(query,values);},update:async(id,form)=>{constname=form.name;constaddress=form.address;constquery=`UPDATE ${table} SET name = ?, address = ? WHERE id = ?`;constvalues=[name,address,id];awaitexecuteQuery(query,values);},delete:async(id)=>{constquery=`DELETE FROM ${table} WHERE id = ?`;constvalues=[id];awaitexecuteQuery(query,values);}};module.exports=users;
constmysql=require('mysql2/promise');constconfig={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'}constexecuteQuery=async(query,values=[])=>{try{constconn=awaitmysql.createConnection(config);const[rows,fields]=awaitconn.execute(query,values);conn.end();returnrows;}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の例)
CREATEDATABASEIFNOTEXISTS`node-app`;CREATETABLEIFNOTEXISTS`node-app`.`users`(`id`INTPRIMARYKEYAUTO_INCREMENT,`name`VARCHAR(100)NOTNULL,`address`VARCHAR(100)NOTNULL);INSERTINTO`node-app`.`users`(`name`,`address`)VALUES('Abe','JPN'),('Trump','USA');
③VSCodeでプロジェクトを開いたら「F5」を押す。
これでVSCodeの下の部分がオレンジになったらデバッグモードに入るので、ブレークポイントが使えます。