沒有 bootstrap 層的樣子

多數教學的 app.ts 長這樣:

const app = express();
 
app.use(morgan('dev'));
app.use(express.json());
app.use(cors());
app.use(session({ secret: process.env.SESSION_SECRET }));
 
// 資料庫
const sequelize = new Sequelize(process.env.DB_NAME!, process.env.DB_USER!, ...);
sequelize.authenticate().then(() => console.log('DB connected'));
 
// routes
app.use('/api/users', userRouter);
app.use('/api/posts', postRouter);
 
// 錯誤處理
app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); });
 
app.listen(3000);

這不是不能跑,但有幾個問題:

一、啟動序列沒有保證sequelize.authenticate() 是 async,但 app.listen(3000) 是同步執行的——DB 連線還沒確認就開始接收請求。

二、app.ts 知道太多事。Middleware 選擇、DB 連線、route 掛載、錯誤處理全在同一個檔,一個檔案幾百行是早晚的事。

三、測試無法只 import app。因為 app.listen() 在同一個檔案,import 就等於啟 server。


Bootstrap 層的設計

Proto 把啟動序列抽成一個獨立的 src/bootstrap/ 層:

// src/app.ts — 只建 Express 實例,呼叫 bootstrap
import express from 'express';
import bootstrap from './bootstrap';
 
const app = express();
bootstrap(app).catch((err) => { process.exit(1); });
export default app;
// src/bootstrap/index.ts — 6 步驟啟動序列
const bootstrap = async (app: Express): Promise<Express> => {
  configureMiddleware(app);    // 1. middleware 掛載
  configureRoutes(app);        // 2. routes 掛載
  configureErrorHandlers(app); // 3. error handlers 掛載
  await initializeDatabase();  // 4. DB 連線(await)
  await initializeRedis();     // 5. Redis 連線(await)
  initializeWorkers();         // 6. worker/queue 啟動
  return app;
};

6 個步驟,順序固定。app.ts 只知道「呼叫 bootstrap」,不知道裡面做了什麼。


每一步的順序理由

Step 1: Middleware(同步,不 await)

// bootstrap/middleware.ts
const configureMiddleware = (app: Express) => {
  app.set('trust proxy', appConfig.trustProxy);
 
  // Phase 1: Core
  app.use(morgan('dev'));
  app.use(express.json());
  app.use(express.urlencoded({ extended: false }));
  app.use(cookieParser());
  app.use(compression());
  app.use(responseTime());
 
  // Phase 1.5: i18n
  app.use(i18nextMiddleware.handle(i18next));
 
  // Phase 2: Security
  app.use(helmet(securityConfig));
  app.use(cors(corsConfig));
  app.use(rateLimit(rateLimitConfig));
 
  // Phase 3: Session
  app.use(session(sessionConfig));
 
  // Phase 4: Static
  app.use('/public', express.static('storage/public'));
};

Middleware 掛載app.use())是同步的,不需要 await。掛載只是登記「這個中介層存在」,不是執行它。所以步驟 1 可以在 DB init 之前做,沒有任何問題。

Phase 的順序有講究:

  • CORS 在 auth 之前:preflight request(OPTIONS)要在打到 auth middleware 前就被處理,不然前端的跨域請求會被攔截
  • body-parser(json/urlencoded)在 routes 之前:route handler 需要解析好的 req.body,沒有 body-parser 就是 undefined
  • helmet 在 routes 之前:response header 的安全設定要在任何 response 送出前就設好

Step 2: Routes(同步)

Routes 的掛載也是同步的,沒有 await。Route handler 只是被登記,不是被執行。

但 routes 一定在 error handlers 之前——因為 Express 的 4-param error middleware 只有在 next(err) 被呼叫後才會接手,而 next(err) 是從 route handler 裡觸發的。

Step 3: Error Handlers(同步,一定在 routes 之後)

// bootstrap/errorHandlers.ts
const configureErrorHandlers = (app: Express) => {
  app.use(apiErrorLog);    // 先 log,保留 request context
  app.use(notFound);       // 404:沒有 route 匹配時
  app.use(errorHandler);   // 全局錯誤格式化
};

Error handler 一定要最後掛,在所有 routes 之後。Express 的 middleware 是按掛載順序執行的,如果 error handler 先掛,它就會攔截到還沒被 route 處理過的請求。

apiErrorLogerrorHandler 前面的理由是:要先 log 才能格式化 response 送出。一旦 response 送出,request context(req.user, req.id 等)就消失了,log 就不完整。

Step 4: Database(await,第一個 async 步驟)

// bootstrap/database.ts
const initializeDatabase = async () => {
  await sequelize.authenticate(); // ← 這裡確認 DB 連線
  await sequelize.sync();         // ← 同步 model schema
};

