前言

完整项目地址: https://github.com/chr233/XinjingdailyBot

因为项目之前是使用运行时反射来完成服务的动态注册,而源生成器是在编译前完成代码生成并参与编译,完全没有运行时的性能开销

运行时反射 - 旧的方案

标记特性

首先创建一个 Attribute, 用来标注需要扫描的服务类

namespace XinjingdailyBot.Infrastructure.Attribute;

/// <summary>
/// 标记服务
/// 1、如果服务是本身 直接在类上使用[AppService]
/// 2、如果服务是接口 在类上使用 [AppService(ServiceType = typeof(实现接口))]
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class AppServiceAttribute : System.Attribute
{
    /// <summary>
    /// 服务声明周期
    /// 不给默认值的话注册的是AddSingleton
    /// </summary>
    public LifeTime ServiceLifetime { get; set; } = LifeTime.Scoped;
    /// <summary>
    /// 指定服务类型
    /// </summary>
    public Type? ServiceType { get; set; }
    /// <summary>
    /// 是否可以从第一个接口获取服务类型
    /// </summary>
    public bool InterfaceServiceType { get; set; }

    /// <summary>
    /// 标记服务
    /// </summary>
    /// <param name="serviceLifetime"></param>
    public AppServiceAttribute(LifeTime serviceLifetime)
    {
        ServiceLifetime = serviceLifetime;
    }

    /// <summary>
    /// 标记服务
    /// </summary>
    /// <param name="serviceType"></param>
    /// <param name="serviceLifetime"></param>
    public AppServiceAttribute(Type? serviceType, LifeTime serviceLifetime)
    {
        ServiceLifetime = serviceLifetime;
        ServiceType = serviceType;
    }

    /// <summary>
    /// 标记服务
    /// </summary>
    /// <param name="serviceType"></param>
    /// <param name="serviceLifetime"></param>
    /// <param name="interfaceServiceType"></param>
    public AppServiceAttribute(Type? serviceType, LifeTime serviceLifetime, bool interfaceServiceType)
    {
        ServiceLifetime = serviceLifetime;
        ServiceType = serviceType;
        InterfaceServiceType = interfaceServiceType;
    }
}

/// <summary>
/// 生命周期
/// </summary>
public enum LifeTime
{
    /// <summary>
    /// 瞬时
    /// </summary>
    Transient,
    /// <summary>
    /// 范围
    /// </summary>
    Scoped,
    /// <summary>
    /// 单例
    /// </summary>
    Singleton
}

基于反射的服务扫描器

然后再主项目启动的时候使用反射扫描添加了该 Attribute 的所有 Class 并注册服务

using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using XinjingdailyBot.Infrastructure.Attribute;

namespace XinjingdailyBot.WebAPI.Extensions;

/// <summary>
/// 动态注册服务扩展
/// </summary>
public static class AppServiceExtensions
{
    private static readonly NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();

    /// <summary>
    /// 注册引用程序域中所有有AppService标记的类的服务
    /// </summary>
    /// <param name="services"></param>
    [RequiresUnreferencedCode("不兼容剪裁")]
    public static void AddAppService(this IServiceCollection services)
    {
        var cls = new string[] {
            "XinjingdailyBot.Repository",
            "XinjingdailyBot.Service",
            "XinjingdailyBot.Command",
            "XinjingdailyBot.WebAPI",
        };

        foreach (var item in cls)
        {
            var assembly = Assembly.Load(item);
            Register(services, assembly);
        }
    }

    /// <summary>
    /// 动态注册服务
    /// </summary>
    /// <param name="services"></param>
    /// <param name="assembly"></param>
    [RequiresUnreferencedCode("不兼容剪裁")]
    private static void Register(IServiceCollection services, Assembly assembly)
    {
        string? name = assembly.GetName().Name;
        _logger.Debug($"===== 注册 {name} 中的服务 =====");
        uint count = 0;
        foreach (var type in assembly.GetTypes())
        {
            var serviceAttribute = type.GetCustomAttribute<AppServiceAttribute>();
            if (serviceAttribute != null)
            {
                count++;
                var serviceType = serviceAttribute.ServiceType;
                //情况1 适用于依赖抽象编程,注意这里只获取第一个
                if (serviceType == null && serviceAttribute.InterfaceServiceType)
                {
                    serviceType = type.GetInterfaces().FirstOrDefault();
                }
                //情况2 不常见特殊情况下才会指定ServiceType,写起来麻烦
                if (serviceType == null)
                {
                    serviceType = type;
                }

                var lifetime = serviceAttribute.ServiceLifetime;
                switch (lifetime)
                {
                    case LifeTime.Singleton:
                        services.AddSingleton(serviceType, type);
                        break;
                    case LifeTime.Scoped:
                        services.AddScoped(serviceType, type);
                        break;
                    case LifeTime.Transient:
                        services.AddTransient(serviceType, type);
                        break;
                    default:
                        services.AddTransient(serviceType, type);
                        break;
                }

                _logger.Debug($"{lifetime} - {serviceType}");
            }
        }
        _logger.Debug($"===== 注册了 {count} 个服务 =====");
    }
}

