前置知识: TypeScript、TypeScript

类型安全的环境变量

20 min中级

构建类型安全的环境变量管理系统,涵盖字面量类型、映射类型、Zod 运行时校验、多环境配置与生产级最佳实践。

阅读提示:正文以代码和白话为主,不出现类型论公式。进阶文档中若出现 Γ ⊢ e : τ 这类记号,第一遍可完全跳过(完整规则见 001-HowToReadThisCourse)。

类型安全的环境变量

前置知识

学习目标

  • 掌握「历史动机与背景」的核心机制、典型用法与常见陷阱
  • 掌握「形式化定义」的核心机制、典型用法与常见陷阱
  • 掌握「理论推导」的核心机制、典型用法与常见陷阱
  • 掌握「代码示例」的核心机制、典型用法与常见陷阱
  • 掌握「对比分析」的核心机制、典型用法与常见陷阱

历史动机与背景

前端环境变量的起源

2010 年前后,Node.js 借鉴 Unix Shell 的环境变量机制,将 process.env 作为应用配置的标准入口。这一设计简单有效:操作系统提供字符串键值对,应用按需读取。然而,这种”字符串字典”的访问方式天然缺乏类型约束:

  • process.env.PORT 返回 string | undefined,但开发者往往需要 number。
  • 拼写错误(process.env.POTR)在运行时才暴露,编译期无任何提示。
  • 布尔值与数字以字符串形式存储("true"、"3000"),需要手动转换且易出错。
  • 必需变量缺失时,应用可能在启动一段时间后才崩溃,难以快速定位。

12-Factor App 的推动

2011 年,Heroku 联合创始人 Adam Wiggins 发布了 The Twelve-Factor App 方法论,其中第三条明确提出”配置应存储在环境中,而非代码里”。这一原则推动了环境变量在前端的普及,但也暴露了类型安全的迫切需求:

  • 配置与代码分离后,配置的正确性只能由运行时保证。
  • 不同环境(dev/staging/prod)的配置差异若缺乏类型约束,极易在部署时出错。
  • 密钥泄漏事故频发——开发者无意间把 VITE_AWS_SECRET 写入前端 bundle,被攻击者从 sourcemap 中提取。

TypeScript 的应对

TypeScript 2.0(2016)引入了 declare global 与接口合并,允许开发者扩展 NodeJS.ProcessEnv:

declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    JWT_SECRET: string;
  }
}

这一机制让 process.env.DATABASE_URL 在编译期获得 string 类型,但仍未解决运行时校验问题。2018 年 Vite 的出现带来了 import.meta.env 与 VITE_ 前缀约定,进一步推动了前端环境变量的标准化。

运行时校验的崛起

2020 年后,Zod、envalid、dotenv-flow 等库相继流行。它们的核心思想是:用 Schema 同时驱动编译期类型与运行时校验,实现”单一数据源”(Single Source of Truth)。这一范式已成为 2024 年后生产级应用的事实标准。

形式化定义

环境变量的本质

设 E\mathcal{E} 为所有可能的环境变量键值对的全集:

E={(k,v)∣k∈String,v∈String∪{undefined}}\mathcal{E} = \{ (k, v) \mid k \in \text{String}, v \in \text{String} \cup \{\text{undefined}\} \}

应用启动时,操作系统向进程注入一个有限的子集 E⊆EE \subseteq \mathcal{E}。环境变量管理的本质是构造一个映射:

Load:E→C\text{Load} : E \to C

其中 CC 是应用的强类型配置对象。该映射必须满足以下性质:

  • 完备性:所有必需字段在 EE 中存在,否则 Load\text{Load} 抛出异常。
  • 类型正确性:字符串值被正确转换为 CC 中声明的类型(number、boolean、URL 等)。
  • 安全性:敏感字段不被泄漏到不可信环境(如前端 bundle)。

类型声明的形式化

TypeScript 通过接口合并扩展 ProcessEnv。设 P0P_0 为 ProcessEnv 的内置定义,开发者声明 P1P_1,合并后的类型为:

P=P0  &  P1P = P_0 \;\&\; P_1

这是**交集类型(Intersection Type)**在声明合并中的应用。访问 process.env[k] 时,编译器查找 kk 在 PP 中的属性类型。若 k∉Pk \notin P,则报错 Property 'k' does not exist on type 'ProcessEnv'。

运行时校验的形式化

设 SS 为 Zod Schema,S:unknown→TS : \text{unknown} \to T(TT 为目标类型)。校验函数 parse 的语义:

parse(S,x)={t∈T若 x  符合  Sthrow否则\text{parse}(S, x) = \begin{cases} t \in T & \text{若 } x \;\text{符合}\; S \\ \text{throw} & \text{否则} \end{cases}

而 safeParse 返回 tagged union:

safeParse(S,x)={{success:true,data:t}若校验通过{success:false,error:e}否则\text{safeParse}(S, x) = \begin{cases} \{ \text{success}: \text{true}, \text{data}: t \} & \text{若校验通过} \\ \{ \text{success}: \text{false}, \text{error}: e \} & \text{否则} \end{cases}

关键性质:T=Infer(S)T = \text{Infer}(S),即编译期类型由 Schema 自动推导,保证”类型与校验逻辑始终一致”。

多环境配置的形式化

设 N={dev,staging,prod}\mathcal{N} = \{ \text{dev}, \text{staging}, \text{prod} \} 为环境集合,每个环境 n∈Nn \in \mathcal{N} 对应配置 CnC_n。多环境配置是一个函数:

Config:N→C\text{Config} : \mathcal{N} \to C

通常实现为查找表 Record<Environment, Config>。关键约束:所有 CnC_n 必须类型相同(共享接口 CC),仅值不同。这保证了应用代码在不同环境下行为一致。

理论推导

推导一:为何 process.env 是字符串字典

Node.js 的 process.env 实质上是一个对操作系统 environ 变量的包装。Unix 与 Windows 的环境变量都以字符串形式存储——操作系统不区分数字、布尔、对象,只有”字符串键 → 字符串值”。

形式化地,process.env 的类型为 NodeJS.ProcessEnv,其内置定义为 Record<string, string | undefined>。这意味着:

  • 访问任意键都返回 string | undefined。
  • 即使开发者通过 declare global 扩展了 DATABASE_URL: string,运行时仍可能是 undefined(变量未设置)。
  • 编译期类型与运行时实际值之间存在”信任鸿沟”。

这一鸿沟只能通过运行时校验弥合——这是 Zod 等库存在的根本理由。

推导二:接口合并的代数性质

TypeScript 的接口合并满足交换律与结合律:

(A  &  B)  &  C≡A  &  (B  &  C)≡A  &  B  &  C(A \;\&\; B) \;\&\; C \equiv A \;\&\; (B \;\&\; C) \equiv A \;\&\; B \;\&\; C

这意味着多个 .d.ts 文件可以分散声明 ProcessEnv 的不同字段,最终合并为一个完整类型。例如:

// env.database.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    DB_POOL_SIZE: string;
  }
}

// env.auth.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    JWT_SECRET: string;
    JWT_EXPIRES_IN: string;
  }
}

合并后,process.env.DATABASE_URL 与 process.env.JWT_SECRET 都有正确类型。这一性质支持了大型项目的环境变量模块化组织。

推导三:Zod 的类型推导链

Zod 的 z.object({...}) 返回一个 ZodObject,其 infer 类型通过条件类型与映射类型推导:

type Infer<T extends ZodType> = T extends ZodType<infer U> ? U : never;

对于 z.object({ PORT: z.coerce.number() }),推导过程为:

  1. z.coerce.number() 返回 ZodNumber,其 output 类型为 number。
  2. z.object({...}) 将每个字段的 output 收集为一个对象类型。
  3. Infer<Schema> 返回 { PORT: number }。

这一推导链保证了”Schema 即类型”——修改 Schema 自动同步类型,杜绝了”声明与校验不一致”的 bug。

复杂度分析

环境变量校验的复杂度分析:

  • 声明扩展:O(1)O(1),接口合并是编译期的静态操作。
  • Zod 校验:O(n)O(n),其中 nn 为字段数。每个字段的校验是常数时间(字符串、数字、URL 等校验器均为 O(1)O(1))。
  • 默认值填充:O(n)O(n),遍历所有字段。
  • 错误聚合:O(e)O(e),其中 ee 为错误数,通常 e≪ne \ll n。

总体而言,环境变量校验在应用启动时执行一次,性能影响可忽略。即便有 100 个字段,校验耗时通常在 1ms 以内。

代码示例

示例 1:基础类型声明

