DockerでMongoDBを立ち上げて開発し、Herokuにデプロイするまで
始めに
フロントエンドではあまりDockerは使われないと思いますが、DBサーバーを用意する場合はDockerを使うと一々アプリをインストールする必要がなくなり便利になると思います。
そこでMongoDBをDockerで立ち上げて開発する流れを記事にしたいと思います。
なお、今回作ったソースはこちらに置いてあります。
使用した技術
- Docker
- MongoDB
- express
- mongoose
- TypeScript
- Vue.js 3系
- Heroku
MongoDBサーバーの起動
Dockerのインストール
まだDockerをインストールしていない方はこちらなどを参考にインストールしてください。
docker-composeでmongoDB環境を構築する
mongodbディレクトリの下に以下のコードが書かれたdocker-compose.yml
を置いてください。
簡単に説明すると、mongo
とmongo-express
を立ち上げるようにして、mongo
がmongoDBサーバーに起動で、mongo-express
がGUIで操作するサーバーを起動しています。
お互い接続するためにusernameとpasswordを合わせる必要があるので、環境変数からアクセスするようにしています。
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_USERNAME=root
MONGODB_PASSWORD=password
ファイルが配置されたら、mongodb
ディレクトリに移動して以下のコマンドを叩くと起動されます。
$ docker-compose up -d
起動したらlocalhost:8081
にアクセスすると以下のような画面が出て、GUIでMongoDBの中身が確認できます。
参考
mongooseでMongoDBの操作
expressサーバーの設定
まずはexpressサーバーの骨組みを作ります。APIはserver/apis
ディレクトリで管理するので、importしたものをそのまま使用します。
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
は以下のようにしました。今回はそこまでやらなくてもいい気はしますが、一応今後のスケールアップも考慮した構成にしています。
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
を使って環境変数ファイルを読み込んだものを使用して合わせます。
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で設定される想定で型付けをしています。
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に対して適切な操作を書きます。
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系の時は問題なかったんですけどね。。)
<!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
というものを使います。
こちらの記事を参考にクラスターを作ってください。
作り終えて接続情報が表示されたら、ここにあるURIをコピーします。ただパスワードは含まれていないので<password>
のところを設定したパスワードで置き換える必要があります。
URIを取得できたらmongoDBの接続方法を以下の切り替えて接続できるか確認します。
// 一旦本番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
という名前で保存します。
この環境変数を使ってアクセスするようにコードを書き換えます。
// 本番じゃない時はローカルの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