控制反转(IoC)和依赖注入(DI)

控制反转(Inversion of Control,IoC)是一种设计原则,它把创建和管理类对象的控制权从调用者转移到框架容器中。事先在框架容器中注册各种类对象,由框架容器统一负责类对象的创建、装配和管理;调用者无须显式创建类对象,只需声明使用即可。在这里,类对象就是服务。IoC进一步实现了各模块之间的解耦,充分体现了技术人在“高内聚、低耦合”设计方向上的不懈追求!

依赖注入(Dependency Injection,DI)是实现IoC的最流行方式,被各大技术框架所采用。

本文详细介绍.NET框架的DI用法,并给出一个Spring框架的DI示例用以对比。

.NET中的依赖注入

使用流程

  • 安装NuGet包:Microsoft.Extensions.DependencyInjection

  • 定义服务,一般包括接口和实现接口的具体类,具体类可能会依赖其它的服务(有其它服务类型的成员变量,即关联关系)

    • 日志服务:定义日志服务接口并实现一个控制台日志服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public interface ILogService {
    public void LogError(string message);
    public void LogInfo(string message);
    }
    public class ConsoleLogService : ILogService {
    public void LogError(string message) {
    Console.WriteLine($"Error: {message}");
    }
    public void LogInfo(string message) {
    Console.WriteLine($"Info: {message}");
    }
    }
    • 配置服务:定义配置服务接口并实现一个环境变量配置服务
    1
    2
    3
    4
    5
    6
    7
    8
    public interface IConfigService {
    public string? GetValue(string key);
    }
    public class EnvVarConfigService : IConfigService {
    public string? GetValue(string key) {
    return Environment.GetEnvironmentVariable(key);
    }
    }
    • 邮件服务:定义邮件服务接口并实现具体的邮件服务
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public interface IMailService {
    public void Send(string title, string to, string body);
    }
    public class MailService : IMailService {
    private readonly ILogService _logProvider;
    private readonly IConfigReader _configService;
    public MailService(ILogService logProvider, IConfigReader configService) {
    _logProvider = logProvider;
    _configService = configService;
    }
    public void Send(string title, string to, string body) {
    // 先读配置
    _logProvider.LogInfo("开始读取配置信息:");
    var port = _configService.GetValue("port");
    if (port == null)
    _logProvider.LogError("读取端口信息失败!");
    else
    _logProvider.LogInfo($"Port: {port}");
    _logProvider.LogInfo("读取配置信息完成!");
    // 发送邮件
    _logProvider.LogInfo("开始发送邮件:");
    Console.WriteLine($"发邮件了:{title},{to},{body}");
    _logProvider.LogInfo("发送邮件完成!");
    }
    }
  • 使用邮件服务:先注册服务,再定位并使用服务,在实际项目中,服务的注册和使用可以分离

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    static void Main(string[] args) {
    var services = new ServiceCollection();
    // 注册日志、配置、邮件三种服务
    services.AddScoped<ILogService, ConsoleLogService>();
    services.AddScoped<IConfigService, EnvVarConfigService>();
    services.AddScoped<IMailService, MailService>();
    // 定位邮件服务发送邮件
    using (var sp = services.BuildServiceProvider()) {
    var mailService = sp.GetRequiredService<IMailService>();
    mailService.Send("第一封邮件", "LiRuohan", "你好,依赖注入!");
    }
    Console.ReadKey();
    }

如果采用传统方式实现邮件服务,我们需要手动创建MailService的对象,并且在创建之前需要创建ConsoleLogServiceEnvVarConfigService的对象:

1
2
3
4
5
6
7
static void Main(string[] args) {
var logService=new ConsoleLogService();
var configService=new EnvVarConfigService();
var mailService=new MailService(logService,configService);
mailService.Send("第一封邮件", "LiRuohan", "你好,依赖注入!");
Console.ReadKey();
}

采用这种方式,一旦需要使用邮件服务,就必须手动创建日志服务对象和配置服务对象,自然而然需要知道三种服务的具体类。如果后期需要用新的服务类替换既有服务类,那么就必须修改所有手动创建服务的代码,这在大型项目中非常令人厌恶!

采用DI方式的代码中,只需要修改注册服务时的服务具体类即可,更加充分体现了“面向接口编程”的思想。

服务注册

创建一个ServiceCollection对象,就可以向该对象中注册服务了,注册服务完成之后,调用该对象的BuildServiceProvider()就可以获得ServiceProvider对象,后续这个对象进行服务定位。

三种生命周期的服务:

  • AddSingleton()
    • 单例模式,容器只会创建一个示例,每次请求服务时,容器都会返回该实例。
    • 适用于无状态服务,例如上面的日志服务和配置服务。
  • AddScoped()
    • 作用域模式,在同一个作用域内容器会返回相同的实例,在不同的作用域内会重新创建新的实例。
    • 作用域理解:一般而言,每次请求服务器时都会创建一个新的作用域,对于非Web应用而言,默认处于一个相同的作用域中,该作用域不同于语法作用域。
    • 适用于有状态服务。
  • AddTransient()
    • 瞬态模式,每次请求服务时,容器都会创建一个新的实例。
    • 慎用,对性能可能造成严重影响,需要评估创建新实例的代价。

常见的服务注册语法:

  • 直接注册实现类,也可以将实现类和接口一起注册
1
2
3
4
5
6
7
8
9
// 由于在邮件服务中包含两个成员变量:ILogService和IConfigService,针对这两个服务必须同时注册接口和实现类
services.AddSingleton<ILogService, ConsoleLogService>();
services.AddSingleton<IConfigService, EnvVarConfigService>();
// 由于没有其他服务依赖邮件服务,可以直接注册具体类
services.AddScoped<MailService>();
using (var sp = services.BuildServiceProvider()) {
var mailService = sp.GetRequiredService<MailService>();
mailService.Send("第一封邮件", "LiRuohan", "你好,依赖注入!");
}
  • 类型参数可以用泛型的方法表达,也可以用类型参数的方式表达,一般用前者即可
1
2
3
4
// 方式1
services.AddSingleton<ILogService, ConsoleLogService>();
// 方式2
services.AddSingleton(typeof(ILogService), typeof(ConsoleLogService));
  • 如果在构造的时候需要给属性赋值,那么可以用Lambda表达式传参
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 以下是Json文件配置服务
public class JsonConfigService : IConfigService {
public string JsonPath { get; set; }
public string? GetValue(string key) {
ConfigurationBuilder configBuilder = new ConfigurationBuilder();
if (string.IsNullOrWhiteSpace(JsonPath))
return null;
configBuilder.AddJsonFile(JsonPath, optional: false, reloadOnChange: false);
IConfigurationRoot configRoot = configBuilder.Build();
return configRoot[key];
}
}
// 注册时需要给JsonPath属性赋值
services.AddScoped<IConfigService>(c => new JsonConfigService() { JsonPath = "./config.json" });
  • 如果要为同一服务注册多个实现类,可以为每个实现类指定一个key,后期用这进行服务定位
1
2
services.AddKeyedScoped<IConfigService>("json", (p, k) => new JsonConfigService() { JsonPath = "./config.json" });
services.AddKeyedScoped<IConfigService, EnvVarConfigService>("config");
  • 如果针对同一接口进行了多次注册,服务定位时以最后那个为准

服务定位

通过容器创建的对象,会自动进行依赖注入,手动创建的对象则不会,这就要求显式从容器中获取根对象。

IServiceCollectionBuildServiceProvider()方法可以返回ServiceProvider对象,通过该对象就可以进行服务定位。

  • GetService<T>():获取服务,没有返回空
  • GetRequiredService<T>():获取服务,没有抛出异常
  • GetServices<>():获取同一接口的所有服务,返回服务数组
  • 也可以通过给定的key名称定位服务

拓展

  • 服务提供者提供服务注册快捷函数,避免服务注册者显式声明服务类,这在.NET框架中被大量使用,主要思路是编写IServiceCollection的扩展方法
