使用Roslyn获取引用程序集中的接口实现

17
我可以在我正在开发的框架中绕过一些经典的汇编扫描技术。因此,假设我已经定义了以下合约:
public interface IModule
{

}

这个接口存在于Contracts.dll中。

现在,如果我想要查找所有实现该接口的内容,我们可能会执行以下类似的操作:

public IEnumerable<IModule> DiscoverModules()
{
    var contractType = typeof(IModule);
    var assemblies = AppDomain.Current.GetAssemblies() // Bad but will do
    var types = assemblies
        .SelectMany(a => a.GetExportedTypes)
        .Where(t => contractType.IsAssignableFrom(t))
        .ToList();

    return types.Select(t => Activator.CreateInstance(t));
}

这不是一个很好的例子,但它可以用。

现在,这些汇编扫描技术可能会非常低效,并且全部都是在运行时完成的,通常会影响启动性能。

在新的DNX环境中,我们可以使用ICompileModule实例作为元编程工具,因此您可以将ICompileModule的实现捆绑到项目中的Compiler\Preprocess文件夹中,并让它做一些有趣的事情。

我的目标是使用ICompileModule实现,在编译时而不是在运行时完成我们要做的工作。

  • 在我的引用(包括编译和组件)以及我的当前编译中,发现所有可实例化的IModule实例
  • 创建一个类,我们称之为ModuleList,并实现返回每个模块实例的方法。
public static class ModuleList
{
    public static IEnumerable<IModule>() GetModules()
    {
        yield return new Module1();
        yield return new Module2();
    }
}

在编译单元中添加了这个类之后,我们可以在运行时调用它并获得一个静态的模块列表,而不是必须搜索所有附加的程序集。我们本质上是将工作转移到编译器而不是运行时。

考虑到我们可以通过 References 属性访问编译的所有引用,我无法看出如何获取任何有用的信息,例如访问字节码,以可能加载用于反射的程序集,或者类似的东西。

你有什么想法吗?


1
你是否考虑过在运行时使用全局静态类来管理这些实例?(在基类中使用自定义属性/小片段) - Jossef Harush Kadouri
2个回答

6

在应对这个挑战时,我的方法是通过查阅大量的参考文献来了解Roslyn可用的不同类型。

为了给最终解决方案加上前缀,让我们创建模块接口,并将其放在Contracts.dll中:

public interface IModule
{
    public int Order { get; }

    public string Name { get; }

    public Version Version { get; }

    IEnumerable<ServiceDescriptor> GetServices();
}

public interface IModuleProvider
{
    IEnumerable<IModule> GetModules();
}

同时,让我们定义出基本的提供者:

public abstract class ModuleProviderBase
{
    private readonly List<IModule> _modules = new List<IModule>();

    protected ModuleProviderBase()
    {
        Setup();
    }

    public IEnumerable<IModule> GetModules()
    {
        return _modules.OrderBy(m => m.Order);
    }

    protected void AddModule<T>() where T : IModule, new()
    {
        var module = new T();
        _modules.Add(module);
    }

    protected virtual void Setup() { }
}

在这种架构中,模块实际上只是一个描述符,因此不应该具有依赖项,它仅表达了它提供的服务。

现在,以 DefaultLogger.dll 为例,一个模块可能看起来像:

public class DefaultLoggerModule : ModuleBase
{
    public override int Order { get { return ModuleOrder.Level3; } }

    public override IEnumerable<ServiceDescriptor> GetServices()
    {
        yield return ServiceDescriptor.Instance<ILoggerFactory>(new DefaultLoggerFactory());
    }
}

为了简洁起见,我省略了ModuleBase的实现。

现在,在我的Web项目中,我添加了对Contracts.dllDefaultLogger.dll的引用,然后添加了以下模块提供程序的实现:

public partial class ModuleProvider : ModuleProviderBase { }

现在,我的ICompileModule

using T = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree;
using F = Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
using K = Microsoft.CodeAnalysis.CSharp.SyntaxKind;

public class DiscoverModulesCompileModule : ICompileModule
{
    private static MethodInfo GetMetadataMethodInfo = typeof(PortableExecutableReference)
        .GetMethod("GetMetadata", BindingFlags.NonPublic | BindingFlags.Instance);
    private static FieldInfo CachedSymbolsFieldInfo = typeof(AssemblyMetadata)
        .GetField("CachedSymbols", BindingFlags.NonPublic | BindingFlags.Instance);
    private ConcurrentDictionary<MetadataReference, string[]> _cache
        = new ConcurrentDictionary<MetadataReference, string[]>();

    public void AfterCompile(IAfterCompileContext context) { }

