C# 中的 Attribute 并不是用来直接完成业务流程的,它更像一组随程序集一起编译的声明式描述。无论是给类打上 Serializable,还是给属性加上 Required,编译器都会把这些标记写入模块的元数据中。运行阶段通过反射读取这些信息,调用方就能把通用逻辑和业务模型解耦。这种能力在框架设计里尤其重要,因为框架作者通常无法预知使用者的类型结构,只能依靠元数据来驱动行为。

很多人第一次接触 Attribute 时会把它当成一种特殊注释,其实二者完全不同。注释在编译时会被丢弃,而 Attribute 会持久化在程序集里。注释只能给人看,Attribute 则是给程序看的。正是由于这个差别,序列化框架才能根据某个属性上的标记改变输出名称,ORM 才能根据字段上的标记决定列的类型和长度。下面通过自定义特性、反射读取和实际校验三个环节把整个链路拆开。
Attribute如何进入程序集元数据
自定义特性本质上是一个继承自 System.Attribute 的类。类名习惯以 Attribute 结尾,使用时可以省略后缀。编译器看到方括号里的特性标记后,会实例化对应的特性类型,并把构造函数参数与命名参数写入程序集的元数据表。这个过程发生在编译期,而不是运行期,因此特性里不能包含随运行时变化的状态,也不能依赖外部服务。
下面定义一个简单的接口信息特性:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class ApiInfoAttribute : Attribute
{
public string Version { get; }
public string Description { get; set; }
public ApiInfoAttribute(string version)
{
Version = version;
}
}
在类和方法上使用它:
[ApiInfo("1.0", Description = "订单服务接口")]
public class OrderService
{
[ApiInfo("2.0", Description = "创建订单")]
public void Create(Order order)
{
// 业务逻辑
}
}
这里的 "1.0" 是位置参数,对应构造函数 ApiInfoAttribute(string version)。Description 是命名参数,对应类中的公共可写属性。编译器会记录这些值,但不会主动触发任何行为。特性只有在某个框架或代码主动通过反射读取时才有意义。换句话说,Attribute 本身不做事,真正做事的是读取它的那部分代码。
运行阶段可以使用反射获取这些标记:
var type = typeof(OrderService);
var attribute = type.GetCustomAttributes(typeof(ApiInfoAttribute), true)
.FirstOrDefault() as ApiInfoAttribute;
if (attribute != null)
{
Console.WriteLine(attribute.Version);
Console.WriteLine(attribute.Description);
}
从元数据到反射读取的完整链路就是:声明特性类、应用到代码元素、编译器写入元数据、运行时通过反射还原实例。这个机制让框架可以在不修改使用者代码的前提下,获得关于类型、方法、属性的额外信息。
AttributeUsage的三个关键选项
自定义特性通常会标注 AttributeUsage,这是特性作用于类本身的特性,用来约束使用范围。参数 AttributeTargets 是一个枚举组合,表示该特性可以贴在哪些目标上,比如 Class、Method、Property、Field、Parameter 等。如果不写 AttributeUsage,默认可以用于所有目标,但明确限制有助于在编译期发现误用。
第二个选项是 AllowMultiple,默认值为 false。也就是说同一个目标上默认只能出现一次同一个特性,重复使用会直接编译报错。如果希望一个类被打上多个标签,可以把 AllowMultiple 设为 true。例如:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public class TagAttribute : Attribute
{
public string Name { get; }
public TagAttribute(string name)
{
Name = name;
}
}
[Tag("order")]
[Tag("core")]
public class OrderService
{
}
读取多个实例时,可以用 GetCustomAttributes 返回数组并遍历:
var tags = typeof(OrderService).GetCustomAttributes(typeof(TagAttribute), true);
foreach (TagAttribute tag in tags)
{
Console.WriteLine(tag.Name);
}
第三个选项是 Inherited,它决定派生类是否会继承基类上的特性。默认值为 true,意味着特性会沿着继承链向下传递。但需要注意,这个继承规则在反射时还受 GetCustomAttributes 方法的第二个参数影响。如果调用时传入 false,即使特性本身设置了 Inherited = true,也不会读取继承来的特性。对于方法重写、属性重写的情况,继承行为可能会和直觉不太一样,具体表现取决于目标类型以及反射时的参数组合。
构造参数和命名参数也需要分清。位置参数必须按照构造函数参数顺序提供,并且必须全部给出。命名参数放在位置参数之后,对应类中公共字段或可写属性。只有 public 的字段或包含 setter 的属性才能作为命名参数。特性参数类型有明确限制,通常只能是基础类型、string、Type、枚举、object 以及这些类型的一维数组。不能把任意复杂对象作为特性参数,因为编译器需要在编译期确定并存储这些值。
用反射实现一个轻量级模型校验器
Attribute 最常见的落地场景之一就是数据校验。与其在每个服务方法里手动写一串 if 判断,不如把校验规则声明在模型字段旁边,再交给一个通用校验器统一执行。先定义两个校验特性:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class RequiredAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class StringLengthAttribute : Attribute
{
public int MaxLength { get; }
public StringLengthAttribute(int maxLength)
{
MaxLength = maxLength;
}
}
然后在模型类上标注:
public class Customer
{
[Required]
[StringLength(20)]
public string Name { get; set; }
[StringLength(100)]
public string Address { get; set; }
}
校验器通过反射遍历属性,读取每个属性上的特性并执行对应规则:
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
public static class Validator
{
public static List<string> Validate(object value)
{
var errors = new List<string>();
foreach (var property in value.GetType().GetProperties())
{
var propertyValue = property.GetValue(value);
var required = property.GetCustomAttributes(typeof(RequiredAttribute), true).FirstOrDefault();
if (required != null && (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())))
{
errors.Add(property.Name + " 不能为空");
}
var length = property.GetCustomAttributes(typeof(StringLengthAttribute), true)
.FirstOrDefault() as StringLengthAttribute;
if (length != null && propertyValue != null)
{
if (propertyValue.ToString().Length > length.MaxLength)
{
errors.Add(property.Name + " 长度不能超过 " + length.MaxLength);
}
}
}
return errors;
}
}
这个 Validator 不依赖具体的 Customer 类,任何标注了 Required 和 StringLength 的模型都可以复用它。约束信息和校验逻辑被拆开了,模型只负责描述自身的数据形状,基础设施负责解释这些描述。这正是元数据驱动编程的核心好处。
当然,反射不是没有代价。频繁反射读取属性信息会带来一定性能损耗,如果请求量很大,应该缓存 PropertyInfo 和特性实例。可以预先构建一个静态元数据缓存,用类型作为 key,保存每个属性对应的校验特性列表,避免每次校验都重新反射。.NET 内置的 System.ComponentModel.DataAnnotations 其实已经实现了类似机制,但自己写一遍能更清楚地看到 Attribute 与反射是如何配合工作的。
Attribute与接口、配置中心的适用边界
Attribute 适合描述静态的、与代码结构紧密相关的元数据。它一旦编译进程序集,就不能在运行时随意改变。如果某个阈值或开关经常调整,把它写成 Attribute 就意味着每次修改都要重新编译部署,这时配置中心或数据库更合适。例如一个限流阈值如果与业务活动有关,就应该放进配置系统,而不是写成 [RateLimit(50)] 这样的硬编码。
接口表达的是能力契约,Attribute 表达的则是声明式约束和横向关注点,两者并不互斥。一个类既可以实现 IOrderService,同时也可以被打上 [ApiInfo("1.0")]。接口保证方法签名一致,Attribute 则给基础设施提供更细粒度的描述信息。ASP.NET Core 中的 [HttpGet] 就是一个典型例子,它同时标记了 HTTP 方法和路由模板,框架启动时扫描控制器并构建路由表,整个过程不需要开发者额外写注册代码。
还要注意 Attribute 类自身不应该承载复杂业务逻辑。因为特性实例通常由反射创建,构造函数里不适合访问数据库、网络或容器服务。如果发现某个特性类里开始出现大量行为代码,往往是设计边界出了问题。Attribute 应当保持为一份轻量描述,真正的处理逻辑应放在读取它的框架或服务中。这样职责清晰,测试和替换也会更容易。
总的来说,Attribute 的核心价值在于把不变的信息从业务逻辑中剥离,交给基础设施统一解读。声明、读取、约束三个环节理解清楚之后,再去看 ASP.NET Core 的特性路由、System.Text.Json 的序列化选项,或自己设计一套元数据驱动机制,都会更加顺畅。
C# Attribute元数据反射修改时间:2026-10-01 07:51:57