// env.d.ts - 环境变量类型声明文件
// 通过接口合并扩展 NodeJS.ProcessEnv,使编译器识别自定义环境变量

declare namespace NodeJS {
  interface ProcessEnv {
    // 必需变量:未设置时类型仍为 string,但运行时校验会捕获
    readonly NODE_ENV: 'development' | 'production' | 'test';
    readonly DATABASE_URL: string;
    readonly JWT_SECRET: string;

    // 可选变量:明确标记为可能 undefined
    readonly PORT?: string;
    readonly REDIS_URL?: string;
    readonly LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error';

    // 布尔值与数字以字符串形式存储
    readonly DEBUG?: string;           // 'true' | 'false'
    readonly DB_POOL_SIZE?: string;    // 数字字符串
    readonly ENABLE_ANALYTICS?: string;
  }
}

// 现在访问环境变量会获得类型提示
// process.env.DATABASE_URL  -> string
// process.env.PORT          -> string | undefined
// process.env.UNKNOWN_VAR   -> 编译错误

示例 2:类型安全的访问器函数

// env.ts - 类型安全的访问器封装
// 提供按类型读取环境变量的工具函数,统一处理转换与默认值

/**
 * 读取必需的字符串环境变量
 * @param key - 环境变量名(必须是 ProcessEnv 的键)
 * @returns 环境变量值
 * @throws Error - 变量未设置时抛出
 */
function getEnv(key: keyof NodeJS.ProcessEnv): string {
  const value = process.env[key];
  if (value === undefined) {
    throw new Error(`[env] 必需的环境变量 ${key} 未设置`);
  }
  return value;
}

/**
 * 读取可选字符串环境变量,提供默认值
 * @param key - 环境变量名
 * @param defaultValue - 默认值
 */
function getOptionalEnv(
  key: keyof NodeJS.ProcessEnv,
  defaultValue: string,
): string {
  return process.env[key] ?? defaultValue;
}

/**
 * 读取布尔值环境变量
 * 接受 'true'/'1'/'yes' 为真,'false'/'0'/'no' 为假
 * @param key - 环境变量名
 * @param defaultValue - 默认值
 */
function getBooleanEnv(
  key: keyof NodeJS.ProcessEnv,
  defaultValue = false,
): boolean {
  const value = process.env[key];
  if (value === undefined) return defaultValue;
  return ['true', '1', 'yes'].includes(value.toLowerCase());
}

/**
 * 读取数字环境变量
 * @param key - 环境变量名
 * @param defaultValue - 默认值
 * @throws Error - 值不是有效数字时抛出
 */
function getNumberEnv(
  key: keyof NodeJS.ProcessEnv,
  defaultValue: number,
): number {
  const value = process.env[key];
  if (value === undefined || value === '') return defaultValue;
  const parsed = Number(value);
  if (Number.isNaN(parsed)) {
    throw new Error(`[env] 环境变量 ${key} 不是有效数字: ${value}`);
  }
  return parsed;
}

/**
 * 读取 JSON 格式的环境变量
 * @param key - 环境变量名
 * @param defaultValue - 默认值
 */
function getJsonEnv<T>(key: keyof NodeJS.ProcessEnv, defaultValue: T): T {
  const value = process.env[key];
  if (value === undefined || value === '') return defaultValue;
  try {
    return JSON.parse(value) as T;
  } catch {
    throw new Error(`[env] 环境变量 ${key} 不是有效 JSON: ${value}`);
  }
}

// 使用示例
const port = getNumberEnv('PORT', 3000);           // number
const debug = getBooleanEnv('DEBUG', false);       // boolean
const dbUrl = getEnv('DATABASE_URL');              // string,未设置时抛错
const redisUrl = getOptionalEnv('REDIS_URL', '');  // string

示例 3:构建配置对象

// config.ts - 从环境变量构建强类型配置对象
// 将分散的环境变量聚合为一个结构化的配置对象,供应用全局使用

interface AppConfig {
  env: 'development' | 'production' | 'test';
  port: number;
  database: {
    url: string;
    poolSize: number;
    ssl: boolean;
  };
  jwt: {
    secret: string;
    expiresIn: string;
  };
  redis: {
    url: string | null;
    ttl: number;
  };
  logging: {
    level: 'debug' | 'info' | 'warn' | 'error';
    pretty: boolean;
  };
  features: {
    debug: boolean;
    analytics: boolean;
    sentry: boolean;
  };
}

/**
 * 从环境变量加载应用配置
 * 集中处理所有类型转换与默认值
 */
function loadConfig(): AppConfig {
  return {
    env: (process.env.NODE_ENV as AppConfig['env']) ?? 'development',
    port: getNumberEnv('PORT', 3000),
    database: {
      url: getEnv('DATABASE_URL'),
      poolSize: getNumberEnv('DB_POOL_SIZE', 10),
      ssl: getBooleanEnv('DB_SSL', false),
    },
    jwt: {
      secret: getEnv('JWT_SECRET'),
      expiresIn: getOptionalEnv('JWT_EXPIRES_IN', '7d'),
    },
    redis: {
      url: process.env.REDIS_URL ?? null,
      ttl: getNumberEnv('REDIS_TTL', 3600),
    },
    logging: {
      level: (process.env.LOG_LEVEL as AppConfig['logging']['level']) ?? 'info',
      pretty: getBooleanEnv('LOG_PRETTY', false),
    },
    features: {
      debug: getBooleanEnv('DEBUG', false),
      analytics: getBooleanEnv('ENABLE_ANALYTICS', false),
      sentry: getBooleanEnv('ENABLE_SENTRY', false),
    },
  };
}

// 全局单例:应用启动时加载一次
const config = loadConfig();

export { config, type AppConfig };

示例 4:Zod 运行时校验

// env.schema.ts - 使用 Zod 定义环境变量 Schema
// 同时获得编译期类型与运行时校验,单一数据源

import { z } from 'zod';

// 定义环境变量 Schema
// z.coerce 自动将字符串转换为目标类型
const envSchema = z.object({
  // 枚举类型:限定取值范围
  NODE_ENV: z
    .enum(['development', 'production', 'test'])
    .default('development'),

  // 数字类型:字符串自动转换
  PORT: z.coerce.number().int().positive().max(65535).default(3000),

  // URL 类型:必须是合法 URL
  DATABASE_URL: z.string().url(),

  // 字符串长度约束:密钥至少 32 字符
  JWT_SECRET: z
    .string()
    .min(32, 'JWT_SECRET 至少需要 32 个字符')
    .max(256, 'JWT_SECRET 不应超过 256 个字符'),

  // 可选 URL
  REDIS_URL: z.string().url().optional(),

  // 布尔值:接受 'true'/'false'/'1'/'0'
  DEBUG: z.coerce.boolean().default(false),

  // 枚举日志级别
  LOG_LEVEL: z
    .enum(['debug', 'info', 'warn', 'error'])
    .default('info'),

  // 数字范围约束
  DB_POOL_SIZE: z.coerce.number().int().positive().max(100).default(10),

  // 正则匹配
  SENTRY_DSN: z
    .string()
    .regex(/^https:\/\/.+@.+\.\d+$/, 'SENTRY_DSN 格式不正确')
    .optional(),
});

// 自动推导类型:Schema 即类型
type Env = z.infer<typeof envSchema>;

/**
 * 解析环境变量
 * 校验失败时抛出 ZodError,包含详细错误信息
 */
function parseEnv(): Env {
  return envSchema.parse(process.env);
}

/**
 * 安全解析环境变量
 * 返回 tagged union,不抛出异常
 */
function safeParseEnv():
  | { success: true; data: Env }
  | { success: false; error: z.ZodError } {
  return envSchema.safeParse(process.env);
}

// 应用启动时校验
const envResult = safeParseEnv();
if (!envResult.success) {
  console.error('[env] 环境变量校验失败:');
  for (const issue of envResult.error.issues) {
    console.error(`  - ${issue.path.join('.')}: ${issue.message}`);
  }
  process.exit(1);
}

const env = envResult.data;
export { env, type Env, envSchema };

示例 5:Vite 前端环境变量

// vite-env.d.ts - Vite 前端环境变量声明
/// <reference types="vite/client" />

// 扩展 ImportMetaEnv,声明应用使用的 VITE_ 前缀变量
interface ImportMetaEnv {
  // API 配置
  readonly VITE_API_URL: string;
  readonly VITE_API_TIMEOUT: string;  // 数字字符串

  // 应用元信息
  readonly VITE_APP_TITLE: string;
  readonly VITE_APP_VERSION: string;

  // 功能开关
  readonly VITE_ENABLE_ANALYTICS?: string;
  readonly VITE_ENABLE_SENTRY?: string;

