本系列以一位資深工程師與他的外甥之間的虛構對話為主軸。每集探討軟體從構想走向正式上線的各個階段。


👦 外甥:叔叔。終於好了。VS Code 已經打開。npm start

👨‍🦳 叔叔:等一下。在你執行之前——這支程式即將連接到哪裡?使用哪個資料庫、誰的憑證、哪個環境?

👦 外甥:……我其實不知道。反正我一執行它就正常運作。

👨‍🦳 叔叔:這正是問題所在。「它剛好能跑」不等於「我清楚它在做什麼」。打開設定檔給我看看。


設定:機密不該寫在程式碼裡

👨‍🦳 叔叔:看這一行。

const dbUrl = "postgres://admin:P@[email protected]:5432/wishlist";

進入全螢幕模式 離開全螢幕模式

👦 外甥:……這是真實的密碼。硬寫在檔案裡,而且這個檔案馬上就要被 commit 到 Git。

👨‍🦳 叔叔:一旦 commit 之後,它就會永遠留在倉庫的歷史紀錄裡,就算你在下一次 commit 刪掉這行也一樣。任何擁有 repo 存取權的人——或者 repo 無意間被公開的人——都能取得你 production 資料庫的密碼。

.env永不 commit —— 已列在 .gitignoreDATABASE_URL=postgres://localhost:5432/wishlist_dev
NODE_ENV=development

進入全螢幕模式 離開全螢幕模式

const dbUrl = process.env.DATABASE_URL;

進入全螢幕模式 離開全螢幕模式

👦 外甥:所以程式碼保持不變,但它連接到哪裡,會依據載入的 .env 而改變?

👨‍🦳 叔叔:沒錯。同樣的程式碼,不同的設定,對應不同環境。本地使用 wishlist_dev,staging 使用 staging 的 URL,production 使用 production 的 URL——這些 URL 都不會被寫進程式碼裡。這與第 4 集的原則相同——一個盒子不應該知道它不需要知道的事情。你的服務程式碼不需要知道自己「處於 production 環境」。它只需要拿到一個 URL 即可。


鏡像:不要假裝資料庫

👦 外甥:在本地開發時,我能不能用更簡單的東西,例如 SQLite,來取代在機器上設定真正的 Postgres?

👨‍🦳 叔叔:可以,但它會悄悄地欺騙你。還記得第 5 集我們設計的 UNIQUE (user_id, product_id) 限制條件嗎?SQLite 與 Postgres 在限制條件、資料型別,甚至某些查詢行為上並不總是完全一致。你可能寫出在本地 SQLite 運作完美的程式碼,在自己的機器上通過所有測試,但一碰到 staging 的真實 Postgres 時,卻出現完全不同的錯誤。

👦 外甥:所以本地必須使用與 production 完全相同的資料庫引擎。

👨‍🦳 叔叔:盡可能接近——相同的引擎、相同的主要版本(若能管理)。不需要做到 pixel-perfect 的完全相同;production 可能執行稍新的 minor 版本,或運行在 Kubernetes,而本地則在單一容器中執行。重要的是行為的一致性,而非字面上的相同。這正是 Docker 的用途——提供一種常見方式,讓你在本地啟動一致且可拋棄的引擎副本。有些團隊選擇 Dev Containers、Podman,或遠端雲端工作區——工具本身不如原則重要:使用與 production 環境相同的引擎,而非行為不同的輕量替代方案。

docker-compose.yml

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: wishlist_dev
    ports:
      - "5432:5432"

進入全螢幕模式 離開全螢幕模式

docker-compose up

進入全螢幕模式 離開全螢幕模式

👦 外甥:只要一行指令,我就能擁有公司實際用於 production 的相同資料庫引擎,只不過是在本地以測試資料執行。

👨‍🦳 叔叔:這就是目標。不是 production 的完美複製——你不需要在本機擁有 production 的規模或真實使用者資料——但行為足夠接近,讓本地通過的測試,在 staging 也有機會通過。


植入真正能測試的資料

👦 外甥:資料庫已經啟動,但裡面是空的。我要如何在沒有任何使用者或產品的情況下測試重複檢查?

👨‍🦳 叔叔:你需要植入資料——刻意植入,而不是隨機植入。

seed.js

users:    [{ id: 'u1', name: 'Test User' }]
products: [
  { id: 'p1', name: 'Running Shoes' },
  { id: 'p2', name: 'Discontinued Jacket', active: false }
]

進入全螢幕模式 離開全螢幕模式

👦 外甥:為什麼要刻意植入一筆 inactive 的產品?

👨‍🦳 叔叔:因為你需要真正執行第 5 集所做的決策——當有人把不再 active 的產品加入願望清單,或已經加入時,會發生什麼事。如果你的植入資料只是「五個隨機的 happy-path 產品」,你永遠無法在本機觸發你花兩集設計的邊緣案例。植入資料應該反映第 1 集所隱藏的問題,而不僅僅是明顯的情境。


「在我機器上可以跑」

👦 外甥:叔叔——「它剛好能跑」的陷阱曾經真的發生在你身上嗎?

