返回
Featured image of post 使用 Fody 实现 .NET 的静态编织

使用 Fody 实现 .NET 的静态编织

AI 摘要
本文介绍了在项目中使用 `OnMethodBoundaryAspect` 基类记录方法日志的经历,以及澄清了之前对项目使用的是 PostSharp 的误解。讨论了面向切面编程(AOP)中的静态编织和动态代理,以及在.NET中使用Fody进行静态编织的实践。展示了使用Fody插件实现静态织入功能的过程,包括编写插件、修改IL代码等细节。通过自定义插件HelloWorld.Fody展示了静态编织的原理和效果。最后总结了静态编织的重要性以及通过IL代码操作插件的实现方式。整体内容涵盖了AOP、Fody插件、IL代码修改等方面的知识。

在很长的一段时间里,我们的项目中一直使用 OnMethodBoundaryAspect 这个基类来记录每个方法的日志。诚然,FodyWeavers.xml 这个文件的存在,早已在冥冥之中暗示我,Fody 才是这座冰山下真正的墨西哥湾暖流。可惜,因为某种阴差阳错的巧合,譬如两者都使用了 OnMethodBoundaryAspect 这个命名,这导致我过去一直以为我们使用的是 PostSharp。如果你是用过 ReSharper 或者 Rider 这些由 JetBrains 出品的工具,你大概会听说过 PostSharp。不过,有趣的是,JetBrains 和 PostSharp 其实没有半毛钱的关系,两者唯一相似的地方,或许是它们都不姓微软😂。当我们谈论 PostSharp 的时候,我其实想说的是静态编织。由此,我们就引出了今天这篇文章的主题,即: .NET 中的静态编织。而对于静态编织,我们这里只需要知道,它是一种在编译时期间将特定的字节码插入到目标类和方法的技术。

再从 AOP 说起

想不到吧,此去经年,我再一次聊起了 AOP 这个话题。众所周知,AOP 是指面向切面编程 (Aspect Oriented Programming),而所谓的切面,可以认为是具体拦截的某个业务点。对于面向对象编程的语言来说,一个业务点通常就是一个方法或者函数。因此,我们谈论 AOP 这个话题的时候,更多的是指在某个方法执行前后插入某种处理逻辑。此时,广义的 AOP 就有静态编织和动态代理两种形式,前者发生在编译时期间,后者发生在运行时期间。如下图所示,我们平时使用的 Castle DynamicProxyAspectCoreDispatchProxy 等等都属于动态代理的范畴,这些都是在运行时期间对代码进行“修改”;而我们今天要讨论的 Fody ,则是属于静态编织的范畴,顾名思义,它是在编译时期间对代码进行“修改”。我们知道,按照实现方式上的不同, AOP 又可以分为代理模式和父子类重写两种“修改”方式。至此,我们对于 AOP 的认知范围被进一步扩大,就像我们以前学习数学的时候,我们对于对于“数”的定义,是先从有理数扩充到无理数,后来又从实数扩充到虚数。那么,屏幕前的你,真的搞懂 AOP 了吗?

广义上的面向切面编程
广义上的面向切面编程

Fody 的初体验

作为一个类库,Fody 在使用上并没有任何非同寻常的地方。这意味着,你可以像使用任何一个第三方库一样,直接通过 NuGet 来安装:

dotnet add package Fody --version 6.6.3

可惜,这样或许会令你感到失望。因为对于 Fody 而言,我们通常使用的是它的插件 (Add-In) 而不是 Fody 本身,除非当你需要真正编写一个插件。身处西安这个十三朝古都,你一定听说过鼎鼎大名的三秦套餐,即:凉皮、冰峰、肉夹馍。这里,我们就以 Rougamo.Fody 这个插件为例来快速体验一下 Fody 。首先,我们通过 NuGet 来安装该插件:

dotnet add package Rougamo.Fody --version 1.1.2

接下来,我们来一起编写下面的代码,一个可以附加到方法上的特性 LoggingAttribute

