Dora.Interception有别于其他AOP框架的最大的一个特点就是采用针对“约定”的拦截器定义方式。如果我们为拦截器定义了一个接口或者基类,那么拦截方法将失去任意注册依赖服务的灵活性。除此之外,由于我们采用了动态代码生成的机制,我们可以针对每一个目标方法生成对应的方法调用上下文,所以定义在拦截上下文上针对参数和返回值的提取和设置都是泛型方法,这样可以避免无谓的装箱和拆箱操作,进而将引入拦截带来的性能影响降到最低。(拙著《ASP.NET Core 6框架揭秘》于日前上市,加入读者群享6折优惠)

目录

一、方法调用上下文

二、拦截器类型约定

三、提取调用上下文信息

四、修改输出参数和返回值

五、控制拦截器的执行顺序

六、短路返回

七、构造函数注入

八、方法注入

九、ASP.NET Core应用的适配

一、方法调用上下文

针对同一个方法调用的所有拦截器都是在同一个方法调用上下文中进行的,我们将这个上下文定义成如下这个InvocationContext基类。我们可以利用Target和MethodInfo属性得到当前方法调用的目标对象和目标方法。泛型的GetArgument和SetArgument用于返回和修改传入的参数,针对返回值的提取和设置则通过GetReturnValue和SetReturnValue方法来完成。如果需要利用此上下文传递数据,可以将其置于Properties属性返回的字典中。InvocationServices属性返回针对当前方法调用范围的IServiceProvider。如果在ASP.NET Core应用中,这个属性将返回针对当前请求的IServiceProvider,否则Dora.Interception会为每次方法调用创建一个服务范围,并返回该范围内的IServiceProvider对象。

  1. public abstract class InvocationContext
  2. {
  3. public object Target { get; }
  4. public abstract MethodInfo MethodInfo { get; }
  5. public abstract IServiceProvider InvocationServices { get; }
  6. public IDictionary<object, object> Properties { get; }
  7. public abstract TArgument GetArgument<TArgument>(string name);
  8. public abstract TArgument GetArgument<TArgument>(int index);
  9. public abstract InvocationContext SetArgument<TArgument>(string name, TArgument value);
  10. public abstract InvocationContext SetArgument<TArgument>(int index, TArgument value);
  11. public abstract TReturnValue GetReturnValue<TReturnValue>();
  12. public abstract InvocationContext SetReturnValue<TReturnValue>(TReturnValue value);
  13.  
  14. protected InvocationContext(object target);
  15.  
  16. public ValueTask ProceedAsync() => Next.Invoke(this);
  17. }

和ASP.NET Core的中间件管道类似,应用到同一个方法上的所有拦截器最终也会根据指定的顺序构建成管道。对于某个具体的拦截器来说,是否需要指定后续管道的操作是由它自己决定的。我们知道ASP.NET Core的中间件最终体现为一个Func<RequestDelegate,RequestDelegate>委托,作为输入的RequestDelegate委托代表后续的中间件管道,当前中间件利用它实现针对后续管道的调用。Dora.Interception针对拦截器采用了更为简单的设计,将其表示为如下这个InvokeDelegate(相当于RequestDelegate),因为InvocationContext(相当于HttpContext)的ProceedAsync方法直接可以帮助我们完整针对后续管道的调用。

  1. public delegate ValueTask InvokeDelegate(InvocationContext context);

二、拦截器类型约定

虽然拦截器最终体现为一个InvokeDelegate对象,但是我们倾向于将其定义成一个类型。作为拦截器的类型具有如下的约定:

  • 必须是一个公共的实例方法;
  • 必须包含一个或者多个公共构造函数,针对构造函数的选择由依赖注入框架决定。被选择的构造函数可以包含任意参数,参数在实例化的时候由依赖注入容器提供或者手工指定。
  • 拦截方法被定义在命名为InvokeAsync的公共实例方法中,此方法的返回类型为ValueTask,其中包含一个表示方法调用上下文的InvocationContext类型的参数,能够通过依赖注入容器提供的服务均可以注入在此方法中。

三、提取调用上下文信息