1
2
3
4
5
6
7
8
9
10
namespace Microsoft.Extensions.DependencyInjection {
public static class ConfigExtensions {
public static void AddJsonConfigService(this IServiceCollection serviceCollection,string jsonPath) {
serviceCollection.AddScoped<IConfigService>(c => new JsonConfigService() { JsonPath = jsonPath });
}
public static void AddEnvVarConfigService(this IServiceCollection serviceCollection) {
serviceCollection.AddScoped<IConfigService, EnvVarConfigService>();
}
}
}
1
2
3
// 使用时这样即可
services.AddJsonConfigService("config.json");
services.AddEnvVarConfigService();
  • 配置覆盖机制,在实际工程中,项目配置可能会集中放置在一台服务器,但是开发过程中应该允许对默认配置进行覆盖,这时可以采用下列方式,在LayeredConfigReader_services会捕捉注册的所有IConfigService服务,在GetValue中就可以按照顺序遍历服务获取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface IConfigReader {
public string? GetValue(string key);
}
public class LayeredConfigReader : IConfigReader {
private readonly IEnumerable<IConfigService> _services;
public LayeredConfigReader(IEnumerable<IConfigService> services) {
_services = services;
}
public string? GetValue(string key) {
string? value = null;
foreach (var service in _services) {
string? newValue= service.GetValue(key);
if (newValue != null) {
value = newValue;
}
}
return value;
}
}
// 注册单个服务和覆盖服务
services.AddScoped<IConfigService>(p => new JsonConfigService() { JsonPath = "config.json" });
services.AddScoped<IConfigService, EnvVarConfigService>();
services.AddScoped<IConfigReader, LayeredConfigReader>();

Spring中的依赖注入

当前,Web开发的框架百花齐放,但是它们的很多原理是相似的,在Spring框架中也有DI机制,且DI是Spring框架的实现基石,下面将上述.NET实例改成Spring实例。

  • 引入相应的包,使之支持DI
1
2
3
4
5
6
7
8
9
10
11
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
  • 日志服务
1
2
3
4
5
6
package com.example.testdi;

public interface LogService {
public void LogError(String message);
public void LogInfo(String message);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.testdi;

import org.springframework.stereotype.Service;

@Service
public class ConsoleLogService implements LogService {

@Override
public void LogError(String message) {
System.out.println("Error: " + message);
}

@Override
public void LogInfo(String message) {
System.out.println("Info: " + message);
}
}
  • 配置服务
1
2
3
4
5
package com.example.testdi;

public interface ConfigService {
public String GetValue(String key);
}
1
2
3
4
5
6
7
8
9
10
11
package com.example.testdi;

import org.springframework.stereotype.Service;

@Service
public class EnvValConfigService implements ConfigService{
@Override
public String GetValue(String key) {
return key + "_1";
}
}
  • 邮件服务
1
2
3
4
5
package com.example.testdi;

public interface MailService {
public void Send(String title, String to, String body);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.testdi;

import org.springframework.stereotype.Service;

@Service
public class MailServiceImpl implements MailService {
private final LogService logService;
private final ConfigService configService;

public MailServiceImpl(LogService log, ConfigService config) {
this.logService = log;
this.configService = config;
}

@Override
public void Send(String title, String to, String body) {
logService.LogInfo("开始读取配置信息:");
var port = configService.GetValue("port");
if (port == null)
logService.LogError("读取端口信息失败!");
else
logService.LogInfo("Port: " + port);
logService.LogInfo("读取配置信息完成!");

logService.LogInfo("开始发送邮件:");
System.out.println("发邮件了: " + title + to + body);
logService.LogInfo("发送邮件完成!");
}
}
  • 主程序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.testdi;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan(basePackages = "com.example.testdi")
public class MailSenderConsoleApp {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MailSenderConsoleApp.class);
var greeting = context.getBean(MailServiceImpl.class);
greeting.Send("第一封邮件", "LiRuohan", "你好,依赖注入!");
context.close();
}
}

Spring中的依赖注入有两种形式:①基于XML配置文件的依赖注入;②基于注解的依赖注入。后者更为简洁灵活,是最常用的方式。

Spring依赖注入的服务注册和服务定位与.NET有很大差异,后者通过代码集中注册;前者采用“注解+扫描”的分布式方式,更为灵活。

