.NET 相依性插入

.NET 支援相依性插入 (DI) 軟體設計模式,這是用來在類別與其相依性之間達成控制權反轉 (IoC) 的技術。 .NET 中的相依性插入是架構內建功能,其他內建功能還有設定、記錄以及選項模式。

相依性是另一個物件依賴的任何物件。 檢查具有 Write 方法的下列 MessageWriter 類別,其他類別相依於此:

public class MessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

類別可建立 MessageWriter 類別的執行個體,以便使用其 Write 方法。 在下列範例中,MessageWriter 類別是 Worker 類別的相依性:

public class Worker : BackgroundService
{
    private readonly MessageWriter _messageWriter = new();

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

類別建立並直接相依於 MessageWriter 類別。 硬式編碼相依性 (例如上述範例) 有問題,因此基於下列原因應該避免使用:

  • 若要將 MessageWriter 取代為不同的實作,必須修改 Worker 類別。
  • MessageWriter 有相依性,那些相依性必須同時由 Worker 類別設定。 在具有多個相依於 MessageWriter 之多個類別的大型專案中,設定程式碼在不同的應用程式之間會變得鬆散。
  • 此實作難以進行單元測試。 應用程式應該使用模擬 (Mock) 或虛設常式 (Stub) MessageWriter 類別,這在使用此方法時無法使用。

相依性插入可透過下列方式解決這些問題:

  • 使用介面或基底類別來將相依性資訊抽象化。
  • 在服務容器中註冊相依性。 .NET 提供內建服務容器 IServiceProvider。 服務通常會於應用程式啟動時註冊,並且附加至 IServiceCollection。 新增所有服務之後,就能用 BuildServiceProvider 建立服務容器。
  • 將服務「插入」到服務使用位置之類別的建構函式。 架構會負責建立相依性的執行個體,並在不再需要時將它捨棄。

例如,IMessageWriter 介面會定義 Write 方法:

namespace DependencyInjection.Example;

public interface IMessageWriter
{
    void Write(string message);
}

這個介面是由具象型別 MessageWriter 所實作:

namespace DependencyInjection.Example;

public class MessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine($"MessageWriter.Write(message: \"{message}\")");
    }
}

程式碼範例會用具象型別 MessageWriter 註冊 IMessageWriter 服務。 AddSingleton 方法會使用單一資料庫存留期註冊服務,也就是應用程式的存留期。 服務存留期會於本文稍後說明。

using DependencyInjection.Example;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHostedService<Worker>();
builder.Services.AddSingleton<IMessageWriter, MessageWriter>();

using IHost host = builder.Build();

host.Run();

在上述程式碼中,範例應用程式會:

  • 建立主機應用程式建立器執行個體。

  • 透過註冊以下項目設定服務:

    • Worker 註冊為託管服務。 如需詳細資訊,請參閱 .NET 中的背景工作角色服務
    • IMessageWriter 介面作為單一資料庫服務,具有對應 MessageWriter 類別的實作。
  • 組建主機並執行。

主機包含相依性插入服務提供者, 也包含其他所有自動具現化 Worker 所需的相關服務,並將對應的 IMessageWriter 實作提供為引數。

namespace DependencyInjection.Example;

public sealed class Worker(IMessageWriter messageWriter) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            messageWriter.Write($"Worker running at: {DateTimeOffset.Now}");
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

使用 DI 模式,背景工作角色就可以:

  • 不使用具象型別 MessageWriter,只使用實作該型別的 IMessageWriter 介面。 這能輕鬆變更背景工作角色服務使用的實作,而無須修改背景工作角色服務。
  • 不建立 MessageWriter 的執行個體。 執行個體會由 DI 容器建立。

IMessageWriter 介面的實作可透過使用內建記錄 API 來改善:

namespace DependencyInjection.Example;

public class LoggingMessageWriter(
    ILogger<LoggingMessageWriter> logger) : IMessageWriter
{
    public void Write(string message) =>
        logger.LogInformation("Info: {Msg}", message);
}

經過更新的 AddSingleton 方法會註冊新的 IMessageWriter 實作:

builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();

HostApplicationBuilder (builder) 型別是 Microsoft.Extensions.Hosting NuGet 套件的一部分。

LoggingMessageWriter 相依於 ILogger<TCategoryName>,且在建構函式中要求之。 ILogger<TCategoryName>架構提供的服務

以鏈結方式使用相依性插入並非不尋常。 每個要求的相依性接著會要求其自己的相依性。 容器會解決圖形中的相依性,並傳回完全解析的服務。 必須先解析的相依性集合組通常稱為「相依性樹狀結構」、「相依性圖形」或「物件圖形」

容器會利用 (泛型) 開放式類型 解析 ILogger<TCategoryName>,讓您不需註冊每個 (泛型) 建構的型別

使用相依性插入用語時,服務會有以下情況:

  • 通常是為其他物件提供服務的物件,例如 IMessageWriter 服務。
  • 與 Web 服務無關,但服務可能使用 Web 服務。

此架構提供強固的記錄系統。 之所以撰寫上述範例示範的 IMessageWriter 實作,是為了示範基礎 DI,而非實作記錄。 多數應用程式應無需寫入記錄器。 下列程式碼示範使用預設記錄功能,其中只要求 Worker 註冊為託管服務 AddHostedService

public class Worker : BackgroundService
{
    private readonly ILogger<Worker> _logger;

    public Worker(ILogger<Worker> logger) =>
        _logger = logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
            await Task.Delay(1_000, stoppingToken);
        }
    }
}

使用上述程式碼時無須更新 Program.cs,因為記錄會由架構提供。

多個建構函式探索規則

若有型別定義超過一個建構函式,則服務提供者會有判斷應使用哪一個建構函式的邏輯。 系統會選擇具備最多參數,且型別可解析 DI 的建構函式。 請考量下列 C# 範例服務:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(FooService fooService, BarService barService)
    {
        // omitted for brevity
    }
}

在上述程式碼中,假設記錄已新增且可供服務提供者解析,但 FooServiceBarService 型別不可解析。 系統會用具備 ILogger<ExampleService> 參數的建構函式解析 ExampleService 執行個體。 即使有定義更多參數的建構函式,FooServiceBarService 型別仍不可解析 DI。