由于拦截器类型的InvokeAsync方法提供了表示调用上下文的InvocationContext参数,我们可以利用它提取基本的调用上下文信息,包括当前调用的目标对象和方法,以及传入的参数和设置的返回值。如下这个FoobarInterceptor类型表示的拦截器会将上述的这些信息输出到控制台上。

  1. public class FoobarInterceptor
  2. {
  3. public async ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. var method = invocationContext.MethodInfo;
  6. var parameters = method.GetParameters();
  7. Console.WriteLine($"Target: {invocationContext.Target}");
  8. Console.WriteLine($"Method: {method.Name}({string.Join(", ", parameters.Select(it => it.ParameterType.Name))})");
  9.  
  10. if (parameters.Length > 0)
  11. {
  12. Console.WriteLine("Arguments (by index)");
  13. for (int index = 0; index < parameters.Length; index++)
  14. {
  15. Console.WriteLine($" {index}:{invocationContext.GetArgument<object>(index)}");
  16. }
  17.  
  18. Console.WriteLine("Arguments (by name)");
  19. foreach (var parameter in parameters)
  20. {
  21. var parameterName = parameter.Name!;
  22. Console.WriteLine($" {parameterName}:{invocationContext.GetArgument<object>(parameterName)}");
  23. }
  24. }
  25.  
  26. await invocationContext.ProceedAsync();
  27. if (method.ReturnType != typeof(void))
  28. {
  29. Console.WriteLine($"Return: {invocationContext.GetReturnValue<object>()}");
  30. }
  31. }
  32. }

我们利用InterceptorAttribute特性将这个拦截器应用到如下这个Calculator类型的Add方法中。由于我们没有为它定义接口,只能将它定义成虚方法才能被拦截。

  1. public class Calculator
  2. {
  3. [Interceptor(typeof(FoobarInterceptor))]
  4. public virtual int Add(int x, int y) => x + y;
  5. }

在如下这段演示程序中,在将Calculator作为服务注册到创建的ServiceCollection集合后,我们调用BuildInterceptableServiceProvider扩展方法构建一个IServiceCollection对象。在利用它得到Calculator对象之后,我们调用其Add方法。

  1. using App;
  2. using Microsoft.Extensions.DependencyInjection;
  3.  
  4. var calculator = new ServiceCollection()
  5. .AddSingleton<Calculator>()
  6. .BuildInterceptableServiceProvider()
  7. .GetRequiredService<Calculator>();
  8.  
  9. Console.WriteLine($"1 + 1 = {calculator.Add(1, 1)}");

针对Add方法的调用会被FoobarInterceptor拦截下来,后者会将方法调用上下文信息以如下的形式输出到控制台上(源代码)。

四、修改输出参数和返回值

拦截器可以篡改输出的参数值,比如我们将上述的FoobarInterceptor类型改写成如下的形式,它的InvokeAsync方法会将输入的两个参数设置为0(源代码)。

  1. public class FoobarInterceptor
  2. {
  3. public ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. invocationContext.SetArgument("x", 0);
  6. invocationContext.SetArgument("y", 0);
  7. return invocationContext.ProceedAsync();
  8. }
  9. }

再次执行上面的程序后就会出现1+1=0的现象。

在完成目标方法的调用后,返回值会存储到上下文中,拦截器也可以将其篡改。如下这个改写的FoobarInterceptor选择将返回值设置为0。程序执行后也会出现上面的输出结果(源代码)。

  1. public class FoobarInterceptor
  2. {
  3. public async ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. await invocationContext.ProceedAsync();
  6. invocationContext.SetReturnValue(0);
  7. }
  8. }

五、控制拦截器的执行顺序

拦截器最终被应用到某个方法上,多个拦截器最终会构成一个由InvokeDelegate委托表示的执行管道,构造管道的拦截器的顺序可以由指定的序号来控制。如下所示的代码片段定义了三个派生于同一个基类的拦截器类型(FooInterceptor、BarInterceptor、BazInterceptor),它们会在目标方法之前后输出当前的类型进而确定它们的执行顺序。

  1. public class InterceptorBase
  2. {
  3. public async ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. Console.WriteLine($"[{GetType().Name}]: Before invoking");
  6. await invocationContext.ProceedAsync();
  7. Console.WriteLine($"[{GetType().Name}]: After invoking");
  8. }
  9. }
  10.  
  11. public class FooInterceptor : InterceptorBase { }
  12. public class BarInterceptor : InterceptorBase { }
  13. public class BazInterceptor : InterceptorBase { }