    public void BeforeCompile(IBeforeCompileContext context)
    {
        // Firstly, I need to resolve the namespace of the ModuleProvider instance in this current compilation.
        string ns = GetModuleProviderNamespace(context.Compilation.SyntaxTrees);

        // Next, get all the available modules in assembly and compilation references.
        var modules = GetAvailableModules(context.Compilation).ToList();
        // Map them to a collection of statements
        var statements = modules.Select(m => F.ParseStatement("AddModule<" + module + ">();")).ToList();

        // Now, I'll create the dynamic implementation as a private class.
        var cu = F.CompilationUnit()
            .AddMembers(
                F.NamespaceDeclaration(F.IdentifierName(ns))
                    .AddMembers(
                        F.ClassDeclaration("ModuleProvider")
                            .WithModifiers(F.TokenList(F.Token(K.PartialKeyword)))
                            .AddMembers(
                                F.MethodDeclaration(F.PredefinedType(F.Token(K.VoidKeyword)), "Setup")
                                    .WithModifiers(
                                        F.TokenList(
                                            F.Token(K.ProtectedKeyword), 
                                            F.Token(K.OverrideKeyword)))
                                    .WithBody(F.Block(statements))
                            )
                    )
            )
            .NormalizeWhitespace(indentation("\t"));

        var tree = T.Create(cu);
        context.Compilation = context.Compilation.AddSyntaxTrees(tree);
    }

    // Rest of implementation, described below
}

本模块实际上包含以下几个步骤:

1 - 解析Web项目中ModuleProvider实例的命名空间,例如:SampleWeb
2 - 通过引用发现所有可用的模块,并将它们作为字符串集合返回,例如:new[] { "SampleLogger.DefaultLoggerModule" }。
3 - 将它们转换为AddModule<SampleLogger.DefaultLoggerModule>();这种类型的语句。
4 - 创建一个partial实现的ModuleProvider,并将其添加到我们的编译中:

namespace SampleWeb
{
    partial class ModuleProvider
    {
        protected override void Setup()
        {
            AddModule<SampleLogger.DefaultLoggerModule>();
        }
    }
}

那么,我是如何发现可用的模块的呢?有三个阶段:

1 - 引用的程序集(例如通过NuGet提供的)
2 - 引用的编译(例如解决方案中引用的项目)。
3 - 当前编译中的模块声明。

对于每个引用的编译,我们重复上述步骤。

private IEnumerable<string> GetAvailableModules(Compilation compilation)
{
    var list = new List<string>();
    string[] modules = null;

    // Get the available references.
    var refs = compilation.References.ToList();

    // Get the assembly references.
    var assemblies = refs.OfType<PortableExecutableReference>().ToList();
    foreach (var assemblyRef in assemblies)
    {
        if (!_cache.TryGetValue(assemblyRef, out modules))
        {
            modules = GetAssemblyModules(assemblyRef);
            _cache.AddOrUpdate(assemblyRef, modules, (k, v) => modules);
            list.AddRange(modules);
        }
        else
        {
            // We've already included this assembly.
        }
    }

    // Get the compilation references
    var compilations = refs.OfType<CompilationReference>().ToList();
    foreach (var compliationRef in compilations)
    {
        if (!_cache.TryGetValue(compilationRef, out modules))
        {
            modules = GetAvailableModules(compilationRef.Compilation).ToArray();
            _cache.AddOrUpdate(compilationRef, modules, (k, v) => modules);
            list.AddRange(modules);
        }
        else
        {
            // We've already included this compilation.
        }
    }

    // Finally, deal with modules in the current compilation.
    list.AddRange(GetModuleClassDeclarations(compilation));

    return list;
}

因此,要获取已引用的程序集模块:
private IEnumerable<string> GetAssemblyModules(PortableExecutableReference reference)
{
    var metadata = GetMetadataMethodInfo.Invoke(reference, nul) as AssemblyMetadata;
    if (metadata != null)
    {
        var assemblySymbol = ((IEnumerable<IAssemblySymbol>)CachedSymbolsFieldInfo.GetValue(metadata)).First();

        // Only consider our assemblies? Sample*?
        if (assemblySymbol.Name.StartsWith("Sample"))
        {
            var types = GetTypeSymbols(assemblySymbol.GlobalNamespace).Where(t => Filter(t));
            return types.Select(t => GetFullMetadataName(t)).ToArray();
        }
    }

    return Enumerable.Empty<string>();
}

