.NET静态代码织入——肉夹馍(Rougamo)

肉夹馍是什么

肉夹馍(https://github.com/inversionhourglass/Rougamo)通过静态代码织入方式实现AOP的组件。.NET常用的AOP有Castle DynamicProxyAspectCore等,以上两种AOP组件都是通过运行时生成一个代理类执行AOP代码的,肉夹馍则是在代码编译时直接修改原始方法IL代码,在原始方法内织入AOP代码的。.NET静态AOP的组件或许有人使用过PostSharp,这是一个功能完善且强大的静态代码织入组件,Postsharp有社区版,但可惜的是社区版不支持异步方法,肉夹馍的实现方式与Postsharp类似,同时也支持了异步方法,如果你仅仅使用了Postsharp方法层级的AOP代码织入功能,可以尝试使用肉夹馍来替代Postsharp。

快速开始

# 添加NuGet引用
dotnet add package Rougamo.Fody
// 1.定义类继承MoAttribute,在该类中定义你在方法执行各阶段需要织入的代码
public class LoggingAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        // 从context对象中能取到包括入参、类实例、方法描述等信息
        Log.Info("方法执行前");
    }

    public override void OnException(MethodContext context)
    {
        Log.Error("方法执行异常", context.Exception);
    }

    public override void OnExit(MethodContext context)
    {
        Log.Info("方法退出时,不论方法执行成功还是异常,都会执行");
    }

    public override void OnSuccess(MethodContext context)
    {
        Log.Info("方法执行成功后");
    }
}

// 2.在需要织入代码的方法上应用LoggingAttribute
public class Service
{
    [Logging]
    public static int Sync(Model model)
    {
        // ...
    }

    [Logging]
    private async Task<Data> Async(int id)
    {
        // ...
    }
}

通过实现空接口的方式进行代码织入

在上面的示例中,我们通过在方法上应用Attribute进行AOP,这种方式目标明确但有些AOP代码我们可能希望应用于某一场景或某一层级,每个方法都去应用Attribute很繁琐,而且代码侵入严重。此时就可以考虑使用实现空接口(IRougamo<>)的方式进行批量Attribute应用

public interface IService : IRougamo<LoggingAttribute> { }

public interface IMyService : IService { }

public class MyService : IMyService
{
}

上面的示例中,MyService所有的public实例方法都将应用LoggingAttribute,你可能注意到我标红的部分了,为什么是public实例方法呢?这是默认值,你可以在继承MoAttribute时通过重写Flags属性来修改这一默认值,比如下面的示例中FullLoggingAttribute将会应用于所有方法。另外需要注意的是Flags属性在Attribute直接应用到方法上时是无效的,比如LoggingAttribute默认仅应用public实例方法,但像快速开始里的代码那样Async方法虽然是private的但还是会应用LoggingAttribute

public class FullLoggingAttribute : LoggingAttribute
{
    public override AccessFlags Flags => AccessFlags.All;
}

实例-Rougamo.OpenTelemetry

快速开始里介绍了肉夹馍两种常用的使用方式,更多的使用方式可以到github查看readme,在本篇文章中就不再做更多介绍了,接下来我将介绍使用肉夹馍的一个项目Rougamo.OpenTelemetry,如果你准备使用肉夹馍,但你还是不太清楚具体应该怎么使用,可以参考这个项目的代码实现。

关于OpenTelemetry