[AttributeUsage(AttributeTargets.Method)]
public class LoggingAttribute: MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        Console.WriteLine("执行方法 {0}() 开始, 参数:{1}.", context.Method.Name, JsonConvert.SerializeObject(context.Arguments));
    }

    public override void OnException(MethodContext context)
    {
        Console.WriteLine("执行方法 {0}() 异常, {1}.", context.Method.Name, context.Exception.Message);
    }

    public override void OnExit(MethodContext context)
    {
        Console.WriteLine("执行方法 {0}() 结束.", context.Method.Name);
    }

    public override void OnSuccess(MethodContext context)
    {
        Console.WriteLine("执行方法 {0}() 成功.", context.Method.Name);
    }
}

为了测试这个特性的效果,相应地,我们再准备几个简单的函数:

[Logging]
static int Add(int a, int b) => a + b;

[Logging]
static Task<int> AddAsync(int a, int b) => Task.FromResult(a + b);

[Logging]
static decimal Divide(decimal a, decimal b) => a / b;

此时,如果我们尝试调用这些方法,就可以获得下面的结果:

使用 Rougamo.Fody 记录日志
使用 Rougamo.Fody 记录日志

可以注意到,这个插件可以悄无声息地帮我们“织入”指定代码,就像小时候妈妈为我们织毛衣一样。那么,这一切究竟是怎么做到的呢? 我想,这一切要从编译原理开始讲起 👻 。

.NET 编译原理示意图
.NET 编译原理示意图

我们都知道,对于 C# 这门语言来说,它第一步是被编译为 IL,即中间代码,然后再经过 JIT 编译为本地代码。当然,现在 .NET 已经支持 AOT 编译模型。可不管使用哪一种编译模型,Fody 的势力范围始终都是 IL 被送到 JIT 或者 AOT 之前,即编译时期间。因此,Fody 或者说静态编织的本质,其实就是对第一步生成的 IL 进行修改,如下图所示,Fody 通过通过插件或者 ModuleWeaver 来对 IL 进行修改:

.NET 静态编织原理示意图
.NET 静态编织原理示意图

对于我们这个示例而言,你可以认为,是 Fody 帮我们把 LoggingAttribute 里的这些代码,悄无声息地“编织”进了我们的代码里,这一点怎么验证呢?其实,我们只需要通过 Ildasm.exe 这个工具对代码进行反编译即可,通常情况下,这个工具位于以下路径:C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools。此时,结果如下图所示:

谁动了我的代码 A
谁动了我的代码 A

可以看到,我们自己写的 Add() 方法,确实被 Fody 给修改了,它用一个 try-catch 语句块把我们的代码包裹在其中,然后在 catchfinally 块里分别调用 OnException()OnSuccess() 两个方法。果然,哪有什么岁月静好,不过是在有人替你负重前行,对不对?

第一个 Add-In

好了,从接触 Fody 到现在,我们一直都是在使用别人写好的插件。虽然通过反编译代码,我们大概知道了 Fody 对我们的代码做了什么。可是,博主相信大家和我一样,对于 Fody 的原理还是云里雾里。下面,我们通过一个自定义的插件来展示更多的细节。按照程序世界的惯例,我们还是从 HelloWorld 开始,对于每一个 Fody 插件而言,其命名风格类似于 Rougamo.Fody,因此,我们创建一个名为 HelloWorld.Fody 的类库项目:

dotnet new classlib --name HelloWorld.Fody

接下来,我们定义一个特性 HelloWorldAttribute ,这个特性里什么都不用写,我们这里只用它来打标记:

[AttributeUsage(AttributeTargets.Method)]
public class HelloWorldAttribute : Attribute { }

这里我们希望做一件什么事情呢?我们希望每一个打上 [HelloWorld] 标签的方法,都可以自动输出一句:Hello World. 。参考 Fody插件开发规范,我们定义一个名为 ModuleWeaver 的类,它继承自 BaseModuleWeaver 这个类,为了使用这个类型,你还需要通过 NuGet 安装 FodyHelpers 这个包。默认情况下,我们需要重写 Execute()GetAssembliesForScanning() 两个方法:

public override void Execute()
{
    foreach (var type in ModuleDefinition.Types)
    {
        foreach (var method in type.Methods)
        {
            var customerAttribute = method.CustomAttributes.FirstOrDefault(x => x.AttributeType.Name == nameof(HelloWorldAttribute));
            if (customerAttribute != null)
            {
                ProcessMethod(method);
            }
        }
    }
}