Autofac依赖注入库

使用流程

为方便对比,将上述.NET实例的DI库调整为Autofac,服务定义与原实例相同,变化体现在服务注册和定位上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void Main(string[] args) {
var builder = new ContainerBuilder();
// 注册日志服务
builder.RegisterType<ConsoleLogService>().As<ILogService>().InstancePerLifetimeScope();
// 注册两个配置服务
builder.RegisterType<EnvVarConfigService>().As<IConfigService>().InstancePerLifetimeScope();
builder.Register(c => new JsonConfigService() { JsonPath = "config.json" }).As<IConfigService>().InstancePerLifetimeScope();
// 注册邮件服务
builder.RegisterType<MailService>().As<IMailService>().InstancePerLifetimeScope();

var container = builder.Build();

var mailService = container.Resolve<IMailService>();
mailService.Send("第一封邮件", "LiRuohan", "你好,依赖注入!");
Console.ReadKey();
}

服务注册

服务的生命周期:

  • 单例模式:builder.RegisterType<ConsoleLogService>().As<ILogService>().SingleInstance()
  • 作用域模式:builder.RegisterType<ConsoleLogService>().As<ILogService>().InstancePerLifetimeScope()
  • 瞬态模式:builder.RegisterType<ConsoleLogService>().As<ILogService>().InstancePerDependency(),不指定什么周期默认就是瞬态模式

服务的注册语法:

  • 直接注册实现类,也可以将实现类和接口一起注册
1
2
3
4
5
6
// 原生库的服务注册
services.AddScope<MailService>();
services.AddScope<IMailService, MailService>();
// Autofac的服务注册
builder.RegisterType<MailService>().As<IMailService>().InstancePerLifetimeScope();
builder.RegisterType<MailService>().InstancePerLifetimeScope();
  • 类型参数可以用泛型的方法表达,也可以用类型参数的方式表达,一般用前者即可
1
2
3
4
// 方式1
builder.RegisterType<MailService>().InstancePerLifetimeScope();
// 方式2
builder.RegisterType(typeof(MailService)).InstancePerLifetimeScope();
  • 如果在构造的时候需要给属性赋值,那么可以用Lambda表达式传参
1
builder.Register(c => new JsonConfigService() { JsonPath = "config.json" }).As<IConfigService>().InstancePerLifetimeScope();
  • 如果要为同一服务注册多个实现类,可以为每个实现类指定一个key,后期用这进行服务定位
1
2
3
builder.Register(c => new JsonConfigService() { JsonPath = "config.json" }).As<IConfigService>().Named<IConfigService>("json").InstancePerLifetimeScope();
builder.RegisterType<EnvVarConfigService>().As<IConfigService>().Named<IConfigService>("config").InstancePerLifetimeScope();

  • 如果针对同一接口进行了多次注册,服务定位时以最后那个为准

服务定位

ContainerBuilderBuild()方法可以返回IContainer对象,通过该对象就可以进行服务定位。

  • Resolve<T>():获取服务,没获取到服务是抛出异常
  • TryResolve<T>():获取服务,没有获取到时返回false
  • 也可以通过给定的key名称定位服务:container.ResolveKeyed<IConfigService>("json")

属性注入

.NET的原生DI库无法实现属性注入,Autofac可以实现,即服务的一个属性也可以通过依赖注入自动赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface IUserRepository {
void AddUser(string userName);
}

public class UserRepository : IUserRepository {
private readonly IDatabaseConnection _connection;

// 属性注入
public ILogger Logger { get; set; }

// 构造函数注入
public UserRepository(IDatabaseConnection connection) {
_connection = connection;
}

public void AddUser(string userName) {
_connection.connect();
Console.WriteLine($"User '{userName}' added to the database.");
Logger.Log("User added.");
}
}
// 服务注册
builder.RegisterType<UserRepository>().As<IUserRepository>().InstancePerLifetimeScope().PropertiesAutowired();

本实例中,UserRepository通过构造函数注入成员变量_connection,通过属性注入Logger

如果一个类中有属性需要进行注入,在注册该类时需要通过方法PropertiesAutowired()开启属性注入。

评论