08-依赖注入与服务容器

一、什么是依赖注入

依赖注入这个名字第一次听起来会有点抽象,但它背后的思想其实很朴素,一个类如果要工作,往往需要依赖别的对象,比如数据库上下文、日志服务、邮件服务、缓存服务。在传统写法里,这个类会自己去 new 这些对象。在依赖注入的写法里,则是由外部把这些对象准备好,再交给这个类使用。你可以把它理解成"把需要的工具提前配好,然后交到你手里",而不是让每个类都自己去造工具。

如果用生活化一点的比喻来理解,传统方式像是你每次要出门都自己造一辆车,依赖注入则像是公司已经给你配好了车、司机和路线,你只需要告诉系统"我要去哪里"。在程序里,这种差异的意义非常大。前者让类和具体实现绑得很死,后者让类只依赖抽象能力,从而更灵活、更容易测试,也更适合大型项目协作。

二、为什么需要依赖注入

理解依赖注入最好的方式,不是直接背定义,而是先看没有依赖注入时会出现什么问题。假设你的控制器要访问数据库,如果它在内部自己创建数据库对象,那么这段代码短期看似简单,长期却会带来很多麻烦。

csharp 复制代码
public class UserController
{
    private DatabaseContext _db;
    
    public UserController()
    {
        _db = new DatabaseContext();  // 自己创建,紧耦合
    }
    
    public User GetUser(int id)
    {
        return _db.Users.Find(id);
    }
}

这段代码的问题不在于它"不能运行",而在于它把控制器和 DatabaseContext 的具体创建过程绑死了。第一,控制器现在只能用这一种数据库实现,未来你想改成别的实现,或者数据库构造函数需要更多配置,控制器本身就得跟着改。第二,单元测试会变得很痛苦,因为你没法轻松换成假的数据库对象来隔离测试。第三,当项目越来越大时,很多类都自己去创建依赖,会让配置散落在各处,维护起来非常混乱。

如果改成依赖注入,代码就会干净很多:

csharp 复制代码
public class UserController
{
    private readonly DatabaseContext _db;
    
    // 通过构造函数注入,数据库从外面传进来
    public UserController(DatabaseContext db)
    {
        _db = db;
    }
    
    public User GetUser(int id)
    {
        return _db.Users.Find(id);
    }
}

改写之后,控制器的职责一下就清晰了。它不再关心数据库对象怎么创建,也不再关心连接字符串、生命周期、配置从哪里来。它只表达一件事:如果你给我一个数据库上下文,我就能完成查询。也就是说,控制器只保留业务行为,不再承担依赖创建责任。这正是依赖注入的核心价值,也是它能显著降低耦合度的根本原因。

三、ASP.NET Core 内置的依赖注入

ASP.NET Core 自带了一套开箱即用的依赖注入容器,所以在 .NET Web 开发里,你基本不需要先额外引入第三方容器就能开始使用。你只需要在 Program.cs 里把服务注册进容器,框架就会在运行时自动分析依赖关系、创建实例,并把需要的对象注入到控制器、服务类、中间件或者 Minimal API 端点里。

很多初学者刚接触依赖注入时,会把注意力全部放在"怎么写 AddScoped"上,但实际上真正需要理解的是生命周期。因为同样一个服务,究竟应该每次创建、每个请求创建一次,还是整个应用只创建一次,直接关系到性能、线程安全和数据一致性。

3.1 Transient(瞬时)

Transient 的意思是"每次请求这个服务时,都新建一个实例"。它适合那些轻量级、无状态、创建成本低的服务。

csharp 复制代码
builder.Services.AddTransient<ILogger, ConsoleLogger>();

这段代码注册了一个瞬时服务。每当容器需要解析 ILogger 时,都会重新创建一个新的 ConsoleLogger 对象。你可以把它理解成"按需现做"。这种模式适合本身不保留状态、也不需要跨多个调用共享数据的服务。因为每次都是新对象,所以通常不会有共享状态带来的副作用,但如果对象创建成本很高,就不适合滥用 Transient。

3.2 Scoped(作用域)

Scoped 的意思是"同一个作用域内共享一个实例"。在 ASP.NET Core Web 应用里,默认一个 HTTP 请求就是一个作用域,所以 Scoped 最常见的理解方式是:同一个请求里共用一个实例,不同请求之间互不共享。

csharp 复制代码
builder.Services.AddScoped<DatabaseContext>();

