Config 层设计
要点
- 前面几篇的代码里,环境变量都是直接用
c.env.JWT_SECRET散落在各个路由和中间件里读取。文件少的时候没什么问题,文件一多就会出现同一个变量名拼写不一致、格式转换逻辑重复 - Config 层的核心职责是把「配置从哪来、怎么解析、怎么校验」集中到一个地方,其他地方只消费一个类型安全的配置对象
- 环境变量按敏感程度分两类处理:非敏感的写在
wrangler.jsonc的vars里随代码提交,敏感的通过wrangler secret单独设置 - 本地开发用
.dev.vars文件提供密钥,Wrangler 启动时自动读取,这个文件必须加到.gitignore - 用 Zod 做配置校验,在应用启动时就暴露缺失或格式错误的配置,而不是等请求进来才发现
- 配置项按用途分类(应用配置、数据库配置、第三方服务配置),避免一个扁平的大对象难以维护
- 多环境配置(development / staging / production)通过
wrangler.jsonc的env字段实现,同一套代码连接不同的资源
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这种做法在项目初期完全没问题。但如果项目规模上来,你会遇到这些情况:
- 同一个环境变量在多个文件里读取,格式转换逻辑(比如
Number()转换、默认值处理)到处重复 - 某处写了
c.env.JWT_SECREET(拼写错误),TypeScript 不会报错——因为c.env的类型声明是手写的,运行时也不校验 - 新增一个配置项时,不确定应该在哪里读、在哪里转换、默认值写在哪
- 测试的时候,想替换配置需要先 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 类型的值,不需要再做转换
// ...
})变化看起来不大,但有几个好处:
- 配置读取逻辑集中在一处,改格式转换、加默认值,只改
loadConfig一个函数 - 拼写错误从运行时变成编译时——
config.jwt.secreet在 TypeScript 里直接报错 - 测试时只要构造一个符合结构的对象传进去,不需要 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 只能是 debug、info、warn、error 之一,传了别的值会直接报错。
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.maxPageSize、config.bindings.db、config.external.resendApiKey 来访问,每个分类各管各的。
5. 配置校验:在启动时就暴露问题
第 3 节已经用 Zod 做了基本的字段校验。再往上一层,还需要处理一些字段之间的依赖关系。
比如 LOG_LEVEL 在生产环境通常设置为 info 或 warn,但如果有人在 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.jsonc 的 env 字段用来定义不同环境的配置:
// 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 production7. 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=debugwrangler dev 启动时会自动读取这个文件,把里面的值注入到 c.env 里。本地开发和线上用完全一样的代码,只是配置来源不同。
这个文件里有密钥,必须加到 .gitignore:
// .gitignore
.dev.vars
.wrangler/
node_modules/
dist/团队里新人入职时,复制一份 .dev.vars.example(可以提交到 Git,里面只放占位值)改成 .dev.vars,填入自己的密钥就能跑起来。
9. 配置的完整流转路径
把前面的内容串起来,一个配置从定义到被代码消费的完整路径:
- 定义:在
wrangler.jsonc里声明环境变量和服务绑定 - 注入:Workers 运行时把
vars、wrangler secret、服务绑定统一注入到c.env - 校验:
loadConfig(c.env)用 Zod schema 校验所有字段,缺失或格式错误时启动失败 - 消费:路由和中间件通过
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.jsonc的vars里随代码提交,敏感密钥通过wrangler secret单独设置 - 本地开发用
.dev.vars提供密钥,配合.dev.vars.example方便团队成员快速配置 - 多环境(staging / production)通过
wrangler.jsonc的env字段配置,同一套代码连接不同的资源,数据完全隔离
下一篇看 Repositories 层的设计——数据库操作怎么组织、Drizzle 查询怎么封装、SQL 和 TypeScript 类型怎么对应。