也不是不能用,就是感觉不够完美,而且因为使用了反射的相关特性,无法简单地启用程序集裁剪和应用 AOT,所有就想到利用 .Net 5.0 新加入的源生成器特性来实现相同的功能,避免使用反射特性和降低运行时的额外开销

源生成器 - 新的方案

源生成器官方文档

项目结构
因为我的项目是按照命名空间分成了几个子项目,而源生成器只能分析当前项目的语法树,如果用文件 IO 的方式读取本地 .cs 文件也是可以实现的,但是会显示警告,因为官方并不推荐在源生成器中使用文件 IO,这会降低编译速度,而且服务类也不是经常新增,每次编译都扫描一遍也是完全没必要的

综上,我的解决方案是写一个 Powershell 脚本来扫描解决方案中的所有服务,并保存到一个文件中,然后在源生成器中读取该文件来生成服务注册的代码

服务扫描脚本

首先是服务扫描脚本 scan_service.ps1

#
# @Author       : Chr_
# @Date         : 2024-01-18 14:25:15
# @LastEditors  : Chr_
# @LastEditTime : 2024-01-18 20:15:18
# @Description  : 生成依赖服务信息
#

# 工作目录
$workDir = (Get-Location).Path;
$outputPath = Join-Path -Path (Join-Path -Path $workDir -ChildPath "XinjingdailyBot.WebAPI") -ChildPath "Properties";

# 包名列表
$libNames = 
"XinjingdailyBot.Model",
"XinjingdailyBot.Repository",
"XinjingdailyBot.Interface",
"XinjingdailyBot.Service",
"XinjingdailyBot.Tasks",
"XinjingdailyBot.Command";

# 输出路径
$appServicePath = Join-Path -Path $outputPath -ChildPath "appService.json";

# 扫描源文件列表
function GetSourceFileList {
    param (
        [string]$Path
    )

    $result = @();

    # 去除 bin obj 目录
    $dirs = Get-ChildItem -Path $Path -Directory -Exclude "bin", "obj"

    # 遍历所有 .cs 文件
    foreach ($dir in $dirs) {
        $files = Get-ChildItem -Path $dir -Filter "*.cs" -File -Recurse

        # 遍历子目录文件
        foreach ($file in $files) {
            $result += $file
        }
    }

    $files = Get-ChildItem -Path $Path -Filter "*.cs" -File

    # 遍历所有 .cs 文件
    foreach ($file in $files) {
        $result += $file
    }

    Write-Output $result

    $result;
}

# 读取命名空间
function GetNamespace {
    param (
        [string] $Content
    )

    if ($Content -match "namespace\s+([^;{]+)") {
        $Matches[1];
    }
}

# 读取类名/接口名
function GetClassName {
    param (
        [string] $Content
    )

    if ($Content -match "(?:class|interface|record)\s+([^({:\s]+)") {
        $Matches[1];
    }
}

# 扫描AppServiceAttribute
function ScanAppServiceAttribute {
    param (
        [string] $Content
    )

    if ($Content -match "\[AppService(?:Attribute)?\(([^\]]+)\)\]") {
        $result = @{}

        $arguments = $Matches[1].Split(",");
        foreach ($argument in $arguments) {
            if ($argument -match "LifeTime\.(Singleton|Transient|Scoped)") {
                $result["LifeTime"] = $Matches[1];
            }
            elseif ($argument -match "typeof\(([^()]+)\)") {
                $result["Interface"] = $Matches[1];
            }
            elseif ($argument -match "true|false") {
                $result["AutoInterface"] = $Matches[1];
            }
            else {
                Write-Output $argument;
            }
        }

        $result;
    }
}

# 类全名表
$clsNamespaces = @{};
$appServiceAttributes = @{};

