在C#中,特性(Attribute)是一种向程序集嵌入元数据的能力,它允许开发者在编译时为类型、方法、属性等目标附加声明式信息。通过自定义Attribute,我们可以将原本散落在业务代码中的规则与配置抽离出来,交由统一的框架逻辑处理,这正是元数据编程的核心思想。

一、自定义Attribute的基础定义
创建自定义特性非常简单,只需要定义一个继承自System.Attribute的类即可。CLR在编译时会将该类的实例信息作为元数据固化到程序集中。不过,若不做任何约束,这个特性可以被贴在任何地方,容易造成误用。
为了规范特性的使用方式,我们需要用AttributeUsage特性来修饰自定义特性本身。它可以指定特性适用的程序元素(如类、方法、属性)、是否允许在同一目标上重复施加,以及是否能被派生类继承。下面是一个最基础的自定义特性示例:
using System;
// 限制该特性只能用于类和方法,不允许重复,且可被继承
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class ModuleInfoAttribute : Attribute
{
public string Name { get; }
public string Version { get; }
public ModuleInfoAttribute(string name, string version)
{
Name = name;
Version = version;
}
}
上述代码中,AttributeTargets枚举通过位或运算组合了类和方法的限定。如果开发者试图将该特性贴在字段上,编译器会直接报错,从而在编译期保障了元数据的规范性。
从元数据视角看,ModuleInfoAttribute的Name与Version并不是运行时的普通属性,而是随程序集一同存储的静态描述。当其他组件加载此程序集时,即便不执行具体业务逻辑,也能通过反射读出这些模块信息,这就是声明式编程带来的解耦优势。
二、通过反射读取特性实现元数据驱动
特性本身不具备主动执行能力,必须配合反射(Reflection)才能在运行时被识别并利用。在实际框架设计中,常见的做法是:在程序启动阶段扫描所有类型,找出带有特定特性的类或方法,然后将其注册到容器或执行特定逻辑。
以下示例展示了一个简易的“模块自动注册器”。我们定义了ModuleInfoAttribute,并在两个业务类上标注,随后通过Assembly.GetTypes遍历类型,提取特性中的元数据完成注册动作。
using System;
using System.Reflection;
using System.Collections.Generic;
[ModuleInfo("订单模块", "1.0.0")]
public class OrderModule { }
[ModuleInfo("用户模块", "2.1.0")]
public class UserModule { }
public class ModuleRegistry
{
private static List<string> _modules = new List<string>();
public static void ScanAndRegister(Assembly assembly)
{
foreach (Type type in assembly.GetTypes())
{
// 获取类上标记的 ModuleInfoAttribute 实例
ModuleInfoAttribute attr = type.GetCustomAttribute<ModuleInfoAttribute>();
if (attr != null)
{
_modules.Add($"已注册: {attr.Name} v{attr.Version}");
}
}
}
public static void Print()
{
foreach (var m in _modules)
{
Console.WriteLine(m);
}
}
}
// 调用示例
class Program
{
static void Main()
{
ModuleRegistry.ScanAndRegister(Assembly.GetExecutingAssembly());
ModuleRegistry.Print();
}
}
运行上述代码,控制台会输出两个模块的名称与版本。这种模式的优势在于:新增模块时只需打上特性,无需修改注册中心的代码,完全符合开闭原则。许多依赖注入框架与单元测试 runner 正是基于这一机制工作的。
需要注意的是,反射操作本身有一定性能开销。在高频调用场景中,应将扫描结果缓存起来,而不是每次都调用GetCustomAttribute。此外,特性构造函数中的参数必须是编译期常量,因此无法传入复杂的运行时对象,这是元数据编程在表达能力上的固有边界。
三、高级应用:基于特性的参数校验
除了类级别的标记,特性在方法参数与属性上的应用更为频繁。我们可以定义校验特性,在统一入口处通过反射读取并执行验证,避免在每个业务方法内部写重复的判空或范围检查。
下面实现一个RangeAttribute,用于限制整型参数的最小值与最大值。结合方法特性与参数特性,我们可以在调用前自动拦截非法输入。
using System;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class RangeAttribute : Attribute
{
public int Min { get; }
public int Max { get; }
public RangeAttribute(int min, int max)
{
Min = min;
Max = max;
}
public bool IsValid(int value)
{
return value >= Min && value <= Max;
}
}
public class ProductService
{
public void CreateProduct([Range(1, 100)] int count)
{
Console.WriteLine($"创建产品,数量: {count}");
}
}
// 校验器辅助类
public static class Validator
{
public static void InvokeWithValidate(object target, string methodName, object[] args)
{
MethodInfo method = target.GetType().GetMethod(methodName);
ParameterInfo[] paras = method.GetParameters();
for (int i = 0; i < paras.Length; i++)
{
var rangeAttr = paras[i].GetCustomAttribute<RangeAttribute>();
if (rangeAttr != null && args[i] is int val)
{
if (!rangeAttr.IsValid(val))
{
throw new ArgumentOutOfRangeException(paras[i].Name);
}
}
}
method.Invoke(target, args);
}
}
在上面的代码中,CreateProduct方法的count参数被Range特性约束。通过Validator.InvokeWithValidate,我们在真正执行方法前完成了参数合法性检查。如果传入 200,程序会抛出ArgumentOutOfRangeException,从而将校验逻辑与业务代码彻底分离。
这种元数据编程方式在 Web API 框架中极为常见,例如模型绑定阶段的[Required]、[StringLength]等特性。它们的本质都是在反序列化后、进入控制器前,由框架统一扫描属性上的特性并执行验证委托。理解这一机制,有助于我们在自研中间件时复用相同的设计思路,提升代码的可维护性。
四、特性与元数据编程的局限和思考
虽然特性极大增强了C#的声明式表达能力,但它并非银弹。首先,特性数据在编译后固化,无法在运行时动态修改;其次,过度依赖反射会让调用链变得隐晦,新人接手项目时往往难以直观追踪逻辑流向。
在架构层面,建议将特性的使用控制在“框架级基础设施”中,例如路由映射、权限标记、序列化忽略等横切关注点。业务规则若频繁变动,仍应写在显式的服务类里,而不是塞进特性参数。只有理清元数据和业务逻辑的边界,才能让C#的元数据编程真正发挥高级价值。
C#自定义Attribute元数据编程修改时间:2026-08-07 23:18:16