Vue.js + Express + Sequelize + DockerでCRUD搭載のTodoリストを作ってみる
この記事に書いてあること
- Expressの実装例
- Vue.jsの実装例
- 実行環境として利用するDockerコンテナの作り方
- Sequelizeの導入方法、簡単な使い方
- 拙い日本語
しがないエンジニアが人生初アドベントカレンダー参加となりますので、諸々ご容赦頂けますと幸いです。
対象者
細かい説明は割愛していますが、初心者向けに書いてます。
「ゴリゴリにDocker使った環境構築が知りたい!」とか、
「俺はSequelizeなんか使わずにSQLを一つずつ組むね!」とか、
「ここどうなってるかもうちょい細かく説明してほしい」みたいな人にはあまり適してません。
あくまで、
記事をなぞっていくだけで手軽にCRUDがVue.js+Expressが体験できる
っていうのを目的にしています。
各種バージョン
Docker for mac 19.03.5
Node.js 12.13.0
express 4.16.1
Sequelize-cli 5.5.1
vue-cli 4.0.5
DBはsqlite3を利用(なんでもいいけど)
作るもの
CRUD機能を搭載した簡単なTodoリストを作ります。
完成イメージはこんな感じです。
各種説明
Docker
言わずと知れたコンテナマン。コンテナの概念やメリットの説明は割愛。今回環境はこれで構築。
(ベストプラクティスを知りたい)
Vue.js
みんな大好きフロントフレームワーク。特に難しいことはしません。
(vue-cliを使って雛形を作成)
Node.js
みんな大好きサーバーサイドで動くJavaScript。特に難しいことはしません。
Sequelize
Nodeで使えるORM。RailsのActiveRecordみたいな物だと思ってもらえればOK。
特に難しいことはしません
全体的なファイル構成
ファイル構成はこんな感じ。
rootディレクトリ配下をコンテナごとに区切り、後々コンテナにマウントしてあげる。
rootDir/
┣ docker-compose.yml
┣ vue/
┃ ┣ Dockerfile
┃ ┗ frontapp/
┃ ┗ Vue.jsの雛形ファイル群が入ってくる
┗ node/
┣ Dockerfile
┗ Expressの雛形ファイル群が入ってくる
全体構成
全体構成はこんな感じです。
基本Vue.jsコンテナがリクエストを受けて、axiosでNode.jsコンテナにサーバ通信しています。
SQLiteを使っているので、DBアクセスはNode.jsコンテナ内で処理されるようなイメージ。
Node.jsの準備
早速開始、と行きたいところですが、まずは実行環境を構築。
Dockerhubから引っ張ってきてもいいのですが、せっかくなのでDockerfileを書いてあげましょう。
今回はとりあえず各種プログラムが実行できる環境が前提なので、コンテナ内は環境のみ。
ソースファイルはコンテナイメージに含めず、docker-composeを使って後でマウントしてあげることにします。
FROM node:12.13
RUN npm install -g express-generator sequelize-cli
Dockerfileの記述が終わったら一旦、Dockerfileからコンテナイメージを作成。
コンテナ起動時にローカルのディレクトリをマウントし、コンテナ内のデータが永続化できるようにします。
docker build node/. -t serverapp:latest
docker run -itd --rm --name serverapp -v $PWD/node:/node serverapp:latest
コンテナの起動が完了したら、コンテナ内にログインする。
docker exec -it serverapp /bin/bash
コンテナログイン後、expressコマンドを実行し、雛形ファイル群を作成。
cd /node
express .
npm install --save sequelize sqlite3 cors nodemon
npm install
docker run
を実行した際にローカルのフォルダをマウントしているので、
ローカルのnode/
以下にexpressの雛形ファイル群ができているはず。
続いて、DBを作成するためにコンテナに入ったままsequelize init
を実行し、
CRUDに必要なtask
モデルを作成する準備をします。
sequelize init
init実行後、一旦コンテナからログアウトし、ローカルでconfig/config.json
を下記のように修正。
{
"development": {
"username": "root",
"password": null,
"database": "database_development",
"host": "127.0.0.1",
"dialect": "sqlite",
"storage": "./data/development.sqlite3",
"operatorsAliases": false
},
"test": {
"username": "root",
"password": null,
"database": "database_test",
"host": "127.0.0.1",
"dialect": "sqlite",
"storage": "./data/test.sqlite3",
"operatorsAliases": false
},
"production": {
"username": "root",
"password": null,
"database": "database_production",
"host": "127.0.0.1",
"dialect": "sqlite",
"storage": "./data/production.sqlite3",
"operatorsAliases": false
}
}
Sequelizeのデータが保存される./data
を作成する。
mkdir data
もう一度コンテナにログインし、task
モデルを作成します。
docker exec -it serverapp /bin/bash
sequelize model:create --name task --underscored --attributes taskname:string
sequelize db:migrate
マイグレーションが無事に成功すればtaskモデルが作成されます。
これでNode.jsの準備は完了。雛形準備に利用したコンテナは停止します。
docker stop serverapp
続いてフロントVue.js側を準備します。
Vue.jsの準備
Node側と同様にDockerfileを記述。
FROM node:12.13
RUN npm install -g @vue/cli
Dockerfileを元にコンテナイメージを作成。起動し、ローカルのフォルダをマウント。
docker build vue/. -t frontapp:latest
docker run -itd --rm --name frontapp -v $PWD/vue:/vue frontapp:latest
Node側同様、Vue.jsの雛形ファイル群を作成するため、一度コンテナ内にログインします。
docker exec -it frontapp /bin/bash
コンテナログイン後、下記コマンドを実行。オプションは default
で問題ありません。
その後の選択肢としては、yarn
とnpm
が出てくるけど、個人的にはnpmのが使いやすいのでそちらで。
cd /vue
vue create frontapp
以上でVue.js側の準備は終了。Node.js側同様に、一度コンテナは停止します。
docker stop frontapp
docker-compose.ymlの準備
Node.jsとVue.jsそれぞれのコンテナを起動する際、composeファイルがあると起動/終了が楽なので、
docker-compose.yml
を下記のように記入。
version: "3"
services:
node:
build: node/.
volumes:
- ./node:/node
working_dir: /node
command: ["npm", "start"]
ports:
- "3000:3000"
vue:
build: vue/.
volumes:
- ./vue:/vue
working_dir: /vue/frontapp
command: ["npm", "run", "serve"]
ports:
- "8080:8080"
プロジェクトのカレントディレクトリで、docker-compose
コマンドを実行し、
Node.jsのコンテナとVue.jsのコンテナを起動する。
docker-compose up -d
# コンテナ終了は docker-compose down
ブラウザで3000ポートにアクセスしExpressの画面、8080ポートにアクセスしVue.jsの画面が表示されれば、開発用コンテナの構築は完了です。
localhost:3000
localhost:8080
docker-composeコマンドで初回起動時にコンテナイメージが再ビルドされ、 docker run
で起動した時のコンテナイメージとは別の名前でイメージ化されるため、 docker run
コマンドで起動した時のコンテナイメージは不要のため削除します。
docker rmi serverapp:latest
docker rmi frontapp:latest
実行環境の作成はこれにて終了。次から各種機能の実装に入っていきます。
Node.js
まずはサーバサイドの処理から実装します。
処理実装に入る前に、ソースコード変更時に自動的にNodeが再起動されるように少しだけ工夫。
app.js
とdocker-compose.yml
を修正します。
...
var indexRouter = require("./routes/index");
var usersRouter = require("./routes/users");
var cors = require("cors");
var app = express();
app.use(cors());
...
app.listen(3000, function() {
console.log("Node server is started");
});
module.exports = app
...
working_dir: /node
command: ["./node_modules/.bin/nodemon", "app"]
ports:
...
Node.jsの初期状態だとVueからのリクエストを受けられない(エラーがでてしまう)ので、
インストールしたcorsモジュールを追加して、corsを許可する必要があります。
また、nodemonを利用しファイル変更を検知、ファイルが変更されたら自動でサーバが再起動されるようにします。
設定を反映するため、一度コンテナ再起動を実行。
docker-compose down
docker-compose up -d
コントローラーの新規追加がめんどくさいので最初から用意されているindex.js
を利用します。
実装内容としてはこんな感じ。
var express = require("express");
var router = express.Router();
const db = require("../models/index");
// Read
router.get("/", async function(req, res, next) {
try {
const result = await db.task.findAll({});
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
//Create
router.post("/task", async function(req, res, next) {
try {
const result = await db.task.create({
taskname: req.body.task
});
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
//Update
router.put("/task/:id", async function(req, res, next) {
try {
const result = await db.task.update(
{
taskname: req.body.task
},
{
where: {
id: req.params.id
}
}
);
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
//Delete
router.delete("/task/:id", async function(req, res, next) {
try {
const result = await db.task.destroy({
where: {
id: req.params.id
}
});
res.send({
result: result
});
} catch (err) {
res.status(500).send(err);
}
});
module.exports = router;
送られてきたパラメータをそのままDBに登録するっていう簡単なCRUD処理の一覧です。
一つずつ説明していきます。
// Read
router.get("/", async function(req, res, next) {
try {
const result = await db.task.findAll({});
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
getで/
にアクセスされた際に、taskテーブルから全てデータを引っ張ってくるように実装。
DBのtaskテーブルから全てデータを引っ張ってくるのに、sequelizeのfindAll
というメソッドを使用します。
取得したデータをres.send
を使用して、リクエスト元に戻してあげます。
router.post("/task", async function(req, res, next) {
try {
const result = await db.task.create({
taskname: req.body.task
});
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
postで/task
にアクセスした際に、リクエストのbody内容をDBのtaskテーブルに登録。
sequelizeのcreate
というメソッドを利用します。
router.put("/task/:id", async function(req, res, next) {
try {
const result = await db.task.update(
{
taskname: req.body.task
},
{
where: {
id: req.params.id
}
}
);
res.send(result);
} catch (err) {
res.status(500).send(err);
}
});
putで/task/:id
にアクセスした際に、idに紐づくレコードをupdateする処理。
sequelizeのupdate
メソッドを利用し、リクエストのbody内容でデータを更新します。
router.delete("/task/:id", async function(req, res, next) {
try {
const result = await db.task.destroy({
where: {
id: req.params.id
}
});
res.send({
result: result
});
} catch (err) {
res.status(500).send(err);
}
});
deleteで/task/:id
にアクセスした際に、idに紐づくレコードを削除する処理。
sequelizeのdestroy
メソッドを利用。
Node.js側CRUDの処理は実装完了です。続いて、Vue.js側の実装。
Vue.js
デフォルトでHelloWorld.vueというコンポーネントが存在しているので、そこに肉付けしていくことにします。
まずは各種部品を設置。
<template>
<div class="hello">
<form>
<input type="text" style="display:none" />
<input type="text" />
<input type="button" value="add!" />
</form>
<table align="center" border="0">
<tr>
<th>task</th>
<th>update</th>
<th>delete</th>
</tr>
<tr>
<td>
<input type="text" />
</td>
<td>
<input type="button" value="update" />
</td>
<td>
<input type="button" value="delete" />
</td>
</tr>
</table>
</div>
</template>
<script>
export default {
name: "HelloWorld"
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin: 0 10px;
}
a {
color: #42b983;
}
.table {
height: 100%;
text-align: center;
}
</style>
まずはGETで/
にアクセスした際に、apiアクセスを行う部分を実装する。
<script>
import axios from "axios";
export default {
name: "HelloWorld",
data: () => ({
tasks: [],
}),
created: async function() {
try {
const result = await axios.get("http://localhost:3000");
this.tasks = result.data;
} catch (err) {
alert(JSON.stringify(err));
}
}
}
...
created
を定義しておくことで、ページが読み込まれた時に処理を実行することが可能。
created
でもmounted
でもどっちでもいいけど、今回の用途にはcreated
の方が適任ですね。
下記記事で詳しく書かれていたので、気になる人は参照してみてください。
Vuejs APIアクセスはcreatedとmountedのどちらで行う?
axiosは非同期で実行されるので、処理を同期的に行うためにawaitで実行。
実行結果としてresult.data
が戻ってくるので、data
内のtasks
の内容をAPI実行結果に変更する。
続いてtask追加処理を実装。まず入力されたデータにアクセスできるようにする必要があるため、
テキストボックスをv-modelでdata
と紐づけます。
...
<form>
<input type="text" style="display:none" />
<input v-model="currentTask" type="text" />
<input type="button" value="add!" />
</form>
...
<script>
import axios from "axios";
export default {
name: "HelloWorld",
data: () => ({
tasks: [],
currentTask: ""
}),
...
こうすることでinput
に入力されたデータが、data
内currentTask
に反映され、関数内からデータが参照可能となります。
次に@click
イベントを実装し、ボタンが押された時に関数を呼び出すよう修正。
...
<form>
<input type="text" style="display:none" />
<input v-model="currentTask" type="text" />
<input type="button" value="add!" @click="taskCreate" />
</form>
...
<script>
...
created: async function() {
try {
const result = await axios.get("http://localhost:3000");
this.tasks = result.data;
} catch (err) {
alert(JSON.stringify(err));
}
},
methods: {
taskCreate: async function() {
alert(this.currentTask);
}
}
};
</script>
こうすることで、ボタンが押された時に、taskCreate
を呼び出すことが可能です。
仮実装としてボタンを押すと、テキストボックス内に入力したデータの内容をalert表示するように実装してます。
何かしらテキストボックスに入力し、ボタンを押したタイミングでalertが表示されてくればOK。
取得したデータをサーバに送信するため、taskCreate
関数の続きを実装していく。
<script>
...
methods: {
taskCreate: async function() {
try {
const result = await axios.post("http://localhost:3000/task", {
task: this.currentTask
});
this.tasks.push(result.data);
this.currentTask = "";
} catch (err) {
alert(JSON.stringify(err));
}
}
...
node.js側で定義したタスク追加の処理(/task)に繋げる。
サーバ処理終了後に下記部分で動的にtasks
にデータを追加。
this.tasks.push(result.data);
// data内tasks配列に戻り値(追加したデータ)を追加
this.currentTask = "";
// data内currentTaskとテキストボックスが双方向でバインドされているので、currentTaskを空にすることでテキストボックスが空になる
これで、入力されたデータがサーバにリクエストで送られ、データ保存が可能となります。
このままだとデータを追加しても追加したデータが表示されないので、data内のtasks
と <tr>
をv-model
を利用し紐づけます。
HelloWorld.vueのtable
部分を下記のように変更。
<table align="center" border="0">
<tr>
<th>task</th>
<th>update</th>
<th>delete</th>
</tr>
<tr v-for="(task, index) in tasks" :key="task.id">
<td>
<input v-model="task.taskname" type="text" />
</td>
<td>
<input type="button" value="update" />
</td>
<td>
<input type="button" value="delete" />
</td>
</tr>
</table>
v-for
を利用することでtasks
の数だけ <tr>
が生成されます。
こうすることで、ページ読み込み時にサーバからデータが取得され、今まで追加したタスクが出てくるようになり、
ボタンを押した時にもタスクが画面に追加されるようになる。
これでタスク追加に関しては実装終了。続いてタスク削除を実装します。
まずは関数を用意。
<script>
...
methods: {
taskCreate: async function() {
try {
const result = await axios.post("http://localhost:3000/task", {
task: this.currentTask
});
this.tasks.push(result.data);
this.currentTask = "";
} catch (err) {
alert(JSON.stringify(err));
}
},
taskDelete: async function(id, index) {
try {
await axios.delete("http://localhost:3000/task/" + id);
this.currentTask = "";
this.tasks.splice(index, 1);
} catch (err) {
alert(JSON.stringify(err));
}
}
}
taskDelete
関数を追加しました。taskのidと配列のindexを引数に持ってあげます。
呼び出し部分はこんな感じ。
<tr v-for="(task, index) in tasks" :key="task.id">
<td>
<input v-model="task.taskname" type="text" />
</td>
<td>
<input type="button" value="update" />
</td>
<td>
<input type="button" value="delete" @click="taskDelete(task.id, index)" />
</td>
</tr>
こうすることで、リクエスト先のURLが動的に作られます。
タスクのidが2
だった場合は、リクエスト先はaxios.delete("http://localhost:3000/task/" + 2);
となり、
タスクのidが10
だった場合は、リクエスト先はaxios.delete("http://localhost:3000/task/" + 10);
となります。
また、配列のindexを引数に持ってあげることによって、spliceメソッドを使って配列を操作することができ、画面のデータを非同期で変更可能です。
this.tasks.splice(index, 1);
// [{タスク1},{タスク2},{タスク3},{タスク4}]
// indexに2が渡された場合は、
// [{タスク1},{タスク2},{タスク4}]
// このように配列が操作される
最後にタスク修正した際の処理を実装する。まずは関数の用意から。
<script>
...
taskUpdate: async function(id, val) {
try {
await axios.put("http://localhost:3000/task/" + id, {
task: val
});
alert("タスクを修正しました");
this.currentTask = "";
} catch (err) {
alert(JSON.stringify(err));
}
}
呼び出し部分はこんな感じ。
<tr v-for="(task, index) in tasks" :key="task.id">
<td>
<input v-model="task.taskname" type="text" />
</td>
<td>
<input
type="button"
value="update"
@click="taskUpdate(task.id, task.taskname)"
/>
</td>
<td>
<input
type="button"
value="delete"
@click="taskDelete(task.id, index)"
/>
</td>
</tr>
taskUpdate
を呼び出す時に、タスクのID、タスク名を引数で渡してあげることにより、
対象のタスクのみアップデートがかかるようにします。
CRUDの全体が出来上がった最終形のHelloWorld.vueとしてはこんな感じ。
<template>
<div class="hello">
<form>
<input type="text" style="display:none" />
<input v-model="currentTask" type="text" />
<input type="button" value="add!" @click="taskCreate" />
</form>
<table align="center" border="0">
<tr>
<th>task</th>
<th>update</th>
<th>delete</th>
</tr>
<tr v-for="(task, index) in tasks" :key="task.id">
<td>
<input v-model="task.taskname" type="text" />
</td>
<td>
<input
type="button"
value="update"
@click="taskUpdate(task.id, task.taskname)"
/>
</td>
<td>
<input
type="button"
value="delete"
@click="taskDelete(task.id, index)"
/>
</td>
</tr>
</table>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "HelloWorld",
data: () => ({
tasks: [],
currentTask: ""
}),
created: async function() {
try {
const result = await axios.get("http://localhost:3000");
this.tasks = result.data;
} catch (err) {
alert(JSON.stringify(err));
}
},
methods: {
taskCreate: async function() {
try {
const result = await axios.post("http://localhost:3000/task", {
task: this.currentTask
});
this.tasks.push(result.data);
this.currentTask = "";
} catch (err) {
alert(JSON.stringify(err));
}
},
taskDelete: async function(id, index) {
try {
await axios.delete("http://localhost:3000/task/" + id);
this.currentTask = "";
this.tasks.splice(index, 1);
} catch (err) {
alert(JSON.stringify(err));
}
},
taskUpdate: async function(id, val) {
try {
await axios.put("http://localhost:3000/task/" + id, {
task: val
});
alert("タスクを修正しました");
this.currentTask = "";
} catch (err) {
alert(JSON.stringify(err));
}
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin: 0 10px;
}
a {
color: #42b983;
}
.table {
height: 100%;
text-align: center;
}
</style>
以上で完成です!
作ってみた感想
Vue.jsのバインディングが思っていた以上に使いやすく、非常に簡単にフロントの実装ができました。
規模が大きくなってきたらVuexとかライフサイクルとか考えないといけないこともあるけど、
この程度の規模のアプリケーションであれば、簡単に作ることができるのが良いですね。
あと、ただ実装するだけだと面白く無いので実行環境はDockerにしたのも◯。
汎用的な実行コンテナとして使えるので、チュートリアル的な使い方には非常に適任。
構築も簡単なので非常に使いやすかったかと。
Sequelizeの導入がちょっとだけめんどくさいけど、一度やってしまえばRailsチックにDB操作ができるので、
SQL書かずに手軽にDB操作したい!って人にはおすすめです。
明日は@kazukimatsumotoさんの番です。よろしくおねがいします!