IoC

IoC 全称 Inversion of Control,直译为控制反转。又被称为依赖注入(Dependency InjectionDI

传统应用模式下,组件的创建(实例化)、使用和销毁都由程序员维护,非常复杂。IoC 将组件的创建交给 IoC 容器,由容器管理组件的创建、销毁。

IoC 可以使用 XML 进行配置,但更推荐使用注解进行配置,因此只介绍后面一种。

初始代码

首先三个组件:User UserServiceMailService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class User {
public long id;
public String email;
public String password;
public String name;

public User(long id, String email, String password, String name) {
this.id = id;
this.email = email;
this.password = password;
this.name = name;
}
// 其对应的 get 和 set 方法略
}
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
30
31
32
33
34
35
36
37
public class UserService {
private MailService mailService;

private List<User> users = new ArrayList<>(Arrays.asList(
new User(1, "bob@example.com", "password", "Bob"), // bob
new User(2, "alice@example.com", "password", "Alice"), // alice
new User(3, "tom@example.com", "password", "Tom") // tom
));

public void setMailService(MailService mailService) { this.mailService = mailService; }

public List<User> getUsers() { return users; }

public User register(String email, String password, String name) {
long maxUserId = 0;
for (User user: users) {
if (user.getEmail().equalsIgnoreCase(email)) {
throw new RuntimeException("email exists");
}
maxUserId = Math.max(maxUserId, user.getId());
}
User user = new User(maxUserId+1, email, password, name);
users.add(user);
mailService.sendRegistrationMail(user);
return user;
}

public User login(String email, String password) {
for (User user: users) {
if (user.getEmail().equalsIgnoreCase(email) && user.getPassword().equals(password)) {
mailService.sendLoginMail(user);
return user;
}
}
throw new RuntimeException("login failed.");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class MailService {
private ZoneId zoneId = ZoneId.systemDefault();

public void setZoneId(ZoneId zoneId) {
this.zoneId = zoneId;
}

public String getTime() {
return ZonedDateTime
.now(this.zoneId)
.format(DateTimeFormatter.ISO_ZONED_DATE_TIME);
}

public void sendRegistrationMail(User user) {
System.err.printf("Welcome, %s!\n", user.getName());
}

public void sendLoginMail(User user) {
System.err.printf("Hi, %s! You're logged in at %s\n", user.getName(), getTime());
}
}

装配 Bean

基础用法

第零步,Maven 安装依赖:

1
2
3
4
5
6
7
8
9
10
11
<properties>
<spring.version>5.2.3.RELEASE</spring.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
</dependencies>

第一步:对上面三个类都标记上 @Component

1
2
3
4
@Component
public class MailService {
// ...
}

第二步,注意到 UserService 依赖 MailService 的一个实例,所以我们要在 UserService 定义 mailService 的地方标记上 @Autowired,把两个东西连起来。

Idea 提示 Field injection is not recommended
廖雪峰提示 别去管 Idea 的警告。改成 method 注入,要多写好多行代码

1
2
3
4
5
6
@Component
public class UserService {
@Autowired
private MailService mailService;
// ...
}

第三步,在 main 函数中生成 ApplicationContext,这个就是 IoC 容器实例,我们可以使用 getBean 获得上面绑定的任何一个组件。

此外,还需要加上 @ComponentScan@Configuration,表示:这个类搜索当前类所在的包;这个类可以被作为 AnnotationConfigApplicationContext 函数的配置参数。

注意这个类不能放在根目录(即 /src/main/java/)下,否则会扫描所有的包;放在根目录下的任何一层均可。

1
2
3
4
5
6
7
8
9
10
@ComponentScan
@Configuration
public class Main {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(Main.class);
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}

修改 Bean 的作用域

对于Spring容器来说,当我们把一个Bean标记为@Component后,它就会自动为我们创建一个单例(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)获取到的Bean总是同一个实例。

还有一种Bean,我们每次调用getBean(Class),容器都返回一个新的实例,这种Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的@Scope注解:

1
2
3
4
5
6
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
// or @Scope("prototype")
public class MailSession {
//...
}

注入 List

@Autowired 不仅可以注入单个实例,还可以注入多个实例组成的 List。

定义验证接口和三个实现了这个接口的验证类,并对类标记 Component

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
30
public interface Validator {
void validate(String email, String password, String name);
}

@Component
public class EmailValidator implements Validator {
public void validate(String email, String password, String name) {
if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
throw new IllegalArgumentException("invalid email: " + email);
}
}
}

@Component
public class PasswordValidator implements Validator {
public void validate(String email, String password, String name) {
if (!password.matches("^.{6,20}$")) {
throw new IllegalArgumentException("invalid password");
}
}
}

@Component
public class NameValidator implements Validator {
public void validate(String email, String password, String name) {
if (name == null || name.isBlank() || name.length() > 20) {
throw new IllegalArgumentException("invalid name: " + name);
}
}
}

最后定义 Validators 作为入口进行验证:

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class Validators {
// 所有 Validator 均
@Autowired
List<Validator> validators;

public void validate(String email, String password, String name) {
for (var validator : this.validators) {
validator.validate(email, password, name);
}
}
}

可选注入

1
2
3
4
5
6
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();
// ...
}

创建第三方 Bean

如果一个 Bean 不在我们自己的 package 管理之内,例如 ZoneId,如何创建它?

答案是我们自己在 @Configuration 类中编写一个 Java 方法创建并返回它,注意给方法标记一个 @Bean 注解:

1
2
3
4
5
6
7
8
9
@Configuration
@ComponentScan
public class AppConfig {
// 创建一个Bean:
@Bean
ZoneId createZoneId() {
return ZoneId.of("Z");
}
}

Spring 对标记为 @Bean 的方法只调用一次,因此返回的 Bean 仍然是单例。

注入资源文件

在 Java 程序中,我们经常会读取配置文件、资源文件等。使用 Spring 容器时,我们也可以把“文件”注入进来,方便程序读取。

例如,AppService 需要读取 logo.txt 这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开 InputStream

Spring 提供了一个 org.springframework.core.io.Resource(注意不是 javax.annotation.Resource),它可以使用 @Value 注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Component
public class AppService {
@Value("classpath:/logo.txt")
private Resource resource;

private String logo;

@PostConstruct
public void init() throws IOException {
try (var reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
this.logo = reader.lines().collect(Collectors.joining("\n"));
}
}
}

上述工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
spring-ioc-resource
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── itranswarp
│ └── learnjava
│ ├── AppConfig.java
│ └── AppService.java
└── resources
└── logo.txt

也可以直接指定文件的路径,例如:@Value("file:/path/to/logo.txt")。但使用 classpath 是最简单的方式。

使用 Maven 的标准目录结构,所有资源文件放入 src/main/resources 即可。

注入配置文件

注入配置 - 廖雪峰的官方网站

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@ComponentScan
@PropertySource("app.properties") // 表示读取classpath的app.properties
public class AppConfig {
@Value("${app.zone:Z}")
String zoneId;

@Bean
ZoneId createZoneId() {
return ZoneId.of(zoneId);
}
}

上述代码读取 classpath 下的 app.properties 文件,然后找到 zone=X,令 zoneIdX。如果没有找到 zone 就设置为 Z

条件注入

Spring 容器可以条件注入。

  • @Profile("test") 表示在 "test" 时注入
  • @Profile("!test") 表示在不是 "test" 时注入

Spring 运行时可以指定多个 Profile

1
-Dspring.profiles.active=test,master

也可以判断编写判断类,然后在需要条件注入的 Bean 上加 @Conditional(OnSmtpEnvCondition.class)