64
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

DMM WEBCAMPAdvent Calendar 2019

Day 3

Vue.js + Express + Sequelize + DockerでCRUD搭載のTodoリストを作ってみる

Last updated at Posted at 2019-12-02

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リストを作ります。
完成イメージはこんな感じです。

cwljp-8g071.gif

各種説明

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コンテナ内で処理されるようなイメージ。

vuexpress.png

Node.jsの準備

早速開始、と行きたいところですが、まずは実行環境を構築。
Dockerhubから引っ張ってきてもいいのですが、せっかくなのでDockerfileを書いてあげましょう。
今回はとりあえず各種プログラムが実行できる環境が前提なので、コンテナ内は環境のみ。
ソースファイルはコンテナイメージに含めず、docker-composeを使って後でマウントしてあげることにします。

node/Dockerfile
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を下記のように修正。

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を記述。

vue/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 で問題ありません。
その後の選択肢としては、yarnnpmが出てくるけど、個人的にはnpmのが使いやすいのでそちらで。

cd /vue
vue create frontapp

以上でVue.js側の準備は終了。Node.js側同様に、一度コンテナは停止します。

docker stop frontapp

docker-compose.ymlの準備

Node.jsとVue.jsそれぞれのコンテナを起動する際、composeファイルがあると起動/終了が楽なので、
docker-compose.ymlを下記のように記入。

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

スクリーンショット 2019-11-23 14.15.48.png

localhost:8080

スクリーンショット 2019-11-23 14.23.14.png

docker-composeコマンドで初回起動時にコンテナイメージが再ビルドされ、 docker run で起動した時のコンテナイメージとは別の名前でイメージ化されるため、 docker run コマンドで起動した時のコンテナイメージは不要のため削除します。

docker rmi serverapp:latest
docker rmi frontapp:latest

実行環境の作成はこれにて終了。次から各種機能の実装に入っていきます。

Node.js

まずはサーバサイドの処理から実装します。
処理実装に入る前に、ソースコード変更時に自動的にNodeが再起動されるように少しだけ工夫。
app.jsdocker-compose.ymlを修正します。

node/app.js
...
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
docker-compose.yml
...
    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を利用します。
実装内容としてはこんな感じ。

node/routes/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というコンポーネントが存在しているので、そこに肉付けしていくことにします。
まずは各種部品を設置。

vue/frontapp/src/components/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>

上の基本系を修正します。今の時点だとこんな感じ。
スクリーンショット 2019-11-23 10.12.44.png

まずはGETで/にアクセスした際に、apiアクセスを行う部分を実装する。

vue/frontapp/src/components/HelloWorld.vue
<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と紐づけます。

vue/frontapp/src/components/HelloWorld.vue
...
    <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に入力されたデータが、datacurrentTaskに反映され、関数内からデータが参照可能となります。
次に@clickイベントを実装し、ボタンが押された時に関数を呼び出すよう修正。

vue/frontapp/src/components/HelloWorld.vue
...
    <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。

スクリーンショット 2019-11-23 10.27.08.png

取得したデータをサーバに送信するため、taskCreate関数の続きを実装していく。

vue/frontapp/src/components/HelloWorld.vue
<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部分を下記のように変更。

vue/frontapp/src/components/HelloWorld.vue
    <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> が生成されます。
こうすることで、ページ読み込み時にサーバからデータが取得され、今まで追加したタスクが出てくるようになり、
ボタンを押した時にもタスクが画面に追加されるようになる。

スクリーンショット 2019-11-23 10.44.46.png

これでタスク追加に関しては実装終了。続いてタスク削除を実装します。
まずは関数を用意。

vue/frontapp/src/components/HelloWorld.vue
<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を引数に持ってあげます。
呼び出し部分はこんな感じ。

vue/frontapp/src/components/HelloWorld.vue
      <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}]
// このように配列が操作される

最後にタスク修正した際の処理を実装する。まずは関数の用意から。

vue/frontapp/src/components/HelloWorld.vue
<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));
      }
    }

呼び出し部分はこんな感じ。

vue/frontapp/src/components/HelloWorld.vue
      <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さんの番です。よろしくおねがいします!

64
35
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
64
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?