首先,这里的 ModuleDefinition 属性定义来自父类,它包含了本次编织中所有可以使用的类型信息,如果你稍微仔细一点,就会发现 ModuleDefinition 其实是定义在 Mono.Cecil 这个程序集里面的,这恰恰印证了我们一开始的说法,即:Fody 是基于 Mono.Cecil 的扩展库。当然,这些细节暂时无关紧要。因为对我们来说,我们只需要遍历所有类型中的方法,然后判断这个方法上面有没有附加 HelloWorldAttribute 这个特性即可。至于具体怎么实现 ProcessMethod() 这个方法,我们可以先暂时放到到一边。

public override IEnumerable<string> GetAssembliesForScanning()
{
    yield return "mscorlib";
    yield return "System";
}

其次,考虑到我们需要调用的 Console.WriteLine() 方法是位于 System 命名空间下面,因此,我们需要在 GetAssembliesForScanning() 这个方法里返回 Systemmscorlib ,这一步的目的是告诉 Fody ,它应该去哪里找这些引用了的类型。

public static void Echo()
{
    Console.WriteLine("Hello World.");
}

OK,让我们先暂时将眼睛从 Fody 上移开,既然 Fody 的秘诀是修改 IL ,我们不妨先从 IL 上入手。这里,我们准备一个 Echo() 方法,然后看看它编译为 IL 会变成什么样子:

从 C# 代码到 IL 代码
从 C# 代码到 IL 代码

这里简单翻译下这段代码,IL_0000 这一行表示把字符串 Hello World. 放入栈中;IL_0005这一行表示调用 Console.WriteLine() 这个方法;IL_000a 这一行表示返回值。当然,对于 void 类型的方法来说,这一句相当于什么都不做。这样,我们就认识了三个指令:ldstrcallret。这里再增加一个指令 nop,它相当于我们平时写代码时的空行,有了这四个指令,我们就可以尝试用 IL 来编程啦!如果你接触过 Emit, 上面这段代码可以改写为下面这样:

// 创建一个无参、无返回值的方法
var dynamicEcho = new DynamicMethod("DynamicEcho", null, Type.EmptyTypes);

// 利用 IL 填充方法体
var ilGenerator = dynamicEcho.GetILGenerator();
ilGenerator.Emit(OpCodes.Ldstr, "Hello World.");
ilGenerator.Emit(OpCodes.Call, typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) }));
ilGenerator.Emit(OpCodes.Ret);

// 调用方法
var dynamicEchoAction = dynamicEcho.CreateDelegate(typeof(Action)) as Action;
dynamicEchoAction.Invoke();

为什么要写这样一段代码呢?首先,是因为我确实不会 Emit,我需要了解它;其次,写 Emit 能加深我们对于 ldstrcallret 这三个指令的理解。有了这个基础,我们就可以来实现 ProcessMethod() 这个方法啦:


private MethodInfo _writeLineMethod  => typeof(Console).GetMethod("WriteLine", new Type[] { typeof(string) });

private void ProcessMethod(MethodDefinition method)
{
    // 获取当前方法体中的第一个 IL 指令
    var processor = method.Body.GetILProcessor();
    var current = method.Body.Instructions.First();

    // 插入一个 Nop 指令,表示什么都不做
    var first = Instruction.Create(OpCodes.Nop);
    processor.InsertBefore(current, first);
    current = first;

    // 构造 Console.WriteLine("Hello World")
    foreach (var instruction in GetInstructions(method))
    {
        processor.InsertAfter(current, instruction);
        current = instruction;
    }
}

private IEnumerable<Instruction> GetInstructions(MethodDefinition method)
{
    yield return Instruction.Create(OpCodes.Nop);
    yield return Instruction.Create(OpCodes.Ldstr, "Hello World.");
    yield return Instruction.Create(OpCodes.Call, ModuleDefinition.ImportReference(_writeLineMethod));
}

