在上一篇我們完成了連接 Node.js 和 MongoDB,這篇我們來建立前端 Vue App、撰寫一些簡單的 API 並讓 MongoDB 的資料在容器停止或刪除後也能被保留下來。
由於主要關注的是如何建立和連接容器,Vue App, Node App 與 Docker 無關的程式碼撰寫會直接跳過說明。
首先我們先來建立 Vue App。在專案根目錄使用 Console 輸入:
npm create vite@latest frontend — — template vue
vite
應該會為我們在根目錄建立一個 frontend
資料夾,並幫我們把套件都指定好了。在 frontend/vite.config.js
增加 host 的設定。完整程式碼如下:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
host: '0.0.0.0'
}
})
接下來我們來撰寫前端的 Dockerfile
:
FROM node:16WORKDIR /appCOPY package.json .RUN npm installRUN mkdir node_modules/.vite && chmod -R 777 node_modules/.viteCOPY . .EXPOSE 5173CMD ["npm", "run", "dev"]
FROM node:16
:基於 node 16.x 版本進行構建WORKDIR /app
:將映像檔內的工作資料夾設為 /app,後續的命令都於此執行COPY package.json
:先將package.json
複製映像檔RUN npm install
:在映像檔中執行npm install
,安裝好需要的套件RUN mkdir node_modules/.vite && chmod -R 777 node_modules/.vite
:我在實際執行時有碰到node_modules/.vite
資料夾中 permission denied 的問題,參考一些文章後,目前的作法是手動建立.vite
資料夾並開權限給 Docker,若有更好的方法也歡迎留言讓我知道EXPOSE 5173
:將 5173 埠號暴露出來讓宿主機可以看到(預設埠號為 5173)CMD [“npm”, “start”]
:在容器被建立時所執行的命令
要啟動容器,輸入:
docker run --rm --name mevn-frontend-app -v "$(pwd)":/app -v /app/node_modules -p 5173:5173 mevn-frontend
--rm
代表自動在容器結束執行時移除它。
--name
讓我們可以為建立的容器命名,我們將其命名為 mevn-frontend-app。
-v
代表了 volume,用於保存容器內的資料。
這邊看到給了兩個值。”$(pwd)”:/app
這邊使用了綁定掛載(bind mount),$(pwd)
代表的是 /frontend
在電腦中所在目錄,/app
則是容器內部的路徑,這裡的設定會讓 /frontend
的檔案映射至容器內的 /app
。
因為我們在 /frontend
中並沒有安裝 node_modules
,綁定掛載會使得容器因為找不到 node_modules
而無法正常啟動伺服器,因此我們另外使用 /app/node_modules
讓 node_modules
映射到另一個由 Docker 管理的地方。當路徑發生衝突時,Docker 會以長的路徑為優先,因此 /app/node_modules
就不會被綁定掛載給覆蓋。
接下來,我們先來撰寫 API,首先停掉容器:
# 停止 MongoDB
docker stop mevn-mongo# 停止 Node.js App
docker stop mevn-backend-app
在 backend
目錄下建立 models
資料夾,在 models
資料夾建立 kitten.js
:
const mongoose = require('mongoose');const kittySchema = new mongoose.Schema({
name: String
});const Kitten = mongoose.model('Kitten', kittySchema);module.exports = Kitten;
改寫 server.js
,增加 GET /kittens
及 POST /kittens
兩支 API:
const express = require("express");
const mongoose = require('mongoose');
const bodyParser = require('body-parser');
const app = express();
const Kitten = require('./models/kitten');app.use(bodyParser.json());app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});app.get("/", (req, res) => {
res.send('hello world')
});app.get("/kittens", async (req, res) => {
try {
const kittens = await Kitten.find();
res.status(200).json({
kittens
});
} catch (err) {
console.error(err.message);
res.status(500).json({ message: "Failed to load kittens." });
}
});app.post("/kittens", async (req, res) => {
const { name: kittenName } = req.body;if (!kittenName || !kittenName.trim()) {
return res.status(422).json({ message: "Invalid kitten name." });
}const kitten = new Kitten({
name: kittenName,
});try {
await kitten.save();
res
.status(201)
.json({ message: "Kitten saved", kitten: { id: kitten.id, name: kittenName } });
} catch (err) {
console.error(err.message);
res.status(500).json({ message: "Failed to save kitten." });
}
});mongoose.connect('mongodb://mongoadmin:secret@mevn-mongo:27017/mevn-cats?authSource=admin', {
useNewUrlParser: true,
useUnifiedTopology: true,
});// 取得資料庫連線狀態
const db = mongoose.connection;
db.on('error', (err) => console.error(err));
db.once('open', () => console.log('successfully connected to mongodb!'));app.listen(3000);
啟動 MongoDB:
docker run --name mevn-mongo -d --rm --network mevn-network -e MONGO_INITDB_ROOT_USERNAME=mongoadmin -e MONGO_INITDB_ROOT_PASSWORD=secret mongo
啟動 Node.js App:
docker run --rm --name mevn-backend-app -v "$(pwd)":/app -v /app/node_modules -p 3000:3000 --network mevn-network mevn-backend
到 http://localhost:3000/kittens,應該可以看到:
接下來撰寫 frontend/src/App.vue
:
<script setup>
import { ref } from 'vue';const kittens = ref([]);
const nameInput = ref('');async function getKittens() {
const response = await fetch('http://localhost:3000/kittens');
const json = await response.json();
kittens.value = json.kittens;
return json;
}async function addKitten() {
const response = await fetch('http://localhost:3000/kittens', {
body: JSON.stringify({
name: nameInput.value,
}),
headers: {
'content-type': 'application/json'
},
method: 'POST', // *GET, POST, PUT, DELETE, etc.
mode: 'cors',
});
const json = await response.json();
kittens.value.push(json.kitten);
}getKittens();</script><template>
<h1>我的貓咪</h1>
<form @submit.prevent="addKitten">
<input v-model="nameInput" type="text" />
<button type="submit">新增</button>
</form>
<ul class="kittens">
<li v-for="kitten in kittens" :key="kitten.id">{{ kitten.name }}</li>
</ul>
</template>
以及 frontend/src/style.css
:
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
}form {
display: flex;
gap: 8px;
}input {
border-radius: 8px;
border: 1px solid #555;
padding: 0 12px;
}button {
border-radius: 8px;
border: 1px solid #555;
cursor: pointer;
padding: 6px 12px;
}li {
text-align: left;
}#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
到 http://localhost:5173 試著操作,應該會看到 API 有串接起來。
在 MongoDB 容器重啟時保留上一次資料
在開始之前一樣可以先停掉 MongoDB:
docker stop mevn-mongo
要保留 MongoDB 的資料,我們只需要對它設定 volume 就好。由於我們不會直接去抓它的原始資料,我們給一個路徑讓 Docker 管理它就好:
docker run --name mevn-mongo -d --rm -v data:/data/db --network mevn-network -e MONGO_INITDB_ROOT_USERNAME=mongoadmin -e MONGO_INITDB_ROOT_PASSWORD=secret mongo
/data/db
這個路徑的由來是 Mongo 在 Docker Hub 的文件直接提供的。
這時可以回到 http://localhost:5173,應該會發現資料會被清空,我們一樣可以試著增加幾筆資料。完成後,如果我們試著關閉 MongoDB 再重啟,資料應該就會原封不動地回到畫面上了。
到這裡,我們完成了前後端的串接外,也讓資料庫的資料可以在重啟時保持原本的狀態。
目前我們容器在啟動上其實是很麻煩的,有非常長的指令要輸入,如果忘記了就要跑 history | grep xxx
回去找,也可能發生輸入的指令不同但一樣可以運作,導致最後忘記到底該跑什麼指令才是正確的。
下一篇我們會使用 docker-compose
把這些指令整合起來,讓我們可以用簡潔的方式操作映像檔和容器。
References: