本文 AI 產出,尚未審核
ExpressJS (TypeScript) – 實戰專案:完整 API 服務
加入 Auth、Validation、Database
簡介
在現代 Web 開發中,API 服務 已成為前後端分離架構的核心。單靠 Express 建立路由與回傳 JSON 已能快速完成原型,但若要在正式環境上線,必須加入 認證(Auth)、參數驗證(Validation) 與 資料庫存取 等基礎設施,才能保證安全性、資料完整性與可維護性。
本篇文章以 TypeScript 為基底,示範如何在 Express 專案中:
- 使用 JWT 完成使用者驗證與授權
- 結合 class‑validator 與 express‑validator 進行請求參數驗證
- 以 Prisma(或 TypeORM)作為 ORM,連接 PostgreSQL/MySQL,完成 CRUD
透過完整、可執行的範例,讓剛踏入 Node.js/Express 的開發者,快速掌握「從零到可上線」的 API 服務建置流程。
核心概念
1️⃣ 建立 TypeScript + Express 基礎環境
先安裝必要套件,並設定 tsconfig.json 讓編譯結果位於 dist 目錄。
npm init -y
npm install express cors helmet morgan
npm install -D typescript ts-node-dev @types/express @types/node
npx tsc --init
tsconfig.json 重要設定:
{
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
src/server.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
const app = express();
// 基礎中介層
app.use(cors());
app.use(helmet());
app.use(morgan('dev'));
app.use(express.json());
// 測試路由
app.get('/', (req, res) => {
res.json({ message: 'Hello Express + TypeScript!' });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`🚀 Server running on http://localhost:${PORT}`));
小技巧:使用
npm run dev透過ts-node-dev監控檔案變動,自動重啟伺服器。
// package.json scripts
"scripts": {
"dev": "ts-node-dev --respawn src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
2️⃣ JWT 認證與授權
2.1 為什麼選 JWT?
- 無狀態:伺服器不需要維護 Session,適合水平擴展。
- 可攜帶資訊:在 token 中加入
role、iat、exp等欄位,讓授權判斷更彈性。
2.2 安裝與設定
npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs
2.3 建立 User Entity(使用 Prisma)
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
password String
role String @default("user")
createdAt DateTime @default(now())
}
注意:
password必須 加鹽雜湊 後儲存,絕不可明文。
2.4 Auth Service(產生 & 驗證 Token)
// src/services/auth.service.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '7d';
export async function register(email: string, password: string) {
const hashed = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { email, password: hashed },
});
return generateToken(user);
}
export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) throw new Error('User not found');
const valid = await bcrypt.compare(password, user.password);
if (!valid) throw new Error('Invalid credentials');
return generateToken(user);
}
function generateToken(user: { id: number; email: string; role: string }) {
const payload = { sub: user.id, email: user.email, role: user.role };
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
}
2.5 Auth Middleware
// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
export interface AuthRequest extends Request {
user?: { sub: number; email: string; role: string };
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing token' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, JWT_SECRET) as { sub: number; email: string; role: string };
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ message: 'Invalid token' });
}
}
/** 角色授權範例 */
export function authorize(allowedRoles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) return res.status(403).json({ message: 'Forbidden' });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ message: 'Insufficient permission' });
}
next();
};
}
2.6 路由範例
// src/routes/auth.routes.ts
import { Router } from 'express';
import { register, login } from '../services/auth.service';
const router = Router();
router.post('/register', async (req, res) => {
const { email, password } = req.body;
try {
const token = await register(email, password);
res.json({ token });
} catch (e) {
res.status(400).json({ message: (e as Error).message });
}
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
try {
const token = await login(email, password);
res.json({ token });
} catch (e) {
res.status(400).json({ message: (e as Error).message });
}
});
export default router;
在 server.ts 中掛載:
import authRoutes from './routes/auth.routes';
app.use('/api/auth', authRoutes);
3️⃣ 請求參數驗證(Validation)
3.1 為什麼需要 Validation?
- 防止 SQL Injection、XSS 等攻擊
- 保證 API contract,減少前端與後端的溝通成本
3.2 使用 class-validator + class-transformer(DTO)
npm install class-validator class-transformer
npm install -D @types/express-serve-static-core
DTO 範例(UserCreateDto):
// src/dto/user-create.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
export class UserCreateDto {
@IsEmail({}, { message: 'Email 必須為有效格式' })
email!: string;
@IsString()
@MinLength(6, { message: '密碼長度至少 6 位' })
password!: string;
}
驗證中介層:
// src/middleware/validation.middleware.ts
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { Request, Response, NextFunction } from 'express';
export function validateDto(dtoClass: any) {
return async (req: Request, res: Response, next: NextFunction) => {
const dtoObj = plainToInstance(dtoClass, req.body);
const errors = await validate(dtoObj);
if (errors.length > 0) {
const messages = errors
.map((err: ValidationError) => Object.values(err.constraints || {}))
.flat();
return res.status(400).json({ errors: messages });
}
// 把已驗證的 DTO 放回 req.body,型別安全
req.body = dtoObj;
next();
};
}
在路由中使用:
// src/routes/auth.routes.ts(續)
import { validateDto } from '../middleware/validation.middleware';
import { UserCreateDto } from '../dto/user-create.dto';
router.post(
'/register',
validateDto(UserCreateDto), // <-- 加入驗證
async (req, res) => {
const { email, password } = req.body; // 已是 DTO 類型
// ... 省略
}
);
3.3 express-validator(快速檢查 query / params)
npm install express-validator
// src/routes/article.routes.ts
import { Router } from 'express';
import { query, param, validationResult } from 'express-validator';
import { authenticate } from '../middleware/auth.middleware';
const router = Router();
router.get(
'/',
[
query('page').optional().isInt({ min: 1 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const page = req.query.page ?? 1;
const limit = req.query.limit ?? 10;
// 取得分頁資料...
res.json({ page, limit, data: [] });
}
);
4️⃣ 資料庫整合:Prisma(或 TypeORM)
4.1 Prisma 初始化
npm install prisma @prisma/client
npx prisma init
prisma/schema.prisma(已示範 User),再加上簡單的 Post 模型:
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
執行遷移:
npx prisma migrate dev --name init
4.2 CRUD Service 範例
// src/services/post.service.ts
import { PrismaClient, Post } from '@prisma/client';
const prisma = new PrismaClient();
export async function createPost(data: { title: string; content?: string; authorId: number }): Promise<Post> {
return prisma.post.create({ data });
}
export async function getPosts(skip = 0, take = 10) {
return prisma.post.findMany({ skip, take, include: { author: true } });
}
export async function getPostById(id: number) {
return prisma.post.findUnique({ where: { id }, include: { author: true } });
}
export async function updatePost(id: number, data: Partial<Post>) {
return prisma.post.update({ where: { id }, data });
}
export async function deletePost(id: number) {
return prisma.post.delete({ where: { id } });
}
4.3 結合路由與授權
// src/routes/post.routes.ts
import { Router } from 'express';
import {
createPost,
getPosts,
getPostById,
updatePost,
deletePost,
} from '../services/post.service';
import { authenticate, authorize } from '../middleware/auth.middleware';
import { body, param, validationResult } from 'express-validator';
const router = Router();
router.use(authenticate); // 所有路由皆需驗證
router.post(
'/',
[
body('title').isString().notEmpty(),
body('content').optional().isString(),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { title, content } = req.body;
const authorId = req.user!.sub; // 由 JWT 取得
const post = await createPost({ title, content, authorId });
res.status(201).json(post);
}
);
router.get('/', async (req, res) => {
const page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const posts = await getPosts((page - 1) * limit, limit);
res.json(posts);
});
router.put(
'/:id',
[
param('id').isInt(),
body('title').optional().isString(),
body('content').optional().isString(),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const id = Number(req.params.id);
const post = await getPostById(id);
if (!post) return res.status(404).json({ message: 'Post not found' });
// 只允許作者或 admin 編輯
if (post.authorId !== req.user!.sub && req.user!.role !== 'admin') {
return res.status(403).json({ message: '沒有權限' });
}
const updated = await updatePost(id, req.body);
res.json(updated);
}
);
router.delete(
'/:id',
[param('id').isInt()],
async (req, res) => {
const id = Number(req.params.id);
const post = await getPostById(id);
if (!post) return res.status(404).json({ message: 'Post not found' });
if (post.authorId !== req.user!.sub && req.user!.role !== 'admin') {
return res.status(403).json({ message: '沒有權限' });
}
await deletePost(id);
res.status(204).send();
}
);
export default router;
在 server.ts 中掛載:
import postRoutes from './routes/post.routes';
app.use('/api/posts', postRoutes);
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
| JWT 密鑰硬編碼 | 把 secret 寫死在程式碼會導致安全漏洞。 |
使用 環境變數 (process.env.JWT_SECRET) 並在部署時設定。 |
| 忘記在 token 中設定過期時間 | 永久有效的 token 會增加被盜用的風險。 | 設定 expiresIn(如 7d)或使用 refresh token 機制。 |
| 驗證錯誤未回傳 | 只回傳 400,卻沒有錯誤訊息,除錯困難。 |
統一 錯誤格式(如 { errors: [...] }),前端可直接呈現。 |
| 資料庫查詢未加索引 | 大量 findMany 時效能急遽下降。 |
依照常用搜尋條件在 Prisma schema 中加入 @@index([...])。 |
| 未使用 Transaction | 多表寫入失敗時會留下不一致資料。 | Prisma 提供 prisma.$transaction([...]),或 TypeORM 的 queryRunner。 |
| 過度信任 client 輸入 | 直接把 req.body 寫入 DB 會產生 SQL Injection。 |
驗證 + 轉型(class‑validator)後再使用 ORM。 |
最佳實踐:
- 統一錯誤處理:在
src/middleware/error.middleware.ts中捕捉所有未處理的例外,回傳標準 JSON。 - 日志與監控:使用
winston或pino記錄請求與錯誤,配合 ELK 或 Grafana 監控。 - 測試驅動:使用
jest+supertest撰寫單元與整合測試,確保 auth、validation、DB 邏輯不會回歸。 - 環境分離:開發、測試、正式環境分別使用不同的資料庫與 JWT 秘鑰。
實際應用場景
| 場景 | 為什麼需要此套件/概念 | 範例實作 |
|---|---|---|
| 社群平台 | 使用者需 註冊 / 登入,發文只能由已驗證帳號執行。 | 前述 auth.routes.ts + post.routes.ts 結合 authorize(['admin']) 讓管理員刪除不當貼文。 |
| 電商系統 | 商品 CRUD 必須驗證 管理員權限,同時防止 價格被惡意修改。 | 在 product.routes.ts 中加入 authorize(['admin', 'manager']),並使用 class-validator 驗證價格為正整數。 |
| 多租戶 SaaS | 每個租戶只能存取自己資料庫資料。 | JWT 中加入 tenantId,在 Prisma query 前加上 where: { tenantId: req.user!.tenantId }。 |
| 行動 App 後端 | 手機端常使用 短期 token(存於 Secure Storage),伺服器需快速驗證。 | 使用 express-jwt 或自訂 authenticate 中介層,配合 CORS、Helmet 增強安全。 |
| 資料分析平台 | 大量查詢需要 分頁 與 排序,同時避免 SQL 注入。 | express-validator 驗證 sort、order 參數,Prisma orderBy 動態組合。 |
總結
本文從 零件化 的角度,示範了在 Express + TypeScript 中加入 認證 (JWT)、參數驗證 以及 資料庫 (Prisma) 的完整流程。透過以下步驟即可打造一個 安全、可維護、可擴充 的 API 服務:
- 初始化 TypeScript + Express,配置基礎中介層。
- 實作 JWT 認證,並以中介層統一擷取使用者資訊。
- 使用 class‑validator / express‑validator,在 DTO 或 query 層面完成驗證。
- 以 Prisma 為 ORM,定義模型、執行遷移,並撰寫 CRUD Service。
- 結合授權、錯誤處理、日誌,提升系統穩定性與可觀測性。
掌握以上核心概念後,你就能在 實戰專案 中快速擴增功能(如 OAuth、Refresh Token、Rate Limiting),並以 測試驅動 的方式持續優化。祝你開發順利,打造出高品質的 API 服務! 🚀