在了解OpenTelemetry前,你需要先了解APM(Application Performance Management/Monitor),在这个微服务的时代,APM已经成为了必不可少的一部分,没有它整个系统对我们而言就是一个黑盒,你无法得知一个请求在微服务之间是如何调用如何完成,难以排查一个用户超时是哪个服务超时或出错。现在市面上有很多开源的APM比如Pinpoint, Zipkin, SkyWalking, CAT, jaeger等,虽说大家基本都是参考google的dapper论文设计出来的,但实现和功能侧重却大相径庭,为了对此形成一个规范,先后出现了OpenTracingOpenCensus,并在此后合并为现在的OpenTelemetry。OpenTelemetry的出现为APM的接入提供了一种可能“应用不需要在意具体的APM服务端使用的是Zipkin还是jaeger或是其他的情况下,应用只需要使用OpenTelemetry的SDK进行埋点,APM通过实现OTLP(OpenTelemetry Protocol)来支持OpenTelemetry数据格式即可”,当前已经有些APM完全采用OpenTelemetry SDK作为默认的SDK比如jaeger,也有部分支持的APM比如skywalking。

关于Rougamo.OpenTelemetry

现在大部分流行的APM都有对应语言的SDK并且还实现了常用的I/O组件埋点,opentelemetry-dotnet也已经提供了包括HttpClientSqlClientAspNetCore等I/O埋点。虽说一般而言服务的耗时一般就在I/O部分,但由于开发人员的代码习惯不同、代码水平不同以及业务复杂度等情况,某些非I/O代码也会产生一定的耗时,同时在一个接口中可能会执行多次I/O操作,如果仅仅只有I/O埋点,可能很难分辨层次关系,此时可能需要一些本地辅助埋点,Rougamo.OpenTelemetry便是用于添加本地埋点的组件。

快速开始

# 启动项目引用Rougamo.OpenTelemetry.Hosting
dotnet add package Rougamo.OpenTelemetry.Hosting
# 添加埋点的项目引用Rougamo.OpenTelemetry
dotnet add package Rougamo.OpenTelemetry
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...

        services.AddOpenTelemetryTracing(builder =>
        {
            builder
                .AddRougamoSource() // 初始化Rougamo.OpenTelemetry
                .AddAspNetCoreInstrumentation()
                .AddJaegerExporter();
        });

        // 修改Rougamo.OpenTelemetry默认配置
        services.AddOpenTelemetryRougamo(options =>
        {
            options.ArgumentsStoreType = ArgumentsStoreType.Tag;
        });
    }
}

class Service
{
    [return: ApmIgnore]     // 返回值不记录
    [Otel] // 默认记录参数和返回值,需要通过ApmIgnoreAttribute来忽略不需要记录的参数或返回值
    public async Task<string> M1(
            [ApmIgnore] string uid, // 该参数不记录
            DateTime time)
    {
        // do something
        return string.Empty;
    }

    [PureOtel] // 默认不记录参数和返回值,需要通过ApmRecordAttribute来记录指定的参数或返回值
    public void M2(
            [ApmRecord] double d1,  // 记录该参数
            double d2)
    {
        // do something
    }
}

// 通过实现空接口织入
public interface ITestService : IRougamo<FullOtelAttribute>
{
    // ...
}
public class TestService : ITestService
{
    // ...
}

Rougamo.OpenTelemetry的埋点会对应生成一个名称为方法全名称(ClassFullName.MethodName)的LocalSpan,根据你使用的是OtelAttribute还是PureOtelAttribute决定默认是否记录参数和返回值。Rougamo.OpenTelemetry是用来丰富APM埋点的,但是切记不要过度添加埋点,过多的埋点会让你的trace看起来很臃肿。
关于Rougamo.OpenTelemetry更多的使用说明,详见github,github上的代码中包含了一个jaeger的示例代码,你可以从jaeger官网上下载一个all-in-one包快速运行一个jaeger服务端,然后启动示例项目,访问http://localhost:5000/test接口,最后访问jaeger uihttp://localhost:16686查看刚刚访问的test接口的trace数据。

更多关于

关于肉夹馍的应用情况

