Day 13 - 按圖施工-後端篇:資料庫建置

終於來到後端服務的重頭戲,目前會先以本機 DB 來開發為主,等有一定的完成度再接上雲端的 MongoDB Atlas。
Docker 容器
利用 Docker 來啟動本機 DB 可以省下一些安裝 DB 應用程式的麻煩,不需要的時候也可以一鍵移除相關檔案。
在 apps/server 專案目錄中新增 docker-compose.yml:
services: mongodb: image: mongo:latest container_name: cozy-chat-db ports: - '27017:27017' volumes: - mongodb_data:/data/db environment: MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USERNAME:-root} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-password} restart: always
volumes: mongodb_data:DB 的帳號密碼這類的機敏資料,都會從 .env 或其他方式讀取,不會寫在原始碼中讓大家看光光:
PORT=8080
MONGO_ROOT_USERNAME=rootMONGO_ROOT_PASSWORD=1234MONGODB_URI=mongodb://root:1234@localhost:27017DB_NAME=cozychat切換到 apps/server 後啟動容器看看:
docker-compose up -d從 Docker Desktop 來看是正常運行的,那麼 MongoDB 的本機設定就算完成了…對,就是這麼簡單(?)

連線設定
操作 MongoDB 會通常會 Mongoose 這套 ODM,不過我自己實測後,總覺得跟 TS 配合不是很好,reddit 上有些開發者提到用原生的 MongoDB Driver 搭配 Zod 就可以產生資料型別與驗證,效果也蠻不錯,因此我決定試試看。
參照官方的教學,新增 src/config/db.ts,加入連線和斷線的邏輯:
import { MongoClient } from 'mongodb';
import type { Db } from 'mongodb';
let client: MongoClient;let db: Db;
async function connectToDB() { try { client = new MongoClient(process.env.MONGODB_URI ?? 'mongodb://root:1234@localhost:27017'); db = client.db(process.env.DB_NAME ?? 'cozychat'); await client.connect(); console.log('DB 連線成功'); } catch (error) { console.error('DB 連線錯誤:', error); throw error; }}
async function disconnectFromDB() { await client.close(); console.log('DB 已斷開連線');}
export { connectToDB, db, disconnectFromDB };在 index.ts 的啟動步驟也要調整,因為 DB 連線是非同步,通常會確定 DB 接通後才進行 Server 啟動:
async function bootstrap() { try { await connectToDB(); new SocketServer(new Server(server));
server.listen(port, () => { console.log(`Server 啟動成功: *:${port}`); }); } catch (error) { console.error('Server 啟動失敗:', error); await disconnectFromDB(); process.exit(1); }}await bootstrap();DB 連線有問題的話,整個後端服務其實也差不多掛了…所以在 catch 使用終止程序的語法 process.exit(1); 直接停機吧!
確認終端的 log 有連線進來的資訊就算是設定完連線了:

定義資料
接下來可以依照先前的規劃來定義每個 collection 的相關操作,在 MVC 架構中,這些直接操作資料庫的邏輯會歸類在 model 層。
新增 src/models/user.model.ts 並實作 createUser:
const userDtoSchema = z.object({ id: z.string(), created_at: z.date(), device: z.enum(['APP', 'MB', 'PC']), last_active_at: z.date(), room_id: z.string().optional(), status: z.enum(['ACTIVE', 'LEFT']),});
type UserDto = z.infer<typeof userDtoSchema>;
const createUserDtoSchema = userDtoSchema.omit({ id: true, room_id: true,});
type CreateUserDto = z.infer<typeof CreateUserSchema>;
type UserEntity = Omit<UserDto, 'id'> & { _id: ObjectId };
async function createUser(dto: CreateUserDto): Promise<UserDto | null> { const users = db.collection<OptionalId<UserEntity>>('users');
try { const candidate = createUserDtoSchema.parse(dto);
const result = await users.insertOne(candidate); console.log('新增 User 成功');
if (result.acknowledged) { return { ...candidate, id: newObjectId.toString(), }; }
return null; } catch (error: unknown) { console.error('新增 User 失敗', error);
return null; }}Zod 提供 omit、pick 等常用來控制物件屬性的語法,可以減少很多重複的 schema 撰寫,而且還可以透過 z.infer 把定義好的 schema 產出型別!
其他 model 和 CRUD 也是照類似的方式做出來,這邊就不放來洗版了~~
特別留意:
-
從 collection 取出來的資料,自動生成的 id 是
{ _id: ObjectId },所以不能直接把UserDto拿來用,這裡我就重新做成type UserEntity。 -
createUser是新增一筆資料,這時候還不會有 id,但流程上會造成讀寫 collection 時有型別檢查的問題,可以在泛型中加入OptionalId:db.collection<OptionalId<UserEntity>>('users')。或是在insertOne時顯式生成new ObjectId()
共用型別
剛剛定義 schema 時有產生型別,這些都可以放到共用庫!這也是最初採用 monorepo 架構的原因之一,也可以讓 model 和整個專案的程式碼更簡潔集中。
device 的定義是 'PC'、MB、APP 其中一個值,這種固定的資料通常也會做成常數型別,Zod 的 z.infer 也支援導出:
// 常數export const Device = z.enum(['APP', 'MB', 'PC']);export const UserStatus = z.enum(['ACTIVE', 'LEFT']);
// 導出型別export type Device = z.infer<typeof Device>;export type UserStatus = z.infer<typeof UserStatus>;
// zod schemaconst userDtoSchema = z.object({ id: z.string(), room_id: z.string(), device: Device, status: UserStatus, last_active_at: z.date(), created_at: z.date(),});共用庫的東西都需要經過 build 產生出編譯檔,才可以在子專案使用。新增 dev 指令,透過 --watch 來隨時重新編譯:
"scripts": { "build": "tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.build.json --watch",也可以整合到根目錄,確保啟動任何子專案時會同時啟動共用庫並即時編譯,達到熱重載的效果,這裡會需要 npm-run-all 這個套件來達到並行執行專案:
"scripts": { "web:dev:app": "pnpm --filter @apps/web dev", "web:dev": "npm-run-all --parallel lib:dev web:dev:app", "server:dev:app": "pnpm --filter @apps/server dev", "server:dev": "npm-run-all --parallel lib:dev server:dev:app",讀寫測試
最後來試試看新增一筆資料吧:
async function addFakeData() { const currentTime = new Date(); const newUser = await userModel.createUser({ device: 'APP', status: 'ACTIVE', last_active_at: currentTime, created_at: currentTime, });
console.log('hello user', newUser);}
本日小結
有 AI 輔助之後,產生 Docker 設定檔與指令操作變得簡單很多,不過還是要了解 Docker 的運作原理,否則遇到一些 CI/CD 的問題會很頭痛 XD