foreach ($libName in $libNames) {
    # 库文件路径
    $libPath = Join-Path -Path $workDir -ChildPath $libName;
    # 获取所有源文件路径
    $filePaths = GetSourceFileList -Path $libPath;

    # 读取Service信息和类命名空间;
    foreach ($filePath in $filePaths) {
        $fileContent = Get-Content -Path $filePath.FullName -Raw;

        $namespace = GetNameSpace -Content $fileContent;
        if ($null -eq $namespace -or $namespace -eq "" ) {
            continue;
        }

        $clsName = GetClassName -Content $fileContent;
        if ($null -eq $namespace -or $namespace -eq "" ) {
            continue;
        }

        $clsNamespaces[$clsName] = $namespace;

        $appService = ScanAppServiceAttribute -Content $fileContent;
        if ($null -ne $appService) {
            $appServiceAttributes[$clsName] = $appService;
        }
    }
}

$services = $appServiceAttributes.Clone();
# 生成服务信息
foreach ($service in $services.Keys) {
    $serviceInfo = $appServiceAttributes[$service];
    $namespace = $clsNamespaces[$service];

    $serviceInfo["Class"] = "$namespace.$service";

    $interface = $serviceInfo["Interface"];
    if ($null -ne $interface) {
        $ns = $clsNamespaces[$interface];
        $serviceInfo["Interface"] = "$ns.$interface";
    }

    $appServiceAttributes[$service] = $serviceInfo;
}

Write-Output "扫描到 $($appServiceAttributes.Count) 个服务";
$appServiceAttributes | ConvertTo-Json | Out-File -FilePath $appServicePath
Write-Output "文件保存至 $appServicePath"

这个脚本的功能是遍历定义的子项目文件,然后扫描所有类和接口,以及所有带有 AppServiceAttribute 特性的服务类,最后生成一个 appService.json

修改生成操作

运行脚本生成文件以后记得修改文件生成操作,不然在分析器里这个文件是不可见的

创建源生成器

跟创建类库一样,但是需要编辑 .csproj 文件,完整文件如下

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
        <IsRoslynComponent>true</IsRoslynComponent>
        <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
        <NoWarn>CS1570;CS1587</NoWarn>
    </PropertyGroup>

    <ItemGroup>
        <None Include="..\.editorconfig" Link=".editorconfig" />
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers">
            <PrivateAssets>all</PrivateAssets>
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.Common">
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp">
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Newtonsoft.Json">
            <GeneratePathProperty>true</GeneratePathProperty>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
    </ItemGroup>

    <ItemGroup>
        <Compile Update="Templates.Designer.cs">
            <DesignTime>True</DesignTime>
            <AutoGen>True</AutoGen>
            <DependentUpon>Templates.resx</DependentUpon>
        </Compile>
    </ItemGroup>

    <ItemGroup>
        <EmbeddedResource Update="Templates.resx">
            <Generator>ResXFileCodeGenerator</Generator>
            <LastGenOutput>Templates.Designer.cs</LastGenOutput>
        </EmbeddedResource>
    </ItemGroup>

    <ItemGroup>
        <!-- Package the generator in the analyzer directory of the nuget package -->
        <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
    </ItemGroup>

    <Target Name="GetDependencyTargetPaths">
        <ItemGroup>
            <TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
        </ItemGroup>
    </Target>
</Project>

我所做的修改如下

  1. 将目标框架改为 netstandard2.0,目前源生成器必须用该版本

  2. 添加 EnforceExtendedAnalyzerRulesIsRoslynComponent 项,第一个是排除别的分析器,第二个是定义该项目是 Roslyn 组件,添加了以后会有额外的调试配置

  3. 添加 GetTargetPathDependsOn 项,意思是从定义的路径中寻找依赖库

  4. 添加 Microsoft.CodeAnalysis.AnalyzersMicrosoft.CodeAnalysis.CommonMicrosoft.CodeAnalysis.CSharp 库,原样从微软文档拷来的,生成器必须的依赖

  5. 添加 Newtonsoft.Json 库(可选),我使用了 Newtonsoft.Json 作为 Json 反序列化库,因此还需要额外配置引用信息(因为源生成器相当于是编译期的扩展,如果不做额外处理会报找不到Dll的错),需要在下面定义

     <ItemGroup>
         <!-- Package the generator in the analyzer directory of the nuget package -->
         <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
     </ItemGroup>
    
     <Target Name="GetDependencyTargetPaths">
         <ItemGroup>
             <TargetPathWithTargetPlatformMoniker Include="$(PKGNewtonsoft_Json)\lib\netstandard2.0\Newtonsoft.Json.dll" IncludeRuntimeDependency="false" />
         </ItemGroup>
     </Target>

然后再主项目中添加项目引用,并修改引用的属性,将被引用项目类型改成分析器,然后排除所有的生成资产

