Skip to content

Commit 85353bb

Browse files
committed
Allow service key in Service attribute itself
This simplifies the API and reads much better than an unnecessary generic attribute which is rarer in C#. We obsolete the older typed attribute since it's unnecessary now. Update readme with docs on this and improve it too.
1 parent 1d8fd3f commit 85353bb

File tree

9 files changed

+85
-66
lines changed

9 files changed

+85
-66
lines changed

readme.md

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
![Icon](https://raw.githubusercontent.com/devlooped/DependencyInjection.Attributed/main/assets/img/icon-32.png) .NET DependencyInjection via [Service] Attribute
1+
![Icon](https://raw.githubusercontent.com/devlooped/DependencyInjection/main/assets/img/icon-32.png) .NET DependencyInjection via [Service] Attribute
22
============
33

4-
[![Version](https://img.shields.io/nuget/vpre/Devlooped.Extensions.DependencyInjection.Attributed.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed)
5-
[![Downloads](https://img.shields.io/nuget/dt/Devlooped.Extensions.DependencyInjection.Attributed.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed)
6-
[![License](https://img.shields.io/github/license/devlooped/DependencyInjection.Attributed.svg?color=blue)](https://github.com//devlooped/DependencyInjection.Attributed/blob/main/license.txt)
7-
[![Build](https://github.com/devlooped/DependencyInjection.Attributed/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/DependencyInjection.Attributed/actions)
4+
[![Version](https://img.shields.io/nuget/vpre/Devlooped.Extensions.DependencyInjection.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection)
5+
[![Downloads](https://img.shields.io/nuget/dt/Devlooped.Extensions.DependencyInjection.svg)](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection)
6+
[![License](https://img.shields.io/github/license/devlooped/DependencyInjection.svg?color=blue)](https://github.com//devlooped/DependencyInjection/blob/main/license.txt)
7+
[![Build](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml/badge.svg)](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml)
88

99
<!-- include https://github.com/devlooped/.github/raw/main/sponsorlinkr.md -->
1010

@@ -15,8 +15,15 @@ from conventions or attributes.
1515

1616
## Usage
1717

18-
After [installing the nuget package](https://www.nuget.org/packages/Devlooped.Extensions.DependencyInjection.Attributed),
19-
a new `[Service(ServiceLifetime)]` attribute will be available to annotate your types:
18+
The package supports two complementary ways to register services in the DI container, both of which are source-generated at compile-time
19+
and therefore have no run-time dependencies or reflection overhead:
20+
21+
- **Attribute-based**: annotate your services with `[Service]` or `[Service<TKey>]` attributes to register them in the DI container.
22+
- **Convention-based**: register services by type or name using a convention-based approach.
23+
24+
### Attribute-based
25+
26+
The `[Service(ServiceLifetime)]` attribute is available to explicitly annotate types for registration:
2027

2128
```csharp
2229
[Service(ServiceLifetime.Scoped)]
@@ -66,6 +73,8 @@ And that's it. The source generator will discover annotated types in the current
6673
project and all its references too. Since the registration code is generated at
6774
compile-time, there is no run-time reflection (or dependencies) whatsoever.
6875

76+
### Convention-based
77+
6978
You can also avoid attributes entirely by using a convention-based approach, which
7079
is nevertheless still compile-time checked and source-generated. This allows
7180
registering services for which you don't even have the source code to annotate:
@@ -77,6 +86,9 @@ builder.Services.AddServices(typeof(IRepository), ServiceLifetime.Scoped);
7786
// ...
7887
```
7988

89+
This will register all types in the current project and its references that are
90+
assignable to `IRepository`, with the specified lifetime.
91+
8092
You can also use a regular expression to match services by name instead:
8193

8294
```csharp
@@ -86,30 +98,30 @@ builder.Services.AddServices(".*Service$"); // defaults to ServiceLifetime.Sing
8698
// ...
8799
```
88100

89-
Or a combination of both, as needed. In all cases, NO run-time reflection is
90-
ever performed, and the compile-time source generator will evaluate the types
91-
that are assignable to the given type or matching full type names and emit
92-
the typed registrations as needed.
101+
You can use a combination of both, as needed. In all cases, NO run-time reflection is
102+
ever performed, and the compile-time source generator will evaluate the types that are
103+
assignable to the given type or matching full type names and emit the typed registrations
104+
as needed.
93105

94106
### Keyed Services
95107

96108
[Keyed services](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-8.0#keyed-services)
97-
are also supported by a separate generic `[Service]` attribute, like:
109+
are also supported by providing a key with the `[Service]` attribute. For example:
98110

99111
```csharp
100112
public interface INotificationService
101113
{
102114
string Notify(string message);
103115
}
104116

105-
[Service<string>("sms")]
117+
[Service("sms")]
106118
public class SmsNotificationService : INotificationService
107119
{
108120
public string Notify(string message) => $"[SMS] {message}";
109121
}
110122

111-
[Service<string>("email")]
112-
[Service<string>("default")]
123+
[Service("email")]
124+
[Service("default")]
113125
public class EmailNotificationService : INotificationService
114126
{
115127
public string Notify(string message) => $"[Email] {message}";
@@ -137,7 +149,7 @@ Note you can also register the same service using multiple keys, as shown in the
137149
138150
## How It Works
139151

140-
The generated code that implements the registration looks like the following:
152+
In all cases, the generated code that implements the registration looks like the following:
141153

142154
```csharp
143155
static partial class AddServicesExtension
@@ -153,7 +165,7 @@ static partial class AddServicesExtension
153165
```
154166

155167
Note how the service is registered as scoped with its own type first, and the
156-
other two registrations just retrieve the same (according to its defined
168+
other two registrations just retrieve the same service (according to its defined
157169
lifetime). This means the instance is reused and properly registered under
158170
all implemented interfaces automatically.
159171

@@ -167,6 +179,8 @@ provider by the implementation factory too, like:
167179
services.AddScoped(s => new MyService(s.GetRequiredService<IMyDependency>(), ...));
168180
```
169181

182+
Keyed services will emit AddKeyedXXX methods instead.
183+
170184
## MEF Compatibility
171185

172186
Given the (more or less broad?) adoption of
@@ -233,16 +247,7 @@ package from your library projects, you can just declare it like so:
233247
public class ServiceAttribute : Attribute
234248
{
235249
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
236-
}
237-
```
238-
239-
Likewise for the keyed service version:
240-
241-
```csharp
242-
[AttributeUsage(AttributeTargets.Class)]
243-
public class ServiceAttribute<TKey> : Attribute
244-
{
245-
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
250+
public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
246251
}
247252
```
248253

@@ -256,14 +261,23 @@ that is adding the services to the collection!
256261
The attribute is matched by simple name, so it can exist in any namespace.
257262

258263
If you want to avoid adding the attribute to the project referencing this package,
259-
set the `$(AddServiceAttribute)` to `true` via MSBuild:
264+
set the `$(AddServiceAttribute)` to `false` via MSBuild:
260265

261266
```xml
262267
<PropertyGroup>
263268
<AddServiceAttribute>false</AddServiceAttribute>
264269
</PropertyGroup>
265270
```
266271

272+
If you want to avoid generating the `AddServices` extension method to the project referencing
273+
this package, set the `$(AddServicesExtension)` to `false` via MSBuild:
274+
275+
```xml
276+
<PropertyGroup>
277+
<AddServicesExtension>false</AddServicesExtension>
278+
</PropertyGroup>
279+
```
280+
267281
### Choose Constructor
268282

269283
If you want to choose a specific constructor to be used for the service implementation
@@ -292,7 +306,7 @@ respectively.
292306
# Dogfooding
293307

294308
[![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)
295-
[![Build](https://github.com/devlooped/DependencyInjection/workflows/build/badge.svg?branch=main)](https://github.com/devlooped/DependencyInjection/actions)
309+
[![Build](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml/badge.svg)](https://github.com/devlooped/DependencyInjection/actions/workflows/build.yml)
296310
297311
We also produce CI packages from branches and pull requests so you can dogfood builds as quickly as they are produced.
298312

src/DependencyInjection.Tests/ConventionsTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public void RegisterServiceByRegex()
3434

3535
var instance = services.GetRequiredService<ConventionsTests>();
3636
var instance2 = services.GetRequiredService<ConventionsTests>();
37-
37+
3838
Assert.NotSame(instance, instance2);
3939
}
4040

src/DependencyInjection.Tests/GenerationTests.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -319,31 +319,31 @@ public class ObservableService : IObservable<MyEvent>
319319
public IDisposable Subscribe(IObserver<MyEvent> observer) => throw new NotImplementedException();
320320
}
321321

322-
[Service<int>(42, ServiceLifetime.Singleton)]
322+
[Service(42, ServiceLifetime.Singleton)]
323323
public class KeyedSingletonService : IFormattable
324324
{
325325
public string ToString(string? format, IFormatProvider? formatProvider) => throw new NotImplementedException();
326326
}
327327

328-
[Service<PlatformID>(PlatformID.Win32NT, ServiceLifetime.Transient)]
328+
[Service(PlatformID.Win32NT, ServiceLifetime.Transient)]
329329
public class KeyedTransientService : ICloneable
330330
{
331331
public object Clone() => throw new NotImplementedException();
332332
}
333333

334-
[Service<string>("A", ServiceLifetime.Scoped)]
334+
[Service("A", ServiceLifetime.Scoped)]
335335
public class KeyedScopedService : IComparable
336336
{
337337
public int CompareTo(object? obj) => throw new NotImplementedException();
338338
}
339339

340-
[Service<string>("FromKeyed", ServiceLifetime.Scoped)]
340+
[Service("FromKeyed", ServiceLifetime.Scoped)]
341341
public class FromKeyedDependency([FromKeyedServices(42)] IFormattable dependency)
342342
{
343343
public IFormattable Dependency => dependency;
344344
}
345345

346-
[Service<string>("FromKeyedTransient", ServiceLifetime.Transient)]
346+
[Service("FromKeyedTransient", ServiceLifetime.Transient)]
347347
public class FromTransientKeyedDependency([FromKeyedServices(42)] IFormattable dependency)
348348
{
349349
public IFormattable Dependency => dependency;
@@ -373,13 +373,14 @@ public interface INotificationService
373373
string Notify(string message);
374374
}
375375

376-
[Service<string>("sms")]
376+
[Service("sms")]
377377
public class SmsNotificationService : INotificationService
378378
{
379379
public string Notify(string message) => $"[SMS] {message}";
380380
}
381381

382-
[Service<string>("email")]
382+
// Showcases that legacy generic Service<TKey> attribute still works
383+
[Service("email")]
383384
[Service<string>("default")]
384385
public class EmailNotificationService : INotificationService
385386
{

src/DependencyInjection/ConventionsAnalyzer.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,12 @@ public class ConventionsAnalyzer : DiagnosticAnalyzer
2323
new DiagnosticDescriptor(
2424
"DDI003",
2525
"Open generic service implementations are not supported for convention-based registration.",
26-
"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.",
26+
"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.",
2727
"Build",
2828
DiagnosticSeverity.Warning,
2929
isEnabledByDefault: true);
3030

31-
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
32-
ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType);
31+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(AssignableTypeOfRequired, OpenGenericType);
3332

3433
public override void Initialize(AnalysisContext context)
3534
{

src/DependencyInjection/DependencyInjection.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
<ItemGroup>
2020
<PackageReference Include="NuGetizer" Version="1.2.1" />
21+
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" PrivateAssets="all" />
2122
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.2.0" Pack="false" />
2223
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
2324
<PackageReference Include="PolySharp" Version="1.14.1" PrivateAssets="all" />

src/DependencyInjection/IncrementalGenerator.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,26 @@ namespace Devlooped.Extensions.DependencyInjection;
2222
[Generator(LanguageNames.CSharp)]
2323
public class IncrementalGenerator : IIncrementalGenerator
2424
{
25-
record ServiceSymbol(INamedTypeSymbol Type, int Lifetime, TypedConstant? Key);
25+
class ServiceSymbol(INamedTypeSymbol type, int lifetime, TypedConstant? key)
26+
{
27+
public INamedTypeSymbol Type => type;
28+
public int Lifetime => lifetime;
29+
public TypedConstant? Key => key;
30+
31+
public override bool Equals(object? obj)
32+
{
33+
if (obj is not ServiceSymbol other)
34+
return false;
35+
36+
return type.Equals(other.Type, SymbolEqualityComparer.Default) &&
37+
lifetime == other.Lifetime &&
38+
Equals(key, other);
39+
}
40+
41+
public override int GetHashCode()
42+
=> HashCode.Combine(SymbolEqualityComparer.Default.GetHashCode(type), lifetime, key);
43+
}
44+
2645
record ServiceRegistration(int Lifetime, TypeSyntax? AssignableTo, string? FullNameExpression)
2746
{
2847
Regex? regex;
@@ -54,8 +73,9 @@ bool IsService(AttributeData attr) =>
5473
attr.ConstructorArguments[0].Type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Microsoft.Extensions.DependencyInjection.ServiceLifetime";
5574

5675
bool IsKeyedService(AttributeData attr) =>
57-
(attr.AttributeClass?.Name == "ServiceAttribute" || attr.AttributeClass?.Name == "Service") &&
58-
attr.AttributeClass?.IsGenericType == true &&
76+
(attr.AttributeClass?.Name == "ServiceAttribute" || attr.AttributeClass?.Name == "Service" ||
77+
attr.AttributeClass?.Name == "KeyedService" || attr.AttributeClass?.Name == "KeyedServiceAttribute") &&
78+
//attr.AttributeClass?.IsGenericType == true &&
5979
attr.ConstructorArguments.Length == 2 &&
6080
attr.ConstructorArguments[1].Kind == TypedConstantKind.Enum &&
6181
attr.ConstructorArguments[1].Type?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::Microsoft.Extensions.DependencyInjection.ServiceLifetime";

src/DependencyInjection/Properties/launchSettings.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@
33
"Tests": {
44
"commandName": "DebugRoslynComponent",
55
"targetProject": "..\\DependencyInjection.Tests\\DependencyInjection.Tests.csproj"
6-
},
7-
"Console": {
8-
"commandName": "DebugRoslynComponent",
9-
"targetProject": "..\\Samples\\ConsoleApp\\ConsoleApp.csproj"
106
}
117
}
128
}
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// <auto-generated />
2+
#nullable enable
23
#if DDI_ADDSERVICE
34
using System;
45

@@ -7,19 +8,18 @@ namespace Microsoft.Extensions.DependencyInjection
78
/// <summary>
89
/// Configures the registration of a service in an <see cref="IServiceCollection"/>.
910
/// </summary>
10-
[AttributeUsage(AttributeTargets.Class)]
11+
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
1112
partial class ServiceAttribute : Attribute
1213
{
1314
/// <summary>
1415
/// Annotates the service with the lifetime.
1516
/// </summary>
16-
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) => Lifetime = lifetime;
17+
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
1718

1819
/// <summary>
19-
/// <see cref="ServiceLifetime"/> associated with a registered service
20-
/// in an <see cref="IServiceCollection"/>.
20+
/// Annotates the service with the given key and lifetime.
2121
/// </summary>
22-
public ServiceLifetime Lifetime { get; }
22+
public ServiceAttribute(object key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
2323
}
2424
}
2525
#endif

src/DependencyInjection/ServiceAttribute`1.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,13 @@ namespace Microsoft.Extensions.DependencyInjection
1010
/// </summary>
1111
/// <typeparam name="TKey">Type of service key.</typeparam>
1212
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
13+
[Obsolete("Use ServiceAttribute(object key, ServiceLifetime lifetime) instead.")]
1314
partial class ServiceAttribute<TKey> : Attribute
1415
{
1516
/// <summary>
1617
/// Annotates the service with the lifetime.
1718
/// </summary>
18-
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton)
19-
=> (Key, Lifetime)
20-
= (key, lifetime);
21-
22-
/// <summary>
23-
/// The key used to register the service in an <see cref="IServiceCollection"/>.
24-
/// </summary>
25-
public TKey Key { get; }
26-
27-
/// <summary>
28-
/// <see cref="ServiceLifetime"/> associated with a registered service
29-
/// in an <see cref="IServiceCollection"/>.
30-
/// </summary>
31-
public ServiceLifetime Lifetime { get; }
19+
public ServiceAttribute(TKey key, ServiceLifetime lifetime = ServiceLifetime.Singleton) { }
3220
}
3321
}
3422
#endif

0 commit comments

Comments
 (0)