  // 构建信息
  readonly VITE_BUILD_TIME?: string;
  readonly VITE_GIT_COMMIT?: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

// vite.config.ts 中类型安全地使用环境变量
import { defineConfig, loadEnv } from 'vite';
import type { UserConfig } from 'vite';

export default defineConfig(({ mode }) => {
  // 加载 .env 文件
  const env = loadEnv(mode, process.cwd(), '');

  // 类型安全访问
  const apiUrl = env.VITE_API_URL;
  if (!apiUrl) {
    throw new Error('VITE_API_URL 未设置');
  }

  return {
    define: {
      // 将环境变量注入到应用代码中
      'import.meta.env.VITE_APP_VERSION': JSON.stringify(
        env.VITE_APP_VERSION || '0.0.0',
      ),
    },
    server: {
      port: Number(env.VITE_DEV_PORT) || 5173,
      proxy: {
        '/api': {
          target: apiUrl,
          changeOrigin: true,
        },
      },
    },
  } satisfies UserConfig;
});

示例 6:运行时注入的环境变量

// runtime-env.ts - 运行时环境变量注入方案
// 适用于需要在构建后动态切换配置的场景(如 Docker 镜像复用)

interface RuntimeEnv {
  API_URL: string;
  WS_URL: string;
  SENTRY_DSN: string;
  VERSION: string;
  BUILD_TIME: string;
}

// 通过 window.__ENV__ 注入运行时环境变量
declare global {
  interface Window {
    __ENV__?: Partial<RuntimeEnv>;
  }
}

/**
 * 加载运行时环境变量
 * 优先从 window.__ENV__ 读取,回退到构建时变量
 */
function loadRuntimeEnv(): RuntimeEnv {
  const injected = window.__ENV__ ?? {};

  // 校验必需字段
  const required: (keyof RuntimeEnv)[] = ['API_URL', 'VERSION'];
  const missing = required.filter((key) => !injected[key]);
  if (missing.length > 0) {
    throw new Error(
      `[runtime-env] 以下运行时变量未注入: ${missing.join(', ')}`,
    );
  }

  return {
    API_URL: injected.API_URL!,
    WS_URL: injected.WS_URL ?? '',
    SENTRY_DSN: injected.SENTRY_DSN ?? '',
    VERSION: injected.VERSION!,
    BUILD_TIME: injected.BUILD_TIME ?? '',
  };
}

// 在 index.html 中通过脚本注入
// <script>
//   window.__ENV__ = {
//     API_URL: '<%= process.env.API_URL %>',
//     VERSION: '<%= process.env.npm_package_version %>',
//   };
// </script>

const runtimeEnv = loadRuntimeEnv();
export { runtimeEnv, type RuntimeEnv };

示例 7:多环境配置管理

// environments.ts - 多环境配置集中管理
// 每个环境的配置共享同一类型,保证应用行为一致

type Environment = 'development' | 'staging' | 'production';

interface EnvironmentConfig {
  apiUrl: string;
  cdnUrl: string;
  debug: boolean;
  logLevel: 'debug' | 'info' | 'warn' | 'error';
  enableAnalytics: boolean;
  enableSentry: boolean;
  sentryDsn: string | null;
  sentryTracesSampleRate: number;
  featureFlags: {
    newDashboard: boolean;
    aiAssistant: boolean;
    experimentalAPI: boolean;
  };
}

// 配置查找表:每个环境一份完整配置
const configs: Record<Environment, EnvironmentConfig> = {
  development: {
    apiUrl: 'http://localhost:3000',
    cdnUrl: 'http://localhost:8080',
    debug: true,
    logLevel: 'debug',
    enableAnalytics: false,
    enableSentry: false,
    sentryDsn: null,
    sentryTracesSampleRate: 1.0,
    featureFlags: {
      newDashboard: true,
      aiAssistant: true,
      experimentalAPI: true,
    },
  },
  staging: {
    apiUrl: 'https://staging-api.example.com',
    cdnUrl: 'https://staging-cdn.example.com',
    debug: false,
    logLevel: 'info',
    enableAnalytics: true,
    enableSentry: true,
    sentryDsn: 'https://staging@sentry.io/123',
    sentryTracesSampleRate: 0.5,
    featureFlags: {
      newDashboard: true,
      aiAssistant: false,
      experimentalAPI: true,
    },
  },
  production: {
    apiUrl: 'https://api.example.com',
    cdnUrl: 'https://cdn.example.com',
    debug: false,
    logLevel: 'warn',
    enableAnalytics: true,
    enableSentry: true,
    sentryDsn: 'https://prod@sentry.io/456',
    sentryTracesSampleRate: 0.1,
    featureFlags: {
      newDashboard: false,
      aiAssistant: false,
      experimentalAPI: false,
    },
  },
};

/**
 * 获取当前环境配置
 * 环境由 NODE_ENV 决定,默认 development
 */
function getEnvironmentConfig(): EnvironmentConfig {
  const env = (process.env.NODE_ENV ?? 'development') as Environment;
  if (!(env in configs)) {
    throw new Error(`[env] 未知环境: ${env}`);
  }
  return configs[env];
}

const envConfig = getEnvironmentConfig();
export { envConfig, type EnvironmentConfig, type Environment };

示例 8:Next.js 环境变量

// next.env.d.ts - Next.js 环境变量声明
// Next.js 区分服务端与客户端环境变量

// 服务端环境变量(无 NEXT_PUBLIC_ 前缀)
declare namespace NodeJS {
  interface ProcessEnv {
    // 数据库
    DATABASE_URL: string;
    DIRECT_URL: string;  // Prisma 直连 URL

    // 认证
    NEXTAUTH_SECRET: string;
    NEXTAUTH_URL: string;

    // 第三方服务(服务端密钥)
    GITHUB_CLIENT_SECRET: string;
    GOOGLE_CLIENT_SECRET: string;
    STRIPE_SECRET_KEY: string;

