👌

DockerでMongoDBを立ち上げて開発し、Herokuにデプロイするまで

2021/04/23に公開

始めに

フロントエンドではあまりDockerは使われないと思いますが、DBサーバーを用意する場合はDockerを使うと一々アプリをインストールする必要がなくなり便利になると思います。
そこでMongoDBをDockerで立ち上げて開発する流れを記事にしたいと思います。

なお、今回作ったソースはこちらに置いてあります。
https://github.com/wintyo/mongodb-vue-todo

使用した技術

  • Docker
  • MongoDB
  • express
  • mongoose
  • TypeScript
  • Vue.js 3系
  • Heroku

MongoDBサーバーの起動

Dockerのインストール

まだDockerをインストールしていない方はこちらなどを参考にインストールしてください。
https://qiita.com/nemui_/items/ed753f6b2eb9960845f7

docker-composeでmongoDB環境を構築する

mongodbディレクトリの下に以下のコードが書かれたdocker-compose.ymlを置いてください。
簡単に説明すると、mongomongo-expressを立ち上げるようにして、mongoがmongoDBサーバーに起動で、mongo-expressがGUIで操作するサーバーを起動しています。
お互い接続するためにusernameとpasswordを合わせる必要があるので、環境変数からアクセスするようにしています。

mongodb/docker-compose.yml
version: '3.1'

services:
  mongo:
    image: mongo:4.4.5
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD}
    ports:
      - 27017:27017
    volumes:
      - ./db:/data/db
      - ./configdb:/data/configdb

  mongo-express:
    image: mongo-express:0.54
    ports:
      - 8081:8081
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: ${MONGODB_USERNAME}
      ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGODB_PASSWORD}

環境変数は同じディレクトリに.envというファイル名で配置すれば自動で読み取ってくれます。

mongodb/.env
MONGODB_USERNAME=root
MONGODB_PASSWORD=password

ファイルが配置されたら、mongodbディレクトリに移動して以下のコマンドを叩くと起動されます。

$ docker-compose up -d

起動したらlocalhost:8081にアクセスすると以下のような画面が出て、GUIでMongoDBの中身が確認できます。

参考
https://qiita.com/mistolteen/items/ce38db7981cc2fe7821a

mongooseでMongoDBの操作

expressサーバーの設定

まずはexpressサーバーの骨組みを作ります。APIはserver/apisディレクトリで管理するので、importしたものをそのまま使用します。

server/server.ts
import Express from 'express';
import http from 'http';
import path from 'path';

import apiRouter from './apis/';

const app = Express();
const port = process.env.PORT || 9000;
const server = http.createServer(app);

app.use(Express.json());
app.use(Express.urlencoded({ extended: true }));

// static以下に配置したファイルは直リンクで見れるようにする
app.use(Express.static(path.resolve(__dirname, 'static')));

// APIの設定
app.use('/api', apiRouter);

server.listen(port, () => {
  console.log(`Listening on port ${port}`);
});

server/apis/index.tsは以下のようにしました。今回はそこまでやらなくてもいい気はしますが、一応今後のスケールアップも考慮した構成にしています。

server/apis/index.ts
import Express from 'express';
// この後作る
// import todoRouter from './router/todos';

const router = Express.Router();

// サーバーの動作確認
router.get('/health', (req, res) => {
  res.send('I am OK!');
});

// ルーティングの設定
// router.use('/todos', todoRouter);

export default router;

一旦これでlocahost:9000/api/healthにアクセスしたら「I am OK!」という文字列か返るようになりました。

mongooseでMongoDBにアクセス

server/server.tsに以下のコードを追加してMongoDBにアクセスします。アクセスする際はusernameとpasswordが必要になるので、dotenvを使って環境変数ファイルを読み込んだものを使用して合わせます。

server/server.ts
import mongoose from 'mongoose';

// 本番じゃない時はローカルのDBに接続する
if (process.env.NODE_ENV !== 'production') {
  require('dotenv').config({ path: path.resolve(__dirname, '../mongodb/.env') });

  const { MONGODB_USERNAME, MONGODB_PASSWORD } = process.env;
  mongoose.connect('mongodb://localhost:27017', {
    useNewUrlParser: true,
    useUnifiedTopology: true,
    user: MONGODB_USERNAME,
    pass: MONGODB_PASSWORD,
    dbName: 'todo-app',
  })
    .catch((err) => {
      console.error(err);
    });
}

モデルの定義

