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

肉夹馍(https://github.com/inversionhourglass/Rougamo)通过静态代码织入方式实现AOP的组件,其主要特点是在编译时完成AOP代码织入,相比动态代理可以减少应用启动的初始化时间让服务更快可用,同时还能对静态方法进行AOP。

摆烂半年又一更,感谢各位的支持,那么就不说废话了,下面开始介绍2.0推出的新功能吧。对于首次接触肉夹馍的朋友,可以先查看我之前的文章,或者直接到项目的github上查看最新的README.

新功能

部分织入

肉夹馍在1.x经过数次迭代,新增了数个新功能,但对于绝大部分使用者来说并用不到全部的功能。比如你只想在方法执行成功或失败的时候执行一些日志操作,你并不需要重写参数、修改返回值或处理异常,甚至都不需要在OnEntryOnExit中执行操作,但在1.x版本中,无论你是否需要,都会把这段处理代码织入到目标方法中。这在无形中增加了目标程序集的大小,同时也会在运行时使你多执行几个分支判断。在2.0版本中,可以通过重写Features属性来选择你使用到的功能。Features可选值如下表所示:

枚举值 功能
All 包含全部功能,默认值
OnEntry 仅OnEntry,不可修改参数值,不可修改返回值
OnException 仅OnException,不可处理异常
OnSuccess 仅OnSuccess,不可修改返回值
OnExit OnExit
RewriteArgs 包含OnEntry,同时可以在OnEntry中修改参数值
EntryReplace 包含OnEntry,同时可以在OnEntry中修改返回值
ExceptionHandle 包含OnException,同时可以在OnEntry中处理异常
SuccessReplace 包含OnSuccess,同时可以在OnSuccess中修改返回值
ExceptionRetry 包含OnException,同时可以在OnException中进行重试
SuccessRetry 包含OnSuccess,同时可以在OnSuccess中进行重试
Retry 包含OnException和OnSuccess,同时可以在OnException和OnSuccess中进行重试
Observe 包含OnEntry、OnException、OnSuccess和OnExit,常用于日志、APM埋点等操作
NonRewriteArgs 包含除修改参数外的所有功能
NonRetry 包含除重试外的所有功能

比如我只想在string类型的方法参数为null时给其赋初值,此时重写Features指定为RewriteArgs,甚至可以省略try..catch..finally的生成:

public class DefaultArgsAttribute : MoAttribute
{
    // 可以反编译对比一下重写和不重写Features的区别
    public override Feature Features => Feature.RewriteArgs;

    public override void OnEntry(MethodContext context)
    {
        var parameters = context.Method.GetParameters();
        for (var i = 0; i < parameters.Length; i++)
        {
            if (parameters[i].ParameterType == typeof(string) && context.Arguments[i] == null)
            {
                context.Arguments[i] = string.Empty;
                context.RewriteArguments = true;
            }
        }
    }
}

支持属性和构造方法

属性这个是老欠账了,构造方法相反是赶上了末班车。在1.x版本MoAttribute是不能直接应用到属性上的,只能应用到gettersetter上,现在直接应用到属性上是同时应用到gettersetter上。同样的,1.x版本是不支持应用到构造方法上,现在是可以的。不过在应用到构造方法时需要谨慎使用,不当的使用容易出现字段/属性未初始化的情况。

除了能够直接将MoAttribute应用到属性和方法上,在将MoAttribute应用到类或程序集时也可以通过Flags属性来选择到属性和构造方法。Flags新增枚举值Method/PropertyGetter/PropertySetter/Property/Constructor分别代表普通方法、属性getter、属性setter、属性getter&setter、构造方法。需要注意的是,在不指定这些值中的任意一个时,默认值为Method|Property,至于为什么,因为在没推出这个功能前,默认就是这样,现在保持与之前的逻辑一致。当不重写Flags属性时,默认匹配所有public的实例方法和属性。

public class TestAttribute : MoAttribute
{
    // 匹配所有普通方法(除属性和构造方法外的方法)
    public override AccessFlags Flags => AccessFlags.All | AccessFlags.Method;

    // 匹配所有实例属性getter
    public override AccessFlags Flags => AccessFlags.Instance | AccessFlags.PropertyGetter;

    // 匹配所有非public的构造方法
    public override AccessFlags Flags => AccessFlags.NonPublic | AccessFlags.Constructor;
}

支持排序

如果有朋友使用肉夹馍实现了多个AOP组件,当多个组件同时对一个方法产生织入时,他们的执行顺序是什么样的,多个Attribute直接方法级别应用那肯定是按你代码从上到下的顺序,那如果你还有应用到类、程序集或者通过代理Attribute、IRougamo实现的呢,此时顺序又是什么样的。其实即使我现在告诉大家是什么样的,大家也记不住,我也记不住,所以直接设置一个排序值才是最直观的方式。

public class SortTestAttribute : MoAttribute
{
    // 直接重写Order属性,不重写时值为0,执行时按从小到大的顺序执行
    public override double Order => 1.23;
}

// 当然也可以应用的时候指定
[assembly: SortTest(Order = 3.14)]

表达式匹配

好了,让版本号变成2.0而不是1.5的功能来了。

Flags在第一个版本的时候就推出了,目的是希望能够在批量应用的场景下提供一些过滤功能。但如你所见,即使到了2.0版本,它能够过滤的特征依然有限,这个限制是枚举给到的,无法使用枚举实现很复杂的过滤功能,这会让枚举变成穷举,体验极差。其实在方法检索上早已有了一个很优秀的方案,那就是java一个广为认知的AOP组件aspectj,字符串表达式的可扩展性是枚举远不能比的。所以肉夹馍采用了同样的方式和相似的语法实现了C#的方法表达式匹配。熟悉aspectj的朋友可能会很容易上手,不过推荐还是看完一遍介绍后再使用,肉夹馍添加了一些针对C#的语法格式。

先来一个简单的示例。

public class PatternAttribute : MoAttribute
{
    // 使用表达式匹配,可以轻松进行方法名称匹配
    // 匹配所有方法名以Get开头的方法
    public override string? Pattern => "method(* *.Get*(..))";

    // 覆盖了特征匹配功能(除了构造方法)
    // 匹配所有public静态方法
    public override string? Pattern => "method(public static * *(..))";

    // 匹配所有getter
    public override string? Pattern => "getter(* *)";

    // 还能进行子类匹配
    // 匹配所有返回值是int集合的方法
    public override string? Pattern => "method(int[]||System.Collections.Generic.IEnumerable<int>+ *(..))";

    // 更多匹配规则,请查看后面的介绍
}

基础概念

特征匹配是重写Flags属性,对应的表达式匹配是重写Pattern属性,由于表达式匹配和特征匹配都是用于过滤/匹配方法的,所以两个不能同时使用,Pattern优先级高于Flags,当Pattern不为null时使用Pattern,否则使用Flags

表达式共支持六种匹配规则,表达式必须是六种的其中一种:

  • method([modifier] returnType declaringType.methodName([parameters]))
  • getter([modifier] propertyType declaringType.propertyName)
  • setter([modifier] propertyType declaringType.propertyName)
  • property([modifier] propertyType declaringType.propertyName)
  • execution([modifier] returnType declaringType.methodName([parameters]))
  • regex(REGEX)

上面的六种规则中,getter, setter, property分别表示匹配属性的getter, setter和全部匹配(getter+setter),method表示匹配普通方法(非getter/setter/constructor),execution表示匹配所有方法,包含getter/setterregex是个特例,将在正则匹配中进行单独介绍。在表达式内容格式上,methodexecutiongetter/setter/property多一个([parameters]),这是因为属性的类型即可表示属性getter的返回值类型和setter的参数类型,所以相对于methodexecution,省略了参数列表。

上面列出的六种匹配规则,除了regex的格式特殊,其他的五种匹配规则的内容主要包含以下五个(或以下)部分:

  • [modifier],访问修饰符,可以省略,省略时表示匹配所有,访问修饰符包括以下七个:
    • private
    • internal
    • protected
    • public
    • privateprotected,即private protected
    • protectedinternal,即protected internal
    • static,需要注意的是,省略该访问修饰符表示既匹配静态也匹配实例,如果希望仅匹配实例,可以与逻辑修饰符!一起使用:!static
  • returnType,方法返回值类型或属性类型,类型的格式较为复杂,详见类型匹配格式
  • declaringType,声明该方法/属性的类的类型,类型匹配格式
  • methodName/propertyName,方法/属性的名称,名称可以使用*进行模糊匹配,比如*Async,Get*,Get*V2等,*匹配0或多个字符
  • [parameters],方法参数列表,Rougamo的参数列表匹配相对简单,没有aspectj那么复杂,仅支持任意匹配和全匹配
    • 使用..表示匹配任意参数,这里说的任意是指任意多个任意类型的参数
    • 如果不进行任意匹配,那么就需要指定参数的个数及类型,当然类型是按照类型匹配格式进行匹配的。Rougamo不能像aspectj一样进行参数个数模糊匹配,比如int,..,double是不支持的

在上面列出的六种匹配规则中不包含构造方法的匹配,主要原因在于构造方法的特殊性。对构造方法进行AOP操作其实是很容易出现问题的,比较常见的就是在AOP时使用了还未初始化的字段/属性,所以我一般认为,对构造方法进行AOP时一般是指定特定构造方法的,一般不会进行批量匹配织入。所以目前对于构造方法的织入,推荐直接在构造方法上应用Attribute进行精确织入。另外由于Flags对构造方法的支持和表达式匹配都是在2.0新增的功能,目前并没有想好构造方法的表达式格式,等大家使用一段时间后,可以综合大家的建议再考虑,也为构造方法的表达式留下更多的操作空间。

类型匹配格式

类型格式

首先我们明确,我们表达某一个类型时有这样几种方式:类型名称;命名空间+类型名称;程序集+命名空间+类型名称。由于Rougamo的应用上限是程序集,同时为了严谨,Rogamo选择使用命名空间+类型名称来表达一个类型。命名空间和类型名称之间的连接采用我们常见的点连接方式,即命名空间.类型名称

嵌套类

嵌套类虽然使用不多,但该支持的还是要支持到。Rougamo使用/作为嵌套类连接符,这里与平时编程习惯里的连接符+不一致,主要是考虑到+是一个特殊字符,表示子类,为了方便阅读,所以采用了另一个符号。比如a.b.c.D/E就表示命名空间为a.b.c,外层类为D的嵌套类E。当然嵌套类支持多层嵌套。

泛型

需要首先声明的是,泛型和static一样,在不声明时匹配全部,也就是既匹配非泛型类型也匹配泛型类型,如果希望仅匹配非泛型类型或仅匹配泛型类型时需要额外定义,泛型的相关定义使用<>表示。

  • 仅匹配非泛型类型:a.b.C<!>,使用逻辑非!表示不匹配任何泛型
  • 匹配任意泛型:a.b.C<..>,使用两个点..表示匹配任意多个任意类型的泛型
  • 匹配指定数量任意类型泛型:a.b.C<,,>,示例表示匹配三个任意类型泛型,每添加一个,表示额外匹配一个任意类型的泛型,你可能已经想到了a.b.C<>表示匹配一个任意类型的泛型
  • 开放式与封闭式泛型类型:未确定泛型类型的称为开放式泛型类型,比如List<T>,确定了泛型类型的称为封闭式泛型类型,比如List<int>,那么在编写匹配表达式时,如果希望指定具体的泛型,而不是像上面介绍的那种任意匹配,那么对于开放式未确定的泛型类型,可以使用我们常用的T1,T2,TA,TX等表示,对于封闭式确定的泛型类型直接使用确定的类型即可。
    // 比如我们有如下泛型类型
    public class Generic<T1, T2>
    {
        public static void M(T1 t1, int x, T2 t2) { }
    }
    
    // 定义匹配表达式时,对于开放式泛型类型,并不需要与类型定义的泛型名称一致,比如上面叫T1,T2,表达式里用TA,TB
    public class TestAttribute : MoAttribute
    {
        public override string? Pattern => "method(* *<TA,TB>.*(TA,int,TB))";
    }
    
  • 泛型方法:除了类可以定义泛型参数,方法也可以定义泛型参数,方法的泛型参数与类型的泛型参数使用方法一致,就不再额外介绍了
    // 比如我们有如下泛型类型
    public class Generic<T1, T2>
    {
        public static void M<T3, T4>(T1 t1, T2 t2, T3 t3, T4 t4) { }
    }
    
    // 定义匹配表达式时,对于开放式泛型类型,并不需要与类型定义的泛型名称一致,比如上面叫T1,T2,表达式里用TA,TB
    public class TestAttribute : MoAttribute
    {
        public override string? Pattern => "method(* *<TA,TB>.*<TX, TY>(TA,TB,TX,TY))";
    
        // 同样可以使用非泛型匹配、任意匹配和任意类型匹配
        // public override string? Pattern => "method(* *<TA,TB>.*<..>(TA,TB,*,*))";
    }
    

模糊匹配

在前面介绍过两种模糊匹配,一种是名称模糊匹配*,一种是参数/泛型任意匹配..。在类型的模糊匹配上依旧使用的是这两个符号。

类型格式中介绍到,类型格式由两部分组成命名空间.类型名称,所以类型的模糊匹配可以分为:命名空间匹配、类型名称匹配、泛型匹配、子类匹配,其中泛型匹配在上一节刚介绍过,子类匹配将在下一节介绍,本节主要讲述类型基本的模糊匹配规则。

  • 类型名称匹配:类型名称的模糊匹配很简单,可以使用*匹配0或多个字符,比如*Service,Mock*,Next*Repo*V2等。需要注意的是,*并不能直接匹配任意嵌套类型,比如期望使用*Service*来匹配AbcService+Xyz是不可行的,嵌套类型需要明确指出,比如*Service/*,匹配名称以Service结尾的类型的嵌套类,如果是二层嵌套类,也需要明确指出*Service/*/*
  • 命名空间匹配
    • 缺省匹配:在命名空间缺省的情况下表示匹配任意命名空间,也就是只要类型名称即可,比如表达式Abc可以匹配l.m.n.Abc也可以匹配x.y.z.Abc
    • 完全匹配:不使用任何通配符,编写完全的命名空间,即可进行完全匹配
    • 名称模糊:命名空间有一或多段,每一段之间用.连接,和类型名称匹配一样,每一段的字符都可以使用*自行匹配,比如*.x*z.ab*.vv
    • 多段模糊:使用..可以匹配0或多段命名空间,比如*..xyz.Abc可以匹配a.b.xyz.Abc也可以匹配lmn.xyz.Abc..也可以多次使用,比如使用a..internal..t*..Ab匹配a.internal.tk.Aba.b.internal.c.t.u.Ab

子类匹配

在前面介绍接口织入时有聊到,我们可以在父类/基础接口实现一个空接口IRougamo<>,这样继承/实现了父类/基础接口的类型的方法在条件匹配的情况下就会进行代码织入。那么这种方式是需要修改父类/基础接口才行,如果父类/基础接口是引用的第三方库或者由于流程原因不能直接修改,又该如何优化操作呢。此时就可以结合assembly attribute和子类匹配表达式来完成匹配织入了,定义匹配表达式method(* a.b.c.IService+.*(..)),这段表达式表示可匹配所有a.b.c.IService子类的所有方法,然后再通过[assembly: Xx]XxAttribute应用到整个程序集即可。

如上面的示例所示,我们使用+表示进行子类匹配。除了方法的声明类型,返回值类型、参数类型都可以使用子类匹配。另外子类匹配还可以与通配符一起使用,比如method(* *(*Provider+))表示匹配方法参数仅一个且参数类型是以Provider结尾的类型的子类。

特殊语法

基础类型简写

对于常用基础类型,Rougamo支持类型简写,让表达式看起来更简洁清晰。目前支持简写的类型有bool, byte, short, int, long, sbyte, ushort, uint, ulong, char, string, float, double, decimal, object, void

Nullable简写

正如我们平时编程一样,我们可以使用?表示Nullable类型,比如int?即为Nullable<int>。需要注意的是,不要将引用类型的Nullable语法也当做Nullable类型,比如string?其实就是string,在Rougamo里面直接写string,而不要写成string?

ValueTuple简写

我们在编写C#代码时,可以直接使用括号表示ValueTuple,在Rougamo中同样支持该比如,比如(int,string)即表示ValueTuple<int, string>Tuple<int, string>

Task简写

现在异步编程已经是基础的编程方式了,所以方法返回值为TaskValueTask的方法将会非常之多,同时如果要兼容TaskValueTask两种返回值,表达式还需要使用逻辑运算符||进行连接,那将大大增加表达式的复杂性。Rougamo增加了熟悉的async关键字用来匹配TaskValueTask返回值,比如Task<int>ValueTask<int>可以统一写为async int,那么对于非泛型的TaskValueTask则写为async null。需要注意的是,目前没有单独匹配async void的方式,void会匹配voidasync void

类型及方法简写

前面有介绍到,类型的表达由命名空间.类型名称组成,如果我们希望匹配任意类型时,标准的写法应该是*..*,其中*..表示任意命名空间,后面的*表示任意类型名称,对于任意类型,我们可以简写为*。同样的,任意类型的任意方法的标准写法应该是*..*.*,其中前面的*..*表示任意类型,之后的.是连字符,最后的*表示任意方法,这种我们同样可以简写为*。所以method(*..* *..*.*(..))method(* *(..))表达的意思相同。

正则匹配

对于每个方法,Rougamo都会为其生成一个字符串签名,正则匹配即是对这串签名的正则匹配。其签名格式与method/execution的格式类似modifiers returnType declaringType.methodName([parameters])

  • modifiers包含两部分,一部分是可访问性修饰符,即private/protected/internal/public/privateprotected/protectedinternal,另一部分是是否静态方法static,非静态方法省略static关键字,两部分中间用空格分隔。
  • returnType/declaringType均为命名空间.类型名称的全写,需要注意的是,在正则匹配的签名中所有的类型都是全名称,不可使用类似int去匹配System.Int32
  • 泛型,类型和方法都可能包含泛型,对于封闭式泛型类型,直接使用类型全名称即可;对于开放式泛型类型,我们遵守以下的规定,泛型从T1开始向后增加,即T1/T2/T3...,增加的顺序按declaringTypemethod后的顺序,详细可看后续的示例
  • parameters,参数按每个参数的全名称展开即可
  • 嵌套类型,嵌套类型使用/连接
namespace a.b.c;

public class Xyz
{
    // 签名:public System.Int32 a.b.c.Xyz.M1(System.String)
    public int M1(string s) => default;

    // 签名:public static System.Void a.b.c.Xyz.M2<T1>(T1)
    public static void M2<T>(T value) { }

    public class Lmn<TU, TV>
    {
        // 签名:internal System.Threading.Tasks.Task<System.DateTime> a.b.c.Xyz/Lmn<T1,T2>.M3<T3,T4>(T1,T2,T3,T4)
        internal Task<DateTime> M3<TO, TP>(TU u, TV v, TO o, TP p) => Task.FromResult(DateTime.Now);

        // 签名:private static System.Threading.Tasks.ValueTask a.b.c.Xyz/Lmn<T1,T2>.M4()
        private static async ValueTask M4() => await Task.Yeild();
    }
}

正则匹配存在编写复杂的问题,同时也不支持子类匹配,所以一般不编写正则匹配规则,其主要是作为其他匹配规则的一种补充,可以支持一些更为复杂的名称匹配。由于Rougamo支持逻辑运算法,所以也给到正则更多辅助的空间,比如我们想要查找方法名不以Async结尾的Task/ValueTask返回值方法method(async null *(..)) && regex(^\S+ (static )?\S+ \S+?(?<!Async)\()

优化、修复及配置

织入代码优化

由于我们可以在一个方法上应用多个MoAttribute,所以在1.x版本中使用数组保存所有的Mo。但大多数情况下,我们一个方法只有一个Mo,此时使用数组来保存显得有些浪费,即使有三个Mo同时使用,实际上使用数组保存也不划算,因为数组的操作指令比较多,相比而言单变量操作指令就简单很多。所以在2.0版本中,默认4个Mo以下的情况下为每个Mo单独定义变量,4个及以上使用数组,该设定可以通过配置项moarray-threshold修改。修改方式参考 README 中的说明。

修复应用Attribute时指定Flags无效

这是社区反馈的 issue,感谢各位反馈的bug和建议。

// issue反馈的是这种应用时指定Flags无效
[FlagsTest(Flags = AccessFlags.Instance)]
public class Test
{
    // ...
}

启用综合可访问性配置

首先明确一点,通过FlagsPattern都可以指定匹配方法的可访问性及其他匹配规则,但是在将MoAttribute直接应用于方法上时,这些匹配规则是无效的,你都怼脸上了,我当然是让你生效的。那么在更高层次应用时就会出现一个问题,除了方法具有可访问性,类同样具有可访问性,比如你方法是public的,但是你的类型是internal的,那实际上你的方法的综合可访问性还是internal。考虑到一般我们说一个方法的可访问性是直接说的方法本身的可访问性,所以默认情况下可访问性匹配的是方法本身的可访问性,同时增加配置项composite-accessibility,设置为true时表示使用综合可访问性。需要注意的是,这个综合可访问性仅对Pattern生效,对Flags无效。

这里仅列出了2.0新增的配置项,如果希望了解其他配置项或配置的方式,可查看 README 中的说明。

最后

随着2.0的推出,也希望大家能多在批量应用上探索一下,直接将Attribute应用到方法上是灵活的用法但也是侵入性大的方式。当然,两种方式配合使用才能让体验达到最优,这个就需要大家自己探索了。那么本次的2.0版本介绍到此结束,感谢各位的支持和反馈,我们下个版本再见。

posted @ 2023-10-10 08:08  nigture  阅读(3217)  评论(10编辑  收藏  举报