如果探索建構函式時有模稜兩可的狀況,則會擲回例外狀況。 請考量下列 C# 範例服務:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(ILogger<ExampleService> logger)
    {
        // omitted for brevity
    }

    public ExampleService(IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

警告

具備模稜兩可可解析 DI 型別參數的 ExampleService 程式碼會擲回例外狀況。 請不要這麼做,因為系統的目的是為了顯示「模稜兩可可解析 DI 型別」的意義。

上述範例共有三種建構函式。 第一種建構函式沒有參數,不需要來自服務提供者的服務。 假設記錄和選項都已新增至 DI 容器且為可解析 DI 的服務。 只要 DI 容器試圖解析 ExampleService 型別,系統就會擲回例外狀況,因為兩個建構函式都模稜兩可。

為避免模稜兩可的狀況,您可定義同時接受兩種可解析 DI 型別的建構函式,而非:

public class ExampleService
{
    public ExampleService()
    {
    }

    public ExampleService(
        ILogger<ExampleService> logger,
        IOptions<ExampleOptions> options)
    {
        // omitted for brevity
    }
}

為服務群組註冊擴充方法

Microsoft 擴充功能會在註冊相關服務群組時使用慣例。 此慣例為使用單一 Add{GROUP_NAME} 擴充方法來註冊所有架構功能需要的服務。 例如,AddOptions 擴充方法會註冊使用選項需要的所有服務。

架構提供的服務

使用任何可用的主機或應用程式建立器模式時,系統會套用預設值,且架構會註冊服務。 請考慮一些最熱門的主機和應用程式建立器模式:

從上述任何 API 建立建立器之後,IServiceCollection 會根據主機的設定方式,具有架構所定義的服務。 針對以 .NET 範本為基礎的應用程式,架構可以註冊上百種服務。

下表列出這類架構註冊型服務的些許範例:

服務類型 存留期
Microsoft.Extensions.DependencyInjection.IServiceScopeFactory 單一
IHostApplicationLifetime 單一
Microsoft.Extensions.Logging.ILogger<TCategoryName> 單一
Microsoft.Extensions.Logging.ILoggerFactory 單一
Microsoft.Extensions.ObjectPool.ObjectPoolProvider 單一
Microsoft.Extensions.Options.IConfigureOptions<TOptions> 暫時性
Microsoft.Extensions.Options.IOptions<TOptions> 單一
System.Diagnostics.DiagnosticListener 單一
System.Diagnostics.DiagnosticSource 單一

執行個體存留期

服務可使用下列任意存留期註冊:

下列小節說明上述每個存留期。 為每個已註冊的服務選擇適當的存留期。

暫時性

每次從服務容器要求暫時性存留期服務時都會建立它們。 若要將服務註冊為 暫時性,請呼叫 AddTransient

在處理要求的應用程式中,暫時性服務會在要求結束時處置。 此存留期會產生每個/要求配置,因為每次都會解析和建構服務。 如需詳細資訊,請參閱 相依性插入指導方針:暫時性和共用實例的IDisposable指引。

具範圍

針對 Web 應用程式,具範圍的存留期是指根據用戶端要求 (連線) 建立一次的服務。 請用 AddScoped 註冊具範圍的服務。

在處理要求的應用程式中,具範圍的服務會在要求結束時處置。

使用 Entity Framework Core 時,AddDbContext 擴充方法預設會用具範圍的存留期註冊 DbContext 型別。

注意

請勿從單一資料庫解析具範圍的服務,並留意切勿間接 (例如透過暫時性服務) 解析之。 處理後續要求時,它可能會導致服務有不正確的狀態。 您可以:

  • 從具範圍或暫時性服務解析單一資料庫服務。
  • 從另一個具範圍或暫時性服務解析具範圍服務。

根據預設,在開發環境中,從另一個具備較長存留期的服務解析服務,系統會擲回例外狀況。 如需詳細資訊,請參閱範圍驗證

單一

單一資料庫存留期服務會在以下狀況建立:

  • 首次受到要求時。
  • 由開發人員直接向容器提供實作執行個體來建立。 不過,需要這麼做的狀況很少。

每個來自相依性插入容器服務實作的後續要求都會使用相同的執行個體。 若應用程式要求單一資料庫行為,請允許服務容器管理服務的存留期。 請勿實作單一資料庫設計模式,也不要提供處置單一資料庫的程式碼。 服務絕不該透過從容器解析服務的程式碼來處置。 如果類型或 Factory 已註冊為 singleton,則容器會自動處置該 singleton。

請用 AddSingleton 註冊單一資料庫服務。 單一資料庫服務必須為安全執行緒,且常用於無狀態服務。

在處理要求的應用程式中,單一資料庫服務會在 ServiceProvider 於應用程式關機受到處置時進行處置。 由於應用程式關機後才會釋放記憶體,請考慮使用搭配單一資料庫服務的記憶體。

服務註冊方法

架構會提供服務註冊擴充方法,這些方法在特定情節中相當實用:

方法 自動
object
處置
多個
實作
傳遞引數
Add{LIFETIME}<{SERVICE}, {IMPLEMENTATION}>()

範例:

services.AddSingleton<IMyDep, MyDep>();
Yes .是 No
Add{LIFETIME}<{SERVICE}>(sp => new {IMPLEMENTATION})

範例:

services.AddSingleton<IMyDep>(sp => new MyDep());
services.AddSingleton<IMyDep>(sp => new MyDep(99));
Yes .是 Yes
Add{LIFETIME}<{IMPLEMENTATION}>()

範例:

services.AddSingleton<MyDep>();
No
AddSingleton<{SERVICE}>(new {IMPLEMENTATION})

範例:

services.AddSingleton<IMyDep>(new MyDep());
services.AddSingleton<IMyDep>(new MyDep(99));
No .是 Yes
AddSingleton(new {IMPLEMENTATION})

範例:

services.AddSingleton(new MyDep());
services.AddSingleton(new MyDep(99));
No Yes

如需類型處置的詳細資訊,請參閱<服務處置>一節。

只用一個實作型別註冊服務,相當於使用相同的實作和服務型別註冊該服務。 這就是一個服務的多個實作無法使用不採用明確服務型別方法註冊的原因。 這些方法可註冊服務的多個執行個體,但這些執行個體全都會有相同的實作型別。

所有上述服務註冊方法都能用來註冊相同服務型別的多個服務執行個體。 在下列範例中,系統兩次呼叫 AddSingleton,其服務型別為 IMessageWriter。 第二次的 AddSingleton 呼叫在解析為 IMessageWriter 時覆寫了第一次的呼叫,並在多個服務透過 IEnumerable<IMessageWriter> 解析時新增至第一次呼叫。 透過 IEnumerable<{SERVICE}> 解析時,服務會以註冊時的順序顯示。

using ConsoleDI.IEnumerableExample;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
builder.Services.AddSingleton<IMessageWriter, LoggingMessageWriter>();
builder.Services.AddSingleton<ExampleService>();

using IHost host = builder.Build();

_ = host.Services.GetService<ExampleService>();

await host.RunAsync();

上述範例原始程式碼註冊了 IMessageWriter 的兩項實作。

using System.Diagnostics;

namespace ConsoleDI.IEnumerableExample;

public sealed class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is LoggingMessageWriter);

        var dependencyArray = messageWriters.ToArray();
        Trace.Assert(dependencyArray[0] is ConsoleMessageWriter);
        Trace.Assert(dependencyArray[1] is LoggingMessageWriter);
    }
}