我们利用InterceptorAttribute特性将这三个拦截器应用到如下这个Invoker类型的Invoke方法上。指定的Order属性最终决定了对应的拦截器在构建管道的位置,进而决定了它们的执行顺序。

  1. public class Invoker
  2. {
  3. [Interceptor(typeof(BarInterceptor), Order = 2)]
  4. [Interceptor(typeof(BazInterceptor), Order = 3)]
  5. [Interceptor(typeof(FooInterceptor), Order = 1)]
  6. public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
  7. }

在如下所示的演示程序中,我们按照上述的方式得到Invoker对象,并调用其Invoke方法。

  1. var invoker = new ServiceCollection()
  2. .AddSingleton<Invoker>()
  3. .BuildInterceptableServiceProvider()
  4. .GetRequiredService<Invoker>();
  5.  
  6. invoker.Invoke();

按照标注InterceptorAttribute特性指定的Order属性,三个拦截器执行顺序依次是:FooInterceptor、BarInterceptor、BazInterceptor,如下所示的输出结果体现了这一点(源代码)。

六、短路返回

任何一个拦截器都可以根据需要选择是否继续执行后续的拦截器以及目标方法,比如入门实例中的缓存拦截器将缓存结果直接设置为调用上下文的返回值,并不再执行后续的操作。对上面定义的三个拦截器类型,我们将第二个拦截器BarInterceptor改写成如下的形式。它的InvokeAsync在输出一段指示性文字后,不再调用上下文的ProceedAsync方法,而是直接返回一个ValueTask对象。

  1. public class BarInterceptor
  2. {
  3. public virtual ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. Console.WriteLine($"[{GetType().Name}]: InvokeAsync");
  6. return ValueTask.CompletedTask;
  7. }
  8. }

再次执行我们的演示程序后会发现FooInterceptor和BarInterceptor会正常执行,但是BazInterceptor目标方法均不会执行(源代码)。

七、构造函数注入

由于拦截器是由依赖注入容器创建的,其构造函数中可以注入依赖服务。但是拦截器具有全局生命周期,所以我们不能将生命周期模式为Scoped的服务对象注入到构造函数中。我们可以利用一个简单的实例来演示这一点。我们定义了如下一个拦截器类型FoobarInspector,其构造函数中注入了依赖服务FoobarSerivice。FoobarInspector被采用如下的方式利用InterceptorAttribute特性应用到Invoker类型的Invoke方法上。

  1. public class FoobarInterceptor
  2. {
  3. public FoobarInterceptor(FoobarService foobarService)=> Debug.Assert(foobarService != null);
  4. public async ValueTask InvokeAsync(InvocationContext invocationContext)
  5. {
  6. Console.WriteLine($"[{GetType().Name}]: Before invoking");
  7. await invocationContext.ProceedAsync();
  8. Console.WriteLine($"[{GetType().Name}]: After invoking");
  9. }
  10. }
  11.  
  12. public class FoobarService { }
  13.  
  14. public class Invoker
  15. {
  16. [Interceptor(typeof(FoobarInterceptor))]
  17. public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
  18. }

在如下的演示程序中,我们利用命令行参数(0,1,2)来指定依赖服务FoobarService采用的生命周期,然后将其作为参数调用辅助方法Invoke方法完成必要的服务注册,利用构建的依赖注入容器提取Invoker对象,并调用应用了FoobarInspector拦截器的Invoke方法。

  1. var lifetime = (ServiceLifetime)int.Parse(args.FirstOrDefault() ?? "0");
  2. Invoke(lifetime);
  3.  
  4. static void Invoke(ServiceLifetime lifetime)
  5. {
  6. Console.WriteLine(lifetime);
  7. try
  8. {
  9. var services = new ServiceCollection().AddSingleton<Invoker>();
  10. services.Add(ServiceDescriptor.Describe(typeof(FoobarService), typeof(FoobarService), lifetime));
  11. var invoker = services.BuildInterceptableServiceProvider().GetRequiredService<Invoker>();
  12. invoker.Invoke();
  13. }
  14. catch (Exception ex)
  15. {
  16. Console.WriteLine(ex.Message);
  17. }
  18. }

