AI 為什麼總喜歡寫防禦性程式碼?

> 大家好 👋,我是 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 寫防禦性程式碼,本質是在彌補上下文缺失

人寫程式時,通常知道很多隱藏前提:

  • 這個參數是不是已經被 DTO 驗證過
  • 這個函式是不是只會在內部呼叫
  • 這個欄位在資料庫裡是不是非空
  • 這個例外應該由上層統一處理,還是在目前函式裡消化
  • 這個設定缺失時應該啟動失敗,還是可以使用預設值

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 生成的程式碼看起來很防禦,不代表它真的安全。它可能只是學會了安全程式碼的外觀,比如加了空值判斷、日誌和預設值,但沒有理解業務契約、權限邊界和失敗語義。

這也是為什麼我們不能只看程式碼有沒有考慮例外,而要看它有沒有把例外處理成正確的系統行為。

防禦性程式碼應該出現在邊界,而不是到處出現

防禦性程式碼本身沒有錯,錯的是位置不對。

真正需要防禦的地方,通常是系統邊界:

  • HTTP 請求參數
  • 表單輸入
  • 上傳檔案
  • 第三方 API 回傳值
  • Webhook payload
  • 環境變數
  • CLI 參數
  • 資料匯入檔
  • 跨租戶資源存取
  • 權限和角色判斷

這些位置的資料來自外部,確實應該嚴格驗證、解析、正規化和拒絕非法輸入。

但在業務核心邏輯裡,到處兜底反而會破壞系統契約。

比如這段程式碼看起來很穩:

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 };
}

這裡的重點不是少寫防禦程式碼,而是讓每一種失敗都有明確含義。參數錯誤直接拋出,業務上可預期的不存在用結構化結果表達,系統異常交給上層統一處理。

這才是工程上的防禦,而不是把所有錯誤都變成空字串、nullundefined

讀取環境變數時,AI 為什麼喜歡加 trim

環境變數確實是邊界輸入。它來自執行環境,不是程式碼內定義的常數。

Twelve-Factor App 的配置原則 建議把不同部署之間會變化的配置放到環境變數裡,例如資料庫連線、外部服務憑證、每個部署不同的主機名稱等。這樣配置可以和程式碼分離,不同環境也能使用同一份程式碼。

Node.js 文件也說明,環境變數最終會進入 process.env,並以字串形式被讀取。也就是說,0truefalse、JSON 字串這些值,在進入應用後都不是數字、布林值或物件,而是字串。

所以 AI 看到下面這種程式碼時:

ini 体验AI代码助手 代码解读复制代码const port = process.env.PORT;
const enableCache = process.env.ENABLE_CACHE;

它會本能地覺得這裡不安全,因為:

  • 值可能不存在
  • 值一定是字串
  • 值可能包含空格
  • 值可能需要轉換成數字、布林值、URL 或列舉
  • 值可能來自 .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() 不能無腦加。配置值不是普通輸入框文字,有些值的空白字元可能本身就是內容的一部分。

trim 最大的問題,是它可能改變配置語義

對於普通列舉、URL、埠號,去掉前後空格通常沒問題。

但對於某些值,空白字元可能就是值的一部分,例如:

  • 密碼
  • Token
  • HMAC secret
  • 私鑰
  • 多行憑證
  • Base64 內容
  • 某些第三方平台產生的金鑰

如果 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;
}

這裡的差異很關鍵:

  • 普通配置可以 trim
  • secret 不要偷偷 trim
  • 如果 secret 不允許前後空白,就直接失敗
  • 不要把配置錯誤自動修成另一個值

這才是真正的防禦性程式碼。它不是幫系統圓過去,而是在錯誤進入業務邏輯之前把它攔下來。

預設值也是 AI 最容易濫用的地方

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_URLJWT_SECRETOPENAI_API_KEYS3_SECRET_KEY 這類配置不能隨便預設。缺失就應該啟動失敗。

否則正式環境可能出現非常隱蔽的問題:

  • 連接到了本地或錯誤資料庫
  • 多個環境共用了同一個預設密鑰
  • JWT 可以被弱密鑰偽造
  • 第三方服務呼叫失敗但應用啟動成功
  • 線上流量進入了測試配置
  • 安全問題直到事故發生才暴露

更好的判斷標準是:

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,有的地方不 trim
  • 有的地方預設,有的地方拋錯
  • 測試和正式行為不一致
  • 型別仍然是 string | 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 預設生成的程式碼更長,但它的工程收益很明確:

  • 配置只在啟動時讀取一次
  • 必填配置缺失時直接失敗
  • 預設值只給低風險配置
  • secret 不會被偷偷修改
  • 數字、列舉、字串都有明確解析規則
  • 業務程式碼不用再處理 process.env.xxx
  • 配置錯誤不會拖到執行中才暴露

這就是環境變數讀取裡真正合理的防禦性程式碼。

使用 Zod,比到處手寫 if 更穩定

如果專案裡已經使用 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 的問題不是加了 trim,而是不知道哪些地方不能 trim

環境變數場景正好能說明 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 少寫錯誤防禦程式碼,可以直接這樣約束

以後讓 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 或弱預設值。

好的防禦性程式碼不是到處兜底,而是:

  • 在邊界處嚴格校驗
  • 在核心邏輯裡保持契約清晰
  • 對可恢復失敗結構化表達
  • 對不可恢復錯誤 fail fast
  • 對正式必填配置拒絕預設值
  • 對 secret 保持原樣,並校驗異常格式

AI 生成程式碼最需要審查的地方,往往不是它有沒有考慮例外,而是它有沒有把真正應該暴露的問題悄悄吞掉。


原文出處:https://juejin.cn/post/7641484939951407155


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬4   ❤️1
464
🥈
alicec
📝1   ❤️2
87
#4
我愛JS
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登