为什么.NET中的自定义属性让我噩梦连连

Lobsters Hottest 工具

摘要

一位开发者吐槽.NET自定义属性在二进制元数据层面的糟糕设计,解释了它们的存储方式以及为何会引发问题。

<p><a href="https://lobste.rs/s/bykj53/why_custom_attributes_net_give_me">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/31 08:17

# 为什么 .NET 中的自定义属性让我噩梦连连 来源:https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/ 有些人可能认为我是 .NET 的推销员。看完我之前的文章(https://blog.washi.dev/posts/misconceptions-about-dotnet),他们或许是对的。然而,尽管我很喜欢 .NET,但有些东西实在让我无法理解,也让我烦扰不已。由于我维护着一个 PE 解析库,对 .NET 二进制文件的内部结构了如指掌,我觉得自己有资格吐槽微软在这个文件格式上做的一些设计选择:)。在这篇文章中,我将吐槽**自定义属性**,以及它们底层的存储机制——我认为这是微软在 .NET 中所做的最差设计之一。过去几年它给我带来了太多痛苦,在 AsmResolver 核心维护者群里已经成了一个梗。我真心相信自定义属性是万恶之源。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/meme01.png) *我真的会梦见自定义属性。* ## 什么是自定义属性? https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#what-are-custom-attributes 对于不熟悉的人来说,自定义属性是你附加到类、方法、字段、参数等之上的额外元数据。它们通常用于指示 C# 编译器做一些额外的事情。一个经典的例子是 `ObsoleteAttribute`(https://learn.microsoft.com/en-us/dotnet/api/system.obsoleteattribute?view=net-10.0),如果某个标记了该属性的对象在用户代码中使用,编译器会产生一个警告: ```csharp [Obsolete] // 标记 MyClass 已过时的自定义属性 public class MyClass { /* ... */ } var x = new MyClass(); // <-- 编译器警告:"warning CS0612: 'MyClass' 已过时" ``` 你也可以定义自己的自定义属性,它们也可以定义参数: ```csharp public class MyAttribute(int x, string y) : Attribute { /* ... */ } [MyAttribute(0x1337, "Hello, world!")] public class MyClass { /* ... */ } ``` 自定义属性是一种很好的方式,可以扩展围绕函数、变量或类型的标准元数据。它主要用于分析器、源代码生成器或动态初始化/检查,非常适合元编程以及自动序列化和反序列化对象等场景。 ## 自定义属性的结构 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#anatomy-of-a-custom-attribute 在 .NET 文件格式中,所有内容都存储在一个由元数据表组成的数据库中。类型、字段、方法、参数等都会存在于各自的表中。这使得通过元数据令牌(即表 + 行索引)高效地引用和查找每个对象成为可能。你可以使用像 CFF Explorer(https://ntcore.com/explorer-suite/)这样的工具查看 .NET 二进制文件中这些元数据表的原始内容: [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff01.png) *.NET 二进制文件中的元数据表。* 自定义属性也不例外。`CustomAttribute` 表中的每一行代表一个属性实例。它包含对属性附加到的成员的引用、对属性构造函数的引用,以及一个指向 blob 流的索引,该索引指向一个数组,该数组包含调用此构造函数所需的参数。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff02.png) *一个自定义属性行* blob 签名是本文的重点。 ## 自定义属性签名 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#the-custom-attribute-signature 自定义属性签名中的所有参数都会被序列化为其二进制表示形式,并按顺序连接。重要的是,这个二进制表示形式**完全由属性构造函数的参数类型隐含**。例如,如果第一个参数的类型是 `int`,那么前四个字节编码一个整数。如果第二个参数是 `string`,接下来就是读取一个以长度为前缀的字符数组。如此继续,直到读取完所有参数。如果一切都正确,你应该正好读到 blob 签名的末尾。 ``` 00000000 37 13 00 00 44 48 65 6c 6c 6f 2c 20 77 6f 72 6c |....DHello, worl| 00000010 64 21 |d!| ``` 这很直观,没有问题。但之后情况急转直下。 ## 包含枚举值的自定义属性 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#custom-attributes-with-enum-values 绝大多数属性实际上并不接受诸如 `int` 或 `string` 这样的基本类型参数,而是经常包含定义为 `enum` 类型的参数。规范规定,枚举值的序列化方式与其底层类型相同。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/ecma01.png) *ECMA 规范关于枚举值的说明。* 考虑以下示例: ```csharp public enum MyIntEnum // C# 中默认是 `int` { Value1 = 1, Value2 = 2, Value3 = 3, // ... } public enum MyShortEnum : short // 显式指定底层类型 `short` { Value1 = 1, Value2 = 2, Value3 = 3, // ... } public class FooAttribute(MyIntEnum a, MyShortEnum b) : Attribute { /* ... */ } [Foo(MyIntEnum.Value2, MyShortEnum.Value3)] public class SomeClass; ``` 由于 `MyIntEnum` 隐式子类化 `int`,而 `MyShortEnum` 子类化 `short`,实例化的 `Foo` 属性的第一个参数将占用 4 字节(`02 00 00 00`),第二个参数只占用 2 字节(`03 00`)。这将产生完整的参数序列:`02 00 00 00 03 00`。 要正确读回枚举参数,你必须知道枚举的底层类型,并从签名中读取相应数量的字节。如果你不知道,就有可能会错误地解释后续参数的字节。 关于这一点,有一个重要的事实: > **确定枚举的底层类型是一个非常昂贵的操作。** 原因如下: ### 解析枚举类型 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#resolving-the-enum-type 由于属性或构造函数签名中都没有指示枚举的底层类型,确定这个底层类型非常麻烦,因为需要解析枚举类型本身,以便检查其元数据结构。类型解析包括以下步骤: - **程序集解析:** 我们首先需要确定该类型存储在哪个程序集中。这涉及在磁盘上不同目录中搜索 DLL 文件(使用复杂的搜索算法,不同 .NET 版本可能有所不同,有时需要解析随二进制文件附带的一两个 JSON 或 XML 文件),解析所有相关的头部,遍历其元数据流,并验证它是否确实是我们正在寻找的程序集(即检查其名称、版本、公钥令牌等)。这不是一个简单的操作。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/files01.png) *找到正确的 DLL 并非易事。* - **类型树遍历:** 一旦找到候选程序集,我们需要实际搜索与枚举引用匹配的类型。这意味着要遍历其 `TypeDef` 表(对于像 `mscorlib.dll` 这样的大型 DLL,可能包含成百上千行),解析每个条目的名称,并检查是否有匹配。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff03.png) *System.Private.CoreLib.dll 的 TypeDef 表,共 2759 行。* 如果枚举类型是嵌套类型,情况会更复杂。`TypeDef` 表中的行只指定了 `Name` 和 `Namespace`(对于嵌套类型,通常为 `null`),并且不存储关于其外层父类型的信息。为此,我们需要查阅另一个表 `NestedClass`,该表将嵌套类型与其直接外层类型关联起来。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff04.png) *NestedClass 表* 这也意味着我们需要递归地处理。如果一个类 `C` 被两个类 `B` 和 `A` 嵌套,则需要遍历 `NestedClass` 表中的两行(一行将 `C` 放入 `B` 内,另一行将 `B` 放入 `A` 内)。 - **类型转发器:** 更糟糕的是,类型甚至可能不定义在我们刚刚找到的程序集中!相反,它可能被转发到另一个程序集。.NET 标准库本身大量使用了这种机制。例如,在 C# 代码中使用枚举 `System.DebuggableAttribute.DebuggingModes` 会将对 `System.Runtime.dll` 程序集的引用添加为声明程序集。 ```csharp [assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default)] // 引用 System.Runtime.dll ``` 然而,一旦你正确找到并解析了 `System.Runtime.dll`,你会发现 `System.DebuggableAttribute.DebuggingModes` 并不在其中!实际上,这里根本没有定义任何类型: [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff05.png) *类型都去哪儿了?* 相反,它被定义为一个导出类型(存储在另一个表中),这个类型将你转发到 `System.Private.CoreLib.dll`(在现代 .NET 版本中)。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff06.png) *类型转发器指向 System.Private.CoreLib.dll* 理论上,你可以有任意多个类型转发器。它们都会一遍又一遍地触发新的程序集和类型解析,直到你最终找到要解析的类型。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff07.png) *终于找到了!* 总之:类型解析并非易事。即使你实现了大量缓存(例如,程序集和类型解析),它仍然比读取一个字节复杂得多,也更容易出错。 ### 遍历枚举类型 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#traversing-the-enum-type 一旦我们最终解析了枚举类型,我们必须找出其底层枚举类型。基于 C# 使用的语法,你可能会认为这是 `TypeDef` 表中的 `Extends` 列(就像其他类型一样),但对于 `enum` 类型,这一列总是设置为 `System.Enum`。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/cff08.png) *枚举 typedef 的基类型* 相反,你需要找到枚举类型中定义的一个特殊的隐藏非静态字段(通常称为 `value__`),这需要遍历另一个表(`Field`)的一个子集。一旦找到这个字段,你需要解析其字段签名以找出其字段类型。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/dnSpy01.png) *字段类型* 恭喜,你终于找到了底层枚举类型!现在你可以用它来决定是否需要从签名中读取 1、2、4 或 8 个字节来处理*单个*基于枚举的参数。如果你的自定义属性定义了另一个枚举参数,你必须重新经历这个过程:)。 ### 我对枚举值的看法 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#my-thoughts-on-enum-values 这个系统太复杂了,尤其是考虑到 99.97% 在自定义属性中使用的枚举都使用普通的 32 位整数值作为其底层存储机制\[需要引用\]。但有些不是,因此你必须拥有这个系统。你还最好希望签名所依赖的所有程序集都在附近,否则程序集解析会失败,你将永远无法确定枚举参数的大小。这个系统也极其不必要。考虑到只有非常少量的“有效”枚举底层类型(例如 `sbyte`、`short`、`int`……),我觉得可以通过简单地在原始值前加上一个 `CorElementType`(https://github.com/dotnet/runtime/blob/32f2c3324e1f3f1da93b6f394abd2e893327e4a5/src/coreclr/inc/corhdr.h#L873-L928)指示字节(即 `ELEMENT_TYPE_I1`、`ELEMENT_TYPE_I2`、`ELEMENT_TYPE_I4`……)来轻松解决。这些指示符在文件格式的其余部分也经常使用,而且编译器在构建时应该已经拥有了这些信息。我不知道他们为什么这样设计。 ## 包含类型值的自定义属性 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#custom-attributes-with-type-values 但等等,还有更糟的!属性也可以将 `Type` 作为参数: ```csharp public class FooAttribute(System.Type type) : Attribute { /* ... */ } [Foo(typeof(int))] public class SomeClass; ``` 当在属性中引用类型时,编译器不存储令牌,而是存储类型的**完全限定名称 (FQN)**。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/ecma02.png) *ECMA 规范关于类型值的说明。* 例如,`typeof(int)` 参数可能会被序列化为: ``` "System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" ``` 赋值给 `object` 参数的值也类似。由于你无法直接推断在该位置会是什么类型的值,你需要将某种指示内置于值的二进制表示中,以告诉你如何解释这些字节。.NET 团队决定通过在该值的二进制表示前加上值的类型前缀来实现这一点,这意味着每个装箱值前面都会跟着一个 FQN 字符串。 [](https://blog.washi.dev/assets/img/posts/custom-attributes/ecma03.png) *ECMA 规范关于装箱值的说明。* ```csharp public class BarAttribute(object value) : Attribute { /* ... */ } [Bar(0x1337)] // 装箱的 int32 public class SomeClass; ``` ``` 00000000 53 79 73 74 65 6d 2e 49 6e 74 33 32 2c 20 6d 73 |System.Int32, ms| 00000010 63 6f 72 6c 69 62 2c 20 56 65 72 73 69 6f 6e 3d |corlib, Version=| 00000020 34 2e 30 2e 30 2e 30 2c 20 43 75 6c 74 75 72 65 |4.0.0.0, Culture| 00000030 3d 6e 65 75 74 72 61 6c 2c 20 50 75 62 6c 69 63 |=neutral, Public| 00000040 4b 65 79 54 6f 6b 65 6e 3d 62 37 37 61 35 63 35 |KeyToken=b77a5c5| 00000050 36 31 39 33 34 65 30 38 39 37 13 00 00 |61934e0897...| ``` 有趣的是,这种方法与 .NET 文件格式中的其他任何做法都完全不同。其他地方的绝大多数签名都使用相同的 `CorElementType`(https://github.com/dotnet/runtime/blob/32f2c3324e1f3f1da93b6f394abd2e893327e4a5/src/coreclr/inc/corhdr.h#L873-L928)标记联合(例如,整数用 `ELEMENT_TYPE_I4`,或者 `ELEMENT_TYPE_CLASS` 后跟一个元数据令牌来引用表中的条目)。出于某种原因,.NET 团队在自定义属性参数中莫名其妙地放弃了这种极其高效的查找系统,转而使用这些字符串。 我们为什么讨论这个? > **在自定义属性中使用字符串引用类型是一个非常糟糕的主意** 原因很多。让我们逐一说明。 ### 类型名称空间效率低下 https://blog.washi.dev/posts/custom-attributes-and-why-they-suck/#type-names-are-space-inefficient 如果这还不够明显的话,FQN 字符串非常大且笨拙。比元数据令牌或索引大得多。如果它们**也不能被去重**的话,这本来不是个大问题。每个指定了 `typeof(int)` 或装箱 `int` 的属性都会有一个完整 FQN 字符串 `"System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"` 的新副本,每个 89 个字符。换句话说,从用 4 个字节表示一个整数,到用 89+4 个字节表示一个装箱整数,每个实例的开销增加了 2000% 以上!更糟糕的是,**在同一个自定义属性内部也没有去重**。在同一属性中两次引用 `typeof(int)` 会导致

相似文章

你讨厌XML吗?(2010)

Hacker News Top

一篇2010年的反思性博客文章,探讨了开发者讨厌XML的原因,追溯了炒作和反弹的周期,并讨论了使用XML进行数据建模和互操作性所面临的挑战。

膨胀

Lobsters Hottest

一位开发者反思了应用中混乱的核心数据结构,承认技术债的存在,但表示除非它阻碍AI代码生成,否则不会优先修复。

持久记忆与身份风险

Reddit r/ArtificialInteligence

一位开发者在开发具有记忆和身份的持久AI运行时,担心发布这样的代理可能会让不良行为者进行危险滥用,并请求安全建议。

我讨厌编译器

Hacker News Top

一篇表达对编译器不满的个人观点文章,可能讨论其复杂性或缺点。