这里,每一个 Instruction 对应着 IL 代码中的一条指令,一旦理解了这一点,静态编织本质上不就是修改 IL 代码吗?所以,我们的做法是:在原有方法的第一个指令前插入一个 nop 指令,然后再在这个 nop 指令后面插入我们自己的指令。这部分指令我们已经用 Emit 写过一次,这里直接抄下来就好啦!至此,我们就完成了整个 HelloWorld.Fody 插件的编写,该插件的目录结构如下:

HelloWorld.Fody
|--- HelloWorld.Fody.csproj
|--- HelloWorldAttribute.cs
|--- ModuleWeaver.cs

OK,现在我们还有最后一个问题要解决,即:Fody 是如何找到这些插件的。一开始在体验 Rougamo.Fody 的时候,我们完全没有这方面的困惑,这是因为它可以在程序生成时,自动找到对应的插件并在 FodyWeavers.xml 文件中生成下列内容:

<Weavers
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"
  >
  <Rougamo />
</Weavers>

是的,Fody 的秘密终于被揭晓了,它通过这个 XML 文件来决定编译时使用哪些插件。不过,博主在写这篇文章的时候发现,我们自己编写的这个插件,不管是通过项目引用还是包引用的方式,Fody 都提示找不到对应的插件,这意味着编译会失败,即使我照猫画虎一般地在 FodyWeavers.xml 文件中加入<HelloWorld /> 节点。最终,我在 官方文档 中找到了答案,Fody 默认会从下面三个路径检索插件:

  • NuGet Package 目录
  • 解决方案路径下的 Tools 目录
  • 解决方案中叫做 Weavers 的项目

不过,最简单粗暴的做法,还是在工程文件中指定插件的路径。虽然,我还是不知道,我写的这个插件和别人写的插件,到底差在哪里?

<Project
  xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <ItemGroup>
    <WeaverFiles Include="$(MsBuildThisFileDirectory)..\weavers\HelloWorld.Fody.dll" />
  </ItemGroup>
</Project>

现在,万事具备,只欠东风,我们修改一下 Ehco() 方法,添加 [HelloWorld] 这个特性:

[HelloWorld]
public static void Echo()
{
    Console.WriteLine("Hello Fody.");
}

此时,我们会得到下面的结果。其中,Hello World. 是我们通过静态编织插入的代码,Hello Fody.Echo() 方法自身的代码:

HelloWorld.Fody 插件使用效果展示
HelloWorld.Fody 插件使用效果展示

现在,你会作何感想,是不是对 AOP 的印象更深了一点,忽略那些切面、拦截器、代理对象、被代理对象…等等的概念,你最终会发现,AOP 越来越呈现出一种返璞归真的状态,毕竟,我们只需要在某个方法体的第一条指令前、最后一条指令后,各自插入一组指令即可。虽然从我接触编程的那一刻起,已经完全不需要再去学习晦涩难懂的汇编语言,可是写这篇文章的时候,我好像知道了写汇编是一种什么样的感觉😏…

本文小结

广义的面向切面编程,有静态编织和动态代理两种形式,它们都可以在某个方法执行前后插入某种处理逻辑。不同的地方在于,前者发生在编译时期间,后者发生在运行时期间。对于 .NET 而言,最常见的静态编织方案是 PostSharpMono.Cecil,两者的区别是:一个付费、一个免费。本文介绍的 Fody 是一个基于 Mono.Cecli 的扩展库,通过 Fody 的各种插件,我们可以向已有代码织入特定的功能,譬如 Rougamo.Fody 这个插件可以让我们对方法进行拦截。基于这个原理,我们实现了一个完全不同于动态代理的拦截器。动态编织的本质是修改 IL 代码,对于这一点我们可以通过 ILdasm.exe 这个工具来验证。为了进一步了解 Fody 是如何修改 IL 代码的,我们参照 Fody 的规范实现了一个自定义的插件,在这个过程中,我们了解了几个常见 IL 指令,以及如何通过 Emit 来生成 IL 指令。此时,我们就接触到比表达式树更为底层的东西,而操作 IL 指令更是让我们体会到写汇编语言的酸爽,同时让我们对 .NET 的编译原理有了更为直观的认识。

Built with Hugo v0.126.1
Theme Stack designed by Jimmy
已创作 274 篇文章,共计 1038468 字