我们需要进行一些反射处理,因为GetMetadata方法不是公共的,在获取元数据时,CachedSymbols字段也是非公共的,所以我们还需要进行更多的反射处理。在确定可用内容方面,我们需要从CachedSymbols属性中获取IEnumerable<IAssemblySymbol>。这将为我们提供参考程序集中所有缓存的符号。Roslyn已经为我们完成了这个操作,所以我们可以滥用它:
private IEnumerable<ITypeSymbol> GetTypeSymbols(INamespaceSymbol ns)
{
    foreach (var typeSymbols in ns.GetTypeMembers().Where(t => !t.Name.StartsWith("<")))
    {
        yield return typeSymbol;
    }

    foreach (var namespaceSymbol in ns.GetNamespaceMembers())
    {
        foreach (var typeSymbol in GetTypeSymbols(ns))
        {
            yield return typeSymbol;
        }
    }
}
GetTypeSymbols方法遍历命名空间并发现所有类型。然后将结果链接到过滤器方法,该方法确保其实现了我们所需的接口:
private bool Filter(ITypeSymbol symbol)
{
    return symbol.IsReferenceType 
        && !symbol.IsAbstract
        && !symbol.IsAnonymousType
        && symbol.AllInterfaces.Any(i => i.GetFullMetadataName(i) == "Sample.IModule");
}

使用 GetFullMetadataName 作为一个实用方法:

private static string GetFullMetadataName(INamespaceOrTypeSymbol symbol)
{
    ISymbol s = symbol;
    var builder = new StringBuilder(s.MetadataName);
    var last = s;
    while (!!IsRootNamespace(s))
    {
        builder.Insert(0, '.');
        builder.Insert(0, s.MetadataName);
        s = s.ContainingSymbol;
    }

    return builder.ToString();
}

private static bool IsRootNamespace(ISymbol symbol)
{
    return symbol is INamespaceSymbol && ((INamespaceSymbol)symbol).IsGlobalNamespace;
}

接下来,当前编译中的模块声明:
private IEnumerable<string> GetModuleClassDeclarations(Compilation compilation)
{
    var trees = compilation.SyntaxTrees.ToArray();
    var models = trees.Select(compilation.GetSemanticModel(t)).ToArray();

    for (var i = 0; i < trees.Length; i++)
    {
        var tree = trees[i];
        var model = models[i];

        var types = tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>().ToList();
        foreach (var type in types)
        {
            var symbol = model.GetDeclaredSymbol(type) as ITypeSymbol;
            if (symbol != null && Filter(symbol))
            {
                yield return GetFullMetadataName(symbol);
            }
        }
    }
}

就是这样!所以,现在在编译时,我的ICompileModule将会:

  • 发现所有可用的模块
  • 实现重载我的ModuleProvider.Setup方法,并加入所有已知的引用模块。

这意味着我可以在启动时添加:

public class Startup
{
    public ModuleProvider ModuleProvider = new ModuleProvider();

    public void ConfigureServices(IServiceCollection services)
    {
        var descriptors = ModuleProvider.GetModules() // Ordered
            .SelectMany(m => m.GetServices());

        // Apply descriptors to services.
    }

    public void Configure(IApplicationBuilder app)
    {
        var modules = ModuleProvider.GetModules(); // Ordered.

        // Startup code.
    }
}

这个东西有点过度设计,比较复杂,但我认为还是很棒的!


很酷,很高兴你解决了它! :) 这个问题似乎很有趣,我也曾经想过研究一两次(但由于时间限制而放弃 = /)。 - flindeberg
@flindeberg 如果你有兴趣玩一下的话,我很乐意把代码放在gist或其他地方。 - Matthew Abbott

6

您有什么想法?

是的。

通常在模块环境中,您希望根据上下文或(如果适用)来自第三方动态加载模块。相反,使用Roslyn编译器框架,您基本上在编译时获取此信息,从而将模块限制为静态引用。

就在昨天,我发布了使用属性动态加载工厂的代码,更新了加载DLL等内容:Naming convention for GoF Factory?。据我所知,它与您正在尝试实现的内容非常相似。这种方法的好处是,您可以在运行时动态加载新的DLL。如果您尝试一下,您会发现它非常快。

您还可以进一步限制要处理的程序集。例如,如果您不处理 mscorlibSystem.* (或者甚至所有GAC程序集),它当然会更快。尽管如此,正如我所说,这不应该是一个问题;只需扫描类型和属性就足够快速。


好的,再提供一些信息和背景。

现在,你可能只是想找一个有趣的谜题。我理解这一点,玩弄技术毕竟很有趣。下面的答案(由Matthew亲自提供)将为您提供所需的所有信息。

如果您想衡量编译时代码生成与运行时方案之间的利弊,请在此处获取更多我的经验信息。

几年前,我决定拥有自己的C#解析器/生成器框架来执行AST转换。它与Roslyn所能做的非常相似;基本上它将整个项目转换为AST树,然后您可以对其进行规范化、生成代码、进行额外检查、做面向方面的编程以及添加新的语言结构。我最初的目标是为C#添加面向方面的编程支持,我有一些实际应用。我会节省您的细节,但对于这个环境来说,足以说明一个基于代码生成的模块/工厂也是我尝试过的东西之一。