</ItemGroup>
    <ProjectReference Include="..\XinjingdailyBot.Generator\XinjingdailyBot.Generator.csproj">
        <OutputItemType>Analyzer</OutputItemType>
        <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
    </ProjectReference>
</ItemGroup>

服务注册源生成器

代码模板存放在 Templates.resx 中,内容如图

代码模板

可以根据输出的结果反推

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Newtonsoft.Json;
using System.Text;
using XinjingdailyBot.Generator.Data;

namespace XinjingdailyBot.Generator;

[Generator]
internal sealed class AppServiceGenerator : ISourceGenerator
{
    const string InputFileName = "appService.json";
    const string OutoutFileName = "GeneratedAppServiceExtensions.g.cs";

    /// <inheritdoc/>
    public void Initialize(GeneratorInitializationContext context)
    {
        // 无需处理
    }

    /// <inheritdoc/>
    public void Execute(GeneratorExecutionContext context)
    {
        try
        {
            var fileText = context.AdditionalFiles.Where(static x => x.Path.EndsWith(InputFileName)).FirstOrDefault()
                ?? throw new FileNotFoundException("缺少配置文件, 请使用 scan_service.ps1 生成");

            ProcessSettingsFile(fileText, context);
        }
        catch (Exception e)
        {
            context.ReportDiagnostic(
             Diagnostic.Create(
                 "XJB_01",
                 nameof(AppServiceGenerator),
                 $"生成注入代码失败,{e.Message}",
                 defaultSeverity: DiagnosticSeverity.Error,
                 severity: DiagnosticSeverity.Error,
                 isEnabledByDefault: true,
                 warningLevel: 0));
        }
    }

    /// <summary>
    /// 生成文件
    /// </summary>
    /// <param name="xmlFile"></param>
    /// <param name="context"></param>
    private void ProcessSettingsFile(AdditionalText xmlFile, GeneratorExecutionContext context)
    {
        var text = xmlFile.GetText(context.CancellationToken)?.ToString() ?? throw new FileLoadException("文件读取失败");
        var json = JsonConvert.DeserializeObject<AppServiceData>(text) ?? throw new FileLoadException("文件读取失败");

        var sb = new StringBuilder();
        sb.AppendLine(Templates.AppServiceHeader);

        foreach (var kv in json)
        {
            var entry = kv.Value;

            var lifeTime = entry.LifeTime?.ToLowerInvariant() switch {
                "singleton" or
                "scoped" or
                "transient" => entry.LifeTime,
                _ => null,
            };

            if (string.IsNullOrEmpty(lifeTime))
            {
                continue;
            }

            if (!string.IsNullOrEmpty(entry.Class))
            {
                if (string.IsNullOrEmpty(entry.Interface))
                {
                    sb.AppendLine(string.Format(Templates.AppServiceContent1, lifeTime, entry.Class));
                }
                else
                {
                    sb.AppendLine(string.Format(Templates.AppServiceContent2, lifeTime, entry.Interface, entry.Class));
                }
            }
        }
        sb.AppendLine(Templates.AppServiceFooter);

        context.AddSource(OutoutFileName, SourceText.From(sb.ToString(), Encoding.UTF8));
    }
}

生成的代码示例,为避免使用 using,这里使用完整类名引用

using Microsoft.Extensions.DependencyInjection;

namespace XinjingdailyBot.WebAPI.Extensions;

/// <summary>
/// 动态注册服务扩展
/// </summary>
public static class GeneratedAppServiceExtensions
{
    private static readonly NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();