ExampleService 定義了兩個建構函式參數:一個單一 IMessageWriter 和一個 IEnumerable<IMessageWriter>。 單一 IMessageWriter 是註冊的最後一個實作,而 IEnumerable<IMessageWriter> 則代表所有已註冊的實作。

此架構也提供 TryAdd{LIFETIME} 擴充方法,唯有在沒有已註冊實作存在的情況下,該方法才會註冊服務。

在下列範例中,針對 AddSingleton 的呼叫將 ConsoleMessageWriter 註冊為 IMessageWriter 的實作。 針對 TryAddSingleton 的呼叫沒有任何作用,因為 IMessageWriter 已具有註冊的實作:

services.AddSingleton<IMessageWriter, ConsoleMessageWriter>();
services.TryAddSingleton<IMessageWriter, LoggingMessageWriter>();

TryAddSingleton 沒有作用,因為該項目已經新增,因此「try」會失敗。 ExampleService 會判斷下列內容:

public class ExampleService
{
    public ExampleService(
        IMessageWriter messageWriter,
        IEnumerable<IMessageWriter> messageWriters)
    {
        Trace.Assert(messageWriter is ConsoleMessageWriter);
        Trace.Assert(messageWriters.Single() is ConsoleMessageWriter);
    }
}

如需詳細資訊,請參閱

如果還沒有相同型別的實作,則 TryAddEnumerable(ServiceDescriptor) 方法會註冊服務。 多個服務會透過 IEnumerable<{SERVICE}> 解析。 註冊服務時,如果任意相同型別還未新增,請新增執行個體。 程式庫建立者會用 TryAddEnumerable 來避免註冊容器中實作的多個複本。

在下列範例中,第一次針對 TryAddEnumerable 的呼叫將 MessageWriter 註冊為 IMessageWriter1 的實作。 第二次呼叫為 IMessageWriter2 註冊 MessageWriter。 第三次呼叫沒有任何作用,因為 IMessageWriter1 已具有 MessageWriter 的已註冊實作:

public interface IMessageWriter1 { }
public interface IMessageWriter2 { }

public class MessageWriter : IMessageWriter1, IMessageWriter2
{
}

services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter2, MessageWriter>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IMessageWriter1, MessageWriter>());

服務註冊通常與順序無關,為相同型別的多個實作註冊時除外。

IServiceCollectionServiceDescriptor 物件的集合。 下列範例示範如何透過建立並新增 ServiceDescriptor 註冊服務:

string secretKey = Configuration["SecretKey"];
var descriptor = new ServiceDescriptor(
    typeof(IMessageWriter),
    _ => new DefaultMessageWriter(secretKey),
    ServiceLifetime.Transient);

services.Add(descriptor);

內建 Add{LIFETIME} 方法使用相同的作法。 如需範例,請參閱 AddScoped 原始程式碼

建構函式插入行為

您可使用以下方法解析服務:

建構函式可以接受不是由相依性插入提供的引數,但引數必須指派預設值。

當服務由 IServiceProviderActivatorUtilities 解析時,建構函式插入會要求 public建構函式。