👨‍🦳 叔叔:有過,而且很嚴重。在我的筆電上,一切都通過測試。完美無缺。然後 CI 不斷失敗,在一個毫無道理的檢查上——一個我程式碼宣稱不存在的設定值。我們花了大半個下午才發現:我的機器還留著六個月前另一個專案的舊環境變數。它悄悄填入了一個其他人設定都沒有的值。我的筆電並不是更正確——它只是以一種看起來像成功的方式,壞得與眾不同。

👦 外甥:所以程式碼其實從來沒有真正正確。

👨‍🦳 叔叔:它只是碰巧在唯一一台機器上正確。從那天起,如果一台新筆電無法乾淨地執行我的專案,我會假設是 設定 出問題——而不是隊友。這個習慣救了我們團隊比這一集其他任何做法都多的時間。


模擬:還不存在的東西

👦 外甥:第 3 集提到一個通知服務——價格下跌提醒。它還不存在。我需要在本機啟動它才能開發 Wishlist 嗎?

👨‍🦳 叔叔:不需要,這也是許多本地環境會悄悄變得痛苦的原因。如果每位工程師都需要其他團隊的服務在本機運行才能開發自己的功能,新人 onboarding 會變成一場噩夢,而團隊一半的時間都在除錯 Docker,而不是寫程式碼。相反——模擬它。

const notificationService = process.env.NODE_ENV === "development"
  ? new MockNotificationService()   // 輸出到 console,不做任何實際動作
  : new NotificationService();      // 真正的網路呼叫

進入全螢幕模式 離開全螢幕模式

👦 外甥:所以 Wishlist Service 仍然呼叫一個名叫 notificationService 的東西。只是本機實際在它背後的是什麼,並不重要。

👨‍🦳 叔叔:沒錯——請注意,這之所以能乾淨地運作,正是因為第 4 集的設計。我們決定 Wishlist Service 不需要知道通知是如何發送的。同樣的邊界讓你可以在不改動任何商業邏輯的情況下,換入一個 mock。


為什麼不直接指向 production?

👦 外甥:老實說——我能不能跳過這些,直接把我的筆電連到真正的 production 資料庫?那樣一定最符合 production,因為它 就是 production。

👨‍🦳 叔叔:除非你喜歡解釋 outage。Production 的資料是真實且有價值的。Production 系統脆弱的方式,你從外面不一定看得到。一次意外的

DELETE FROM wishlist;

進入全螢幕模式 離開全螢幕模式

沒有 WHERE 子句,在你「只是快速測試一下」時執行——你的下午就會變成全公司的 incident review。這就是為什麼本地開發會成為一門獨立的學問:給你一個犯錯成本低、可逆轉、完全由你自己承擔的空間。


真正重要的測試:新隊友能不能跑起來?

👨‍🦳 叔叔:判斷你的本地環境是否真的好,而不僅是對你個人有效的方法——把一台從未看過這個 repo 的新隊友的筆電交給他,看看會發生什麼。

git clone ...
cp .env.example .env
docker-compose up
npm install
npm run seed
npm start

進入全螢幕模式 離開全螢幕模式

👦 外甥:如果這在不是我的機器上無法從頭跑到尾……

👨‍🦳 叔叔:那它就不是真正可重現的——它只是「在我機器上可以跑」,而這句話在業界造成的工程時數損失,幾乎不亞於任何 bug。


Configure → Mirror → Mock

👦 外甥:這個框架。

👨‍🦳 叔叔:

Configure
  ↓
Mirror
  ↓
Mock

進入全螢幕模式 離開全螢幕模式

Configure —— 所有機密與環境特定值都存在程式碼之外,在執行期載入。同樣的程式碼應該能在每個環境執行,而不需要改動任何一行。

Mirror —— 你的本地環境應該使用與 production 相同的核心技術——相同的資料庫引擎、相同的主要版本——而不是行為不同的輕量替代方案。

Mock —— 任何真正位於此功能邊界之外的事物——另一個團隊的服務、尚未存在的相依項目——在本地都應該用替代品,讓你的環境保持精簡,每位工程師都能在本機執行,而不需要把整個公司的基礎設施都搬上筆電。


叔叔的金句

「安靜地欺騙你的本地環境,比完全無法運作還糟糕——至少壞掉的設定會告訴你它壞掉了。」


👦 外甥:所以在寫真正的 addItem 邏輯之前,我必須先做完這些。

👨‍🦳 叔叔:全部都要。現在你可以真正開始寫程式碼,知道它在與什麼溝通,並且相信在你機器上運作的東西,在 staging 也會有相同的行為。但寫程式碼只是紀律的一半——如何儲存、分享,以及在不踩到隊友的情況下合併程式碼,是下一堂課。


🧠 像工程師一樣思考——作業

以你從第 3 集開始設計的同一個功能為例。

  • Configure —— 至少列出三個這個功能不應該硬寫在程式碼裡的值(一個 URL、一個 key、一個 flag)。草擬你的 .env 檔案內容。
  • Mirror —— 指出一個地方,如果使用「更簡單」的本地替代方案(不同的資料庫、記憶體中的假物件),可能會隱藏只有在 staging 或 production 才會出現的 bug。
  • Mock —— 指出這個功能對其自身邊界之外的某個相依項目,草擬你如何在本地 stub 它,而不需要真正運行該項目。

不用擔心工具是否完全正確。目標是練習察覺你的功能在哪些地方暗中依賴其環境,在環境改變之前先發現。

—— 第 7 集結束 ——