这段代码注册了一个作用域服务。假设一个请求进入系统后,控制器、服务层、仓储层都需要用到 DatabaseContext,那么在这个请求范围内,它们拿到的是同一个实例。这样做非常适合数据库上下文,因为同一个请求里的数据库操作通常天然属于同一批业务流程,共享上下文更容易保持一致性。等请求结束之后,这个实例也会被释放,不会继续泄漏到下一个请求中。

3.3 Singleton(单例)

Singleton 表示"整个应用生命周期内只创建一次,以后所有地方都复用这一个实例"。它适合那些线程安全、无请求状态、创建成本较高并且应该被全局共享的服务。

csharp 复制代码
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();

这段代码的含义是,应用启动后容器会准备一个 ConfigurationService 实例,以后任何地方需要 IConfigurationService 时,都拿这同一个对象。这样可以避免重复创建,提高效率。但单例也是最容易被误用的生命周期。如果你把请求相关的数据、用户状态或者可变共享数据塞进单例服务里,就很容易引发并发问题和数据污染。所以只要一个服务和当前请求强相关,或者内部持有会变化的业务状态,就应该非常谨慎地避免用 Singleton。

把这三种生命周期放在一起理解会更清楚。Transient 强调"每次都新建",Scoped 强调"每个请求共享一次",Singleton 强调"整个应用共享一次"。真正的选择标准不是背定义,而是问自己:这个服务是否有状态,是否与请求相关,是否适合跨请求共享,以及它的创建成本是否值得缓存。

四、实战案例:构建一个邮件服务系统

概念理解之后,我们用一个完整例子把依赖注入真正跑起来。这个案例会模拟一个用户注册流程,用户注册时需要发送欢迎邮件。这样你就能直观看到接口、实现类、业务服务和依赖注入容器是怎样配合起来的。

4.1 第一步:定义服务接口和实现

先从最外层能力开始建模。既然系统需要"发送邮件"这个能力,那最自然的做法就是先定义一个邮件服务接口,然后准备两个实现:一个是真实的 SMTP 发送器,另一个是开发和测试阶段使用的模拟实现。

csharp 复制代码
// 邮件发送接口
public interface IEmailService
{
    void SendEmail(string to, string subject, string body);
}

// SMTP邮件实现
public class SmtpEmailService : IEmailService
{
    private readonly string _smtpServer;
    
    public SmtpEmailService(string smtpServer)
    {
        _smtpServer = smtpServer;
    }
    
    public void SendEmail(string to, string subject, string body)
    {
        Console.WriteLine($"[SMTP] 发送邮件到: {to}");
        Console.WriteLine($"主题: {subject}");
        Console.WriteLine($"SMTP服务器: {_smtpServer}");
    }
}

// 模拟邮件实现(用于测试)
public class MockEmailService : IEmailService
{
    public void SendEmail(string to, string subject, string body)
    {
        Console.WriteLine($"[模拟] 邮件发送成功: {to}");
    }
}

这里最关键的一步是先定义 IEmailService,因为它把"发送邮件"这个能力抽象出来了。后面的业务代码只需要依赖这个能力,而不需要依赖 SMTP 的具体实现细节。SmtpEmailService 可以理解成真实环境里真正负责发邮件的版本,它需要 SMTP 服务器地址,所以通过构造函数接收 _smtpServerMockEmailService 则是一个假的实现,它不真正连接邮件服务器,只是在控制台里输出一行日志,方便开发调试和测试验证。

这就是依赖注入最重要的思路之一,业务代码不要直接依赖"某种具体实现",而应该依赖"某种能力接口"。一旦你这样设计,未来要替换邮件服务提供商、切换测试实现、增加日志包装器,都会简单很多,因为业务层的代码不用跟着到处改。

4.2 第二步:定义用户服务

有了邮件服务之后,再来看用户注册逻辑。注册用户显然属于业务服务,所以我们继续定义一个用户服务接口和实现,并让它依赖前面的邮件服务。

csharp 复制代码
// 用户服务接口
public interface IUserService
{
    void RegisterUser(string email, string password);
}

// 用户服务实现
public class UserService : IUserService
{
    private readonly IEmailService _emailService;
    
    // 依赖注入:需要邮件服务
    public UserService(IEmailService emailService)
    {
        _emailService = emailService;
    }
    
    public void RegisterUser(string email, string password)
    {
        // 注册逻辑
        Console.WriteLine($"注册用户: {email}");
        
        // 发送欢迎邮件
        _emailService.SendEmail(
            email, 
            "欢迎注册", 
            "感谢您的注册!"
        );
    }
}

