参数校验和序列化,听着像后端面试题,但实际开发里几乎天天见。你写了个接口,接进来的数据乱七八糟;你存个对象,发出去的时候多带了一堆没用字段。每天手动判断 if (x === undefined) 写到想吐。这篇文章想聊一种工程化思路:用装饰器把校验和序列化的逻辑收拢起来,让代码看起来干净,跑起来安心,同时让 TypeScript 在编译期还能帮咱们盯住类型。
一、开篇:为什么需要校验和序列化
先设想一个普通场景。你的程序要从外部拿一个用户对象,里面得有姓名、年龄、邮箱。没经过处理的数据可能长这样:姓名是数字、年龄是字符串、邮箱根本不合规。你不得不写一堆防御代码:
// 技术栈:TypeScript
function getUser(input: any) {
if (typeof input.name !== 'string') {
throw new Error('name 必须是字符串');
}
if (typeof input.age !== 'number' || input.age < 0) {
throw new Error('age 必须是数字且大于等于0');
}
if (!/^\S+@\S+\.\S+$/.test(input.email)) {
throw new Error('email 格式不对');
}
return input;
}
这只是三个字段。真实接口字段二十个起步,每个都这么判断,代码里全是 if,看着就累。序列化也类似:一个对象内部有密码、有临时缓存,发给前端时得删掉;字段名对内是驼峰,对外要下划线,手写映射又臭又长。
装饰器能干这件事。它能在类、属性、方法上“贴标签”,我们就能在运行时读取这些标签,统一做校验、定制输出格式。TypeScript 支持装饰器,搭配 reflect-metadata 可以存放元数据,让逻辑更好组织。
二、装饰器是什么?先给个直观感受
装饰器本质上是个函数,它接收类或成员的信息,咱们可以在这个函数里装饰逻辑。比如在属性上贴一个“必须为字符串”的标签:
// 技术栈:TypeScript
// 目标:给属性添加一个“必须是字符串”的标记
function IsString(target: any, propertyKey: string) {
// 记录到一个全局 Map 里
const map = globalThis.__validationRules || (globalThis.__validationRules = new Map());
const rules = map.get(target.constructor.name) || [];
rules.push({ propertyKey, type: 'string' });
map.set(target.constructor.name, rules);
}
class User {
@IsString
name: string;
}
这里没做任何校验,只是打标记。真正校验时,我们读一下这个标记,就知道 name 必须是字符串。这就是装饰器校验的核心思路:把“规则”附加到成员上,运行前或运行时统一处理。
reflect-metadata 是更好的选择,它能用原生的方式挂载元数据,不用自己搓全局 Map。后面示例都基于它。
三、用装饰器设计参数校验
3.1 定义校验装饰器
先定义几个常用的校验器:字符串、数字、邮箱。每个装饰器都在属性上打上对应的规则标记。
// 技术栈:TypeScript
import 'reflect-metadata';
// 集中管理元数据的 key
const META_KEY = 'validation:rules';
// 注册一条规则到类的元数据上
function addRule(target: any, propertyKey: string, rule: string) {
const rules = Reflect.getMetadata(META_KEY, target.constructor) || [];
rules.push({ propertyKey, rule });
Reflect.defineMetadata(META_KEY, rules, target.constructor);
}
// 装饰器:必须为字符串
export function IsString(target: any, propertyKey: string) {
addRule(target, propertyKey, 'string');
}
// 装饰器:必须为数字
export function IsNumber(target: any, propertyKey: string) {
addRule(target, propertyKey, 'number');
}
// 装饰器:必须满足邮箱格式
export function IsEmail(target: any, propertyKey: string) {
addRule(target, propertyKey, 'email');
}
这段代码里,Reflect.getMetadata 和 Reflect.defineMetadata 是 reflect-metadata 提供的 API。第一个参数是元数据 key,第二个是值。我们把规则数组存在类的构造函数上,这样所有实例共享同一份规则。
3.2 把校验信息存到元数据
现在写一个校验函数。它拿到类名和实例,找到规则数组,挨个检查。如果发现不符合规则,就抛出明确的错误。
// 技术栈:TypeScript
// 校验入口
export function validate(obj: any): void {
const constructor = obj.constructor;
const rules = Reflect.getMetadata(META_KEY, constructor) || [];
for (const { propertyKey, rule } of rules) {
const value = obj[propertyKey];
if (rule === 'string' && typeof value !== 'string') {
throw new Error(`${propertyKey} 必须是字符串,当前值是 ${JSON.stringify(value)}`);
}
if (rule === 'number' && (typeof value !== 'number' || Number.isNaN(value))) {
throw new Error(`${propertyKey} 必须是数字,当前值是 ${JSON.stringify(value)}`);
}
if (rule === 'email' && !/^\S+@\S+\.\S+$/.test(value)) {
throw new Error(`${propertyKey} 邮箱格式不正确,当前值是 ${JSON.stringify(value)}`);
}
}
}
这个 validate 函数就是运行时负责“验证数据”的主力。它能放在请求入口处,也能放在构造函数里。接下来看看类怎么用:
// 技术栈:TypeScript
import { IsString, IsNumber, IsEmail, validate } from './validators';
class User {
@IsString
name: string;
@IsNumber
age: number;
@IsEmail
email: string;
constructor(name: any, age: any, email: any) {
this.name = name;
this.age = age;
this.email = email;
// 在构造时校验,让数据一进对象就保证合规
validate(this);
}
}
// 正常情况
const u1 = new User('张三', 25, 'zhangsan@example.com');
console.log(u1.name); // 张三
// 错误情况:age 是字符串,会抛错
try {
new User('李四', '三十', 'lisi@example.com');
} catch (e) {
console.error(e.message); // age 必须是数字,当前值是 "三十"
}
这里有个关键点:validate 放在构造函数里执行,意味着对象创建时就必须满足所有规则。如果不满足,直接阻断创建过程。这适合对可信度要求高的内部数据。
3.3 运行时执行校验
有时候不希望构造时就校验,而是等数据真正被使用时再校验。比如外部传参一个对象,你复制到类实例之后不急着抛错误,先记录下来,最后统一处理。这里把校验函数单独拿出来,用在哪都行。
// 技术栈:TypeScript
// 模拟外部传入的数据
const rawData = {
name: '王五',
age: '28', // 这里应该是数字,却传了字符串
email: 'wang@test.com'
};
// 先用一个普通对象接收
const user = new User();
Object.assign(user, rawData);
// 在某个业务节点统一校验
try {
validate(user);
console.log('数据没问题,继续执行业务');
} catch (error) {
console.error('校验失败', error.message);
// 你可以收集所有错误而不是只抛第一个,这里简化处理
}
运行时校验和构造时校验的区别在于“时机”。构造函数内校验适合保护内部状态,运行时校验适合处理从外部接收的数据。装饰器规则定义一次,不管在哪个时机调用,逻辑都是一样的,这就是它的工程价值。
四、序列化怎么办
校验是针对“输入”的,序列化则是针对“输出”的。一个对象要返回给前端时,我们往往希望去掉敏感字段、改掉字段名。装饰器同样可以处理。
4.1 序列化装饰器
先定义两个装饰器:Expose(暴露,输出时保留)和 Exclude(排除,输出时去掉)。再给 Expose 加一个参数,允许指定输出的字段名。
// 技术栈:TypeScript
import 'reflect-metadata';
const SERIALIZE_KEY = 'serialize:rules';
// 给属性添加序列化规则
function addSerializeRule(target: any, propertyKey: string, rule: any) {
const rules = Reflect.getMetadata(SERIALIZE_KEY, target.constructor) || [];
rules.push({ propertyKey, ...rule });
Reflect.defineMetadata(SERIALIZE_KEY, rules, target.constructor);
}
// 输出时保留,可选地给字段换名字
export function Expose(alias?: string) {
return function (target: any, propertyKey: string) {
addSerializeRule(target, propertyKey, { exposed: true, alias });
};
}
// 输出时排除
export function Exclude(target: any, propertyKey: string) {
addSerializeRule(target, propertyKey, { exposed: false });
}
这里 Expose 用了一个工厂函数,因为我们需要接收别名参数。装饰器返回一个函数,让 TypeScript 知道它是属性装饰器。
4.2 结合校验的序列化流程
写一个 serialize 函数。它遍历类的序列化规则,如果属性被标记为 exposed,就保留,并且可以重命名;如果被标记为 excluded,就直接跳过。对于没做任何标记的属性,我们可以选择默认保留或默认排除。通常安全做法是:没标记的默认排除,这样内部控制更严格。
// 技术栈:TypeScript
export function serialize(obj: any): Record<string, any> {
const rules = Reflect.getMetadata(SERIALIZE_KEY, obj.constructor) || [];
const result: Record<string, any> = {};
for (const { propertyKey, exposed, alias } of rules) {
if (exposed) {
const outputKey = alias || propertyKey;
result[outputKey] = obj[propertyKey];
}
// exposed 为 false 时直接跳过,相当于排除
}
return result;
}
现在定义一个“输出专用”的类。注意它和用户输入类可以是同一个类,也可以分开。为了示范,我们把校验和序列化放在同一个类上。
// 技术栈:TypeScript
import { IsString, IsNumber, IsEmail, validate } from './validators';
import { Expose, Exclude, serialize } from './serialize';
class Account {
@IsString
@Exclude() // 用户名内部使用,不暴露
internalId: string;
@IsString
@Expose() // 对外显示 name
name: string;
@IsEmail
@Expose('emailAddress') // 对外改名 emailAddress
email: string;
@IsNumber
@Expose()
age: number;
@Exclude() // 密码必须排除
password: string;
constructor(name: any, email: any, age: any, password: string, internalId?: string) {
this.name = name;
this.email = email;
this.age = age;
this.password = password;
this.internalId = internalId || Math.random().toString(36).slice(2);
validate(this);
}
}
// 创建账号
const account = new Account('小明', 'xiaoming@example.com', 20, 'secret123');
// 输出前序列化
const output = serialize(account);
console.log(output);
// { name: '小明', emailAddress: 'xiaoming@example.com', age: 20 }
看到了吗?password 和 internalId 被过滤掉了,email 改成了 emailAddress。对外数据干干净净,没有多余的敏感信息。
五、完整工程化方案
把上面的校验器和序列化器合起来,构成一个完整的方案。这个方案的核心是“类即模型”:通过装饰器声明字段规则,通过元数据驱动校验和输出。我们可以在任何需要输入输出的地方使用同一个模型类。
5.1 类定义
更贴近真实业务的例子:订单数据。一个订单有订单号、商品列表、优惠券、购买时间。商品列表是一个数组,里面每个元素都得校验。这需要支持嵌套校验。
// 技术栈:TypeScript
import 'reflect-metadata';
// ---------- 基础校验装饰器 ----------
type Validator = 'string' | 'number' | 'email' | 'array' | 'nested';
function addRule(target: any, propertyKey: string, rule: Validator, nestedType?: any) {
const rules = Reflect.getMetadata('validation:rules', target.constructor) || [];
rules.push({ propertyKey, rule, nestedType });
Reflect.defineMetadata('validation:rules', rules, target.constructor);
}
// 各属性装饰器
function IsString(target: any, ctx: any) { addRule(target, ctx.name, 'string'); }
function IsNumber(target: any, ctx: any) { addRule(target, ctx.name, 'number'); }
function IsEmail(target: any, ctx: any) { addRule(target, ctx.name, 'email'); }
function IsArray(target: any, ctx: any) { addRule(target, ctx.name, 'array'); }
function IsNested(nestedType: any) {
return function (target: any, ctx: any) {
addRule(target, ctx.name, 'nested', nestedType);
};
}
// ---------- 序列化装饰器 ----------
const SERIALIZE_KEY = 'serialize:rules';
function Expose(alias?: string) {
return function (target: any, ctx: any) {
const rules = Reflect.getMetadata(SERIALIZE_KEY, target.constructor) || [];
rules.push({ propertyKey: ctx.name, exposed: true, alias });
Reflect.defineMetadata(SERIALIZE_KEY, rules, target.constructor);
};
}
function Exclude(target: any, ctx: any) {
const rules = Reflect.getMetadata(SERIALIZE_KEY, target.constructor) || [];
rules.push({ propertyKey: ctx.name, exposed: false });
Reflect.defineMetadata(SERIALIZE_KEY, rules, target.constructor);
}
这里注意,属性装饰器现在接收两个参数,因为 TypeScript 的新版装饰器语法(Stage 3)把第二个参数改成了 context 对象。为了兼容性,这里采用新语法,它用起来更简洁,但需要注意你的 TypeScript 版本要 >= 5.0,并且在 tsconfig.json 里打开 "experimentalDecorators": true 实际上不需要了,新语法不需要实验性标志。新语法对类型更安全。为了示范,我们就用新语法。
5.2 执行入口
再实现通用的 validate 和 serialize,需要支持嵌套对象。这里用递归来验证嵌套类实例。
// 技术栈:TypeScript
function validate(obj: any) {
const rules = Reflect.getMetadata('validation:rules', obj.constructor) || [];
for (const { propertyKey, rule, nestedType } of rules) {
const value = obj[propertyKey];
if (rule === 'string' && typeof value !== 'string') {
throw new Error(`${propertyKey} 必须是字符串`);
}
if (rule === 'number' && typeof value !== 'number') {
throw new Error(`${propertyKey} 必须是数字`);
}
if (rule === 'email' && !/^\S+@\S+\.\S+$/.test(value)) {
throw new Error(`${propertyKey} 邮箱格式不正确`);
}
if (rule === 'array' && !Array.isArray(value)) {
throw new Error(`${propertyKey} 必须是数组`);
}
if (rule === 'nested') {
// 支持单层嵌套,实际项目里也可以用泛型支持多层
if (!(value instanceof nestedType)) {
throw new Error(`${propertyKey} 必须是 ${nestedType.name} 的实例`);
}
validate(value); // 递归校验嵌套对象
}
}
}
function serialize(obj: any) {
const rules = Reflect.getMetadata(SERIALIZE_KEY, obj.constructor) || [];
const result: Record<string, any> = {};
for (const { propertyKey, exposed, alias } of rules) {
if (exposed) {
const value = obj[propertyKey];
// 如果值是嵌套对象,并且有自己的序列化规则,则递归序列化
const nestedRules = Reflect.getMetadata(SERIALIZE_KEY, value?.constructor);
result[alias || propertyKey] = nestedRules ? serialize(value) : value;
}
}
return result;
}
现在定义订单模型和商品模型。
// 技术栈:TypeScript
class Product {
@IsString
@Expose()
sku: string;
@IsNumber
@Expose('price') // 对外叫 price
priceCents: number;
@IsNumber
@Exclude() // 内部供应商代码不暴露
vendorCode: number;
constructor(sku: string, priceCents: number, vendorCode: number) {
this.sku = sku;
this.priceCents = priceCents;
this.vendorCode = vendorCode;
validate(this);
}
}
class Order {
@IsString
@Expose('orderNo')
orderId: string;
@IsArray
@Expose('items')
products: Product[];
@IsNumber
@Exclude() // 内部折扣金额不给客户端
discountCents: number;
constructor(orderId: string, products: Product[], discountCents: number) {
this.orderId = orderId;
this.products = products;
this.discountCents = discountCents;
validate(this);
}
}
创建订单并序列化输出:
// 技术栈:TypeScript
const product1 = new Product('SKU-001', 2999, 101);
const product2 = new Product('SKU-002', 1999, 102);
const order = new Order('NO-2024001', [product1, product2], 500);
console.log(JSON.stringify(serialize(order), null, 2));
// {
// "orderNo": "NO-2024001",
// "items": [
// { "sku": "SKU-001", "price": 2999 },
// { "sku": "SKU-002", "price": 1999 }
// ]
// }
注意看,discountCents 被排除了,vendorCode 被排除了,嵌套的 items 被递归序列化,字段名也做了重命名。校验呢?在构造函数里已经完成了,所以序列化出去的数据一定是合法数据。
如果直接创建错误数据,就会在构造时就拦截:
// 技术栈:TypeScript
try {
new Product('SKU-003', '三十元', 103); // priceCents 应该是数字,这里给了字符串
} catch (e) {
console.error(e.message); // priceCents 必须是数字
}
六、应用场景
这套装饰器方案特别适合下面几类场景。
第一,API 边界。后端的接口控制器(Controller)接收请求体,先实例化模型,自动校验,然后序列化响应。你可以非常优雅地定义每个接口的“入参模型”和“出参模型”,让代码自文档化。
第二,配置文件解析。配置文件里很多字段容易写错,比如数字写成字符串。定义好配置类,在加载时校验,能第一时间发现问题。比如数据库连接配置,端口号被判成字符串,连接直接失败。用装饰器校验,启动时就报错,省去排查时间。
第三,表单处理。前端表单提交的数据,类型往往不严格,有可能是字符串数字。在后端模型里定义规则,一进接口就校验和清洗。
第四,事件消息。微服务之间通过消息传递数据,消息可能被篡改。消费者拿到消息后,将数据填充到模型中校验,避免脏数据进入业务逻辑。
第五,ORM 与数据库实体。定义实体类时,可以用装饰器标记字段的长度、是否必填、是否唯一,序列化时自动剔除只读字段或日志字段。
七、技术优缺点
装饰器方案不是银弹,它有自己擅长的地方,也有代价。
先聊优点。第一,代码复用性高。一套校验和序列化逻辑可以挂在任意类上,你只要写一次装饰器,所有模型都能用。第二,声明式编程让业务意图清晰。类属性旁边直接写着 @IsString、@Expose,别人看代码立刻知道期望的值类型和输出规则,不需要去读一长串的 if。第三,类型保持完整。装饰器没有改变类本身的 TypeScript 类型,你依然可以享受编译期的类型检查。第四,运行时元数据提供了动态能力。比如在某些框架里,根据元数据自动生成 Swagger 文档,或者自动生成表单描述。第五,与依赖注入、反射等机制配合,能构建非常强大的框架级功能。
再说缺点。第一,运行时开销。每次校验和序列化都需要遍历元数据并执行函数,对于高并发、对性能极端敏感的场景,不能忽略这个成本。不过大多数业务场景这点开销完全可以接受。第二,元数据是隐式的,调试困难。如果装饰器写错了,或者元数据没有正确写入,很难一眼看出来。你可能会看到“校验没生效”的诡异问题。第三,装饰器语法依赖编译环境。老旧的 TypeScript 版本或者纯 JavaScript 项目用起来不顺手。第四,类型安全是编译期的,运行时数据依然不可信,你不能因为写了 @IsString 就放松对输入来源的管控。第五,装饰器只能修饰在类、属性、方法等结构上,无法直接用于任意函数参数,除非借助额外库(比如 zod 那种用法,但那就不是装饰器了)。
八、注意事项
在使用这套方案时,有几个容易踩坑的地方。
第一,注意装饰器执行顺序。对于属性装饰器,执行顺序是从上到下,但元数据的写入顺序可能影响输出。比如你写多个装饰器,要明确它们的依赖关系。例如先 @IsEmail 再 @Expose,这没有关系,因为校验和序列化是分开的 key。
第二,reflect-metadata 必须被 import。没有它,Reflect.getMetadata 和 Reflect.defineMetadata 都不存在,程序会直接报错。
第三,嵌套校验的深度问题。上面示例只实现了一层嵌套递归,实际项目中可能有多层嵌套,比如订单包含用户,用户包含地址。你的 validate 函数需要足够通用,最好支持循环引用检测,避免死循环。
第四,默认行为要明确。校验时未标记的属性默认不校验;序列化时未标记的属性默认忽略。这个约定必须跟团队统一,否则有人会以为没标记的属性也会输出,造成数据缺失。
第五,序列化返回的是新对象,不是原对象引用。这一点很重要,否则你修改序列化结果会污染原始对象。上面 serialize 函数返回的是新的 result,但内部如果直接赋值引用类型,比如数组或对象,那么外部修改数组元素,原对象可能也会被改。实际工程中,建议做深度拷贝。
// 技术栈:TypeScript
// 补充一个简易的深拷贝,避免序列化结果和原对象共享引用
function deepClone(value: any, seen = new Map()): any {
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const clone = Array.isArray(value) ? [] : {};
seen.set(value, clone);
for (const key of Object.keys(value)) {
clone[key] = deepClone(value[key], seen);
}
return clone;
}
序列化时,对每个 value 先 deepClone 再复制给结果,这样更安全。但注意,元数据驱动的序列化只会复制你暴露的字段,所以深拷贝不会把敏感字段带进去,可以放心。
第六,装饰器新语法和旧语法不要混用。如果你的项目使用的是 TypeScript 4.x 的旧装饰器语法,装饰器函数的参数是 (target, propertyKey),而新语法是 (target, context)。二选一,不要混。上面示例用了新语法,它更干净,尤其适合属性装饰器。
第七,校验错误的信息要友好。最好包含字段名、规则、实际值,方便定位问题。但注意不要泄露敏感字段值,比如密码错误时,别把密码明文打印到日志里。
九、总结
装饰器实现参数校验与序列化,本质上是一种“元编程”思想:在编译期描述规则,在运行时执行规则。它让业务代码里的数据边界变得更加清晰,也让重复的防御代码集中到一处。我们用一个模型类同时承载校验规则和输出规则,通过 reflect-metadata 存储元数据,通过装饰器将元数据附加到属性上,最后在构造时校验、在输出前序列化。整个流程没有破坏 TypeScript 的类型系统,字段的类型检查依然在编译期生效,运行时的实际数据又被装饰器规则兜底,两者互补。
对于大多数 Web 服务、配置加载、消息处理场景,这套方案完全够用,而且比手写一堆 if 优雅得多。唯一要记住的是,装饰器并不是魔法,它只是函数,你需要清楚它何时执行、元数据存在哪、默认行为是什么。如果你能把这个工程化方案吃透,以后面对复杂的数据输入输出,你会有一种“手里有刀,心里不慌”的踏实感。
最后,请根据你的项目实际情况,决定是否引入类似 class-validator、class-transformer 这类成熟库。它们提供了更多内置规则和复杂功能,但原理与本文展示的装饰器思路完全相同。理解底层,再取舍工具,你就真正掌握了这项技术。
评论
围绕“装饰器实现参数校验与序列化的工程化方案:如何在运行时依赖元数据完成数据验证,同时保持编译期类型清晰。”参与讨论