    // 可选
    REDIS_URL?: string;
    SENTRY_SERVER_DSN?: string;
  }
}

// 客户端环境变量(NEXT_PUBLIC_ 前缀)
interface NextPublicEnv {
  readonly NEXT_PUBLIC_API_URL: string;
  readonly NEXT_PUBLIC_APP_NAME: string;
  readonly NEXT_PUBLIC_GA_ID?: string;
  readonly NEXT_PUBLIC_SENTRY_DSN?: string;
  readonly NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string;
}

// 类型安全的客户端环境变量访问
function getClientEnv(): NextPublicEnv {
  const env = process.env;
  return {
    NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL!,
    NEXT_PUBLIC_APP_NAME: env.NEXT_PUBLIC_APP_NAME!,
    NEXT_PUBLIC_GA_ID: env.NEXT_PUBLIC_GA_ID,
    NEXT_PUBLIC_SENTRY_DSN: env.NEXT_PUBLIC_SENTRY_DSN,
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
      env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
  };
}

// 使用:服务端组件中可直接访问 process.env
// 客户端组件中使用 getClientEnv()

示例 9:环境变量文档自动生成

// env-doc-generator.ts - 从 Schema 自动生成环境变量文档
// 解决文档与实际配置不同步的问题

import { z } from 'zod';

interface EnvDocEntry {
  name: string;
  type: string;
  required: boolean;
  default: string | null;
  description: string;
  example: string;
  deprecated: boolean;
}

/**
 * 从 Zod Schema 提取环境变量元信息
 * 用于生成 .env.example 与 Markdown 文档
 */
function extractEnvDoc(schema: z.ZodObject<z.ZodRawShape>): EnvDocEntry[] {
  const entries: EnvDocEntry[] = [];

  for (const [name, field] of Object.entries(schema.shape)) {
    // 处理 ZodEffects(如 z.coerce.number())
    let inner = field;
    while (inner instanceof z.ZodEffects) {
      inner = inner.innerType();
    }

    // 判断是否可选
    const isOptional = inner instanceof z.ZodOptional;
    const actual = isOptional ? (inner as z.ZodOptional<any>).unwrap() : inner;

    // 提取类型名
    const typeName = actual.constructor.name
      .replace('Zod', '')
      .toLowerCase();

    // 提取默认值
    const hasDefault = '_def' in actual && 'defaultValue' in actual._def;
    const defaultValue = hasDefault
      ? JSON.stringify(actual._def.defaultValue())
      : null;

    entries.push({
      name,
      type: typeName,
      required: !isOptional && !hasDefault,
      default: defaultValue,
      description: actual.description ?? '',
      example: getExampleValue(typeName),
      deprecated: false,
    });
  }

  return entries;
}

function getExampleValue(type: string): string {
  switch (type) {
    case 'number': return '3000';
    case 'boolean': return 'true';
    case 'string': return 'example-value';
    default: return '';
  }
}

/**
 * 生成 .env.example 文件内容
 */
function generateEnvExample(entries: EnvDocEntry[]): string {
  const lines = ['# 环境变量示例文件', '# 复制此文件为 .env 并填入实际值', ''];

  for (const entry of entries) {
    lines.push(`# ${entry.description || entry.name}`);
    lines.push(`# 类型: ${entry.type}, 必需: ${entry.required}`);
    if (entry.default) {
      lines.push(`# 默认值: ${entry.default}`);
    }
    lines.push(`${entry.name}=${entry.example}`);
    lines.push('');
  }

  return lines.join('\n');
}

/**
 * 生成 Markdown 文档
 */
function generateMarkdownDoc(entries: EnvDocEntry[]): string {
  const lines = [
    '# 环境变量文档',
    '',
    '| 变量名 | 类型 | 必需 | 默认值 | 说明 |',
    '|--------|------|------|--------|------|',
  ];

  for (const entry of entries) {
    lines.push(
      `| \`${entry.name}\` | ${entry.type} | ${entry.required ? '是' : '否'} | ${entry.default ?? '-'} | ${entry.description} |`,
    );
  }

  return lines.join('\n');
}

对比分析

方案对比:声明式 vs. Zod vs. envalid

维度纯声明式(.d.ts)Zod Schemaenvalid
编译期类型优秀优秀(自动推导)良好
运行时校验无完整完整
默认值手动内置内置
错误信息无详细(path + message)详细
包大小0 KB~12 KB~5 KB
学习曲线低中低
生态集成原生极广(tRPC、React Hook Form)较窄
Schema 复用否是(前端表单共用)否
类型推导手动自动自动
推荐场景小型项目、原型中大型项目轻量后端

构建时 vs. 运行时注入

维度构建时替换(Vite/webpack)运行时注入(window.ENV)
灵活性低(每个环境单独构建)高(一份构建多环境部署)
性能极佳(值内联到代码)略差(多一次 window 读取)
Docker 友好否(需多阶段构建)是(镜像复用)
调试便利高(值在代码中可见)中(需查看注入脚本)
缓存影响改 env 需重新构建不影响构建缓存
推荐场景静态站点、CDN 部署容器化部署、多租户

Vite vs. Next.js 环境变量约定

维度ViteNext.js
前缀约定VITE_NEXT_PUBLIC_
服务端变量不支持(纯前端)支持(无前缀)
客户端变量import.meta.envprocess.env(构建时替换)
运行时注入需手动需手动
类型声明ImportMetaEnvNodeJS.ProcessEnv
.env 加载顺序.env, .env.local, .env.[mode].env.local, .env.[development/production], .env

常见陷阱与反模式

陷阱 1:前端泄漏密钥

反模式:在前端代码中直接访问服务端密钥。

// 错误:VITE_ 前缀的变量会被打包到前端 bundle
// .env
// VITE_AWS_SECRET=AKIAIOSFODNN7EXAMPLE

// 代码
const awsSecret = import.meta.env.VITE_AWS_SECRET;
// 此值会出现在构建产物中,任何人都能从浏览器 DevTools 看到

正确做法:服务端密钥不使用 VITE_ 前缀,通过 BFF(Backend for Frontend)代理访问。

// .env(仅服务端可见)
// AWS_SECRET=AKIAIOSFODNN7EXAMPLE

// 前端通过 API 调用,不直接持有密钥
const data = await fetch('/api/upload', { method: 'POST' });

陷阱 2:默认值掩盖配置缺失

反模式:为必需变量提供默认值,导致生产环境配置缺失时不报错。

// 错误:生产环境的 JWT_SECRET 不应有默认值
const jwtSecret = getOptionalEnv('JWT_SECRET', 'default-secret');
// 若生产环境忘记设置 JWT_SECRET,应用会用 'default-secret' 启动
// 攻击者知道默认值后可伪造 token

正确做法:安全相关变量必需,不提供默认值。

const jwtSecret = getEnv('JWT_SECRET'); // 未设置时抛错,应用拒绝启动

陷阱 3:类型声明与运行时不一致

反模式:声明文件说变量是 string,但运行时可能是 undefined。

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;  // 声明为 string
  }
}

// 代码
const dbUrl = process.env.DATABASE_URL;
// TypeScript 认为是 string,但运行时可能是 undefined
await connect(dbUrl);  // 若未设置,运行时崩溃

正确做法:必需变量用 Zod 校验,可选变量声明为 string | undefined。

// 用 Zod 校验
const env = envSchema.parse(process.env);
const dbUrl = env.DATABASE_URL;  // 校验通过后必定是 string

陷阱 4:布尔值解析不严谨

反模式:简单的 === 'true' 判断,遗漏边界情况。

// 错误:遗漏 '1'、'yes'、大小写
function getBoolean(key: string): boolean {
  return process.env[key] === 'true';  // 'TRUE' 会返回 false
}

正确做法:使用统一的布尔解析函数。

function getBooleanEnv(key: string, defaultValue = false): boolean {
  const value = process.env[key]?.toLowerCase();
  if (value === undefined) return defaultValue;
  return ['true', '1', 'yes', 'on'].includes(value);
}

陷阱 5:数字解析的 NaN 陷阱

反模式:用 parseInt 不检查 NaN。

// 错误:parseInt('abc') 返回 NaN,但 TypeScript 类型是 number
const port = parseInt(process.env.PORT || '3000', 10);
// 若 PORT='abc',port 是 NaN,后续 new Server(port) 行为异常

正确做法:显式检查 NaN。

function getNumberEnv(key: string, defaultValue: number): number {
  const value = process.env[key];
  if (value === undefined || value === '') return defaultValue;
  const parsed = Number(value);
  if (Number.isNaN(parsed)) {
    throw new Error(`环境变量 ${key} 不是有效数字: ${value}`);
  }
  return parsed;
}

陷阱 6:.env 文件提交到版本控制

反模式:把包含真实密钥的 .env 提交到 Git。

# 错误:.env 包含生产密钥
git add .env
git commit -m "add env"
# 密钥永久留在 Git 历史中,即使后续删除也可被恢复

正确做法:

  • .gitignore 中加入 .env、.env.local、.env.*.local。
  • 提交 .env.example 作为模板。
  • 真实密钥通过 CI/CD Secrets 或密钥管理服务(AWS Secrets Manager、HashiCorp Vault)注入。

陷阱 7:日志泄漏敏感变量

反模式:调试时打印整个 process.env。

// 错误:日志会包含所有环境变量,包括密钥
console.log('环境变量:', process.env);
// 日志聚合系统(如 ELK)会永久存储,存在泄漏风险

正确做法:打印脱敏后的配置。

function maskSecret(value: string): string {
  if (value.length <= 8) return '****';
  return value.slice(0, 4) + '****' + value.slice(-4);
}

function getSafeConfigLog(config: AppConfig): object {
  return {
    ...config,
    jwt: { ...config.jwt, secret: maskSecret(config.jwt.secret) },
    database: {
      ...config.database,
      url: config.database.url.replace(/\/\/.*@/, '//****@'),
    },
  };
}

console.log('应用配置:', getSafeConfigLog(config));
// 输出:jwt.secret: "abcd****wxyz",database.url: "postgres://****@db.example.com"

陷阱 8:异步加载环境变量

反模式:在模块顶层使用 await 加载环境变量。

// 错误:顶层 await 限制模块使用
const env = await loadEnvFromRemote();  // 从远程配置中心拉取
export const config = buildConfig(env);
// 依赖此模块的代码必须支持顶层 await,兼容性差

正确做法:提供同步访问接口,异步初始化在应用入口完成。

// config.ts
let _config: AppConfig | null = null;

export function getConfig(): AppConfig {
  if (_config === null) {
    throw new Error('配置未初始化,请先调用 initConfig()');
  }
  return _config;
}

export async function initConfig(): Promise<void> {
  const remoteEnv = await loadEnvFromRemote();
  _config = buildConfig({ ...process.env, ...remoteEnv });
}

// 应用入口
async function main() {
  await initConfig();
  startServer();
}
main();

工程实践

实践 1:分层架构组织

生产级项目应将环境变量管理分为三层:

flowchart TD
    T0["src/"]
    T1["config/"]
    T2["env.schema.ts      # Zod Schema 定义(单一数据源)"]
    T3["env.ts             # 校验与加载逻辑"]
    T4["config.ts          # 应用配置对象"]
    T5["environments.ts    # 多环境配置"]
    T6["types/"]
    T7["env.d.ts           # 类型声明(如不用 Zod)"]
    T8["env/                   # .env 文件目录(不入版本控制)"]
    T9[".env               # 通用配置"]
    T10[".env.development   # 开发环境"]
    T11[".env.staging       # 预发环境"]
    T12[".env.production    # 生产环境"]
    T13[".env.example       # 模板(入版本控制)"]
    T0 --> T1
    T5 --> T6
    T7 --> T8
    T8 --> T9
    T8 --> T10
    T8 --> T11
    T8 --> T12
    T8 --> T13

实践 2:CI/CD 中校验环境变量

在部署前校验环境变量,避免运行时崩溃:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Validate environment variables
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          JWT_SECRET: ${{ secrets.JWT_SECRET }}
          NODE_ENV: production
        run: npx tsx scripts/validate-env.ts

      - name: Deploy
        run: npm run deploy
typescript
// scripts/validate-env.ts - CI 环境变量校验脚本
import { envSchema } from '../src/config/env.schema';

const result = envSchema.safeParse(process.env);
if (!result.success) {
  console.error('环境变量校验失败:');
  for (const issue of result.error.issues) {
    console.error(`  ${issue.path.join('.')}: ${issue.message}`);
  }
  process.exit(1);
}
console.log('环境变量校验通过');

实践 3:密钥轮换支持

设计环境变量结构时,预留密钥轮换的支持:

const envSchema = z.object({
  // 当前密钥
  JWT_SECRET: z.string().min(32),

  // 旧密钥(轮换期间双密钥共存)
  JWT_SECRET_PREVIOUS: z.string().min(32).optional(),
});

// 认证逻辑支持双密钥验证
function verifyToken(token: string): JWTPayload {
  try {
    return jwt.verify(token, env.JWT_SECRET);
  } catch {
    if (env.JWT_SECRET_PREVIOUS) {
      return jwt.verify(token, env.JWT_SECRET_PREVIOUS);
    }
    throw new Error('Invalid token');
  }
}

实践 4:开发环境热重载

利用 chokidar 监听 .env 文件变化,自动重载配置:

import chokidar from 'chokidar';
import { envSchema } from './env.schema';

let config = envSchema.parse(process.env);

if (process.env.NODE_ENV === 'development') {
  chokidar.watch('.env').on('change', () => {
    console.log('[env] 检测到 .env 变化,重新加载');
    // 重新加载 dotenv
    require('dotenv').config({ override: true });
    try {
      config = envSchema.parse(process.env);
      console.log('[env] 配置重载成功');
    } catch (error) {
      console.error('[env] 配置重载失败:', error);
    }
  });
}

export function getConfig() {
  return config;
}

实践 5:Monorepo 共享配置

在 pnpm Monorepo 中,通过共享 package 统一环境变量管理:

flowchart TD
    T0["packages/"]
    T1["shared-config/          # 共享配置包"]
    T2["src/"]
    T3["env.schema.ts   # 统一 Schema"]
    T4["index.ts"]
    T5["package.json"]
    T6["web-app/                # 前端应用"]
    T7["api-server/             # 后端服务"]
    T8["worker/                 # 后台任务"]
    T0 --> T1
    T5 --> T6
    T5 --> T7
    T5 --> T8
typescript
// packages/shared-config/src/env.schema.ts
import { z } from 'zod';

export const baseEnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});

export const webEnvSchema = baseEnvSchema.extend({
  VITE_API_URL: z.string().url(),
});

export const apiEnvSchema = baseEnvSchema.extend({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
});

export type WebEnv = z.infer<typeof webEnvSchema>;
export type ApiEnv = z.infer<typeof apiEnvSchema>;

实践 6:性能优化——延迟加载

对于体积较大的环境变量(如 JSON 配置),使用懒加载避免启动开销:

// 懒加载大型配置
let _featureFlags: FeatureFlags | null = null;

function getFeatureFlags(): FeatureFlags {
  if (_featureFlags === null) {
    const json = process.env.FEATURE_FLAGS_JSON;
    _featureFlags = json ? JSON.parse(json) : defaultFlags;
  }
  return _featureFlags;
}

// 首次访问时才解析,避免冷启动延迟

案例研究

案例一:Vite + React 前端项目

背景:某 SaaS 前端项目使用 Vite + React + TypeScript,需支持 dev/staging/prod 三环境。

挑战:

  • 前端 bundle 不应包含服务端密钥。
  • 不同环境的 API 地址、CDN 地址、Sentry DSN 不同。
  • 需要支持 Docker 镜像复用(运行时注入)。

方案:

// src/config/env.ts
import { z } from 'zod';

// 构建时变量(VITE_ 前缀)
const buildEnvSchema = z.object({
  VITE_API_URL: z.string().url(),
  VITE_CDN_URL: z.string().url(),
  VITE_APP_VERSION: z.string(),
  VITE_SENTRY_DSN: z.string().optional(),
});

// 运行时注入变量(window.__ENV__)
declare global {
  interface Window {
    __ENV__?: Record<string, string>;
  }
}

type BuildEnv = z.infer<typeof buildEnvSchema>;

interface AppEnv {
  apiUrl: string;
  cdnUrl: string;
  version: string;
  sentryDsn: string;
  // 运行时注入的额外配置
  featureFlags: Record<string, boolean>;
}

function loadEnv(): AppEnv {
  // 1. 校验构建时变量
  const buildEnv = buildEnvSchema.parse(import.meta.env);

  // 2. 合并运行时注入变量
  const runtimeEnv = window.__ENV__ ?? {};

  return {
    apiUrl: runtimeEnv.API_URL ?? buildEnv.VITE_API_URL,
    cdnUrl: runtimeEnv.CDN_URL ?? buildEnv.VITE_CDN_URL,
    version: buildEnv.VITE_APP_VERSION,
    sentryDsn: runtimeEnv.SENTRY_DSN ?? buildEnv.VITE_SENTRY_DSN ?? '',
    featureFlags: JSON.parse(runtimeEnv.FEATURE_FLAGS ?? '{}'),
  };
}

export const env = loadEnv();

部署策略:

  • 开发环境:使用 .env.development,Vite 自动加载。
  • 生产环境:Docker 镜像构建时用占位值,启动脚本通过 sed 替换为真实值。
# Dockerfile
FROM node:20-alpine as builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# 启动时注入环境变量
COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
bash
#!/bin/sh
# docker-entrypoint.sh
# 将运行时环境变量注入到 index.html
envsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.js
exec "$@"

效果:

  • 一份 Docker 镜像可在 dev/staging/prod 复用。
  • 密钥不进入 bundle,安全性提升。
  • 配置变更无需重新构建,部署时间从 10 分钟降至 30 秒。

案例二:Next.js 全栈应用

背景:某电商平台使用 Next.js App Router,前端需要 Stripe 公钥,后端需要 Stripe 私钥。

方案:

// src/config/stripe.ts
import Stripe from 'stripe';

// 客户端配置(公钥,可暴露到前端)
const publicStripeSchema = z.object({
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
});

// 服务端配置(私钥,绝不暴露)
const privateStripeSchema = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
});

// 服务端组件中
export function getStripeServer(): Stripe {
  const env = privateStripeSchema.parse(process.env);
  return new Stripe(env.STRIPE_SECRET_KEY, {
    apiVersion: '2024-06-20',
  });
}

// 客户端组件中
export function getStripeClient() {
  const env = publicStripeSchema.parse(process.env);
  return env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
}

关键决策:

  • 公钥用 NEXT_PUBLIC_ 前缀,构建时注入。
  • 私钥无前缀,仅在服务端组件/API 路由中可访问。
  • Webhook 签名密钥独立配置,防止与 API 密钥混淆。

踩坑记录:早期版本误将 STRIPE_SECRET_KEY 加了 NEXT_PUBLIC_ 前缀,导致私钥泄漏到客户端 bundle。后引入 CI 校验脚本,扫描构建产物中是否包含 sk_ 开头的字符串,杜绝此类问题。

案例三:Node.js 微服务

背景:某微服务架构包含 12 个服务,每个服务有独立的环境变量配置。

挑战:

  • 各服务环境变量不一致,维护成本高。
  • 新增服务时容易遗漏必需变量。
  • 跨服务共享配置(如数据库连接)重复定义。

方案:

// packages/shared-config/src/index.ts
import { z } from 'zod';

// 基础 Schema(所有服务共享)
const baseSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  DATADOG_API_KEY: z.string().optional(),
  SENTRY_DSN: z.string().url().optional(),
});

// 服务特化 Schema
export const userServiceSchema = baseSchema.extend({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3001),
});

export const orderServiceSchema = baseSchema.extend({
  DATABASE_URL: z.string().url(),
  RABBITMQ_URL: z.string().url(),
  PAYMENT_SERVICE_URL: z.string().url(),
  PORT: z.coerce.number().default(3002),
});

export const paymentServiceSchema = baseSchema.extend({
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  PORT: z.coerce.number().default(3003),
});

// 统一加载函数
export function loadEnv<T extends z.ZodObject<any>>(
  schema: T,
): z.infer<T> {
  const result = schema.safeParse(process.env);
  if (!result.success) {
    console.error(`[${process.env.SERVICE_NAME}] 环境变量校验失败:`);
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
    process.exit(1);
  }
  return result.data;
}
typescript
// services/user-service/src/env.ts
import { loadEnv, userServiceSchema } from '@shared/config';

export const env = loadEnv(userServiceSchema);
// env 类型自动推导为 { NODE_ENV, LOG_LEVEL, ..., DATABASE_URL, PORT }

效果:

  • 新增服务时,只需在 shared-config 中定义 Schema,自动获得校验。
  • 跨服务共享配置(如 DATADOG_API_KEY)通过基础 Schema 统一。
  • CI 流水线扫描各服务的 Schema,生成配置清单,便于运维审计。

案例四:Vercel 部署的 Next.js 应用

背景:某博客平台部署在 Vercel,需要支持 Preview Deployment(每个 PR 一个预览环境)。

挑战:

  • 预览环境的 API 地址动态变化(<branch>.example.com)。
  • 需要根据是否为预览环境禁用 Analytics。
  • 数据库连接串因环境而异。

方案:

// src/config/env.ts
import { z } from 'zod';

const envSchema = z.object({
  // Vercel 自动注入的环境变量
  VERCEL_ENV: z.enum(['development', 'preview', 'production']),
  VERCEL_URL: z.string().optional(),        // 预览部署的 URL
  VERCEL_GIT_COMMIT_SHA: z.string().optional(),

  // 应用配置
  NEXT_PUBLIC_API_URL: z.string().url(),
  NEXT_PUBLIC_GA_ID: z.string().optional(),
  DATABASE_URL: z.string().url(),
});

type Env = z.infer<typeof envSchema>;

function loadEnv(): Env {
  const env = envSchema.parse(process.env);

  // 预览环境特殊处理
  if (env.VERCEL_ENV === 'preview') {
    return {
      ...env,
      // 预览环境不启用 GA
      NEXT_PUBLIC_GA_ID: undefined,
      // 预览环境使用独立的 API 地址
      NEXT_PUBLIC_API_URL: `https://preview-api.example.com`,
    };
  }

  return env;
}

export const env = loadEnv();

效果:

  • 每个 PR 自动创建预览环境,配置自动适配。
  • 生产环境的 Analytics 在预览环境被禁用,避免污染数据。
  • 数据库连接串通过 Vercel 环境变量面板按环境配置,无需改代码。

案例五:Electron 桌面应用

背景:某 Electron 应用需要区分开发与打包模式,并支持用户自定义配置。

方案:

// main/config.ts
import { app } from 'electron';
import path from 'path';
import fs from 'fs';

interface ElectronEnv {
  isDev: boolean;
  appVersion: string;
  userDataPath: string;
  apiUrl: string;
  enableAutoUpdate: boolean;
}

function loadElectronEnv(): ElectronEnv {
  // 判断是否为开发模式
  const isDev = !app.isPackaged;

  // 读取用户级配置(覆盖默认值)
  const configPath = path.join(app.getPath('userData'), 'config.json');
  let userConfig: Partial<ElectronEnv> = {};
  if (fs.existsSync(configPath)) {
    userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
  }

  return {
    isDev,
    appVersion: app.getVersion(),
    userDataPath: app.getPath('userData'),
    apiUrl: userConfig.apiUrl ?? (
      isDev ? 'http://localhost:3000' : 'https://api.example.com'
    ),
    enableAutoUpdate: userConfig.enableAutoUpdate ?? !isDev,
  };
}

export const env = loadElectronEnv();

关键决策:

  • 开发模式通过 app.isPackaged 判断,不依赖 NODE_ENV(Electron 主进程不设置)。
  • 用户配置存储在 userData 目录,与安装目录分离,升级时不丢失。
  • API 地址允许用户覆盖,支持自建服务器的用户。

案例六:容器化部署的运行时配置

背景:某 Kubernetes 部署的应用,需要根据 Namespace 动态加载配置。

方案:

// src/config/k8s-env.ts
import { z } from 'zod';
import fs from 'fs';

// 从 Kubernetes ConfigMap 挂载的文件读取
function loadConfigMapEnv(): Record<string, string> {
  const configDir = '/etc/app-config';
  const env: Record<string, string> = {};

  if (fs.existsSync(configDir)) {
    for (const file of fs.readdirSync(configDir)) {
      const value = fs.readFileSync(path.join(configDir, file), 'utf-8').trim();
      env[file] = value;
    }
  }

  return env;
}

// 从 Kubernetes Secret 挂载的文件读取
function loadSecretEnv(): Record<string, string> {
  const secretDir = '/etc/app-secrets';
  const env: Record<string, string> = {};

  if (fs.existsSync(secretDir)) {
    for (const file of fs.readdirSync(secretDir)) {
      const value = fs.readFileSync(path.join(secretDir, file), 'utf-8').trim();
      env[file] = value;
    }
  }

  return env;
}

// 合并所有配置源
function loadK8sEnv() {
  const env = {
    ...process.env,
    ...loadConfigMapEnv(),
    ...loadSecretEnv(),
  };

  return envSchema.parse(env);
}

部署清单:

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  template:
    spec:
      containers:
        - name: app
          env:
            - name: NODE_ENV
              value: production
          envFrom:
            - configMapRef:
                name: app-config
          volumeMounts:
            - name: secrets
              mountPath: /etc/app-secrets
              readOnly: true
      volumes:
        - name: secrets
          secret:
            secretName: app-secrets

效果:

  • 配置与密钥分离:ConfigMap 存普通配置,Secret 存敏感数据。
  • 应用启动时统一加载,类型校验通过后才提供服务。
  • Namespace 隔离:每个 Namespace 有独立的 ConfigMap 与 Secret。

基础题

题目 1:编写一个 .d.ts 文件,声明以下环境变量:

  • API_URL(string,必需)
  • TIMEOUT(数字字符串,可选)
  • ENABLE_CORS(布尔字符串,可选)

参考答案要点:

declare namespace NodeJS {
  interface ProcessEnv {
    readonly API_URL: string;
    readonly TIMEOUT?: string;
    readonly ENABLE_CORS?: string;
  }
}

题目 2:解释为什么 process.env.PORT 即使声明为 string,运行时仍可能是 undefined。

参考答案要点:接口声明仅影响编译期类型检查,不改变运行时行为。process.env 是操作系统注入的字典,声明为 string 只是告诉 TypeScript 编译器”假设它是 string”,但运行时是否真的设置取决于环境。需要运行时校验(如 Zod)才能保证类型正确。

题目 3:编写一个 getEnumEnv 函数,读取枚举类型的环境变量。

参考答案要点:

function getEnumEnv<T extends string>(
  key: string,
  values: readonly T[],
  defaultValue: T,
): T {
  const value = process.env[key];
  if (value === undefined) return defaultValue;
  if (!values.includes(value as T)) {
    throw new Error(
      `环境变量 ${key} 必须是 ${values.join('|')} 之一,实际: ${value}`,
    );
  }
  return value as T;
}

// 使用
const logLevel = getEnumEnv(
  'LOG_LEVEL',
  ['debug', 'info', 'warn', 'error'] as const,
  'info',
);

进阶题

题目 4:使用 Zod 构建一个环境变量 Schema,满足:

  • NODE_ENV:枚举 development/production/test,默认 development
  • PORT:数字,范围 1024-65535,默认 3000
  • DATABASE_URL:PostgreSQL URL(以 postgresql:// 开头)
  • CORS_ORIGINS:逗号分隔的 URL 列表,自动解析为数组

参考答案要点:

import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z
    .enum(['development', 'production', 'test'])
    .default('development'),

  PORT: z.coerce
    .number()
    .int()
    .min(1024, '端口必须大于 1023')
    .max(65535, '端口必须小于 65536')
    .default(3000),

  DATABASE_URL: z
    .string()
    .url()
    .refine(url => url.startsWith('postgresql://'), {
      message: 'DATABASE_URL 必须是 PostgreSQL URL',
    }),

  CORS_ORIGINS: z
    .string()
    .transform(s => s.split(',').map(u => u.trim()))
    .pipe(z.array(z.string().url()))
    .default([]),
});

type Env = z.infer<typeof envSchema>;

题目 5:分析以下代码的问题并修复:

// 有问题的代码
const config = {
  port: parseInt(process.env.PORT || '3000'),
  debug: process.env.DEBUG === 'true',
  dbUrl: process.env.DATABASE_URL!,
};

export default config;

参考答案要点:

问题:

  1. parseInt 不检查 NaN,PORT=abc 时 port 为 NaN。
  2. debug 仅匹配 'true',遗漏 '1'、'yes'、大小写。
  3. DATABASE_URL! 使用非空断言,运行时可能是 undefined。
  4. 无运行时校验,配置错误只在运行时暴露。

修复:

import { z } from 'zod';

const configSchema = z.object({
  port: z.coerce.number().int().positive().default(3000),
  debug: z.coerce.boolean().default(false),
  dbUrl: z.string().url(),
});

export const config = configSchema.parse({
  port: process.env.PORT,
  debug: process.env.DEBUG,
  dbUrl: process.env.DATABASE_URL,
});

题目 6:设计一个支持密钥轮换的环境变量结构。

参考答案要点:

const envSchema = z.object({
  JWT_SECRET: z.string().min(32),
  JWT_SECRET_PREVIOUS: z.string().min(32).optional(),
  JWT_SECRET_ROTATION_DEADLINE: z.string().datetime().optional(),
});

// 验证 token 时双密钥兼容
function verifyToken(token: string): JWTPayload {
  try {
    return jwt.verify(token, env.JWT_SECRET);
  } catch {
    if (env.JWT_SECRET_PREVIOUS) {
      return jwt.verify(token, env.JWT_SECRET_PREVIOUS);
    }
    throw new Error('Invalid token');
  }
}

// 轮换流程:
// 1. 设置 JWT_SECRET_PREVIOUS = 旧密钥
// 2. 设置 JWT_SECRET = 新密钥
// 3. 等待所有旧 token 过期
// 4. 移除 JWT_SECRET_PREVIOUS

挑战题

题目 7:为 Monorepo 设计一个共享环境变量管理方案,满足:

  • 基础配置(日志、监控)所有包共享
  • 各服务有独立配置
  • 支持本地开发覆盖
  • CI 自动校验

参考答案要点:

// packages/shared-config/src/index.ts
import { z } from 'zod';

// 基础 Schema
export const baseSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  SENTRY_DSN: z.string().url().optional(),
});

// 服务 Schema 模板
export function createServiceSchema<T extends z.ZodRawShape>(
  shape: T,
) {
  return baseSchema.extend(shape);
}

// 各服务定义自己的 Schema
export const apiSchema = createServiceSchema({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
});

export const workerSchema = createServiceSchema({
  REDIS_URL: z.string().url(),
  QUEUE_CONCURRENCY: z.coerce.number().default(4),
});

// 统一加载函数
export function loadEnv<S extends z.ZodObject<any>>(
  schema: S,
): z.infer<S> {
  const result = schema.safeParse(process.env);
  if (!result.success) {
    console.error('环境变量校验失败:');
    result.error.issues.forEach(issue => {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    });
    process.exit(1);
  }
  return result.data;
}

题目 8:实现一个环境变量脱敏日志工具,要求:

  • 自动识别密钥类变量(名称包含 SECRET、PASSWORD、KEY、TOKEN)
  • 部分脱敏(保留前 4 位与后 4 位)
  • 支持嵌套对象

参考答案要点:

const SENSITIVE_PATTERNS = [
  /SECRET/i,
  /PASSWORD/i,
  /KEY/i,
  /TOKEN/i,
  /CREDENTIAL/i,
];

function isSensitive(key: string): boolean {
  return SENSITIVE_PATTERNS.some(pattern => pattern.test(key));
}

function maskValue(value: string): string {
  if (value.length <= 8) return '****';
  return value.slice(0, 4) + '****' + value.slice(-4);
}

function deepMask<T>(obj: T): T {
  if (typeof obj !== 'object' || obj === null) return obj;

  if (Array.isArray(obj)) {
    return obj.map(deepMask) as unknown as T;
  }

  const result: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
    if (typeof value === 'string' && isSensitive(key)) {
      result[key] = maskValue(value);
    } else if (typeof value === 'object' && value !== null) {
      result[key] = deepMask(value);
    } else {
      result[key] = value;
    }
  }
  return result as T;
}

// 使用
const config = loadConfig();
console.log('应用配置:', deepMask(config));
// jwt.secret: "abcd****wxyz"
// database.password: "pass****1234"

题目 9:实现一个环境变量变更检测工具,在 CI 中对比当前与上次部署的环境变量,检测:

  • 新增的必需变量
  • 移除的变量
  • 类型变更的变量

参考答案要点:

// scripts/diff-env.ts
import { envSchema } from '../src/config/env.schema';

interface EnvSnapshot {
  fields: Record<string, { type: string; required: boolean }>;
}

function snapshotSchema(): EnvSnapshot {
  const fields: EnvSnapshot['fields'] = {};
  for (const [name, field] of Object.entries(envSchema.shape)) {
    const isOptional = field instanceof z.ZodOptional;
    fields[name] = {
      type: field.constructor.name,
      required: !isOptional,
    };
  }
  return { fields };
}

function diffSnapshots(
  old: EnvSnapshot,
  current: EnvSnapshot,
): string[] {
  const changes: string[] = [];

  for (const [name, currentField] of Object.entries(current.fields)) {
    const oldField = old.fields[name];
    if (!oldField) {
      changes.push(`[新增] ${name}: ${currentField.type}`);
    } else if (oldField.type !== currentField.type) {
      changes.push(
        `[类型变更] ${name}: ${oldField.type} -> ${currentField.type}`,
      );
    } else if (oldField.required !== currentField.required) {
      changes.push(
        `[必需性变更] ${name}: ${oldField.required} -> ${currentField.required}`,
      );
    }
  }

  for (const name of Object.keys(old.fields)) {
    if (!current.fields[name]) {
      changes.push(`[移除] ${name}`);
    }
  }

  return changes;
}

// 读取上次快照
const oldSnapshot = JSON.parse(
  fs.readFileSync('.env-snapshot.json', 'utf-8'),
);

const currentSnapshot = snapshotSchema();
const changes = diffSnapshots(oldSnapshot, currentSnapshot);

if (changes.length > 0) {
  console.log('环境变量变更:');
  changes.forEach(c => console.log('  ' + c));
}

// 保存当前快照
fs.writeFileSync(
  '.env-snapshot.json',
  JSON.stringify(currentSnapshot, null, 2),
);

题目 10:设计一个支持远程配置中心(如 Apollo、Nacos)的环境变量同步方案,要求:

  • 本地环境变量优先于远程
  • 远程配置变更时热更新
  • 网络故障时使用本地缓存

参考答案要点:

interface RemoteConfigClient {
  getConfig(namespace: string): Promise<Record<string, string>>;
  subscribe(namespace: string, callback: (config: Record<string, string>) => void): void;
}

class EnvManager {
  private config: Record<string, string> = {};
  private remoteClient: RemoteConfigClient;
  private localCachePath: string;

  constructor(remoteClient: RemoteConfigClient, localCachePath: string) {
    this.remoteClient = remoteClient;
    this.localCachePath = localCachePath;
  }

  async init(): Promise<void> {
    // 1. 加载本地缓存
    const cached = await this.loadLocalCache();

    // 2. 合并:本地环境变量 > 远程 > 缓存
    this.config = {
      ...cached,
      ...await this.fetchRemote(),
      ...process.env,
    };

    // 3. 订阅远程变更
    this.remoteClient.subscribe('app', (remoteConfig) => {
      this.config = { ...remoteConfig, ...process.env };
      this.saveLocalCache();
      this.emitChange();
    });
  }

  private async fetchRemote(): Promise<Record<string, string>> {
    try {
      return await this.remoteClient.getConfig('app');
    } catch (error) {
      console.warn('[env] 远程配置拉取失败,使用本地缓存');
      return await this.loadLocalCache();
    }
  }

  private async loadLocalCache(): Promise<Record<string, string>> {
    try {
      return JSON.parse(await fs.readFile(this.localCachePath, 'utf-8'));
    } catch {
      return {};
    }
  }

  private async saveLocalCache(): Promise<void> {
    await fs.writeFile(
      this.localCachePath,
      JSON.stringify(this.config, null, 2),
    );
  }

  private emitChange(): void {
    // 通知应用配置已更新
  }

  get(key: string): string | undefined {
    return this.config[key];
  }
}

官方文档

经典教材

  • 《TypeScript: The Complete Developer’s Guide》(Matt Pocock, 2024) 第 11 章”Environment Configuration”详细讨论了类型安全的环境变量管理。

  • 《Effective TypeScript》(Dan Vanderkam, 2nd ed., 2024) 第 9 章涉及环境变量声明合并与类型安全访问。

  • 《Building Microservices》(Sam Newman, 2nd ed., 2021) 第 7 章”Configuration Management”讨论微服务架构下的环境变量与配置中心。

前沿论文

  • Cito, J., et al. (2022). “An Empirical Study of Configuration Management in Node.js Applications.” ICSE 2022. 对 1,200 个 Node.js 项目的环境变量使用模式进行实证研究,揭示常见反模式。

  • Rahman, A., et al. (2021). “Secrets in Source Code: A Study of 100,000 Repositories.” MSR 2021. 分析 GitHub 上 10 万个仓库中的密钥泄漏问题,提出检测与防护建议。

相关开源项目

附录 A:常见环境变量命名约定

前缀约定

前缀含义示例
VITE_Vite 前端变量(暴露到 bundle)VITE_API_URL
NEXT_PUBLIC_Next.js 客户端变量NEXT_PUBLIC_GA_ID
REACT_APP_CRA 前端变量REACT_APP_API_URL
npm_package_npm 自动注入的包信息npm_package_version

命名风格

  • 全大写 + 下划线:DATABASE_URL、JWT_SECRET(Unix 传统,最常见)
  • PascalCase:DatabaseUrl(少数项目使用)
  • camelCase:databaseUrl(不推荐,与 Shell 不兼容)

常见变量名

变量名用途类型
NODE_ENV运行环境enum
PORT / HTTP_PORT服务端口number
DATABASE_URL / DB_URL数据库连接串url
JWT_SECRETJWT 签名密钥string
LOG_LEVEL日志级别enum
DEBUG调试模式开关boolean
CORS_ORIGINSCORS 允许的源string[]
REDIS_URLRedis 连接串url
SENTRY_DSNSentry 错误上报 DSNurl

附录 B:环境变量校验失败的标准处理流程

flowchart TD
    A[应用启动] --> B[读取环境变量]
    B --> C{Zod 校验}
    C -- 通过 --> D[继续启动]
    C -- 失败 --> E[输出详细错误]
    E --> E1[路径(变量名)]
    E --> E2[期望类型]
    E --> E3[实际值(脱敏)]
    E --> E4[修复建议]
    E --> E5[process.exit(1)]
    D --> F[加载配置对象]
    F --> G[注册全局单例]

附录 C:不同框架的 .env 文件加载顺序

Vite 加载顺序(从低到高优先级)

  1. .env
  2. .env.local
  3. .env.[mode]
  4. .env.[mode].local

Next.js 加载顺序(从低到高优先级)

  1. .env
  2. .env.local(除 test 环境外始终加载)
  3. .env.[development|production|test]
  4. .env.[development|production|test].local

Node.js(dotenv)加载顺序

  • 手动调用 dotenv.config() 加载 .env
  • dotenv-flow 支持 NODE_ENV 自动加载对应文件

附录 D:安全检查清单

部署前检查以下安全项:

  • .gitignore 包含 .env、.env.local、.env.*.local
  • .env.example 不包含真实密钥
  • 前端 bundle 中无 sk_、pk_live 等敏感前缀字符串
  • CI 流水线扫描构建产物中的密钥模式
  • 日志中不直接打印 process.env
  • 错误信息不泄露环境变量值
  • 密钥通过 Secrets Manager 或 CI Secrets 注入
  • 密钥轮换流程文档化
  • 生产环境数据库连接串使用 SSL
  • JWT 密钥长度 ≥ 32 字符
  • CORS 允许列表不包含 *(生产环境)
  • CSP 头部配置正确

附录 E:TypeScript 版本兼容性

TypeScript 版本特性支持
2.0+declare global 接口合并
3.7+断言函数(asserts x is T)
4.0+可变元组类型(用于多变量校验)
4.1+模板字面量类型(用于变量名约束)
4.9+satisfies 操作符(用于配置对象)
5.0+装饰器(用于配置注入)
5.4+NoInfer<T>(用于默认值推导)

附录 F:调试技巧

技巧 1:打印所有环境变量(脱敏)

function logEnv(): void {
  const safeEnv: Record<string, string> = {};
  for (const [key, value] of Object.entries(process.env)) {
    if (isSensitive(key)) {
      safeEnv[key] = maskValue(value ?? '');
    } else {
      safeEnv[key] = value ?? '';
    }
  }
  console.table(safeEnv);
}

技巧 2:验证特定变量

// 启动时添加 --debug-env 参数打印特定变量
if (process.argv.includes('--debug-env')) {
  const target = process.argv[process.argv.indexOf('--debug-env') + 1];
  console.log(`[env] ${target} = ${process.env[target]}`);
}

技巧 3:Zod 错误友好化

function formatZodError(error: z.ZodError): string {
  return error.issues
    .map(issue => {
      const path = issue.path.join('.') || '(root)';
      return `  - ${path}: ${issue.message}`;
    })
    .join('\n');
}

// 使用
try {
  envSchema.parse(process.env);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('环境变量校验失败:\n' + formatZodError(error));
  }
  process.exit(1);
}

附录 G:与其他语言对比

Go 的环境变量管理

// Go 标准库
port := os.Getenv("PORT")

// 流行库 envconfig
type Config struct {
    Port    int    `envconfig:"PORT" default:"3000"`
    DBUrl   string `envconfig:"DATABASE_URL" required:"true"`
}

特点:使用 struct tag 声明,编译期无类型检查(依赖运行时),但库生态成熟。

Python 的环境变量管理

# 标准库
import os
port = os.environ.get('PORT', '3000')

# pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    port: int = 3000
    db_url: str

    class Config:
        env_file = '.env'

settings = Settings()

特点:pydantic-settings 与 Zod 理念一致,Schema 驱动类型与校验。

Rust 的环境变量管理

use std::env;

// 标准库
let port: u16 = env::var("PORT")
    .unwrap_or("3000".to_string())
    .parse()
    .expect("PORT must be a number");

// dotenv crate
dotenv::dotenv().ok();

特点:标准库简洁但无校验,config crate 提供更完整的方案。

附录 H:未来演进方向

趋势 1:Bun 原生支持

Bun 运行时内置 .env 文件加载,无需 dotenv:

// Bun 自动加载 .env,直接使用
const port = process.env.PORT;

未来 TypeScript 项目可能减少对 dotenv 的依赖。

趋势 2:类型安全的配置注入

TypeScript 5.0+ 的装饰器为依赖注入提供了基础,未来可能出现:

@Configurable()
class AppService {
  @Env('API_URL')
  apiUrl!: string;

  @Env('PORT', { type: 'number', default: 3000 })
  port!: number;
}

趋势 3:远程配置与本地环境变量融合

随着微服务与云原生普及,远程配置中心(Apollo、Nacos、Consul KV)与本地环境变量的边界将模糊。未来可能出现统一的”配置订阅”接口,屏蔽本地与远程差异。

趋势 4:AI 辅助配置审计

利用 LLM 分析环境变量配置,自动检测:

  • 弱密钥(如 JWT_SECRET=password)
  • 配置不一致(如 dev 与 prod 的 CORS 策略差异)
  • 潜在泄漏(代码中硬编码的值与环境变量重复)

附录 I:术语对照表

英文术语中文翻译说明
Environment Variable环境变量操作系统向进程注入的配置
Process Env进程环境process.env 对象
Import Meta Env导入元环境import.meta.env(Vite/ESM)
Schema模式/架构描述数据结构的声明
Type Inference类型推导编译器自动推断类型
Runtime Validation运行时校验程序运行时检查数据合法性
Safe Parse安全解析不抛异常的校验,返回 tagged union
Coerce强制转换字符串自动转目标类型
Default Value默认值变量未设置时使用的回退值
Required必需变量必须设置
Optional可选变量可不设置
Config Map配置映射Kubernetes 中的配置资源
Secret密钥/机密敏感配置数据
Rotation轮换定期更换密钥的流程
Build-time构建时编译打包阶段
Runtime运行时程序执行阶段
Hot Reload热重载不重启应用更新配置
Single Source of Truth单一数据源配置唯一来源原则
Frontmatter前置元数据Markdown 顶部的 YAML 块
Interface Merging接口合并同名接口自动合并
Discriminated Union判别式联合共享字面量属性的联合类型