写肉夹馍的动机是公司在使用postsharp做AOP,起初公司的代码是framework的并且基本使用同步方法,所以postsharp的免费版本是足足够用的,随着.NET的发展,公司的代码也逐渐从同步发展到异步从framework发展到core,然后我们通过购买付费版本的postsharp也能继续维持着,不过由于个人对postsharp的实现产生了兴趣,所以悄悄的建立了这个项目,但是由于个人比较懒,这个早在19年就建立了的项目直到21年才完成。
在发布1.0.1之前,项目一直处于闭源状态,但在闭源状态下已经在公司内部发布了几个测试版本,其中1.0.0版本已经在公司测试环境沉淀了一个季度有余,现在已经将1.0.0版本发布到了线上使用中,发布在nuget.org上的1.0.1版本相对于1.0.0版本在代码上没有任何修改。Rougamo项目的TargetFramework是netstandard2.0,公司应用了Rougamo的项目都是.NET Core3.1的,所以如果你的项目是.NET Core3.1的,你可以相对放心的使用(如果不着急应用,也推荐测试环境沉淀一下),如果你是其他版本,那么推荐你在测试环境沉淀一段时间,肉夹馍作为一个新项目,可能还会存在一些未知BUG,如果有任何BUG请反馈到github issue中

关于.NET的静态代码织入

.NET的静态代码织入其实我了解的也不是特别多,我知道鼻祖应该是Mono.Cecil,百度也能搜到很多它的介绍,然后就是很强大(但大部分功能收费)的Postsharp,以及对Mono.Cecil进行封装,使其更易用的Fody,肉夹馍便是使用Fody实现AOP代码织入的。
静态代码织入在我观察下来使用得并不是很普遍,这或许是因为动态代理早已成熟的缘故吧。那么静态织入相对于动态代理有什么优势呢?说实话,开发肉夹馍很大一部分原因是个人兴趣,但这并不代表它没有优势,静态织入是在编译时进行的,静态织入只会让编译时间稍长些许,而动态代理的方式都是在应用启动时动态生成代理类来实现的,这个过程必定会占用些许时间,并且在这个初始化动作完成前,服务是不会进入就绪状态的,也就是这个服务暂时为不可用状态的,服务初始化时间越短,服务整体的可用性就会越好,这就是静态织入带来的优势。当然,有些朋友可能会认为这是在钻牛角尖,确实,很多时候我们可能认为这种耗时是微乎其微的,事实也确实如此,但做基础架构关注的就是这些微乎其微耗时,我们经常能看到java的一些技术博文上会写到他们做了很多字节码层面的优化,他们的这种优化很多时候只是优化了那么几个指令,单拎出来看着似乎没有多大的性能提升,然而在大流量高吞吐的服务中,这样优化的效果将会显现出来,静态织入也是如此,性能就是这样一点一点扣出来的。

关于Fody

.NET的开发者应该或多或少都听说甚至使用过ABP,它是.NET中非常流行的一套DDD框架了,如果你还看过ABP的源码,你或许见过Fody的影子,是的ABP也有使用到Fody,使用的是ConfigureAwait.Fody,我们在编写异步方法的时候经常会增加一个.ConfigureAwait(false)ConfigureAwait.Fody的功能就是为异步调用默认加上这个方法调用。
进入到Fody的github首页你将能看到很多借助于Fody开发的组件,我们也可以直接在nuget.org上以Fody为关键字进行搜索,你将能看到更多以Fody开发的组件,同时你可能还会发现,在下载量很高的NuGet包中有两个AOP相关实现MethodDecorator.FodyMethodBoundaryAspect.Fody,早在我建立肉夹馍这个项目前我就看到了这两个项目,但当时的他们没有对异步方法的支持,就在这篇文章写到这里的时候我再次去查看了这两个项目,他们对异步的支持依旧不能满足我的需求,他们的OnExit方法都是在状态机在第一次返回也就是在遇到第一个await的时候执行的,这时候这个异步方法实际上可能并没有执行完毕,下面我会给一个例子,各位可以自己进行尝试。关于为什么我没有直接参与他们的项目,而是自己新建了一个项目,主要有两个原因:一是我有一丢丢懒,不确定这个项目我会投入多少精力并且什么时候去完成,事实也正如我的预期,两年过去了,二是我的英语有一丢丢差,IL方面我也不算老手,我担心有些问题交流起来有困难,所以最终也就独立建了肉夹馍这个项目了。