    /// <summary>
    /// 注册引用程序域中所有有AppService标记的类的服务
    /// </summary>
    /// <param name="services"></param>
    public static void AddAppServiceGenerated(this IServiceCollection services)
    {
        services.AddTransient<XinjingdailyBot.Repository.OldPostRepository>();
        services.AddScoped<XinjingdailyBot.Interface.Bot.Common.IDispatcherService, XinjingdailyBot.Service.Bot.Common.DispatcherService>();
        services.AddSingleton<XinjingdailyBot.Repository.TagRepository>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Common.IChannelService, XinjingdailyBot.Service.Bot.Common.ChannelService>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IAttachmentService, XinjingdailyBot.Service.Data.AttachmentService>();
        services.AddScoped<XinjingdailyBot.Command.ReviewCommand>();
        services.AddScoped<XinjingdailyBot.Interface.Bot.Handler.IJoinRequestHandler, XinjingdailyBot.Service.Bot.Handler.JoinRequestHandler>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IBanRecordService, XinjingdailyBot.Service.Data.BanRecordService>();
        services.AddSingleton<XinjingdailyBot.Repository.RejectReasonRepository>();
        services.AddScoped<XinjingdailyBot.Interface.Bot.Common.IUpdateService, XinjingdailyBot.Service.Bot.Common.UpdateService>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IForwardMessageHandler, XinjingdailyBot.Service.Bot.Handler.ForwardMessageHandler>();
        services.AddScoped<XinjingdailyBot.Command.CommonCommand>();
        services.AddSingleton<XinjingdailyBot.Repository.LevelRepository>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.ICommandHandler, XinjingdailyBot.Service.Bot.Handler.CommandHandler>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IDialogueService, XinjingdailyBot.Service.Data.DialogueService>();
        services.AddSingleton<XinjingdailyBot.Interface.Data.IPostService, XinjingdailyBot.Service.Data.PostService>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IChannelOptionService, XinjingdailyBot.Service.Data.ChannelOptionService>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IChannelPostHandler, XinjingdailyBot.Service.Bot.Handler.ChannelPostHandler>();
        services.AddSingleton<XinjingdailyBot.Interface.Data.IUserService, XinjingdailyBot.Service.Data.UserService>();
        services.AddTransient<XinjingdailyBot.Interface.Helper.IImageHelperService, XinjingdailyBot.Service.Helper.ImageHelperService>();
        services.AddSingleton<XinjingdailyBot.Interface.Data.INameHistoryService, XinjingdailyBot.Service.Data.NameHistoryService>();
        services.AddScoped<XinjingdailyBot.Command.NormalCommand>();
        services.AddSingleton<XinjingdailyBot.Interface.Data.IMediaGroupService, XinjingdailyBot.Service.Data.MediaGroupService>();
        services.AddScoped<XinjingdailyBot.Command.ObsoleteCommand>();
        services.AddScoped<XinjingdailyBot.Command.PostCommand>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IAdvertisePostService, XinjingdailyBot.Service.Data.AdvertisePostsService>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IAdvertiseService, XinjingdailyBot.Service.Data.AdvertiseService>();
        services.AddSingleton<XinjingdailyBot.Repository.GroupRepository>();
        services.AddTransient<XinjingdailyBot.Interface.Helper.IHttpHelperService, XinjingdailyBot.Service.Helper.HttpHelperService>();
        services.AddTransient<XinjingdailyBot.Interface.Helper.ITextHelperService, XinjingdailyBot.Service.Helper.TextHelperService>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IMessageHandler, XinjingdailyBot.Service.Bot.Handler.MessageHandler>();
        services.AddTransient<XinjingdailyBot.Interface.Data.ICmdRecordService, XinjingdailyBot.Service.Data.CmdRecordService>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IInlineQueryHandler, XinjingdailyBot.Service.Bot.Handler.InlineQueryHandler>();
        services.AddTransient<XinjingdailyBot.Repository.PostRepository>();
        services.AddSingleton<XinjingdailyBot.Interface.Data.IUserTokenService, XinjingdailyBot.Service.Data.UserTokenService>();
        services.AddTransient<XinjingdailyBot.Interface.Helper.IMarkupHelperService, XinjingdailyBot.Service.Helper.MarkupHelperService>();
        services.AddScoped<XinjingdailyBot.Command.AdminCommand>();
        services.AddScoped<XinjingdailyBot.Command.SuperCommand>();
        services.AddTransient<XinjingdailyBot.Interface.Data.IReviewStatusService, XinjingdailyBot.Service.Data.ReviewStatusService>();
        services.AddSingleton<XinjingdailyBot.Interface.Bot.Handler.IGroupMessageHandler, XinjingdailyBot.Service.Bot.Handler.GroupMessageHandler>();
    }
}

调试源生成器

首先删除源生成器项目的所有调试目标,然后新增 Roslyn Component 目标,并在右边将 Target Project 设为主项目,然后将启动项目改为生成器
新增调试配置

然后在生成器的 Initialize 方法中开启调试器

    public void Initialize(GeneratorInitializationContext context)
    {
        // 无需处理
        if (!Debugger.IsAttached)
        {
            Debugger.Launch();
        }
    }

这样就可以动态调试生成器代码了

实际使用

将启动项目改为主功能,去掉源生成器的调试代码,然后重新生成一遍解决方案,如果一切正常,将会在分析器中看到生成的代码文件

生成的代码

如果后续有新的服务,重新执行 scan_service.ps1 即可更新配置文件

最后修改:2024 年 01 月 24 日
Null