这一段代码几乎把依赖注入的核心价值完整体现出来了。UserService 自己并没有去 new SmtpEmailService(),它只是声明:"要完成注册逻辑,我需要一个能发邮件的服务。" 至于这个服务究竟是 SMTP 实现还是 Mock 实现,UserService 完全不关心。它只知道自己拿到的是 IEmailService,可以调用 SendEmail

从运行时流程看,当容器创建 UserService 时,会发现它的构造函数需要一个 IEmailService。于是容器会继续去找 IEmailService 的注册项,再创建或取出对应实例,然后把这个实例传给 UserService。这就是容器"自动解析依赖树"的过程。你不需要手动一层层 new 出来,框架会按你注册的规则替你完成。

4.3 第三步:在 Program.cs 中配置服务

接口和实现定义好了,还需要把它们注册到容器中。否则容器根本不知道"请求 IEmailService 时该给谁"。这一部分是 ASP.NET Core 依赖注入里最常写、也最值得真正理解的地方。

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 注册服务(根据环境选择不同的实现)
if (builder.Environment.IsDevelopment())
{
    // 开发环境用模拟的,避免真的发邮件
    builder.Services.AddSingleton<IEmailService, MockEmailService>();
}
else
{
    // 生产环境用真实的SMTP
    builder.Services.AddSingleton<IEmailService>(sp => 
        new SmtpEmailService("smtp.example.com"));
}

// 注册用户服务
builder.Services.AddScoped<IUserService, UserService>();

var app = builder.Build();

// 测试端点
app.MapGet("/register", (IUserService userService) =>
{
    userService.RegisterUser("test@example.com", "123456");
    return "注册成功!";
});

app.Run();

这段代码首先根据运行环境决定 IEmailService 的具体实现。开发环境使用 MockEmailService,这样你每次本地调试都不会真的发邮件;生产环境则换成真实的 SmtpEmailService。这里你能非常直观地看到依赖注入带来的灵活性:业务代码完全不用改,只需要改变注册方式,就能切换底层实现。

builder.Services.AddScoped<IUserService, UserService>() 则表示每个 HTTP 请求都会拿到一个新的 UserService 实例。容器在创建 UserService 时,会自动看它的构造函数需要 IEmailService,再按前面的注册结果把对应实例注入进去。最终 MapGet 这个端点甚至不需要自己去容器里手动解析服务,只要把 IUserService 写在参数列表里,框架就知道应该从容器里取出来并传给你。

如果你运行这个示例,开发环境和生产环境的输出会明显不同:

text 复制代码
开发环境访问 /register:
注册用户: test@example.com
[模拟] 邮件发送成功: test@example.com

生产环境访问 /register:
注册用户: test@example.com
[SMTP] 发送邮件到: test@example.com
主题: 欢迎注册
SMTP服务器: smtp.example.com

从这组输出你可以直接看出依赖注入的价值。调用方始终都是 userService.RegisterUser(...),但底层执行细节会因为容器注册不同而发生变化。对上层业务代码来说,这种变化是透明的。

五、依赖注入到底带来了什么

到这里,你应该已经能感受到依赖注入不是"为了显得高级而写接口",而是为了让代码结构更稳定。它首先带来的好处是解耦。比如用户服务要发邮件,这只是业务需要一个"发邮件能力",并不意味着它必须知道 SMTP 的地址、连接过程和发送细节。依赖注入把"做什么"和"怎么做"拆开了,前者留在业务层,后者交给实现类和容器配置。

第二个好处是测试会简单很多。因为依赖可以替换,所以你在测试时不必真的连接数据库、真的发邮件、真的访问外部 API,只要提供一个假的实现就可以验证业务逻辑本身是否正确。

csharp 复制代码
public class UserServiceTests
{
    [Fact]
    public void RegisterUser_ShouldSendEmail()
    {
        // 创建假的邮件服务
        var mockEmail = new MockEmailService();
        var userService = new UserService(mockEmail);
        
        userService.RegisterUser("test@example.com", "123456");
        
        // 验证邮件发送了(这里可以扩展验证逻辑)
        Assert.True(true);
    }
}

这段测试代码虽然简单,但它非常能说明问题。UserService 之所以可以被单独测试,就是因为它依赖的是接口,而测试里可以直接传入一个假的邮件服务对象。如果 UserService 内部死死写着 new SmtpEmailService(),你就很难在测试中把真实发送过程隔离掉。