我们以命令行参数的形式启动程序,并指定三种不同的生命周期模式。从输出结果可以看出,如果注册的FoobarService服务采用Scoped生命周期模式会抛出异常(源代码)。

八、方法注入

如果FoobarInspector依赖一个Scoped服务,或者依赖的服务采用Transient生命周期模式,但是希望在每次调用的时候创建新的对象(如果将生命周期模式设置为Transient,实际上是希望采用这样的服务消费方式)。此时可以利用InvocationContext的InvocationServices返回的IServiceProvider对象。在如下的实例演示中,我们定义了派生于ServiceBase 的三个将会注册为对应生命周期的服务类型SingletonService 、ScopedService 和TransientService 。为了确定依赖服务实例被创建和释放的时机,ServiceBase实现了IDisposable接口,并在构造函数和Dispose方法中输出相应的文字。在拦截器类型FoobarInterceptor的InvokeAsync方法中,我们利用InvocationContext的InvocationServices返回的IServiceProvider对象两次提取这三个服务实例。FoobarInterceptor依然应用到Invoker类型的Invoke方法中。

  1. public class FoobarInterceptor
  2. {
  3. public async ValueTask InvokeAsync(InvocationContext invocationContext)
  4. {
  5. var provider = invocationContext.InvocationServices;
  6.  
  7. _ = provider.GetRequiredService<SingletonService>();
  8. _ = provider.GetRequiredService<SingletonService>();
  9.  
  10. _ = provider.GetRequiredService<ScopedService>();
  11. _ = provider.GetRequiredService<ScopedService>();
  12.  
  13. _ = provider.GetRequiredService<TransientService>();
  14. _ = provider.GetRequiredService<TransientService>();
  15.  
  16. Console.WriteLine($"[{GetType().Name}]: Before invoking");
  17. await invocationContext.ProceedAsync();
  18. Console.WriteLine($"[{GetType().Name}]: After invoking");
  19. }
  20. }
  21.  
  22. public class ServiceBase : IDisposable
  23. {
  24. public ServiceBase()=>Console.WriteLine($"{GetType().Name}.new()");
  25. public void Dispose() => Console.WriteLine($"{GetType().Name}.Dispose()");
  26. }
  27.  
  28. public class SingletonService : ServiceBase { }
  29. public class ScopedService : ServiceBase { }
  30. public class TransientService : ServiceBase { }
  31.  
  32. public class Invoker
  33. {
  34. [Interceptor(typeof(FoobarInterceptor))]
  35. public virtual void Invoke() => Console.WriteLine("Invoker.Invoke()");
  36. }

在如下的演示程序中,我们将三个服务按照对应的生命周期模式添加到创建的ServiceCollection集合中。在构建出作为依赖注入容器的IServiceProvider对象后,我们利用它提取出Invoker对象,并先后两次调用应用了拦截器的Invoke方法。为了释放所有由ISerivceProvider对象提供的服务实例,我们调用了它的Dispose方法。

  1. var provider = new ServiceCollection()
  2. .AddSingleton<SingletonService>()
  3. .AddScoped<ScopedService>()
  4. .AddTransient<TransientService>()
  5. .AddSingleton<Invoker>()
  6. .BuildInterceptableServiceProvider();
  7. using (provider as IDisposable)
  8. {
  9. var invoker = provider .GetRequiredService<Invoker>();
  10. invoker.Invoke();
  11. Console.WriteLine();
  12. invoker.Invoke();
  13. }

程序运行后会在控制台上输出如下的结果,可以看出SingletonService 对象只会创建一次,并最终在作为跟容器的ISerivceProvider对象被释放时随之被释放。ScopedSerivce对象每次方法调用都会创建一次,并在调用后自动被释放。每次提取TransientService 都会创建一个新的实例,它们会在方法调用后与ScopedSerivce对象一起被释放(源代码)。

