Config 层设计

要点

  • 前面几篇的代码里,环境变量都是直接用 c.env.JWT_SECRET 散落在各个路由和中间件里读取。文件少的时候没什么问题,文件一多就会出现同一个变量名拼写不一致、格式转换逻辑重复
  • Config 层的核心职责是把「配置从哪来、怎么解析、怎么校验」集中到一个地方,其他地方只消费一个类型安全的配置对象
  • 环境变量按敏感程度分两类处理:非敏感的写在 wrangler.jsoncvars 里随代码提交,敏感的通过 wrangler secret 单独设置
  • 本地开发用 .dev.vars 文件提供密钥,Wrangler 启动时自动读取,这个文件必须加到 .gitignore
  • 用 Zod 做配置校验,在应用启动时就暴露缺失或格式错误的配置,而不是等请求进来才发现
  • 配置项按用途分类(应用配置、数据库配置、第三方服务配置),避免一个扁平的大对象难以维护
  • 多环境配置(development / staging / production)通过 wrangler.jsoncenv 字段实现,同一套代码连接不同的资源

1. 环境变量散落在代码里的问题

前面几篇的代码里,环境变量都是直接在用到的地方通过 c.env.XXX 读取:

// anti-pattern.ts
// 在 auth 中间件里
const secret = c.env.JWT_SECRET
 
// 在 posts 路由里
const apiKey = c.env.API_KEY
 
// 在另一个中间件里,又要读一遍
const secret = c.env.JWT_SECRET
const maxPageSize = Number(c.env.MAX_PAGE_SIZE) || 20

这种做法在项目初期完全没问题。但如果项目规模上来,你会遇到这些情况:

  1. 同一个环境变量在多个文件里读取,格式转换逻辑(比如 Number() 转换、默认值处理)到处重复
  2. 某处写了 c.env.JWT_SECREET(拼写错误),TypeScript 不会报错——因为 c.env 的类型声明是手写的,运行时也不校验
  3. 新增一个配置项时,不确定应该在哪里读、在哪里转换、默认值写在哪
  4. 测试的时候,想替换配置需要先 mock 整个 c.env 对象

这些问题的根源是一样的:配置读取逻辑散落在各个业务文件里,没有一个集中的地方来管理

2. Config 层是什么

Config 层就是把这些散落的配置读取集中到一个文件里。其他模块不再直接碰 c.env,而是消费一个解析好的、有类型的配置对象。

// src/config/env.ts
export function loadConfig(env: Record<string, unknown>) {
  return {
    jwt: {
      secret: env.JWT_SECRET as string,
      expiresIn: Number(env.JWT_EXPIRES_IN || '7d'),
    },
    database: {
      url: env.DATABASE_URL as string,
    },
    app: {
      name: env.APP_NAME as string,
      maxPageSize: Number(env.MAX_PAGE_SIZE || '50'),
    },
    external: {
      resendApiKey: env.RESEND_API_KEY as string,
    },
  }
}

入口文件在 Worker 启动时调用一次,把结果存起来:

// src/index.ts
import { Hono } from 'hono'
import type { AppEnv } from './types'
import { loadConfig } from './config/env'
 
const app = new Hono<AppEnv>()
 
// 请求进来之前,配置已经准备好了
app.use('*', async (c, next) => {
  const config = loadConfig(c.env)
  c.set('config', config)
  await next()
})

路由和中间件里通过 c.get('config') 拿到完整的配置对象:

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
 
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
  const config = c.get('config')
  const secret = config.jwt.secret
  // 直接拿到 string 类型的值,不需要再做转换
  // ...
})

变化看起来不大,但有几个好处:

  1. 配置读取逻辑集中在一处,改格式转换、加默认值,只改 loadConfig 一个函数
  2. 拼写错误从运行时变成编译时——config.jwt.secreet 在 TypeScript 里直接报错
  3. 测试时只要构造一个符合结构的对象传进去,不需要 mock 整个环境变量

3. 类型安全:让 TypeScript 帮你检查

第 2 节的 loadConfig 里有很多 as string 类型断言,这只是把问题推后了——如果环境变量缺失,运行时拿到的是 undefined,断言骗过了 TypeScript 但骗不过实际运行。

更稳妥的做法是用 Zod 同时拿到类型定义和运行时校验:

// src/config/schema.ts
import { z } from 'zod'
 
// 定义配置的 schema——既是类型定义,也是校验规则
const configSchema = z.object({
  // 必填项:缺失时校验直接失败
  JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
  RESEND_API_KEY: z.string().min(1, 'RESEND_API_KEY is required'),
 
  // 可选项:带默认值
  APP_NAME: z.string().default('My API'),
  MAX_PAGE_SIZE: z.coerce.number().default(50),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
 
  // Cloudflare 服务绑定——运行时由平台注入,类型标注但不校验内容
  DB: z.any(),
  KV: z.any(),
  BUCKET: z.any(),
})
 
// 从 schema 自动推导出 TypeScript 类型
// 后续代码里用的 Config 类型全部从这里导出
export type Config = z.infer<typeof configSchema>
 
export function loadConfig(env: Record<string, unknown>): Config {
  const result = configSchema.safeParse(env)
 
  if (!result.success) {
    // 把校验错误整理成容易阅读的格式
    const missing = result.error.issues
      .map((issue) => `  - ${issue.path.join('.')}: ${issue.message}`)
      .join('\n')
 
    throw new Error(`Configuration validation failed:\n${missing}`)
  }
 
  return result.data
}

safeParse 做的事情很简单:遍历 schema 里定义的每个字段,检查 env 里有没有对应的值、值是否符合格式。不通过的字段全部收集起来,最后一次性报出来。

这样应用启动时就会立刻知道哪个配置缺失或格式错误,而不是等到某个请求进来、走到某个分支才发现。启动失败比线上报错更容易排查。

z.coerce.number() 会自动把字符串 "50" 转成数字 50,不用再手写 Number() 转换。z.enum() 限定 LOG_LEVEL 只能是 debuginfowarnerror 之一,传了别的值会直接报错。

4. 配置项分类

配置项按用途分三类,分别放在独立的文件里:

应用配置

应用自身的行为参数,通常非敏感,可以提交到 Git:

// src/config/app.ts
export const appConfig = {
  name: 'My API',
  // 分页上限,防止客户端传一个过大的值把数据库打爆
  maxPageSize: 50,
  // 允许的 CORS 源
  allowedOrigins: ['https://example.com'],
  // 日志级别:debug | info | warn | error
  logLevel: 'info',
}

数据库与存储配置

Cloudflare 服务绑定的类型定义,值由运行时注入:

// src/config/bindings.ts
// 只定义类型,实际值由 Cloudflare Workers 运行时提供
export type CloudflareBindings = {
  DB: D1Database
  KV: KVNamespace
  BUCKET: R2Bucket
}

这些绑定不需要在代码里手动创建或连接。wrangler.jsonc 配好之后,Workers 运行时自动注入到 c.env 里,Drizzle 直接拿来用。

第三方服务配置

外部 API 的密钥和端点,属于敏感信息:

// src/config/external.ts
export const externalConfigSchema = {
  RESEND_API_KEY: 'string',   // 邮件服务
  STRIPE_SECRET_KEY: 'string', // 支付服务(如果需要)
}

这三类配置在 loadConfig 里组装到一起:

// src/config/env.ts
import { loadConfig as loadAppConfig } from './schema'
import { appConfig } from './app'
 
export function loadConfig(env: Record<string, unknown>) {
  const envConfig = loadAppConfig(env)
 
  return {
    app: {
      ...appConfig,
      // 环境变量里的值覆盖默认配置
      name: envConfig.APP_NAME,
      maxPageSize: envConfig.MAX_PAGE_SIZE,
      logLevel: envConfig.LOG_LEVEL,
    },
    jwt: {
      secret: envConfig.JWT_SECRET,
    },
    external: {
      resendApiKey: envConfig.RESEND_API_KEY,
    },
    bindings: {
      db: envConfig.DB,
      kv: envConfig.KV,
      bucket: envConfig.BUCKET,
    },
  }
}

代码里通过 config.app.maxPageSizeconfig.bindings.dbconfig.external.resendApiKey 来访问,每个分类各管各的。

5. 配置校验:在启动时就暴露问题

第 3 节已经用 Zod 做了基本的字段校验。再往上一层,还需要处理一些字段之间的依赖关系。

比如 LOG_LEVEL 在生产环境通常设置为 infowarn,但如果有人在 staging 环境设成了 debug,你希望应用能打印一条警告:

// src/config/env.ts
import { loadConfig as validateSchema } from './schema'
 
export function loadConfig(env: Record<string, unknown>) {
  const config = validateSchema(env)
 
  // 跨字段校验:生产环境不建议用 debug 日志级别
  if (env.ENVIRONMENT === 'production' && config.LOG_LEVEL === 'debug') {
    console.warn(
      '[Config] LOG_LEVEL=debug in production is not recommended. ' +
      'This may expose sensitive information in logs.'
    )
  }
 
  return config
}

Zod 本身也支持这种跨字段的校验,用 .refine() 可以在 schema 层面加自定义规则:

// src/config/schema.ts
const configSchema = z.object({
  // ... 前面的字段
  ENVIRONMENT: z.string().default('development'),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
}).refine(
  (data) => {
    // 生产环境禁用 debug 级别的日志
    if (data.ENVIRONMENT === 'production' && data.LOG_LEVEL === 'debug') {
      return false
    }
    return true
  },
  {
    message: 'LOG_LEVEL cannot be debug in production environment',
    path: ['LOG_LEVEL'],
  }
)

配置校验的目的是把「错误配置」挡在启动阶段。应用跑起来之后才发现少了某个环境变量,排查成本远高于启动时就报错。

6. 多环境配置

真实项目至少需要两套环境:一套测试验证(staging),一套对外服务(production)。两套环境跑同样的代码,但连接不同的数据库、用不同的密钥,数据互不干扰。

wrangler.jsoncenv 字段用来定义不同环境的配置:

// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
 
  // 默认配置(本地开发使用)
  "vars": { "APP_NAME": "My API (dev)", "LOG_LEVEL": "debug" },
 
  "env": {
    "staging": {
      "name": "my-api-staging",
      "vars": { "APP_NAME": "My API (staging)", "LOG_LEVEL": "info" },
      "d1_databases": [{ "binding": "DB", "database_name": "my-db-staging", "database_id": "staging-xxxx" }]
    },
    "production": {
      "name": "my-api-production",
      "vars": { "APP_NAME": "My API", "LOG_LEVEL": "warn" },
      "d1_databases": [{ "binding": "DB", "database_name": "my-db-prod", "database_id": "prod-xxxx" }]
    }
  }
}

部署时指定环境:

// terminal
wrangler deploy --env staging
wrangler deploy --env production

代码里不需要关心当前是哪个环境——同一个 c.env.APP_NAME 在不同环境里自动拿到不同的值。Config 层的 loadConfig 也不用改,它只负责把 c.env 里的值解析出来。

密钥也是按环境独立设置的:

// terminal
wrangler secret put JWT_SECRET --env staging
wrangler secret put JWT_SECRET --env production

7. wrangler.jsonc 配置详解

wrangler.jsonc 的完整字段说明(第 2 篇已介绍过基本结构,这里补充 Config 层相关的要点):

// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2024-12-01",
 
  // 非敏感环境变量,随代码提交到 Git
  "vars": {
    "APP_NAME": "My API",
    "MAX_PAGE_SIZE": "50",
    "LOG_LEVEL": "info"
  },
 
  // Cloudflare 服务绑定
  "d1_databases": [{ "binding": "DB", "database_name": "my-db", "database_id": "xxxx-xxxx" }],
  "kv_namespaces": [{ "binding": "KV", "id": "xxxx-xxxx" }],
  "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-bucket" }]
}

容易混淆的地方:

  • vars 里的值全部是字符串类型,代码里需要数字时要用 z.coerce.number() 转换
  • binding 是代码里 c.env.XXX 用的名字,database_name / bucket_name 是 Cloudflare 控制台里显示的名字
  • database_id 在 Cloudflare 控制台创建 D1 数据库后生成,复制粘贴过来即可
  • 敏感密钥不出现在这个文件里,通过 wrangler secret put 单独设置

8. .dev.vars 本地开发配置

wrangler secret 设置的密钥存在 Cloudflare 的云端。但本地 wrangler dev 启动时,请求不走云端,拿不到这些密钥。

解决办法是在项目根目录创建一个 .dev.vars 文件:

// .dev.vars
JWT_SECRET=local-dev-secret-key
RESEND_API_KEY=re_local_test_key
APP_NAME=My API (local)
LOG_LEVEL=debug

wrangler dev 启动时会自动读取这个文件,把里面的值注入到 c.env 里。本地开发和线上用完全一样的代码,只是配置来源不同。

这个文件里有密钥,必须加到 .gitignore

// .gitignore
.dev.vars
.wrangler/
node_modules/
dist/

团队里新人入职时,复制一份 .dev.vars.example(可以提交到 Git,里面只放占位值)改成 .dev.vars,填入自己的密钥就能跑起来。

9. 配置的完整流转路径

把前面的内容串起来,一个配置从定义到被代码消费的完整路径:

  1. 定义:在 wrangler.jsonc 里声明环境变量和服务绑定
  2. 注入:Workers 运行时把 varswrangler secret、服务绑定统一注入到 c.env
  3. 校验loadConfig(c.env) 用 Zod schema 校验所有字段,缺失或格式错误时启动失败
  4. 消费:路由和中间件通过 c.get('config') 访问类型安全的配置对象
// flow
wrangler.jsonc + wrangler secret + .dev.vars
  → Workers 运行时注入到 c.env
    → loadConfig(c.env) 校验 + 解析
      → c.set('config', parsedConfig)
        → 路由/中间件通过 c.get('config') 消费

整个流程里,业务代码不直接碰 c.env。后续要加新的配置项,在 wrangler.jsonc.dev.vars 里加变量、在 Zod schema 里加字段、代码里通过 config.xxx 访问——三个步骤都有 TypeScript 类型保护。

总结

Config 层在 Hono 工程化项目里承担的角色:

  • 把环境变量读取从散落的 c.env.XXX 收拢到 src/config/ 目录下,集中管理解析和校验逻辑
  • 用 Zod schema 做运行时校验,配置缺失或格式错误在应用启动时就暴露,而不是等请求进来才报错
  • 配置项按用途分类——应用配置、绑定配置、第三方服务配置——避免一个扁平的大对象难以维护
  • 非敏感配置写在 wrangler.jsoncvars 里随代码提交,敏感密钥通过 wrangler secret 单独设置
  • 本地开发用 .dev.vars 提供密钥,配合 .dev.vars.example 方便团队成员快速配置
  • 多环境(staging / production)通过 wrangler.jsoncenv 字段配置,同一套代码连接不同的资源,数据完全隔离

下一篇看 Repositories 层的设计——数据库操作怎么组织、Drizzle 查询怎么封装、SQL 和 TypeScript 类型怎么对应。