dotnet add package Rougamo.Fody
dotnet add package MethodDecorator.Fody
dotnet add package MethodBoundaryAspect.Fody
<!--FodyWeavers.xml-->
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Rougamo />
  <MethodDecorator />
  <MethodBoundaryAspect />
</Weavers>
public sealed class RougamoLogAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on entry");
    }

    public override void OnSuccess(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on success");
    }

    public override void OnException(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on exception: {context.Exception.Message}");
    }

    public override void OnExit(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on exit");
    }
}

[AttributeUsage(AttributeTargets.Method)]
public class MethodDecoratorLogAttribute : Attribute, IMethodDecorator
{
    public void Init(object instance, MethodBase method, object[] args)
    {
        Console.WriteLine($"[MethodDecorator] on init");
    }

    public void OnEntry()
    {
        Console.WriteLine($"[MethodDecorator] on entry");
    }

    public void OnExit()
    {
        Console.WriteLine($"[MethodDecorator] on exit");
    }

    public void OnException(Exception exception)
    {
        Console.WriteLine($"[MethodDecorator] on exception: {exception.Message}");
    }
}

public sealed class MethodBoundaryAspectLogAttribute : OnMethodBoundaryAspect
{
    public override void OnEntry(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on entry");
    }

    public override void OnExit(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on exit");
    }

    public override void OnException(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on exception: {args.Exception.Message}");
    }
}

class Program
{
    static async Task Main(string[] args)
    {
        try
        {
            await Async();
        }
        catch
        {
        }
    }

    [RougamoLog]
    //[MethodDecoratorLog]
    //[MethodBoundaryAspectLog]
    static async Task Async()
    {
        Console.WriteLine(1);
        await Task.Delay(10);
        Console.WriteLine(2);
        throw new NotImplementedException("not implemented");
    }
}

分别用三个Attribute运行上面的程序你会得到下面的输出,肉夹馍的异常信息是在输出2之后输出,exit信息在最后输出(也就是异步方法执行完毕后);MethodDecorator没有捕获到异步的异常,并且exit信息在输出2之前就输出了;MethodBoundaryAspect捕获到了异步的异常信息,但是exit信息在输出2之前输出了,也就是你无法在异步方法真正执行完毕后织入代码。

[Rougamo] on entry
1
2
[Rougamo] on exception: not implemented
[Rougamo] on exit

[MethodDecorator] on init
[MethodDecorator] on entry
1
[MethodDecorator] on exit
2

[MethodBoundaryAspect] on entry
1
[MethodBoundaryAspect] on exit
2
[MethodBoundaryAspect] on exception: not implemented

关于使用肉夹馍开发组件的注意事项

最后如果你准备使用肉夹馍,并且你准备使用肉夹馍开发一个供他人使用的NuGet组件,那么你需要把项目文件(.csproj)中Rougamo.Fody的引用改成下面这样,不然你发布的NuGet其他人引用后将需要额外引用Fody,否则将无法进行代码织入,具体可以参考Rougamo.OpenTelemetry

<PackageReference Include="Rougamo.Fody" Version="1.0.1" IncludeAssets="all" PrivateAssets="contentfiles;analyzers" />

最后的最后,即使你不准备使用肉夹馍,也希望通过此文让你了解到静态代码织入,了解到Mono.CecilFody,如果.NET能够发展壮大起来,那么静态代码织入也终将得到更大的发展。这篇文章中不论是Rougamo还是Rougamo.OpenTelemetry都没有进行完整的介绍,如果你准备使用它们,请移步github了解更多。

posted @ 2021-12-21 08:05  nigture  阅读(4858)  评论(28编辑  收藏  举报