对于我来说,性能、灵活性和代码量(在非库解决方案中)是决定运行时和编译时决策的关键因素。让我们分开来看:

  • 性能。这很重要,因为我不能假设库代码不在关键路径上。运行时会每个应用程序域实例花费几毫秒。(有关如何/为什么的注释,请参见下文)。
  • 灵活性。它们在属性/扫描方面大致相同。但是,在运行时,您可以更多地改变规则(例如动态插入模块等)。我有时会使用此功能,特别是基于配置,这样我就不必在同一个解决方案中开发所有内容(因为这是低效的)。
  • 代码量。通常情况下,较少的代码通常是更好的代码。如果做得正确,两者都会导致您需要在类上使用单个属性。换句话说,这两种解决方案在此处给出相同的结果。

然而,有关性能的一些说明是必要的。我在我的代码中不仅使用反射工厂模式。我基本上在这里拥有一个广泛的“工具”库,其中包括所有设计模式(以及大量其他内容)。一些示例:我自动生成运行时代码,用于工厂、责任链、装饰器、模拟、缓存/代理(以及更多)。其中一些已经要求我扫描程序集。

作为一个简单的经验法则,我总是使用属性来表示某些东西需要被更改。你可以利用这个:通过简单地在单例/字典中存储具有属性(正确的程序集/命名空间)的每种类型,你可以使应用程序运行得更快(因为你只需要扫描一次)。从Microsoft扫描程序集也没有什么用处。我在大型项目上进行了很多测试,并发现在我发现的最坏情况下,扫描会在应用程序启动时间上增加约10毫秒。请注意,这仅在appdomain实例化一次时发生,这意味着你甚至不会注意到它。
类型的激活确实是你将获得的唯一“真正”的性能惩罚。这个惩罚可以通过发出IL代码来优化掉;这真的不难。最终的结果是这里不会有任何区别。
总之,这是我的结论: - 性能: 没有显著差异。 - 灵活性: 运行时胜出。 - 代码量: 没有显著差异。
根据我的经验,尽管许多框架希望支持可插拔架构并从放置装配中受益,但实际上适用这种方法的用例并不是很多。如果不适用,则可以考虑首先不使用工厂模式。另外,如果适用,则我已经证明没有真正的缺点,即:只要你正确实现它。不幸的是,我必须在这里承认我见过很多糟糕的实现。
至于它实际上并不适用,我认为这只是部分正确的。放置数据提供程序非常常见(它从3层架构逻辑上推导出来)。我还使用工厂来连接通信/WCF API、缓存提供程序和修饰符(这从n层架构逻辑上推导出来)。一般来说,它用于任何类型的提供程序。
如果论点是它会导致性能损失,那么您基本上想要删除整个类型扫描过程。就个人而言,我将其用于大量不同的事情,特别是缓存、统计、日志记录和配置。此外,我认为性能下降可以忽略不计。
以上仅代表个人观点,希望有所帮助。

谢谢!是的,这就是反射方法,我不想采用。根据我的经验,虽然很多框架希望支持插拔式架构,可以从插入程序集中受益,但实际上并没有太多适用情况。99%的时间,当你想要向Orchard或Umbraco添加新插件时,你实际上是在你的项目解决方案中,引用新插件,重新编译,然后开始工作。如果你已经在编译,为什么不有一个硬编码列表,它仍然是动态的,因为你不需要重启应用程序? - Matthew Abbott
@MatthewAbbott 我已经添加了一些关于我的经验的更多信息,特别针对这些评论。我最缺少的是你为什么如此迫切地想要它作为编译时构造的原因;从我的经验来看,在运行时解决方案中并没有真正的不利因素。也许我在这里漏掉了什么? - atlaste
我试图通过编译时解决方案来实现的目标是摆脱大量的重复工作,以便运行时代码使用硬引用而不是松散耦合、动态编译的组件。虽然这似乎是一种反模式,但在这些 Web 应用程序场景中,我认为减少和更高效的启动代码是有益的。 - Matthew Abbott
@MatthewAbbott 我理解你的意图。 "[...]我看到减少和提高启动代码性能的好处。" -- 嗯,我的观点是这并不重要。在ASP.NET中,应用程序域只有在很多请求后才会被创建一次;这就像优化每天大约10毫秒左右。"从以往使用几个不同框架的经验来看..." -- 是的,你说得对。这个问题的主要原因是因为它们通常为许多初始化使用数据库,并且因为编译器必须为ASPX执行大量工作。 - atlaste
特别是冷启动必须从数据库中检索CRM内容,然后在内部进行缓存。通常它们使用过多的调用。老实说,你可以在那里优化很多东西;这就是我也使用轻量级自制CRM的原因。:-) 但我不会在这个问题上进行优化——只需执行1个数据库调用所需的时间,您就可以轻松扫描所有程序集中的所有类型(并将结果存储在静态变量中)。 - atlaste
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接