前言
完整项目地址: 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>
我所做的修改如下
-
将目标框架改为
netstandard2.0
,目前源生成器必须用该版本 -
添加
EnforceExtendedAnalyzerRules
和IsRoslynComponent
项,第一个是排除别的分析器,第二个是定义该项目是 Roslyn 组件,添加了以后会有额外的调试配置 -
添加
GetTargetPathDependsOn
项,意思是从定义的路径中寻找依赖库 -
添加
Microsoft.CodeAnalysis.Analyzers
和Microsoft.CodeAnalysis.Common
和Microsoft.CodeAnalysis.CSharp
库,原样从微软文档拷来的,生成器必须的依赖 -
添加
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 即可更新配置文件
本文链接:https://blog.chrxw.com/archives/2024/01/24/1751.html
转载请保留本文链接,谢谢