其实利用InvocationServices提取所需的依赖服务并不是我们推荐的编程方式,更好的方式是以如下的方式将依赖服务注入拦截器的InvokeAsync方法中。上面演示程序的FoobarInterceptor改写成如下的方式后,执行后依然会输出如上的结果(源代码)。

  1. public class FoobarInterceptor
  2. {
  3. public async ValueTask InvokeAsync(InvocationContext invocationContext,
  4. SingletonService singletonService1, SingletonService singletonService2,
  5. ScopedService scopedService1, ScopedService scopedService2,
  6. TransientService transientService1, TransientService transientService2)
  7. {
  8. Console.WriteLine($"[{GetType().Name}]: Before invoking");
  9. await invocationContext.ProceedAsync();
  10. Console.WriteLine($"[{GetType().Name}]: After invoking");
  11. }
  12. }

九、ASP.NET Core应用的适配

对于上面演示实例来说,Scoped服务所谓的“服务范围”被绑定为单次方法调用,但是在ASP.NET Core应用应该绑定为当前的请求上下文,Dora.Interception对此做了相应的适配。我们将上面定义的FoobarInterceptor和Invoker对象应用到一个ASP.NET Core MVC程序中。为此我们定义了如下这个HomeController,其Action方法Index中注入了Invoker对象,并先后两次调用了它的Invoke方法。

  1. public class HomeController
  2. {
  3. [HttpGet("/")]
  4. public string Index([FromServices] Invoker invoker)
  5. {
  6. invoker.Invoke();
  7. Console.WriteLine();
  8. invoker.Invoke();
  9. return "OK";
  10. }
  11. }

MVC应用的启动程序如下。

  1. var builder = WebApplication.CreateBuilder(args);
  2. builder.Host.UseInterception();
  3. builder.Services
  4. .AddLogging(logging=>logging.ClearProviders())
  5. .AddSingleton<Invoker>()
  6. .AddSingleton<SingletonService>()
  7. .AddScoped<ScopedService>()
  8. .AddTransient<TransientService>()
  9. .AddControllers();
  10. var app = builder.Build();
  11. app
  12. .UseRouting()
  13. .UseEndpoints(endpint => endpint.MapControllers());
  14. app.Run();

启动程序后针对根路径“/”(只想HomeController的Index方法)的请求(非初次请求)会在服务端控制台上输出如下的结果,可以看出ScopedSerivce对象针对每次请求只会被创建一次。

全新升级的AOP框架Dora.Interception[1]: 编程体验
全新升级的AOP框架Dora.Interception[2]: 基于约定的拦截器定义方式
全新升级的AOP框架Dora.Interception[3]: 基于“特性标注”的拦截器注册方式
全新升级的AOP框架Dora.Interception[4]: 基于“Lambda表达式”的拦截器注册方式
全新升级的AOP框架Dora.Interception[5]: 实现任意的拦截器注册方式
全新升级的AOP框架Dora.Interception[6]: 框架设计和实现原理