続いてTodoデータのモデルを定義します。折角TypeScriptにしているので静的型付けも上手くやりたいのですが、思っていたより苦労しました。。最終的にはほぼ自作の型定義みたいになってしまいました。
ITodoFieldsが基本的なデータの型ですが、timestampsオプションをつけてcreatedAtとupdatedAt`も付与されます。これがtrueとfalseの時で切り替える必要があるのですが、設定が難しいので全部trueで設定される想定で型付けをしています。

server/model/Todo.ts
import mongoose from 'mongoose';

interface ITimestamps {
  createdAt: Date;
  updateAt: Date;
}

type tDocument<Fields> = Fields & ITimestamps & mongoose.Document;
type tSchema<Fields> = {
  [K in keyof Fields]: mongoose.SchemaDefinitionProperty<Fields[K]>;
};

interface ITodoFields {
  isDone: boolean;
  text: string;
}

const todoSchema: tSchema<ITodoFields> = {
  isDone: { type: Boolean, default: false },
  text: String,
}

export default mongoose.model<tDocument<tSchema<ITodoFields>>>(
  'Todo',
  new mongoose.Schema(todoSchema, { timestamps: true })
);

参考

API定義とDB操作

後はこのモデルを使って各APIに対して適切な操作を書きます。

server/apis/router/todos.ts
import Express from 'express';
import TodoModel from '../../models/Todo';

const router = Express.Router();

router.route('/')
  .get((req, res) => {
    TodoModel
      .find()
      .sort({ createdAt: -1 })
      .then((todos) => {
        res.json(todos);
      })
      .catch((err) => {
        res.status(500).send(err);
      });
  })
  .post((req, res) => {
    const { text } = req.body;

    const todo = new TodoModel();
    todo.text = text;
    todo.save((err) => {
      if (err) {
        res.status(500).send(err);
        return;
      }
      res.send('ok');
    });
  });

router.route('/:todoId')
  .delete((req, res) => {
    const { todoId } = req.params;
    TodoModel
      .deleteOne({ _id: todoId })
      .then(() => {
        res.send('ok');
      })
      .catch((err) => {
        res.status(500).send(err);
      });
  });

router.route('/check/:todoId')
  .put((req, res) => {
    const { todoId } = req.params;
    const { isDone } = req.body;

    TodoModel
      .findOne({ _id: todoId })
      .then((todo) => {
        if (todo == null) {
          res.status(400).send(`Todo (id: ${todoId}) not found.`);
          return;
        }
        todo.isDone = isDone;
        todo.save((err) => {
          if (err) {
            res.status(500).send(err);
            return;
          }
          res.send(todo);
        });
      })
      .catch((err) => {
        res.status(500).send(err);
      });
  });

export default router;

APIを作ったら試しでcurlで送ってみてください。todo-appデータベースが作られて、todosコレクションの中にデータが1つ入っていたら成功です。

$ curl -X POST -H "Content-Type: applicati
on/json" -d '{"text":"test todo"}' localhost:9000/api/todos

TODOリスト画面の作成

APIができたので後は画面を作ったら完成です。こちらもTypeScriptで書いても良かったのですが、今回はサーバーメインということでフロントは簡単にindex.htmlに配置するだけとしました。
一番上のTodoを削除するとアニメーションが上手くいかないのが気になりますが、一旦良しとしました。(Vue.js 2系の時は問題なかったんですけどね。。)

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>MongoDBを使ったTODOリスト</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.0.4/vue.global.js"></script>
  <script src="https://unpkg.com/vue-types"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
  <style>
    * {
      box-sizing: border-box;
    }

    .list {
      position: relative;
      padding: 0;
    }

    .item {
      position: relative;
      display: flex;
      align-items: center;
      width: 100%;
      transition: all 0.5s;
      padding: 10px;
      margin-top: 10px;
      border: solid 1px #ddd;
      border-radius: 5px;
      box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
      cursor: pointer;
    }

    .item__text {
      flex: 1 1 0;
      padding: 0 5px;
    }

    /* 要素が入るときのアニメーション */
    .flip-enter-from {
      opacity: 0;
      transform: translate3d(0, -30px, 0);
    }
    .flip-enter-to {
      opacity: 1;
      transform: translate3d(0, 0, 0);
    }

    /* 要素が消える時のアニメーション */
    .flip-leave-active {
      position: absolute;
    }
    .flip-leave-from {
      opacity: 1;
      transform: translate3d(0, 0, 0);
    }
    .flip-leave-to {
      opacity: 0;
      transform: translate3d(0, -30px, 0);
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <script>
    const { reactive, onBeforeMount } = Vue;

    // APIリクエストの設定
    const api = axios.create({
      baseURL: '/api',
      timeout: 15000,
    });

    /**
     * 入力フォームコンポーネント
     */
    const InputForm = {
      template: `
        <form @submit="onSubmit">
          <input v-model="state.text" type="text" placeholder="TODO" />
          <button type="submit" :disabled="state.text === ''">送信</button>
        </form>
      `,
      emits: {
        submit: (text) => {
          return text != null;
        },
      },
      setup(props, context) {
        const state = reactive({
          text: '',
        });

        return {
          state,
          /**
           * 送信時
           * @param {event} event - DOMのイベント
           */
          onSubmit(event) {
            event.preventDefault();
            context.emit('submit', state.text);
            state.text = '';
          },
        };
      },
    };

    /**
     * TODOリストコンポーネント
     */
    const TodoList = {
      template: `
        <transition-group tag="ul" class="list" name="flip">
          <template
            v-for="item in $props.todoList"
            :key="item._id"
          >
            <li
              class="item"
              @click="$emit('check', item._id, !item.isDone)"
            >
              <input type="checkbox" :checked="item.isDone" />
              <span class="item__text">{{ item.text }}</span>
              <button @click="onDeleteTodo($event, item._id)">削除</button>
            </li>
          </template>
        </transition-group>
      `,
      emits: {
        check: (todoId, isChecked) => {
          return todoId != null && isChecked != null;
        },
        delete: (todoId) => {
          return todoId != null;
        },
      },
      props: {
        todoList: VueTypes.arrayOf(VueTypes.shape({
          _id: VueTypes.string.isRequired,
          isDone: VueTypes.bool.isRequired,
          text: VueTypes.string.isRequired
        }).loose).isRequired
      },
      setup(props, context) {
        return {
          /**
           * 削除ボタンをクリックした時
           * @param {event} event - DOMのイベント
           * @param {number} todoId - TODOのID
           */
          onDeleteTodo(event, todoId) {
            event.stopPropagation();
            context.emit('delete', todoId);
          },
        };
      },
    };

    const app = Vue.createApp({
      template: `
        <div>
          <InputForm
            @submit="onSubmit"
          />
          <p>TODO LIST</p>
          <TodoList
            :todoList="state.todoList"
            @check="onCheckTodo"
            @delete="onDeleteTodo"
          />
        </div>
      `,
      components: {
        InputForm,
        TodoList,
      },
      setup() {
        const state = reactive({
          todoList: [],
        });

        const fetchTodoList = async () => {
          const response = await api.get('/todos');
          state.todoList = response.data;
        };

        onBeforeMount(async () => {
          await fetchTodoList();
        });

        return {
          state,
          /**
           * 送信された時
           * @param {string} text - テキスト
           */
          async onSubmit(text) {
            await api.post('/todos', {
              text,
            });
            await fetchTodoList();
          },
          /**
           * TODOのチェック
           * @param {number} todoId - TODOのID
           * @param {boolean} isNextDone - 次更新する終了ステータス
           */
          async onCheckTodo(todoId, isNextDone) {
            await api.put(`/todos/check/${todoId}`, {
              isDone: isNextDone,
            });
            await fetchTodoList();
          },
          /**
           * TODOの削除ボタンがクリックされた時
           * @param {number} todoId - TODOのID
           */
          async onDeleteTodo(todoId) {
            await api.delete(`/todos/${todoId}`);
            await fetchTodoList();
          },
        }
      },
    });

    app.mount('#app');
  </script>
</body>
</html>

Herokuにデプロイ

本番用DBサーバーの用意

最後にHerokuにデプロイしますが、その前に本番用のDBを用意します。昔はmLabというアドオンがあったのですが、今はなくなってしまったのでMongoDB Atlasというものを使います。

こちらの記事を参考にクラスターを作ってください。
https://qiita.com/n0bisuke/items/4d4a4599ee7ce9cf4fd9

作り終えて接続情報が表示されたら、ここにあるURIをコピーします。ただパスワードは含まれていないので<password>のところを設定したパスワードで置き換える必要があります。

URIを取得できたらmongoDBの接続方法を以下の切り替えて接続できるか確認します。

server/server.ts
// 一旦本番DBに接続する
if (process.env.NODE_ENV !== 'production') {
  const MONGODB_URI = '<password>を書き換えてコピーしたURI';
  mongoose.connect(MONGODB_URI, {
    useNewUrlParser: true,
    useUnifiedTopology: true,
    dbName: 'todo-app',
  })
    .catch((err) => {
      console.error(err);
    });
}

Herokuで環境変数の設定とデプロイ

Herokuのアプリを作ったらSettingsページで環境変数を追加します。
名前はなんでも良いですが、ここではMONGODB_URIという名前で保存します。

この環境変数を使ってアクセスするようにコードを書き換えます。

server/server.ts
 // 本番じゃない時はローカルのDBに接続する
 if (process.env.NODE_ENV !== 'production') {
   require('dotenv').config({ path: path.resolve(__dirname, '../mongodb/.env') });

   const { MONGODB_USERNAME, MONGODB_PASSWORD } = process.env;
   mongoose.connect('mongodb://localhost:27017', {
     useNewUrlParser: true,
     useUnifiedTopology: true,
     user: MONGODB_USERNAME,
     pass: MONGODB_PASSWORD,
     dbName: 'todo-app',
   })
     .catch((err) => {
       console.error(err);
     });
-}
+} else {
+  mongoose.connect(process.env.MONGODB_URI || '', {
+    useNewUrlParser: true,
+    useUnifiedTopology: true,
+    dbName: 'todo-app',
+  })
+    .catch((err) => {
+      console.error(err);
+    });
+}

後はHerokuにデプロイして完成です。

終わりに

以上がDockerを使ってMongoDBサーバーを立ち上げて開発し、Herokuにデプロイするまでの流れでした。昔はmLabという無料のMongoDBアドオンがあったのでHerokuを使うことにメリットがあったのですが、今だとその恩恵を受けることができなそうでした。やはり無料でDBを使っていくのは難しいですかねぇ。小さいデータを無料枠でとなると、firebaseの方が気軽にやれそうですし、わざわざDBを使う意味が・・・😅
需要があるか分かりませんが、誰かのお役に立てれば幸いです。

Discussion