Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 43 additions & 29 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- include https://github.com/devlooped/.github/raw/main/sponsorlinkr.md -->
*This project uses [SponsorLink](https://github.com/devlooped#sponsorlink) to attribute sponsor status (direct, indirect or implicit).*
Expand All @@ -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<TKey>]` 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)]
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -90,30 +102,30 @@ 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
{
string Notify(string message);
}

[Service<string>("sms")]
[Service("sms")]
public class SmsNotificationService : INotificationService
{
public string Notify(string message) => $"[SMS] {message}";
}

[Service<string>("email")]
[Service<string>("default")]
[Service("email")]
[Service("default")]
public class EmailNotificationService : INotificationService
{
public string Notify(string message) => $"[Email] {message}";
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -171,6 +183,8 @@ provider by the implementation factory too, like:
services.AddScoped(s => new MyService(s.GetRequiredService<IMyDependency>(), ...));
```

Keyed services will emit AddKeyedXXX methods instead.

## MEF Compatibility

Given the (more or less broad?) adoption of
Expand Down Expand Up @@ -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<TKey> : Attribute
{
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
}
```

Expand All @@ -260,14 +265,23 @@ 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
<PropertyGroup>
<AddServiceAttribute>false</AddServiceAttribute>
</PropertyGroup>
```

If you want to avoid generating the `AddServices` extension method to the project referencing
this package, set the `$(AddServicesExtension)` to `false` via MSBuild:

```xml
<PropertyGroup>
<AddServicesExtension>false</AddServicesExtension>
</PropertyGroup>
```

### Choose Constructor

If you want to choose a specific constructor to be used for the service implementation
Expand Down Expand Up @@ -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.

Expand Down
15 changes: 8 additions & 7 deletions src/DependencyInjection.Tests/GenerationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -319,31 +319,31 @@ public class ObservableService : IObservable<MyEvent>
public IDisposable Subscribe(IObserver<MyEvent> observer) => throw new NotImplementedException();
}

[Service<int>(42, ServiceLifetime.Singleton)]
[Service(42, ServiceLifetime.Singleton)]
public class KeyedSingletonService : IFormattable
{
public string ToString(string? format, IFormatProvider? formatProvider) => throw new NotImplementedException();
}

[Service<PlatformID>(PlatformID.Win32NT, ServiceLifetime.Transient)]
[Service(PlatformID.Win32NT, ServiceLifetime.Transient)]
public class KeyedTransientService : ICloneable
{
public object Clone() => throw new NotImplementedException();
}

[Service<string>("A", ServiceLifetime.Scoped)]
[Service("A", ServiceLifetime.Scoped)]
public class KeyedScopedService : IComparable
{
public int CompareTo(object? obj) => throw new NotImplementedException();
}

[Service<string>("FromKeyed", ServiceLifetime.Scoped)]
[Service("FromKeyed", ServiceLifetime.Scoped)]
public class FromKeyedDependency([FromKeyedServices(42)] IFormattable dependency)
{
public IFormattable Dependency => dependency;
}

[Service<string>("FromKeyedTransient", ServiceLifetime.Transient)]
[Service("FromKeyedTransient", ServiceLifetime.Transient)]
public class FromTransientKeyedDependency([FromKeyedServices(42)] IFormattable dependency)
{
public IFormattable Dependency => dependency;
Expand Down Expand Up @@ -373,13 +373,14 @@ public interface INotificationService
string Notify(string message);
}

[Service<string>("sms")]
[Service("sms")]
public class SmsNotificationService : INotificationService
{
public string Notify(string message) => $"[SMS] {message}";
}

[Service<string>("email")]
// Showcases that legacy generic Service<TKey> attribute still works
[Service("email")]
[Service<string>("default")]
public class EmailNotificationService : INotificationService
{
Expand Down
5 changes: 2 additions & 3 deletions src/DependencyInjection/ConventionsAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType);

public override void Initialize(AnalysisContext context)
{
Expand Down
1 change: 1 addition & 0 deletions src/DependencyInjection/DependencyInjection.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

<ItemGroup>
<PackageReference Include="NuGetizer" Version="1.2.1" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" Pack="false" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="PolySharp" Version="1.14.1" PrivateAssets="all" />
Expand Down
26 changes: 23 additions & 3 deletions src/DependencyInjection/IncrementalGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 0 additions & 4 deletions src/DependencyInjection/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@
"Tests": {
"commandName": "DebugRoslynComponent",
"targetProject": "..\\DependencyInjection.Tests\\DependencyInjection.Tests.csproj"
},
"Console": {
"commandName": "DebugRoslynComponent",
"targetProject": "..\\Samples\\ConsoleApp\\ConsoleApp.csproj"
}
}
}
10 changes: 5 additions & 5 deletions src/DependencyInjection/ServiceAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// <auto-generated />
#nullable enable
#if DDI_ADDSERVICE
using System;

Expand All @@ -7,19 +8,18 @@ namespace Microsoft.Extensions.DependencyInjection
/// <summary>
/// Configures the registration of a service in an <see cref="IServiceCollection"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
partial class ServiceAttribute : Attribute
{
/// <summary>
/// Annotates the service with the lifetime.
/// </summary>
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) => Lifetime = lifetime;
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) { }

/// <summary>
/// <see cref="ServiceLifetime"/> associated with a registered service
/// in an <see cref="IServiceCollection"/>.
/// Annotates the service with the given key and lifetime.
/// </summary>
public ServiceLifetime Lifetime { get; }
public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
}
}
#endif
16 changes: 2 additions & 14 deletions src/DependencyInjection/ServiceAttribute`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,13 @@ namespace Microsoft.Extensions.DependencyInjection
/// </summary>
/// <typeparam name="TKey">Type of service key.</typeparam>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
[Obsolete("Use ServiceAttribute(object key, ServiceLifetime lifetime) instead.")]
partial class ServiceAttribute<TKey> : Attribute
{
/// <summary>
/// Annotates the service with the lifetime.
/// </summary>
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton)
=> (Key, Lifetime)
= (key, lifetime);

/// <summary>
/// The key used to register the service in an <see cref="IServiceCollection"/>.
/// </summary>
public TKey Key { get; }

/// <summary>
/// <see cref="ServiceLifetime"/> associated with a registered service
/// in an <see cref="IServiceCollection"/>.
/// </summary>
public ServiceLifetime Lifetime { get; }
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
}
}
#endif
Loading