From bce97564acad83363ff527234c4cffc88d7c7409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=90=BD=E7=AC=94wys?= <46271592+Skymly@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:16:58 +0800 Subject: [PATCH] Add Singleton lifecycle diagnostics --- .github/workflows/ci.yml | 31 ++- .nuke/build.schema.json | 4 +- .../AnalyzerReleases.Unshipped.md | 4 + .../SingletonLifecycleAnalyzer.cs | 180 ++++++++++++++++++ .../DesignPatternsDiagnosticDescriptors.cs | 40 ++++ DesignPatterns.Diagnostics/DiagnosticIds.cs | 5 + .../DesignPatterns.Extensions.Autofac.csproj | 13 +- ...erns.Extensions.DependencyInjection.csproj | 13 +- .../AnalyzerReleases.Unshipped.md | 5 + .../Generators/GenerateSingletonGenerator.cs | 53 +++++- .../Syntax/SingletonSyntaxFactory.cs | 49 ++++- .../Creational/GenerateSingletonAttribute.cs | 10 + Directory.Packages.props | 1 + README.md | 8 +- build/Program.cs | 95 +++++++-- .../SingletonLifecycleAnalyzerTests.cs | 91 +++++++++ .../GenerateSingletonGeneratorTests.cs | 26 +++ .../GenerateSingletonIntegrationTests.cs | 46 +++++ 18 files changed, 636 insertions(+), 38 deletions(-) create mode 100644 DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs create mode 100644 tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 866f5c8..5458a70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,9 +87,38 @@ jobs: working-directory: DesignPatterns.Samples run: dotnet run --project build/_build.csproj -- --root . --target Ci --configuration Release + build-test-net48: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj', '**/global.json', '**/*.slnx') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Run net48 tests via Nuke + run: dotnet run --project build/_build.csproj -- --root . --target CiNet48 --configuration Release + + - name: Upload net48 test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-net48 + path: TestResults/ + if-no-files-found: warn + pack: runs-on: ubuntu-latest - needs: [build-test, samples] + needs: [build-test, build-test-net48, samples] steps: - uses: actions/checkout@v4 diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 5c9aa20..410b20f 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -33,6 +33,7 @@ "type": "string", "enum": [ "Ci", + "CiNet48", "CiPack", "Clean", "Compile", @@ -44,7 +45,8 @@ "Publish", "Restore", "Test", - "UnitTest" + "UnitTest", + "UnitTestNet48" ] }, "Verbosity": { diff --git a/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md b/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md index 9128640..fa74702 100644 --- a/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md +++ b/DesignPatterns.Analyzers/AnalyzerReleases.Unshipped.md @@ -3,3 +3,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DP066 | DesignPatterns.Analyzers | Warning | Singleton factory delegate captures shorter-lived service +DP068 | DesignPatterns.Analyzers | Warning | Generated singleton is also registered with DI +DP069 | DesignPatterns.Analyzers | Info | Non-thread-safe singleton has mutable instance state +DP070 | DesignPatterns.Analyzers | Info | Static mutable singleton candidate +DP071 | DesignPatterns.Analyzers | Warning | Static mutable singleton is also registered with DI diff --git a/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs b/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs new file mode 100644 index 0000000..d485530 --- /dev/null +++ b/DesignPatterns.Analyzers/SingletonLifecycleAnalyzer.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using DesignPatterns.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DesignPatterns.Analyzers; + +/// +/// Detects conflicting generated or mutable static singleton lifecycles. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SingletonLifecycleAnalyzer : DiagnosticAnalyzer +{ + private const string GenerateSingletonAttributeName = "DesignPatterns.Creational.GenerateSingletonAttribute"; + + private static readonly DiagnosticDescriptor GeneratedAndDiRule = + DesignPatternsDiagnosticDescriptors.GenerateSingletonDiDoubleRegistration; + private static readonly DiagnosticDescriptor NonThreadSafeStateRule = + DesignPatternsDiagnosticDescriptors.GenerateSingletonNonThreadSafeMutableState; + private static readonly DiagnosticDescriptor StaticMutableRule = + DesignPatternsDiagnosticDescriptors.StaticMutableSingleton; + private static readonly DiagnosticDescriptor StaticMutableAndDiRule = + DesignPatternsDiagnosticDescriptors.StaticMutableSingletonDiDoubleRegistration; + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(GeneratedAndDiRule, NonThreadSafeStateRule, StaticMutableRule, StaticMutableAndDiRule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + var singletonRegistrations = new HashSet(SymbolEqualityComparer.Default); + context.RegisterSyntaxNodeAction( + syntaxContext => CollectSingletonRegistration(syntaxContext, singletonRegistrations), + SyntaxKind.InvocationExpression); + context.RegisterCompilationEndAction(endContext => Analyze(endContext, singletonRegistrations)); + } + + private static void CollectSingletonRegistration( + SyntaxNodeAnalysisContext context, + HashSet registrations) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method) + { + return; + } + + var isMsDiSingleton = method.Name == "AddSingleton" && + method.ContainingNamespace.ToDisplayString().StartsWith("Microsoft.Extensions.DependencyInjection", StringComparison.Ordinal); + var isAutofacRegistration = method.Name == "RegisterType" && + method.ContainingNamespace.ToDisplayString().StartsWith("Autofac", StringComparison.Ordinal) && + HasSingleInstanceInChain(invocation); + + if (!isMsDiSingleton && !isAutofacRegistration) + { + return; + } + + var implementation = method.TypeArguments.LastOrDefault() as INamedTypeSymbol; + if (implementation is not null) + { + registrations.Add(implementation); + } + } + + private static void Analyze( + CompilationAnalysisContext context, + HashSet registrations) + { + foreach (var type in AnalyzerSymbolHelper.GetAllTypes(context.Compilation.Assembly.GlobalNamespace)) + { + var generatedSingleton = type.GetAttributes() + .FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == GenerateSingletonAttributeName); + + if (generatedSingleton is not null) + { + AnalyzeGeneratedSingleton(context, type, generatedSingleton, registrations); + } + + AnalyzeStaticMutableSingleton(context, type, registrations); + } + } + + private static void AnalyzeGeneratedSingleton( + CompilationAnalysisContext context, + INamedTypeSymbol type, + AttributeData attribute, + HashSet registrations) + { + var location = type.Locations.FirstOrDefault(); + if (location is null) + { + return; + } + + if (registrations.Contains(type)) + { + context.ReportDiagnostic(Diagnostic.Create( + GeneratedAndDiRule, + location, + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + + var threadSafe = attribute.NamedArguments + .FirstOrDefault(argument => argument.Key == "ThreadSafe").Value.Value as bool? ?? true; + if (threadSafe) + { + return; + } + + foreach (var field in type.GetMembers().OfType() + .Where(field => !field.IsStatic && !field.IsReadOnly && !field.IsConst)) + { + context.ReportDiagnostic(Diagnostic.Create( + NonThreadSafeStateRule, + field.Locations.FirstOrDefault() ?? location, + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + field.Name)); + } + } + + private static void AnalyzeStaticMutableSingleton( + CompilationAnalysisContext context, + INamedTypeSymbol type, + HashSet registrations) + { + ISymbol? candidate = type.GetMembers() + .OfType() + .FirstOrDefault(field => field.IsStatic && !field.IsReadOnly && IsSingletonName(field.Name)); + + candidate ??= type.GetMembers() + .OfType() + .FirstOrDefault(property => property.IsStatic && property.SetMethod is not null && IsSingletonName(property.Name)); + + if (candidate is null) + { + return; + } + + var location = candidate.Locations.FirstOrDefault() ?? type.Locations.FirstOrDefault(); + if (location is null) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + StaticMutableRule, + location, + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + candidate.Name)); + + if (registrations.Contains(type)) + { + context.ReportDiagnostic(Diagnostic.Create( + StaticMutableAndDiRule, + location, + type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat))); + } + } + + private static bool HasSingleInstanceInChain(InvocationExpressionSyntax invocation) => + invocation.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Parent is InvocationExpressionSyntax parent && + (memberAccess.Name.Identifier.ValueText == "SingleInstance" || HasSingleInstanceInChain(parent)); + + private static bool IsSingletonName(string name) => + name.IndexOf("instance", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("singleton", StringComparison.OrdinalIgnoreCase) >= 0; +} diff --git a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs index 68a12a9..50151ae 100644 --- a/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs +++ b/DesignPatterns.Diagnostics/DesignPatternsDiagnosticDescriptors.cs @@ -31,6 +31,46 @@ public static class DesignPatternsDiagnosticDescriptors DiagnosticSeverity.Error, GeneratorCategory); + public static DiagnosticDescriptor GenerateSingletonInitializeAsyncInvalid { get; } = Create( + DiagnosticIds.GenerateSingletonInitializeAsyncInvalid, + "GenerateSingleton asynchronous initializer is invalid", + "InitializeAsync method '{0}' on '{1}' must be static and have signature 'Task|ValueTask {0}({1} instance, CancellationToken cancellationToken)'.", + "An asynchronous singleton initializer must accept the generated instance and a cancellation token, and return Task or ValueTask.", + DiagnosticSeverity.Error, + GeneratorCategory); + + public static DiagnosticDescriptor GenerateSingletonDiDoubleRegistration { get; } = Create( + DiagnosticIds.GenerateSingletonDiDoubleRegistration, + "Generated singleton is also registered with DI", + "Type '{0}' uses [GenerateSingleton] and is also registered as a DI Singleton. Use one lifecycle owner to avoid two independent singleton instances.", + "Generated singleton instances and DI singleton registrations have separate lifecycles.", + DiagnosticSeverity.Warning, + AnalyzerCategory); + + public static DiagnosticDescriptor GenerateSingletonNonThreadSafeMutableState { get; } = Create( + DiagnosticIds.GenerateSingletonNonThreadSafeMutableState, + "Non-thread-safe singleton has mutable instance state", + "Type '{0}' sets ThreadSafe=false but contains mutable instance field '{1}'. Concurrent access can race; make the field readonly or enable thread safety.", + "Non-thread-safe singletons should not expose mutable instance state to concurrent callers.", + DiagnosticSeverity.Info, + AnalyzerCategory); + + public static DiagnosticDescriptor StaticMutableSingleton { get; } = Create( + DiagnosticIds.StaticMutableSingleton, + "Static mutable singleton candidate", + "Type '{0}' exposes mutable static singleton state through '{1}'. Prefer an immutable Lazy-backed singleton or a DI-managed lifecycle.", + "Mutable static singleton state is vulnerable to accidental replacement and concurrent access.", + DiagnosticSeverity.Info, + AnalyzerCategory); + + public static DiagnosticDescriptor StaticMutableSingletonDiDoubleRegistration { get; } = Create( + DiagnosticIds.StaticMutableSingletonDiDoubleRegistration, + "Static mutable singleton is also registered with DI", + "Type '{0}' exposes mutable static singleton state and is also registered as a DI Singleton. Use one lifecycle owner.", + "Combining mutable static singleton state with DI singleton registration creates two independent lifecycle owners.", + DiagnosticSeverity.Warning, + AnalyzerCategory); + // Strategy (DP003–DP004, DP007) public static KeyedRegistrationDiagnostics RegisterStrategy { get; } = new( diff --git a/DesignPatterns.Diagnostics/DiagnosticIds.cs b/DesignPatterns.Diagnostics/DiagnosticIds.cs index d5259f8..ad11f15 100644 --- a/DesignPatterns.Diagnostics/DiagnosticIds.cs +++ b/DesignPatterns.Diagnostics/DiagnosticIds.cs @@ -71,4 +71,9 @@ public static class DiagnosticIds public const string CompositeChildTypeNotAllowed = "DP064"; public const string CompositeNodeCountExceeded = "DP065"; public const string FactoryDelegateCaptiveDependency = "DP066"; + public const string GenerateSingletonInitializeAsyncInvalid = "DP067"; + public const string GenerateSingletonDiDoubleRegistration = "DP068"; + public const string GenerateSingletonNonThreadSafeMutableState = "DP069"; + public const string StaticMutableSingleton = "DP070"; + public const string StaticMutableSingletonDiDoubleRegistration = "DP071"; } diff --git a/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj b/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj index 16aa6c0..be45133 100644 --- a/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj +++ b/DesignPatterns.Extensions.Autofac/DesignPatterns.Extensions.Autofac.csproj @@ -7,11 +7,17 @@ latest enable true - false + true + Skymly.DesignPatterns.Extensions.Autofac + Skymly DesignPatterns Autofac Extensions + Autofac integration for Skymly.DesignPatterns. + design-patterns;dependency-injection;autofac + README.md - + + @@ -19,7 +25,8 @@ - + + diff --git a/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj b/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj index 7b0f856..4150bf5 100644 --- a/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj +++ b/DesignPatterns.Extensions.DependencyInjection/DesignPatterns.Extensions.DependencyInjection.csproj @@ -7,11 +7,17 @@ latest enable true - false + true + Skymly.DesignPatterns.Extensions.DependencyInjection + Skymly DesignPatterns Microsoft DI Extensions + Microsoft.Extensions.DependencyInjection integration for Skymly.DesignPatterns. + design-patterns;dependency-injection;msdi + README.md - + + @@ -25,7 +31,8 @@ - + + diff --git a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md index 552e61c..433a71c 100644 --- a/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/DesignPatterns.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -68,3 +68,8 @@ DP063 | DesignPatterns.Generators | Warning | Composite tree max depth excee DP064 | DesignPatterns.Generators | Error | Composite child type not allowed by parent AllowedChildTypes DP065 | DesignPatterns.Generators | Warning | Composite node count exceeds MaxNodes limit DP066 | DesignPatterns.Analyzers | Warning | Singleton factory delegate captures shorter-lived service +DP067 | DesignPatterns.Generators | Error | GenerateSingleton asynchronous initializer has an invalid signature +DP068 | DesignPatterns.Analyzers | Warning | Generated singleton is also registered with DI +DP069 | DesignPatterns.Analyzers | Info | Non-thread-safe singleton has mutable instance state +DP070 | DesignPatterns.Analyzers | Info | Static mutable singleton candidate +DP071 | DesignPatterns.Analyzers | Warning | Static mutable singleton is also registered with DI diff --git a/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs b/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs index a70ca88..afa51c5 100644 --- a/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs +++ b/DesignPatterns.SourceGenerators/Generators/GenerateSingletonGenerator.cs @@ -25,6 +25,9 @@ public sealed class GenerateSingletonGenerator : IIncrementalGenerator private static readonly DiagnosticDescriptor InvalidTargetDescriptor = DesignPatternsDiagnosticDescriptors.GenerateSingletonInvalidTarget; + private static readonly DiagnosticDescriptor InvalidInitializeAsyncDescriptor = + DesignPatternsDiagnosticDescriptors.GenerateSingletonInitializeAsyncInvalid; + /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -52,6 +55,7 @@ private static Result GetTargetInfo(GeneratorAttributeSynta } var threadSafe = true; + string? initializeAsync = null; foreach (var attribute in context.Attributes) { foreach (var named in attribute.NamedArguments) @@ -60,6 +64,10 @@ private static Result GetTargetInfo(GeneratorAttributeSynta { threadSafe = value; } + else if (named.Key == "InitializeAsync" && named.Value.Value is string methodName) + { + initializeAsync = methodName; + } } } @@ -81,6 +89,14 @@ private static Result GetTargetInfo(GeneratorAttributeSynta new DiagnosticInfo(NotPartialDescriptor, location, className)); } + if (initializeAsync is not null && + (string.IsNullOrWhiteSpace(initializeAsync) || + !TryValidateInitializeAsync(symbol, initializeAsync))) + { + return Result.Failure( + new DiagnosticInfo(InvalidInitializeAsyncDescriptor, location, initializeAsync, className)); + } + return Result.Success(new SingletonTargetInfo( location, className, @@ -90,7 +106,8 @@ private static Result GetTargetInfo(GeneratorAttributeSynta threadSafe, isStatic, typeKind, - isPartial)); + isPartial, + initializeAsync)); } private static void Execute(SourceProductionContext context, Result result) @@ -103,7 +120,8 @@ private static void Execute(SourceProductionContext context, Result()) + { + if (!method.IsStatic || method.Parameters.Length != 2 || + !SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, type) || + method.Parameters[1].Type.ToDisplayString() != "System.Threading.CancellationToken") + { + continue; + } + + var returnType = method.ReturnType.ToDisplayString(); + if (returnType == "System.Threading.Tasks.Task") + { + return true; + } + + if (returnType == "System.Threading.Tasks.ValueTask") + { + return true; + } + } + + return false; + } } diff --git a/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs b/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs index 1e10ded..e69884f 100644 --- a/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs +++ b/DesignPatterns.SourceGenerators/Syntax/SingletonSyntaxFactory.cs @@ -15,12 +15,24 @@ internal static class SingletonSyntaxFactory public static CompilationUnitSyntax CreateCompilationUnit( string? namespaceName, string className, - bool threadSafe) + bool threadSafe, + string? initializeAsync) { - var member = CreateSingletonMembers(className, threadSafe); var partialClass = SyntaxFactory.ClassDeclaration(className) - .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PartialKeyword))) - .AddMembers(member.field, member.instanceProperty); + .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PartialKeyword))); + + if (initializeAsync is null) + { + var member = CreateSingletonMembers(className, threadSafe); + partialClass = partialClass.AddMembers(member.field, member.instanceProperty); + } + else + { + partialClass = partialClass.AddMembers( + CreateAsyncSingletonField(className, threadSafe, initializeAsync), + CreateGetInstanceAsyncMethod(className)); + } + partialClass = GeneratedCodeHelper.AddGeneratedCodeAttribute(partialClass); partialClass = GeneratedCodeHelper.WithXmlDoc(partialClass, $"Provides the singleton instance for {className}."); @@ -31,15 +43,38 @@ public static CompilationUnitSyntax CreateCompilationUnit( .AddMembers(partialClass); } - return SyntaxFactory.CompilationUnit() + var compilationUnit = SyntaxFactory.CompilationUnit() .WithLeadingTrivia(GeneratedCodeHelper.CreateAutoGeneratedHeader("GenerateSingletonGenerator")) .AddMembers(namespaceMember) .AddUsings( SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System")), - SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading"))) - .NormalizeWhitespace(); + SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading"))); + + if (initializeAsync is not null) + { + compilationUnit = compilationUnit.AddUsings( + SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Threading.Tasks"))); + } + + return compilationUnit.NormalizeWhitespace(); + } + + private static FieldDeclarationSyntax CreateAsyncSingletonField( + string className, + bool threadSafe, + string initializeAsync) + { + var mode = threadSafe ? "LazyThreadSafetyMode.ExecutionAndPublication" : "LazyThreadSafetyMode.None"; + return (FieldDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration( + $"private static readonly Lazy> _instance = new Lazy>(async () => {{ var instance = new {className}(); await {initializeAsync}(instance, CancellationToken.None); return instance; }}, {mode});")!; } + private static MethodDeclarationSyntax CreateGetInstanceAsyncMethod(string className) => + GeneratedCodeHelper.WithXmlDoc( + (MethodDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration( + $"public static ValueTask<{className}> GetInstanceAsync() => new ValueTask<{className}>(_instance.Value);")!, + "Gets the singleton instance after asynchronous initialization."); + private static (FieldDeclarationSyntax field, PropertyDeclarationSyntax instanceProperty) CreateSingletonMembers( string className, bool threadSafe) diff --git a/DesignPatterns/Creational/GenerateSingletonAttribute.cs b/DesignPatterns/Creational/GenerateSingletonAttribute.cs index 6251cbe..65cc53e 100644 --- a/DesignPatterns/Creational/GenerateSingletonAttribute.cs +++ b/DesignPatterns/Creational/GenerateSingletonAttribute.cs @@ -14,4 +14,14 @@ public sealed class GenerateSingletonAttribute : Attribute /// When , uses . /// public bool ThreadSafe { get; set; } = true; + + /// + /// Gets or sets the name of a static asynchronous initialization method. + /// When specified, the generator emits GetInstanceAsync() instead + /// of Instance. The method must accept the generated instance and a + /// , and return + /// or + /// . + /// + public string? InitializeAsync { get; set; } } diff --git a/Directory.Packages.props b/Directory.Packages.props index a84f0c0..165d476 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + diff --git a/README.md b/README.md index 40db675..39d539c 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ | 手动注册 | `AddStrategyRegistry` / `AddFactoryRegistry` / `AddHandlerPipeline` / `AddTransitionTable` / `AddStateMachine` | `RegisterTransitionTable` / `RegisterStateMachine` | | 生成器输出 | `{Contract}Registry.RegisterDi(services)` | `{Contract}Registry.RegisterAutofac(builder)` | -引用对应扩展项目/包时,源生成器自动输出 `RegisterDi`(MSDI)或 `RegisterAutofac`(Autofac)方法,从容器解析实例。 +引用元包与对应扩展包时,源生成器自动输出 `RegisterDi`(MSDI)或 `RegisterAutofac`(Autofac)方法,从容器解析实例。 -- **MSDI**:引用 `DesignPatterns.Extensions.DependencyInjection`(自动导入 `build/*.targets`) -- **Autofac**:引用 `DesignPatterns.Extensions.Autofac`(自动导入 `build/*.targets`) +- **MSDI**:引用 `Skymly.DesignPatterns` 与 `Skymly.DesignPatterns.Extensions.DependencyInjection`(自动导入 `build/*.targets`) +- **Autofac**:引用 `Skymly.DesignPatterns` 与 `Skymly.DesignPatterns.Extensions.Autofac`(自动导入 `build/*.targets`) -当前元包 **`Skymly.DesignPatterns`** 不包含 DI 扩展;独立 NuGet 发布策略待 API 与发版流程稳定后再确定。 +元包 **`Skymly.DesignPatterns`** 不包含 DI 扩展;两个扩展保持独立、与元包锁步发布。 ## 仓库结构 diff --git a/build/Program.cs b/build/Program.cs index 5962f35..777feb7 100644 --- a/build/Program.cs +++ b/build/Program.cs @@ -40,6 +40,8 @@ sealed class Build : NukeBuild readonly NuGetConsumerFeed ConsumerFeed = NuGetConsumerFeed.Local; AbsolutePath PackProject => Root / "DesignPatterns.Package" / "DesignPatterns.Package.csproj"; + AbsolutePath DependencyInjectionPackProject => Root / "DesignPatterns.Extensions.DependencyInjection" / "DesignPatterns.Extensions.DependencyInjection.csproj"; + AbsolutePath AutofacPackProject => Root / "DesignPatterns.Extensions.Autofac" / "DesignPatterns.Extensions.Autofac.csproj"; static readonly string[] TestProjectRelativePaths = [ @@ -52,7 +54,18 @@ sealed class Build : NukeBuild "tests/DesignPatterns.Extensions.Configuration.Tests/DesignPatterns.Extensions.Configuration.Tests.csproj", ]; + static readonly string[] Net48TestProjectRelativePaths = + [ + "tests/DesignPatterns.Extensions.AppSettings.Tests.Net48/DesignPatterns.Extensions.AppSettings.Tests.Net48.csproj", + "tests/DesignPatterns.Extensions.Configuration.Tests.Net48/DesignPatterns.Extensions.Configuration.Tests.Net48.csproj", + ]; + const string ExpectedPackageId = "Skymly.DesignPatterns"; + static readonly string[] ExtensionPackageIds = + [ + "Skymly.DesignPatterns.Extensions.DependencyInjection", + "Skymly.DesignPatterns.Extensions.Autofac", + ]; public static int Main() => Execute(x => x.Ci); @@ -106,27 +119,49 @@ sealed class Build : NukeBuild } }); + Target UnitTestNet48 => _ => _ + .DependsOn(Compile) + .Executes(() => + { + foreach (string relativePath in Net48TestProjectRelativePaths) + { + AbsolutePath projectFile = Root / relativePath; + Assert.FileExists(projectFile, $"Test project not found: {projectFile}"); + DotNetTest(s => s + .SetProjectFile(projectFile) + .SetConfiguration(Configuration) + .SetNoBuild(true) + .SetResultsDirectory(TestResultsDirectory) + .SetLoggers("trx;LogFileName=" + projectFile.NameWithoutExtension + ".trx")); + } + }); + Target Pack => _ => _ .DependsOn(UnitTest) .Executes(() => { PackageOutputDirectory.CreateOrCleanDirectory(); - DotNetPack(s => + foreach (AbsolutePath project in new[] { PackProject, DependencyInjectionPackProject, AutofacPackProject }) { - s = s - .SetProject(PackProject) - .SetConfiguration(Configuration) - .SetProperty("PackageOutputPath", PackageOutputDirectory) - .SetProperty("ContinuousIntegrationBuild", "true"); - - if (!string.IsNullOrWhiteSpace(Version)) + DotNetPack(s => { - s = s.SetVersion(Version); - } - - return s; - }); + s = s + .SetProject(project) + .SetConfiguration(Configuration) + .SetProperty("PackageOutputPath", PackageOutputDirectory) + .SetProperty("ContinuousIntegrationBuild", "true") + .SetProperty("DesignPatternsPackageDependency", "true") + .EnableNoBuild(); + + if (!string.IsNullOrWhiteSpace(Version)) + { + s = s.SetVersion(Version); + } + + return s; + }); + } }); Target PackVerify => _ => _ @@ -160,6 +195,11 @@ sealed class Build : NukeBuild Assert.True(entries.Contains("README.md"), $"{ExpectedPackageId}: missing package README.md"); Assert.True(entries.Contains("LICENSE") || entries.Contains("LICENSE.md"), $"{ExpectedPackageId}: missing LICENSE"); + + foreach (string packageId in ExtensionPackageIds) + { + VerifyExtensionPackage(packageId); + } }); Target NuGetConsumerSmoke => _ => _ @@ -225,6 +265,9 @@ void RunNuGetConsumerSmoke( Target Ci => _ => _ .DependsOn(UnitTest); + Target CiNet48 => _ => _ + .DependsOn(UnitTestNet48); + Target Test => _ => _ .DependsOn(UnitTest); @@ -280,11 +323,31 @@ AbsolutePath GetExpectedPackage() throw new InvalidOperationException($"No packages found in {PackageOutputDirectory}"); } - return nupkgs.SingleOrDefault(p => p.Name.StartsWith(ExpectedPackageId + ".", StringComparison.Ordinal)) - ?? throw new InvalidOperationException( - $"Expected a single '{ExpectedPackageId}.*.nupkg' package, found: {string.Join(", ", nupkgs.Select(p => p.Name))}"); + return GetPackage(ExpectedPackageId); } + void VerifyExtensionPackage(string packageId) + { + AbsolutePath nupkg = GetPackage(packageId); + using ZipArchive archive = ZipFile.OpenRead(nupkg); + HashSet entries = archive.Entries + .Select(e => e.FullName.Replace('\\', '/')) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + string assemblyName = packageId.Replace("Skymly.", string.Empty); + Assert.True(entries.Contains($"lib/netstandard2.0/{assemblyName}.dll"), $"{packageId}: missing netstandard2.0 assembly"); + Assert.True(entries.Contains($"lib/net8.0/{assemblyName}.dll"), $"{packageId}: missing net8.0 assembly"); + Assert.True(entries.Contains($"build/{packageId}.targets"), $"{packageId}: missing automatic integration targets"); + Assert.True(entries.Contains("README.md"), $"{packageId}: missing package README.md"); + } + + AbsolutePath GetPackage(string packageId) => + PackageOutputDirectory.GlobFiles("*.nupkg") + .SingleOrDefault(p => p.Name.StartsWith(packageId + ".", StringComparison.Ordinal) + && p.Name.Length > packageId.Length + 1 + && char.IsDigit(p.Name[packageId.Length + 1])) + ?? throw new InvalidOperationException($"Expected a '{packageId}.*.nupkg' package."); + static string GetPackageVersion(AbsolutePath nupkg) { string fileName = nupkg.Name; diff --git a/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs b/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs new file mode 100644 index 0000000..1535f1b --- /dev/null +++ b/tests/DesignPatterns.Analyzers.Tests/SingletonLifecycleAnalyzerTests.cs @@ -0,0 +1,91 @@ +using System.Linq; +using DesignPatterns.Analyzers; + +namespace DesignPatterns.Analyzers.Tests; + +public sealed class SingletonLifecycleAnalyzerTests +{ + [Fact] + public async Task ReportsDp068AndDp069_ForGeneratedNonThreadSafeDiSingleton() + { + const string source = """ + using DesignPatterns.Creational; + using Microsoft.Extensions.DependencyInjection; + + namespace TestAssembly; + + [GenerateSingleton(ThreadSafe = false)] + public partial class Settings + { + private int _version; + } + + public static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddSingleton(); + } + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync( + source, + new SingletonLifecycleAnalyzer()); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP068"); + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP069"); + } + + [Fact] + public async Task ReportsDp070AndDp071_ForMutableStaticDiSingleton() + { + const string source = """ + using Microsoft.Extensions.DependencyInjection; + + namespace TestAssembly; + + public class Settings + { + public static Settings Instance = new Settings(); + } + + public static class Startup + { + public static void Configure(IServiceCollection services) + { + services.AddSingleton(); + } + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync( + source, + new SingletonLifecycleAnalyzer()); + + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP070"); + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP071"); + } + + [Fact] + public async Task DoesNotReportDp069_ForConstInstanceData() + { + const string source = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateSingleton(ThreadSafe = false)] + public partial class Settings + { + private const int Version = 1; + } + """; + + var diagnostics = await AnalyzerTestContext.RunAnalyzersAsync( + source, + new SingletonLifecycleAnalyzer()); + + Assert.DoesNotContain(diagnostics, diagnostic => diagnostic.Id == "DP069"); + } +} diff --git a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs index 225ac67..dbaa335 100644 --- a/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs +++ b/tests/DesignPatterns.SourceGenerators.Tests/Generators/GenerateSingletonGeneratorTests.cs @@ -84,4 +84,30 @@ public static class InvalidStatic return Verifier.Verify(SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult)); } + + [Fact] + public void ReportsDp067WhenAsyncInitializerSignatureIsInvalid() + { + const string source = """ + using DesignPatterns.Creational; + + namespace TestAssembly; + + [GenerateSingleton(InitializeAsync = nameof(InitializeAsync))] + public partial class InvalidAsyncInitializer + { + public void InitializeAsync( + InvalidAsyncInitializer instance, + System.Threading.CancellationToken cancellationToken) + { + } + } + """; + + var runResult = SourceGeneratorTestContext.Run( + ("InvalidAsyncInitializer.cs", source)); + + var diagnostics = SourceGeneratorTestContext.GetGeneratorDiagnostics(runResult); + Assert.Contains(diagnostics, diagnostic => diagnostic.Id == "DP067"); + } } diff --git a/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs b/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs index e0f0378..db8bccd 100644 --- a/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs +++ b/tests/DesignPatterns.Tests/Integration/GenerateSingletonIntegrationTests.cs @@ -14,6 +14,34 @@ public partial class IntegrationFastCache public int Value { get; set; } } +[GenerateSingleton(InitializeAsync = nameof(InitializeAsync))] +public partial class IntegrationAsyncSettings +{ + public bool Initialized { get; private set; } + + public static System.Threading.Tasks.Task InitializeAsync( + IntegrationAsyncSettings settings, + System.Threading.CancellationToken cancellationToken) + { + settings.Initialized = true; + return System.Threading.Tasks.Task.CompletedTask; + } +} + +[GenerateSingleton(InitializeAsync = nameof(InitializeAsync))] +public partial class IntegrationValueTaskSettings +{ + public bool Initialized { get; private set; } + + public static System.Threading.Tasks.ValueTask InitializeAsync( + IntegrationValueTaskSettings settings, + System.Threading.CancellationToken cancellationToken) + { + settings.Initialized = true; + return System.Threading.Tasks.ValueTask.CompletedTask; + } +} + public sealed class GenerateSingletonIntegrationTests { [Fact] @@ -34,4 +62,22 @@ public void GeneratedSingleton_NonThreadSafeInstance_ReturnsSameReference() Assert.Same(first, second); } + + [Fact] + public async System.Threading.Tasks.Task GeneratedSingleton_AsyncInitializer_ReturnsInitializedSameReference() + { + var first = await IntegrationAsyncSettings.GetInstanceAsync(); + var second = await IntegrationAsyncSettings.GetInstanceAsync(); + + Assert.Same(first, second); + Assert.True(first.Initialized); + } + + [Fact] + public async System.Threading.Tasks.Task GeneratedSingleton_ValueTaskInitializer_ReturnsInitializedInstance() + { + var instance = await IntegrationValueTaskSettings.GetInstanceAsync(); + + Assert.True(instance.Initialized); + } }