從這步開始是 await。DB 連線必須在 server 開始接受請求之前確認。如果 DB 連不上,整個 bootstrap 丟出 exception,外層的 catch 呼叫 process.exit(1)——服務直接退出,而不是帶著壞的 DB 連線繼續跑。

為什麼是 step 4 而不是 step 1?

因為 middleware 和 routes 的「掛載」本身不需要 DB。DB 只在 route handler 真正被執行時才被呼叫。把 DB init 推後,讓前三步快速完成(全同步),再做耗時的 async 連線。實際上 bootstrap 從啟動到可以接受請求的時間差不多,但邏輯上更清晰。

Step 5: Redis(await)

// bootstrap/redis.ts
const initializeRedis = async () => {
  if (!redisConfig.isEnabled) return; // ← feature flag,關掉就跳過
  // ioredis 連線...
};

Redis 在 DB 之後。原因是某些 cache warming 邏輯可能需要先從 DB 讀資料放進 Redis,所以 DB 先 init 保險。

Redis 用 feature flag(IS_ENABLE_REDIS=true/false)控制,不啟用就跳過,不影響其他步驟。

Step 6: Workers(同步,不 await)

// bootstrap/workers.ts
const initializeWorkers = () => {
  if (!isQueueEnabled()) {
    // queue disabled:用 sync handler 取代
    registerHandler('notification', async (data) => { /* sync exec */ });
    return;
  }
  // 啟動 Bull processor
  const notificationQueue = getNotificationQueue();
  registerNotificationProcessor(notificationQueue);
};

Workers 是最後一步,而且是同步的(不 await)。原因:

  • Workers 需要 DB(處理 job 時需要讀寫資料)→ DB 已在 step 4 init 完成
  • Workers 需要 Redis(Bull queue 底層用 Redis)→ Redis 已在 step 5 init 完成
  • Worker 的啟動本身是非同步的,但我們不需要 await 它,因為 worker 是在背景跑的

依賴方向圖

bootstrap/index.ts
├── configureMiddleware  ─→ app/configs/{cors,session,rateLimit,security}
├── configureRoutes      ─→ routes/
├── configureErrorHandlers ─→ app/exceptions/
├── initializeDatabase   ─→ app/configs/database
├── initializeRedis      ─→ app/configs/redis
└── initializeWorkers    ─→ app/queues/

所有箭頭都是 bootstrap → 其他層,沒有反向依賴。這是讓這個設計可以長期維護的原因:bootstrap 是頂層的編排者,不被任何 app 層呼叫。


啟動失敗的正確行為

app.ts 有一個關鍵細節:

bootstrap(app)
  .then(() => { info('Application bootstrapped successfully'); })
  .catch((err) => {
    error('Failed to bootstrap application:', err);
    process.exit(1); // ← 啟動失敗直接退出
  });

process.exit(1) 是故意的。如果 DB 連不上或 Redis 連不上,繼續跑只是帶著壞的狀態接受請求——每個需要 DB 的 API 都會回 500,但 server 看起來是「活著」的。

讓 process 直接 exit,讓 orchestrator(PM2 / K8s)重啟,比帶著壞狀態跑更乾淨。

K8s 有 livenessProbereadinessProbe 可以偵測這種情況,但前提是服務在壞狀態時不要假裝自己是 healthy 的。


bin/www.ts:server 什麼時候真正啟動

Bootstrap 完成後,Express app 已經設定好了——但還沒有真正開始接受 TCP 連線。那是 bin/www.ts 做的事:

import app from '../app'; // ← import 觸發 bootstrap
const server = http.createServer(app);
server.listen(port);

import app 觸發 bootstrap(app) 執行。Bootstrap 完成(Promise resolve)後,app 就是一個設定好的 Express 實例,隨時可以被 createServer() 包起來 listen。

HTTP server 啟動的細節、SIGTERM 處理、graceful shutdown 的完整流程在下一篇 [[backend/framework/express/startup-shutdown|[express][M2-2] 啟動點與 Graceful Shutdown]] 詳細拆開。


本系列文章

  • [[backend/framework/express/init|[express][01] Express + TypeScript 從零開始]]
  • [[backend/framework/express/configs-layer|[express][M1] Config 層設計]]
  • [[backend/framework/express/src-structure|[express][M1-2] 目錄結構設計]]
  • [express][M2] Bootstrap 模式(本篇)
  • [[backend/framework/express/startup-shutdown|[express][M2-2] 啟動點與 Graceful Shutdown]]
  • [[backend/framework/express/app-error-class|[express][M3] AppError 類別設計]]