當服務由 ActivatorUtilities 解析時,建構函式插入只要求只能有一個適用的建構函式存在。 支援建構函式多載,但只能有一個多載存在,其引數可以藉由相依性插入而完成。

範圍驗證

應用程式在 Development 環境中執行並呼叫 CreateApplicationBuilder 來組建主機時,預設服務提供者會執行檢查來確定:

  • 具範圍的服務未從根服務提供者受到解析。
  • 具範圍的服務未插入單一資料庫。

根服務提供者會在呼叫 BuildServiceProvider 時建立。 當提供者啟動應用程式時,根服務提供者的存留期與應用程式的存留期一致,並會在應用程式關閉時處置。

範圍服務會由建立這些服務的容器處置。 若是在根容器中建立範圍服務,因為當應用程式關機時,服務只會由根容器處理,所以服務的存留期會提升為單一服務等級。 當呼叫 BuildServiceProvider 時,驗證服務範圍會攔截到這些情況。

範圍情節

系統會一律將 IServiceScopeFactory 註冊為單一資料庫,但 IServiceProvider 可根據所包含類別的存留期而有所不同。 例如,假使您從範圍解析服務,其中任意服務採用 IServiceProvider,則會變為具範圍的執行個體。

若要在 IHostedService 的實作內取得設定範圍的服務 (例如 BackgroundService),請勿透過建構函式插入插入服務相依性。 反之,請插入 IServiceScopeFactory,建立範圍,然後從範圍解析相依性,藉此使用適當的服務存留期。

namespace WorkerScope.Example;

public sealed class Worker(
    ILogger<Worker> logger,
    IServiceScopeFactory serviceScopeFactory)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using (IServiceScope scope = serviceScopeFactory.CreateScope())
            {
                try
                {
                    logger.LogInformation(
                        "Starting scoped work, provider hash: {hash}.",
                        scope.ServiceProvider.GetHashCode());

                    var store = scope.ServiceProvider.GetRequiredService<IObjectStore>();
                    var next = await store.GetNextAsync();
                    logger.LogInformation("{next}", next);

                    var processor = scope.ServiceProvider.GetRequiredService<IObjectProcessor>();
                    await processor.ProcessAsync(next);
                    logger.LogInformation("Processing {name}.", next.Name);

                    var relay = scope.ServiceProvider.GetRequiredService<IObjectRelay>();
                    await relay.RelayAsync(next);
                    logger.LogInformation("Processed results have been relayed.");

                    var marked = await store.MarkAsync(next);
                    logger.LogInformation("Marked as processed: {next}", marked);
                }
                finally
                {
                    logger.LogInformation(
                        "Finished scoped work, provider hash: {hash}.{nl}",
                        scope.ServiceProvider.GetHashCode(), Environment.NewLine);
                }
            }
        }
    }
}

在上述程式碼中,應用程式執行時,背景服務將會:

  • 相依於 IServiceScopeFactory
  • 建立 IServiceScope 來解析其他服務。
  • 解析具範圍的服務以供使用。
  • 使用正在處理的物件,轉送之,最後將之標示為已處理。

從範例原始程式碼中,您會發現 IHostedService 的實作如何從具範圍的服務存留期中受益。

具有索引鍵的服務

從 .NET 8 開始,支援以金鑰為基礎的服務註冊和查閱,這表示可以使用不同的金鑰註冊多個服務,並使用此金鑰進行查閱。

例如,假設您有介面 IMessageWriter 的不同實作:MemoryMessageWriterQueueMessageWriter

您可以使用支援金鑰作為參數的服務註冊方法 (先前所見) 多載來註冊這些服務:

services.AddKeyedSingleton<IMessageWriter, MemoryMessageWriter>("memory");
services.AddKeyedSingleton<IMessageWriter, QueueMessageWriter>("queue");

key 不限於 string,只要型別正確實作 Equals,它可以是您想要的任何 object

在使用 IMessageWriter 類別的建構函式中,您可以新增 FromKeyedServicesAttribute 來指定要解析服務的索引鍵:

public class ExampleService
{
    public ExampleService(
        [FromKeyedServices("queue")] IMessageWriter writer)
    {
        // Omitted for brevity...
    }
}

另請參閱