> 大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 開發 [DocFlow](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fxun082%2FDocFlow)。這是一個面向 AI 場景的協作文件平台,整合了基於 `Tiptap` 的富文字編輯、`NestJS` 後端服務、即時協作與智慧化工作流程等核心模組。
在這個專案的持續打磨過程中,我累積了不少實戰經驗,不只是
Tiptap的深度客製化、編輯器效能優化和協作方案設計,也包括前端工程化建設、React 原始碼理解以及複雜專案架構實務。如果你對 AI 全端開發、文件編輯器、前端工程化或者 React 原始碼相關內容感興趣,歡迎加我的微信
yunmz777一起交流。覺得專案還不錯的話,也歡迎給 DocFlow 點個 star ⭐
AI 生成程式碼時,經常會寫出一種看起來很謹慎的風格:到處判斷空值、到處給預設值、到處包 try/catch,讀取環境變數時還特別喜歡加 trim() 和 fallback。
比如下面這種程式碼很常見:
ini 体验AI代码助手 代码解读复制代码const port = Number(process.env.PORT?.trim() || 3000);
const apiKey = process.env.API_KEY?.trim() || "";
const timeout = Number(process.env.TIMEOUT || 5000);
try {
// do something
} catch (error) {
console.error(error);
return null;
}
它表面上很安全:空值兜住了、預設值給了、字串也 trim 了,例外也 catch 了。但真實工程裡,這類寫法常常不是讓系統更可靠,而是把本該暴露的問題悄悄藏起來。
尤其是讀取環境變數時,AI 很容易自動加 trim()、|| default、?? default。因為它把環境變數當成不可信輸入來處理,這個判斷有一半是對的:環境變數確實來自執行環境,不是程式碼內部常數。但另一半很危險:不是所有設定都能被自動修正,也不是所有缺失都應該給預設值。
真正的問題不是 AI 寫了防禦性程式碼,而是它不知道防禦應該放在哪裡,哪些錯誤應該被兜底,哪些錯誤必須直接暴露。
人寫程式時,通常知道很多隱藏前提:
AI 往往不知道這些前提。它只能看到局部程式碼片段,所以會傾向於選擇一種局部看起來更穩的寫法:多判斷一點、多兜底一點、多 catch 一點。
於是它很容易寫出這種程式碼:
kotlin 体验AI代码助手 代码解读复制代码if (!user) {
return null;
}
if (!items?.length) {
return [];
}
try {
return await service.run();
} catch {
return undefined;
}
這些程式碼在局部看起來不會崩,但在系統層面可能更糟。因為它把本來應該暴露的問題,改造成了一個看似正常的回傳值。
比如 return null 可能掩蓋了使用者不存在、權限不足、資料庫異常、呼叫參數錯誤等完全不同的問題。呼叫方拿到 null 以後,不知道該重試、提示使用者、回滾交易,還是告警排查。
fail fast 的核心思想是:錯誤越早、越明確地暴露,越容易定位和修復。系統如果自動繞過錯誤,問題可能會在更深的鏈路裡變成更隱蔽、更難排查的故障。
所以,AI 的防禦性程式碼經常不是工程健壯,而是局部自保。
AI 程式碼模型學到的不是某個專案的架構約束,而是大量公開程式碼、教學、問答社群、文件範例裡的高頻模式。
公開程式碼裡有大量這樣的寫法:
ini 体验AI代码助手 代码解读复制代码const value = input || defaultValue;
const name = user?.profile?.name ?? "";
const port = process.env.PORT || 3000;
久而久之,模型會形成一種傾向:不確定時就加預設值、不確定時就加空值判斷、不確定時就包一層 try/catch。
但公開程式碼裡也包含大量不安全、過時或不適合生產環境的寫法。AI 生成的程式碼看起來很防禦,不代表它真的安全。它可能只是學會了安全程式碼的外觀,比如加了空值判斷、日誌和預設值,但沒有理解業務契約、權限邊界和失敗語義。
這也是為什麼我們不能只看程式碼有沒有考慮例外,而要看它有沒有把例外處理成正確的系統行為。
防禦性程式碼本身沒有錯,錯的是位置不對。
真正需要防禦的地方,通常是系統邊界:
這些位置的資料來自外部,確實應該嚴格驗證、解析、正規化和拒絕非法輸入。
但在業務核心邏輯裡,到處兜底反而會破壞系統契約。
比如這段程式碼看起來很穩:
csharp 体验AI代码助手 代码解读复制代码async function getUserName(userId?: string) {
if (!userId) {
return "";
}
const user = await userRepository.findById(userId);
return user?.name ?? "";
}
呼叫方拿到空字串以後,根本不知道發生了什麼:
userId 沒傳?更好的做法,是把失敗語義區分清楚:
typescript 体验AI代码助手 代码解读复制代码type GetUserNameResult =
| { ok: true; name: string }
| { ok: false; reason: "USER_NOT_FOUND" };
async function getUserName(userId: string): Promise<GetUserNameResult> {
if (!userId) {
throw new Error("userId is required");
}
const user = await userRepository.findById(userId);
if (!user) {
return { ok: false, reason: "USER_NOT_FOUND" };
}
return { ok: true, name: user.name };
}
這裡的重點不是少寫防禦程式碼,而是讓每一種失敗都有明確含義。參數錯誤直接拋出,業務上可預期的不存在用結構化結果表達,系統異常交給上層統一處理。
這才是工程上的防禦,而不是把所有錯誤都變成空字串、null 或 undefined。
環境變數確實是邊界輸入。它來自執行環境,不是程式碼內定義的常數。
Twelve-Factor App 的配置原則 建議把不同部署之間會變化的配置放到環境變數裡,例如資料庫連線、外部服務憑證、每個部署不同的主機名稱等。這樣配置可以和程式碼分離,不同環境也能使用同一份程式碼。
Node.js 文件也說明,環境變數最終會進入 process.env,並以字串形式被讀取。也就是說,0、true、false、JSON 字串這些值,在進入應用後都不是數字、布林值或物件,而是字串。
所以 AI 看到下面這種程式碼時:
ini 体验AI代码助手 代码解读复制代码const port = process.env.PORT;
const enableCache = process.env.ENABLE_CACHE;
它會本能地覺得這裡不安全,因為:
.env、Docker、Kubernetes、CI 或部署平台於是它很容易生成:
ini 体验AI代码助手 代码解读复制代码const port = Number(process.env.PORT?.trim() || 3000);
這裡的 trim() 不是完全沒道理。它的潛台詞是:我先把配置值前後的意外空格去掉,避免部署時因為複製貼上多了空格導致解析失敗。
在某些配置上,這樣做是合理的,例如:
ini 体验AI代码助手 代码解读复制代码const nodeEnv = process.env.NODE_ENV?.trim();
const databaseUrl = process.env.DATABASE_URL?.trim();
const redisUrl = process.env.REDIS_URL?.trim();
但問題是,trim() 不能無腦加。配置值不是普通輸入框文字,有些值的空白字元可能本身就是內容的一部分。
對於普通列舉、URL、埠號,去掉前後空格通常沒問題。
但對於某些值,空白字元可能就是值的一部分,例如:
如果 AI 寫成這樣:
ini 体验AI代码助手 代码解读复制代码const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";
const privateKey = process.env.PRIVATE_KEY?.trim();
這裡至少有兩個問題。
第一,trim() 可能改變 secret 的真實值。很多 secret 前後空格不是常見需求,但設定載入器不應該擅自修改它。更穩的做法是:如果不允許前後空格,就驗證並報錯,而不是悄悄幫它修。
第二,預設值 "secret" 非常危險。正式環境裡金鑰缺失時,系統應該啟動失敗,而不是自動使用一個弱預設值繼續執行。
更合理的策略,是按配置類型分類處理:
typescript 体验AI代码助手 代码解读复制代码function requireEnv(name: string): string {
const value = process.env[name];
if (value === undefined || value === "") {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function requireTrimmedEnv(name: string): string {
const value = requireEnv(name);
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new Error(`Environment variable ${name} cannot be blank`);
}
return trimmed;
}
function requireSecretEnv(name: string): string {
const value = requireEnv(name);
if (value !== value.trim()) {
throw new Error(
`Environment variable ${name} contains leading or trailing whitespace`,
);
}
return value;
}
這裡的差異很關鍵:
trimtrim這才是真正的防禦性程式碼。它不是幫系統圓過去,而是在錯誤進入業務邏輯之前把它攔下來。
AI 讀取環境變數時,也很喜歡寫預設值:
ini 体验AI代码助手 代码解读复制代码const port = Number(process.env.PORT || 3000);
const databaseUrl = process.env.DATABASE_URL || "postgres://localhost:5432/app";
const jwtSecret = process.env.JWT_SECRET || "secret";
const enableDebug = process.env.ENABLE_DEBUG || false;
這類寫法看起來方便,但它把三種完全不同的配置混在了一起:
比如 PORT 預設成 3000 通常可以接受,因為它不是安全敏感配置。
但 DATABASE_URL、JWT_SECRET、OPENAI_API_KEY、S3_SECRET_KEY 這類配置不能隨便預設。缺失就應該啟動失敗。
否則正式環境可能出現非常隱蔽的問題:
更好的判斷標準是:
diff 体验AI代码助手 代码解读复制代码可以默认:
- PORT
- LOG_LEVEL
- REQUEST_TIMEOUT_MS
- FEATURE_FLAG 默认关闭
- 分页大小
- 非生产环境 mock 开关
不应该默认:
- DATABASE_URL
- JWT_SECRET
- SESSION_SECRET
- API_KEY
- S3_SECRET_KEY
- ENCRYPTION_KEY
- OAUTH_CLIENT_SECRET
- WEBHOOK_SECRET
預設值不是不能用,而是只能用於缺失也不會破壞安全和資料正確性的配置。
|| default 經常比看起來更危險AI 很喜歡寫:
ini 体验AI代码助手 代码解读复制代码const timeout = Number(process.env.TIMEOUT_MS) || 5000;
這個寫法有一個隱藏問題:它會把所有 falsy 值都當成缺失。
比如:
javascript 体验AI代码助手 代码解读复制代码Number("0") || 5000;
結果是 5000,不是 0。
如果 0 在業務裡代表停用逾時、關閉重試、不限制數量,這個預設值就會悄悄改變行為。
更好的寫法是先判斷是否缺失,再解析:
typescript 体验AI代码助手 代码解读复制代码function optionalIntEnv(name: string, defaultValue: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") {
return defaultValue;
}
const value = Number(raw);
if (!Number.isInteger(value)) {
throw new Error(`Environment variable ${name} must be an integer`);
}
return value;
}
const timeoutMs = optionalIntEnv("REQUEST_TIMEOUT_MS", 5000);
這樣至少能區分三種情況:
AI 經常把這三種情況混在一起,所以程式碼看起來短,實際風險更高。
環境變數不要散落在業務程式碼裡。
不推薦這樣寫:
javascript 体验AI代码助手 代码解读复制代码export async function callModel(prompt: string) {
const apiKey = process.env.OPENAI_API_KEY?.trim() || "";
if (!apiKey) {
return null;
}
// ...
}
這會帶來幾個問題:
trim,有的地方不 trimstring | undefined更推薦在應用啟動時集中解析:
typescript 体验AI代码助手 代码解读复制代码type AppConfig = {
nodeEnv: "development" | "test" | "production";
port: number;
databaseUrl: string;
jwtSecret: string;
requestTimeoutMs: number;
};
function parseNodeEnv(): AppConfig["nodeEnv"] {
const value = process.env.NODE_ENV?.trim() || "development";
if (!["development", "test", "production"].includes(value)) {
throw new Error(`Invalid NODE_ENV: ${value}`);
}
return value as AppConfig["nodeEnv"];
}
function requireTrimmedString(name: string): string {
const value = process.env[name];
if (value === undefined) {
throw new Error(`Missing required environment variable: ${name}`);
}
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new Error(`Environment variable ${name} cannot be empty`);
}
return trimmed;
}
function requireSecret(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`Missing required secret: ${name}`);
}
if (value !== value.trim()) {
throw new Error(`Secret ${name} contains leading or trailing whitespace`);
}
return value;
}
function optionalInteger(name: string, defaultValue: number): number {
const value = process.env[name];
if (value === undefined || value.trim() === "") {
return defaultValue;
}
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new Error(`Environment variable ${name} must be an integer`);
}
return parsed;
}
export const config: AppConfig = {
nodeEnv: parseNodeEnv(),
port: optionalInteger("PORT", 3000),
databaseUrl: requireTrimmedString("DATABASE_URL"),
jwtSecret: requireSecret("JWT_SECRET"),
requestTimeoutMs: optionalInteger("REQUEST_TIMEOUT_MS", 5000),
};
這個版本看起來比 AI 預設生成的程式碼更長,但它的工程收益很明確:
process.env.xxx這就是環境變數讀取裡真正合理的防禦性程式碼。
如果專案裡已經使用 Zod,可以把環境變數當成一個邊界輸入,用 Schema 統一校驗。
javascript 体验AI代码助手 代码解读复制代码import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
PORT: z
.string()
.optional()
.transform((value) => {
if (value === undefined || value.trim() === "") {
return 3000;
}
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new Error("PORT must be an integer");
}
return parsed;
}),
DATABASE_URL: z
.string()
.trim()
.min(1, "DATABASE_URL is required"),
JWT_SECRET: z
.string()
.min(1, "JWT_SECRET is required")
.refine((value) => value === value.trim(), {
message: "JWT_SECRET must not contain leading or trailing whitespace",
}),
REQUEST_TIMEOUT_MS: z
.string()
.optional()
.transform((value) => {
if (value === undefined || value.trim() === "") {
return 5000;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error("REQUEST_TIMEOUT_MS must be a positive integer");
}
return parsed;
}),
});
export const config = envSchema.parse(process.env);
這裡不是簡單地到處 .trim().default(),而是按配置類型分開處理。
DATABASE_URL 可以 trim,因為它通常不應該包含前後空格。
JWT_SECRET 不直接 trim,而是校驗是否存在意外空白。因為 secret 是身份和簽名邊界,系統不應該擅自修改它。
環境變數場景正好能說明 AI 防禦性程式碼的核心問題。
AI 加 trim() 的動機是合理的:環境變數是外部輸入,確實可能有格式問題。
但它經常不區分:
這就導致它寫出一種很圓滑但危險的配置讀取程式碼:
ini 体验AI代码助手 代码解读复制代码const apiKey = process.env.API_KEY?.trim() || "";
const databaseUrl = process.env.DATABASE_URL?.trim() || "localhost";
const jwtSecret = process.env.JWT_SECRET?.trim() || "secret";
這不是生產級健壯性,而是在用預設值掩蓋部署錯誤。
更好的工程原則是:
体验AI代码助手 代码解读复制代码環境變數讀取可以防禦,但不能靜默兜底。
普通字串:可以 trim,但要校驗空值。
數字配置:先判斷缺失,再解析,再校驗範圍。
列舉配置:trim 後必須命中允許列表。
URL 配置:trim 後用 URL 解析校驗。
secret 配置:不要偷偷 trim,發現意外空白就啟動失敗。
正式必填配置:不要預設值,缺失就 fail fast。
低風險配置:可以有明確預設值。
以後讓 AI 寫配置程式碼時,不要只說幫我寫得健壯一點。這句話很容易讓它到處加兜底。
可以直接這樣要求:
diff 体验AI代码助手 代码解读复制代码請寫一個 TypeScript 配置載入模組,要求:
- 所有環境變數只允許在 config 模組中讀取
- 應用啟動時完成解析和校驗
- 必填配置缺失時直接拋錯,禁止靜默 fallback
- PORT、REQUEST_TIMEOUT_MS 這類低風險配置可以有預設值
- DATABASE_URL、JWT_SECRET、API_KEY、SESSION_SECRET 禁止預設值
- 普通 URL 和列舉值可以 trim
- secret 不要自動 trim,如果出現前後空白應直接報錯
- 不要使用 process.env.X || default 這種寫法
- 數字配置必須顯式 parse,並校驗整數、正數和範圍
- 輸出一個型別明確的 config 物件,業務程式碼只能使用 config,不直接讀 process.env
這樣生成的程式碼會穩定很多,因為你把防禦的位置和不能兜底的位置都說清楚了。
AI 喜歡寫防禦性程式碼,是因為它面對的是不完整上下文。它不知道哪些錯誤應該拋出,哪些錯誤可以恢復,哪些值已經在上游驗證過,於是傾向於用空值判斷、預設值、trim()、try/catch 來讓局部程式碼看起來更穩。
讀取環境變數時,這種傾向會更明顯。環境變數確實屬於邊界輸入,需要解析、校驗和型別轉換。Node.js 中環境變數最終都是字串,配置又會隨著部署環境變化,所以 AI 自動加 trim() 和預設值並不奇怪。
真正的問題是,環境變數不能被粗暴兜底。PORT 可以預設,JWT_SECRET 不能預設;普通 URL 可以 trim,secret 不應該偷偷 trim;非法配置應該啟動失敗,而不是執行時回傳空字串、null 或弱預設值。
好的防禦性程式碼不是到處兜底,而是:
AI 生成程式碼最需要審查的地方,往往不是它有沒有考慮例外,而是它有沒有把真正應該暴露的問題悄悄吞掉。