diff --git a/readme.md b/readme.md index 49f7c54..eb35bf9 100644 --- a/readme.md +++ b/readme.md @@ -1,10 +1,10 @@ -![Icon](https://raw.githubusercontent.com/devlooped/DependencyInjection.Attributed/main/assets/img/icon-32.png) .NET DependencyInjection via [Service] Attribute +![Icon](https://raw.githubusercontent.com/devlooped/DependencyInjection/main/assets/img/icon-32.png) .NET DependencyInjection via [Service] Attribute ============ -[![Version](https://img.shields.io/nuget/vpre/Devlooped.Extensions.DependencyInjection.Attributed.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed) -[![Downloads](https://img.shields.io/nuget/dt/Devlooped.Extensions.DependencyInjection.Attributed.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed) -[![License](https://img.shields.io/github/license/devlooped/DependencyInjection.Attributed.svg?color=blue)](https://github.com//devlooped/DependencyInjection.Attributed/blob/main/license.txt) -[![Build](https://github.com/devlooped/DependencyInjection.Attributed/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/DependencyInjection.Attributed/actions) +[![Version](https://img.shields.io/nuget/vpre/Devlooped.Extensions.DependencyInjection.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection) +[![Downloads](https://img.shields.io/nuget/dt/Devlooped.Extensions.DependencyInjection.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection) +[![License](https://img.shields.io/github/license/devlooped/DependencyInjection.svg?color=blue)](https://github.com//devlooped/DependencyInjection/blob/main/license.txt) +[![Build](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml/badge.svg)](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml) *This project uses [SponsorLink](https://github.com/devlooped#sponsorlink) to attribute sponsor status (direct, indirect or implicit).* @@ -19,8 +19,15 @@ from conventions or attributes. ## Usage -After [installing the nuget package](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed), -a new `[Service(ServiceLifetime)]` attribute will be available to annotate your types: +The package supports two complementary ways to register services in the DI container, both of which are source-generated at compile-time +and therefore have no run-time dependencies or reflection overhead: + +- **Attribute-based**: annotate your services with `[Service]` or `[Service]` attributes to register them in the DI container. +- **Convention-based**: register services by type or name using a convention-based approach. + +### Attribute-based + +The `[Service(ServiceLifetime)]` attribute is available to explicitly annotate types for registration: ```csharp [Service(ServiceLifetime.Scoped)] @@ -70,6 +77,8 @@ And that's it. The source generator will discover annotated types in the current project and all its references too. Since the registration code is generated at compile-time, there is no run-time reflection (or dependencies) whatsoever. +### Convention-based + You can also avoid attributes entirely by using a convention-based approach, which is nevertheless still compile-time checked and source-generated. This allows registering services for which you don't even have the source code to annotate: @@ -81,6 +90,9 @@ builder.Services.AddServices(typeof(IRepository), ServiceLifetime.Scoped); // ... ``` +This will register all types in the current project and its references that are +assignable to `IRepository`, with the specified lifetime. + You can also use a regular expression to match services by name instead: ```csharp @@ -90,15 +102,15 @@ builder.Services.AddServices(".*Service$"); // defaults to ServiceLifetime.Sing // ... ``` -Or a combination of both, as needed. In all cases, NO run-time reflection is -ever performed, and the compile-time source generator will evaluate the types -that are assignable to the given type or matching full type names and emit -the typed registrations as needed. +You can use a combination of both, as needed. In all cases, NO run-time reflection is +ever performed, and the compile-time source generator will evaluate the types that are +assignable to the given type or matching full type names and emit the typed registrations +as needed. ### Keyed Services [Keyed services](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-8.0#keyed-services) -are also supported by a separate generic `[Service]` attribute, like: +are also supported by providing a key with the `[Service]` attribute. For example: ```csharp public interface INotificationService @@ -106,14 +118,14 @@ public interface INotificationService string Notify(string message); } -[Service("sms")] +[Service("sms")] public class SmsNotificationService : INotificationService { public string Notify(string message) => $"[SMS] {message}"; } -[Service("email")] -[Service("default")] +[Service("email")] +[Service("default")] public class EmailNotificationService : INotificationService { public string Notify(string message) => $"[Email] {message}"; @@ -141,7 +153,7 @@ Note you can also register the same service using multiple keys, as shown in the ## How It Works -The generated code that implements the registration looks like the following: +In all cases, the generated code that implements the registration looks like the following: ```csharp static partial class AddServicesExtension @@ -157,7 +169,7 @@ static partial class AddServicesExtension ``` Note how the service is registered as scoped with its own type first, and the -other two registrations just retrieve the same (according to its defined +other two registrations just retrieve the same service (according to its defined lifetime). This means the instance is reused and properly registered under all implemented interfaces automatically. @@ -171,6 +183,8 @@ provider by the implementation factory too, like: services.AddScoped(s => new MyService(s.GetRequiredService(), ...)); ``` +Keyed services will emit AddKeyedXXX methods instead. + ## MEF Compatibility Given the (more or less broad?) adoption of @@ -237,16 +251,7 @@ package from your library projects, you can just declare it like so: public class ServiceAttribute : Attribute { public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) { } -} -``` - -Likewise for the keyed service version: - -```csharp -[AttributeUsage(AttributeTargets.Class)] -public class ServiceAttribute : Attribute -{ - public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { } + public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { } } ``` @@ -260,7 +265,7 @@ that is adding the services to the collection! The attribute is matched by simple name, so it can exist in any namespace. If you want to avoid adding the attribute to the project referencing this package, -set the `$(AddServiceAttribute)` to `true` via MSBuild: +set the `$(AddServiceAttribute)` to `false` via MSBuild: ```xml @@ -268,6 +273,15 @@ set the `$(AddServiceAttribute)` to `true` via MSBuild: ``` +If you want to avoid generating the `AddServices` extension method to the project referencing +this package, set the `$(AddServicesExtension)` to `false` via MSBuild: + +```xml + + false + +``` + ### Choose Constructor If you want to choose a specific constructor to be used for the service implementation @@ -296,7 +310,7 @@ respectively. # Dogfooding [![CI Version](https://img.shields.io/endpoint?url=https://shields.kzu.app/vpre/Devlooped.Extensions.DependencyInjection/main&label=nuget.ci&color=brightgreen)](https://pkg.kzu.app/index.json) -[![Build](https://github.com/devlooped/DependencyInjection/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/DependencyInjection/actions) +[![Build](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml/badge.svg)](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml) We also produce CI packages from branches and pull requests so you can dogfood builds as quickly as they are produced. diff --git a/src/DependencyInjection.Tests/GenerationTests.cs b/src/DependencyInjection.Tests/GenerationTests.cs index 34a1493..a4489c8 100644 --- a/src/DependencyInjection.Tests/GenerationTests.cs +++ b/src/DependencyInjection.Tests/GenerationTests.cs @@ -319,31 +319,31 @@ public class ObservableService : IObservable public IDisposable Subscribe(IObserver observer) => throw new NotImplementedException(); } -[Service(42, ServiceLifetime.Singleton)] +[Service(42, ServiceLifetime.Singleton)] public class KeyedSingletonService : IFormattable { public string ToString(string? format, IFormatProvider? formatProvider) => throw new NotImplementedException(); } -[Service(PlatformID.Win32NT, ServiceLifetime.Transient)] +[Service(PlatformID.Win32NT, ServiceLifetime.Transient)] public class KeyedTransientService : ICloneable { public object Clone() => throw new NotImplementedException(); } -[Service("A", ServiceLifetime.Scoped)] +[Service("A", ServiceLifetime.Scoped)] public class KeyedScopedService : IComparable { public int CompareTo(object? obj) => throw new NotImplementedException(); } -[Service("FromKeyed", ServiceLifetime.Scoped)] +[Service("FromKeyed", ServiceLifetime.Scoped)] public class FromKeyedDependency([FromKeyedServices(42)] IFormattable dependency) { public IFormattable Dependency => dependency; } -[Service("FromKeyedTransient", ServiceLifetime.Transient)] +[Service("FromKeyedTransient", ServiceLifetime.Transient)] public class FromTransientKeyedDependency([FromKeyedServices(42)] IFormattable dependency) { public IFormattable Dependency => dependency; @@ -373,13 +373,14 @@ public interface INotificationService string Notify(string message); } -[Service("sms")] +[Service("sms")] public class SmsNotificationService : INotificationService { public string Notify(string message) => $"[SMS] {message}"; } -[Service("email")] +// Showcases that legacy generic Service attribute still works +[Service("email")] [Service("default")] public class EmailNotificationService : INotificationService { diff --git a/src/DependencyInjection/ConventionsAnalyzer.cs b/src/DependencyInjection/ConventionsAnalyzer.cs index 7e3f0fa..47cf44a 100644 --- a/src/DependencyInjection/ConventionsAnalyzer.cs +++ b/src/DependencyInjection/ConventionsAnalyzer.cs @@ -23,13 +23,12 @@ public class ConventionsAnalyzer : DiagnosticAnalyzer new DiagnosticDescriptor( "DDI003", "Open generic service implementations are not supported for convention-based registration.", - "Only the concrete (closed) implementations of the open generic interface will be registered Register open generic services explicitly using the built-in service collection methods.", + "Only the concrete (closed) implementations of the open generic interface will be registered. Register open generic services explicitly using the built-in service collection methods.", "Build", DiagnosticSeverity.Warning, isEnabledByDefault: true); - public override ImmutableArray SupportedDiagnostics { get; } = - ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType); + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType); public override void Initialize(AnalysisContext context) { diff --git a/src/DependencyInjection/DependencyInjection.csproj b/src/DependencyInjection/DependencyInjection.csproj index 50742b4..0b9244d 100644 --- a/src/DependencyInjection/DependencyInjection.csproj +++ b/src/DependencyInjection/DependencyInjection.csproj @@ -18,6 +18,7 @@ + diff --git a/src/DependencyInjection/IncrementalGenerator.cs b/src/DependencyInjection/IncrementalGenerator.cs index 60a58a2..b0082f3 100644 --- a/src/DependencyInjection/IncrementalGenerator.cs +++ b/src/DependencyInjection/IncrementalGenerator.cs @@ -22,7 +22,26 @@ namespace Devlooped.Extensions.DependencyInjection; [Generator(LanguageNames.CSharp)] public class IncrementalGenerator : IIncrementalGenerator { - record ServiceSymbol(INamedTypeSymbol Type, int Lifetime, TypedConstant? Key); + class ServiceSymbol(INamedTypeSymbol type, int lifetime, TypedConstant? key) + { + public INamedTypeSymbol Type => type; + public int Lifetime => lifetime; + public TypedConstant? Key => key; + + public override bool Equals(object? obj) + { + if (obj is not ServiceSymbol other) + return false; + + return type.Equals(other.Type, SymbolEqualityComparer.Default) && + lifetime == other.Lifetime && + Equals(key, other); + } + + public override int GetHashCode() + => HashCode.Combine(SymbolEqualityComparer.Default.GetHashCode(type), lifetime, key); + } + record ServiceRegistration(int Lifetime, TypeSyntax? AssignableTo, string? FullNameExpression) { Regex? regex; @@ -54,8 +73,9 @@ bool IsService(AttributeData attr) => attr.ConstructorArguments[0].Type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Microsoft.Extensions.DependencyInjection.ServiceLifetime"; bool IsKeyedService(AttributeData attr) => - (attr.AttributeClass?.Name == "ServiceAttribute" || attr.AttributeClass?.Name == "Service") && - attr.AttributeClass?.IsGenericType == true && + (attr.AttributeClass?.Name == "ServiceAttribute" || attr.AttributeClass?.Name == "Service" || + attr.AttributeClass?.Name == "KeyedService" || attr.AttributeClass?.Name == "KeyedServiceAttribute") && + //attr.AttributeClass?.IsGenericType == true && attr.ConstructorArguments.Length == 2 && attr.ConstructorArguments[1].Kind == TypedConstantKind.Enum && attr.ConstructorArguments[1].Type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Microsoft.Extensions.DependencyInjection.ServiceLifetime"; diff --git a/src/DependencyInjection/Properties/launchSettings.json b/src/DependencyInjection/Properties/launchSettings.json index 3e64e72..bba7c6a 100644 --- a/src/DependencyInjection/Properties/launchSettings.json +++ b/src/DependencyInjection/Properties/launchSettings.json @@ -3,10 +3,6 @@ "Tests": { "commandName": "DebugRoslynComponent", "targetProject": "..\\DependencyInjection.Tests\\DependencyInjection.Tests.csproj" - }, - "Console": { - "commandName": "DebugRoslynComponent", - "targetProject": "..\\Samples\\ConsoleApp\\ConsoleApp.csproj" } } } \ No newline at end of file diff --git a/src/DependencyInjection/ServiceAttribute.cs b/src/DependencyInjection/ServiceAttribute.cs index fdfb64b..0a526d4 100644 --- a/src/DependencyInjection/ServiceAttribute.cs +++ b/src/DependencyInjection/ServiceAttribute.cs @@ -1,4 +1,5 @@ // +#nullable enable #if DDI_ADDSERVICE using System; @@ -7,19 +8,18 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Configures the registration of a service in an . /// - [AttributeUsage(AttributeTargets.Class)] + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] partial class ServiceAttribute : Attribute { /// /// Annotates the service with the lifetime. /// - public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) => Lifetime = lifetime; + public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) { } /// - /// associated with a registered service - /// in an . + /// Annotates the service with the given key and lifetime. /// - public ServiceLifetime Lifetime { get; } + public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { } } } #endif \ No newline at end of file diff --git a/src/DependencyInjection/ServiceAttribute`1.cs b/src/DependencyInjection/ServiceAttribute`1.cs index ee45c7b..4de59fb 100644 --- a/src/DependencyInjection/ServiceAttribute`1.cs +++ b/src/DependencyInjection/ServiceAttribute`1.cs @@ -10,25 +10,13 @@ namespace Microsoft.Extensions.DependencyInjection /// /// Type of service key. [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + [Obsolete("Use ServiceAttribute(object key, ServiceLifetime lifetime) instead.")] partial class ServiceAttribute : Attribute { /// /// Annotates the service with the lifetime. /// - public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) - => (Key, Lifetime) - = (key, lifetime); - - /// - /// The key used to register the service in an . - /// - public TKey Key { get; } - - /// - /// associated with a registered service - /// in an . - /// - public ServiceLifetime Lifetime { get; } + public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { } } } #endif \ No newline at end of file