全新升级的AOP框架Dora.Interception[2]: 基于&ldquo;约定&rdquo;的拦截器定义方式的更多相关文章

  1. 全新升级的AOP框架Dora.Interception[3]: 基于特性标注的拦截器注册方式

    在Dora.Interception(github地址,觉得不错不妨给一颗星)中按照约定方式定义的拦截器可以采用多种方式注册到目标方法上.本篇文章介绍最常用的基于"特性标注"的拦截 ...

  2. 全新升级的AOP框架Dora.Interception[4]: 基于Lambda表达式的拦截器注册方式

    如果拦截器应用的目标类型是由自己定义的,Dora.Interception(github地址,觉得不错不妨给一颗星)可以在其类型或成员上标注InterceptorAttribute特性来应用对应的拦截 ...

  3. 全新升级的AOP框架Dora.Interception[1]: 编程体验

    多年之前利用IL Emit写了一个名为Dora.Interception(github地址,觉得不错不妨给一颗星)的AOP框架.前几天利用Roslyn的Source Generator对自己为公司写的 ...

  4. 全新升级的AOP框架Dora.Interception[6]: 实现任意的拦截器注册方式

    Dora.Interception提供了两种拦截器注册方式,一种是利用标注在目标类型.属性和方法上的InterceptorAttribute特性,另一种采用基于目标方法或者属性的调用表达式.通过提供的 ...

  5. 全新升级的AOP框架Dora.Interception[6]: 框架设计和实现原理

    本系列前面的五篇文章主要介绍Dora.Interception(github地址,觉得不错不妨给一颗星)的编程模式以及对它的扩展定制,现在我们来聊聊它的设计和实现原理.(拙著<ASP.NET C ...

  6. 全新升级的AOP框架Dora.Interception[汇总,共6篇]

    多年之前利用IL Emit写了一个名为Dora.Interception(github地址,觉得不错不妨给一颗星)的AOP框架.前几天利用Roslyn的Source Generator对自己为公司写的 ...

  7. AOP框架Dora.Interception 3.0 [5]: 基于策略的拦截器注册方式

    注册拦截器旨在解决如何将拦截器应用到目标方法的问题.在我看来,针对拦截器的注册应该是明确而精准的,也就是我们提供的注册方式应该让拦截器准确地应用到期望的目标方法上,不能多也不能少.如果注册的方式过于模 ...

  8. AOP框架Dora.Interception 3.0 [1]: 编程体验

    .NET Core正式发布之后,我为.NET Core度身定制的AOP框架Dora.Interception也升级到3.0.这个版本除了升级底层类库(.NET Standard 2.1)之外,我还对它 ...

  9. AOP框架Dora.Interception 3.0 [2]: 实现原理

    和所有的AOP框架一样,我们必须将正常的方法调用进行拦截,才能将应用到当前方法上的所有拦截器纳入当前调用链.Dora.Interception采用IL Eimit的方式实现对方法调用的拦截,接下来我们 ...

随机推荐

  1. linux添加磁盘及分区挂载

    磁盘管理 1.为什么要添加磁盘 随着系统的使用,磁盘的内容会越来越少,所以这时要添加磁盘增加空间 Linux系统中磁盘管理就是将硬盘通过挂载的方式挂载到linux文件系统中. 2.系统添加磁盘并分区 ...

  2. 推荐个我在用的免费翻译软件,支持多家翻译API整合

    前段时间发了个关于<Spring支持PHP>的视频:点击查看 然后有小伙伴留言说:"你这个翻译好像很好用的样子". 的确,我自己也觉得很好用.之前视频没看过的不知道是哪 ...

  3. Windbg调试工具命令详解

    .cls -------------------------------清屏 ~ ----------------------------------查看当前程序的所有线程 ~0s --------- ...

  4. 2021.11.10 P5231 [JSOI2012]玄武密码(AC自动机)

    2021.11.10 P5231 [JSOI2012]玄武密码(AC自动机) https://www.luogu.com.cn/problem/P5231 题意: 给出字符串S和若干T,求S与每个T的 ...

  5. 机器学习基础:奇异值分解(SVD)

    SVD 原理 奇异值分解(Singular Value Decomposition)是线性代数中一种重要的矩阵分解,也是在机器学习领域广泛应用的算法,它不光可以用于降维算法中的特征分解,还可以用于推荐 ...

  6. 【Java分享客栈】一文搞定CompletableFuture并行处理,成倍缩短查询时间。

    前言   工作中你可能会遇到很多这样的场景,一个接口,要从其他几个service调用查询方法,分别获取到需要的值之后再封装数据返回.   还可能在微服务中遇到类似的情况,某个服务的接口,要使用好几次f ...

  7. python3 常见错误(一)

    以下全部是在python3中适用. 错误一: 函数默认参数 Python允许为函数的参数提供默认的可选值.但是它可能会导致一些易变默认值的混乱.例子: 我们希望每次调用myFun函数,list1都为默 ...

  8. 《计算机组成原理/CSAPP》网课总结(一)

    现在是2022年4月17日晚10点,本月计划的网课<csapp讲解>视频课看到了第八章"异常"第三讲,视频讲的很好但更新很慢,暂时没有最新的讲解,所以先做一个简单总结. ...

  9. java基础4.19

    1.JAVA 的反射机制的原理. JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法:对于任意一个对象,都能够调用它的任意一个方法:这种动态获取的信息以及动态调用对象的方 ...

  10. 论文解读(SimGRACE)《SimGRACE: A Simple Framework for Graph Contrastive Learning without Data Augmentation》

    论文信息 论文标题:SimGRACE: A Simple Framework for Graph Contrastive Learning without Data Augmentation论文作者: ...