第三个价值是生命周期管理。对象什么时候创建、什么时候销毁、是否应该共享,全部都交给容器管理,而不是散落在业务代码里。这样做的最大好处是,服务生命周期会更一致,资源管理也更集中。例如数据库上下文适合按请求范围创建,配置服务适合单例共享,这些都不应该交给每个业务类自己随意决定。

六、常见问题和真实开发中的判断

依赖注入学到这里,很多人会开始遇到几个非常典型的问题。第一个问题通常是到底什么时候该用 Singleton,什么时候该用 Scoped?一个很实用的判断标准是先看服务是否和请求相关。如果它持有当前用户、当前事务、当前数据库上下文这类请求期数据,那通常应该是 Scoped,如果它只是读取配置、做纯工具计算或者包装一个线程安全的客户端,往往更适合 Singleton,如果它完全无状态而且创建成本很低,也可以考虑 Transient。真正重要的不是死记定义,而是把服务的"状态"和"共享范围"想清楚。

第二个常见问题是如果一个类构造函数参数越来越多,是不是说明依赖注入不好用了?答案通常恰恰相反,这往往不是依赖注入的问题,而是这个类承担了太多职责。一个控制器如果同时依赖十几个服务,往往说明它做了过多业务协调、数据转换和基础设施操作,应该考虑拆分职责,或者引入专门的工厂、门面服务、应用服务来收敛复杂度。

csharp 复制代码
public interface IServiceFactory
{
    IComplexService Create();
}

public class MyController
{
    private readonly IServiceFactory _factory;
    
    public MyController(IServiceFactory factory)
    {
        _factory = factory;
    }
}

工厂模式在这里的意义不是"替代依赖注入",而是当创建逻辑本身很复杂、需要动态选择实现时,用工厂把复杂度从控制器或业务类里拿出去。这样类本身仍然只依赖一个抽象,而不是直接知道所有创建细节。

第三个非常值得注意的问题是循环依赖。所谓循环依赖,就是 A 依赖 B,而 B 又依赖 A,容器在创建时就会陷入无法完成解析的死循环。这个问题本质上不是容器技术问题,而是设计问题,因为它通常说明两个类之间的职责边界已经缠在一起了。

csharp 复制代码
public class A
{
    private readonly Lazy<B> _b;
    
    public A(Lazy<B> b)
    {
        _b = b;
    }
}

Lazy<T> 这样的方式有时可以在特定场景下延迟获取依赖,但它更像一种缓冲手段,而不是首选方案。更稳妥的做法通常还是重构设计,把共享逻辑抽到第三个服务里,让 A 和 B 分别依赖它,而不是彼此相互依赖。

七、更高级的用法

当项目复杂度继续上升之后,你会发现"一个接口对应一个固定实现"并不能覆盖所有场景。有时候你需要根据参数动态选择实现,或者同一个接口在系统里确实有多个合法实现。这时候就需要比基础注入再往前走一步。

7.1 工厂模式

如果你需要根据运行时参数来决定创建哪种服务,实现工厂会更自然。因为这时选择逻辑本身就是业务的一部分,不能简单靠固定注册关系解决。

csharp 复制代码
builder.Services.AddSingleton<Func<string, IEmailService>>(sp => 
{
    return (type) => 
    {
        if (type == "smtp")
            return new SmtpEmailService("smtp.example.com");
        else
            return new MockEmailService();
    };
});

// 使用
app.MapGet("/send", (Func<string, IEmailService> factory) =>
{
    var emailService = factory("smtp");  // 动态创建
    emailService.SendEmail("test@example.com", "测试", "内容");
    return "OK";
});

这段代码的重点不在 Func<string, IEmailService> 这个语法本身,而在它表达了一件事,某些服务并不是在启动时就唯一确定的,而是要在运行时按条件选择。通过工厂,选择逻辑被集中到了一个地方,而调用方仍然保持简洁。

7.2 多实现选择

另一个常见场景是同一个接口天然就有多个实现,而且它们都需要同时存在。比如一个系统既要支持 SMTP 发邮件,也要支持本地模拟发送。这个时候,关键问题就不是"能不能注册多个实现",而是"调用方如何知道自己应该取哪一个"。

csharp 复制代码
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
builder.Services.AddSingleton<IEmailService, MockEmailService>();

// 用命名区分
builder.Services.AddSingleton("smtp", new SmtpEmailService("smtp.example.com"));
builder.Services.AddSingleton("mock", new MockEmailService());

// 使用
app.MapGet("/send-smtp", (IServiceProvider sp) =>
{
    var email = sp.GetRequiredService<IEmailService>("smtp");
    email.SendEmail("test@example.com", "测试", "内容");
    return "OK";
});

这类场景通常要结合工厂、键控服务或者自定义选择逻辑来做。核心思想仍然没变:调用方尽量只表达"我需要哪种能力",而不是把各种实现细节写死在业务代码里。

八、最佳实践

写依赖注入时,一个很重要的原则是尽量依赖接口,而不是直接依赖具体类。这样做不是形式主义,而是为了让业务代码对实现细节保持隔离。当你后面想替换实现、加缓存层、做测试替身时,接口会成为非常稳定的边界。

csharp 复制代码
// 好
public UserController(IUserService userService) { }

// 不好
public UserController(UserService userService) { }

另一个非常稳妥的原则是优先使用构造函数注入。因为构造函数注入能明确表达"这个类想正常工作,必须具备哪些依赖",而且对象一创建就处于完整可用状态。相比之下,属性注入或者类内部手动 new 对象,都会让依赖关系变得隐蔽,增加空状态和初始化顺序问题。

csharp 复制代码
// 推荐
public MyService(IDependency dep) { }

// 不推荐(属性注入)
public MyService()
{
    _dep = new Dependency();
}

除此之外,服务最好尽量保持无状态,尤其是 Singleton 和 Scoped 服务,不要把用户输入、当前请求数据或者可变共享状态长期放在字段里。最后,注册服务时也建议按层次组织,比如框架服务、业务服务、基础设施服务分开注册,这样 Program.cs 会更清晰,后期维护也更省力。

csharp 复制代码
// 基础服务
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>();

// 业务服务
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IEmailService, EmailService>();

// 第三方服务
builder.Services.AddRedis();

九、总结

依赖注入是现代 .NET 开发里几乎绕不过去的基础设施。它看起来像是在"多写几层接口和注册代码",但真正换来的,是更松耦合的结构、更容易测试的业务逻辑、更清晰的生命周期管理,以及更适合扩展的系统设计。你越往后做真实项目,就越会发现一个类如果既负责业务,又负责创建依赖,最终几乎一定会变得难维护。

掌握依赖注入之后,你会开始习惯从"能力边界"和"对象关系"来组织代码,而不是从"我现在需要直接 new 什么对象"出发思考。这种思维方式,会直接影响你后面学习中间件、配置系统、日志系统、数据库上下文以及各种 AI 服务接入时的代码质量。

下一篇我们学习中间件,这和依赖注入经常一起出现。到那时你会更直观地看到,ASP.NET Core 是怎样依靠容器把各种服务注入到请求处理流程中的。

练习题:

  1. 新建一个 ASP.NET Core 项目,定义 INotificationService 接口,分别实现 SmsNotificationService(短信)和 EmailNotificationService(邮件)两个版本,在开发环境注册邮件实现,在生产环境注册短信实现,并通过一个 API 端点验证切换效果。
  2. 解释 Transient、Scoped、Singleton 三种生命周期的区别,并各举一个实际场景说明为什么选择该生命周期,而不是另外两种。
  3. 创建一个 OrderService,它同时依赖 IPaymentServiceIInventoryService,编写对应的单元测试,用 Mock 实现替换真实依赖,验证下单逻辑是否正确触发了支付和库存扣减。
  4. 如果在一个 Singleton 服务中注入了一个 Scoped 服务,会发生什么问题?尝试复现这个错误,并说明正确的解决方式。
  5. 参考第七节的工厂模式示例,实现一个根据配置文件中 notify:channel 字段动态选择通知渠道的工厂,支持 "email""sms" 两种值,并在端点中调用验证。
相关推荐
’长谷深风‘2 小时前
从零开始学 SQLite:从基础命令到 C 语言编程实战
c语言·数据库·sqlite·软件编程
jackletter2 小时前
在pgsql中封装一个json函数,让它完全模拟mysql中的json_set
数据库·mysql·json·pgsql·json_set
冬夜戏雪2 小时前
【学习日记】
java·开发语言·数据库
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-03-11
大数据·数据库·人工智能·经验分享·搜索引擎
2301_767902642 小时前
mysql语言
数据库·mysql·oracle
她说..2 小时前
Redis 中常用的操作方法
java·数据库·spring boot·redis·缓存
倔强的石头_3 小时前
MySQL 兼容性深度解析:从内核级优化到“零修改”迁移工程实践
前端·数据库
水杉i3 小时前
Redis 使用笔记
数据库·redis·笔记
学不完的3 小时前
redis
数据库·redis·缓存·运维开发