跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

上一章把 Controller 定义为应用的 HTTP 边界。HealthController 已经能够从路径或 JSON 中取得服务名称,并调用下面两个方法:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
healthService.check();
2
healthService.check("postgresql");

Controller 只知道自己需要发起哪一种检查,具体怎样处理默认配置、校验服务名称和生成结果,都应该由 HealthService 完成。本章沿着这两个方法继续向内实现 Service,并说明它怎样组织业务规则、依赖与状态。

1. Service 的职责

Service 表示应用能够完成的一项业务能力。Controller 解决「客户端怎样发起调用」,Service 解决「应用收到调用后应该做什么」。

在心跳案例中,两者的分工如下:

service-boundary.txt
01
HealthController
02
-> 读取 HTTP 请求中的 service
03
-> 调用 HealthService.check(service)
04
05
HealthService
06
-> 校验服务名称是否符合业务要求
07
-> 读取心跳配置
08
-> 执行检查规则
09
-> 返回 HealthStatus
10
11
HealthController
12
-> 把 HealthStatus 包装成 HTTP 响应
Controller 关心的内容Service 关心的内容
URL 和 HTTP 方法可以执行哪些业务操作
路径、查询参数和 JSON方法需要哪些业务输入
@Valid 请求格式校验业务约束是否满足
HTTP 状态码和响应头业务结果或业务失败
请求与响应 DTO领域对象和协作组件

Service 不只是为了让 Controller 变短。它建立了独立于 HTTP 的业务入口,同一项能力以后可以被另一个 Controller、定时任务、消息消费者或测试代码直接调用。

不过,并非每个 Controller 方法都必须机械地对应一个 Service。如果接口只返回固定文本,没有业务规则、协作对象或复用需求,直接在 Controller 中完成也可以。随着心跳接口开始读取配置、规范服务名称并需要保存结果,独立的 HealthService 才真正承担了业务职责,而不是为了凑齐分层结构增加一次无意义转发。

以方法调用来观察边界会更清楚:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
@GetMapping("/health/{service}")
2
public HealthResponse healthByService(@PathVariable String service) {
3
HealthStatus status = healthService.check(service);
4
return new HealthResponse(0, "success", status);
5
}

@PathVariable 和 HealthResponse 属于 Controller,check(service) 和 HealthStatus 属于 Service 提供的业务能力。

2. 声明与注入

使用 @Service 可以把业务类注册为 Spring Bean,并表达它在项目中的职责:

src/main/java/cn/usehook/spring/health/HealthService.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import org.springframework.stereotype.Service;
05
06
@Service
07
public class HealthService {
08
09
private final HealthProperties properties;
10
11
public HealthService(HealthProperties properties) {
12
this.properties = properties;
13
}
14
}

@Service 建立在 @Component 之上。当前类位于 cn.usehook.spring 根包之下,所以组件扫描会发现它。默认 Bean 名称是 healthService,默认作用域是 singleton。

@Service 主要表达组件角色,并让类能够参与组件扫描。它不会因为名称是 Service 就自动获得事务、缓存、重试或异步执行能力;这些行为需要相应依赖、配置和注解单独启用。

构造方法声明 HealthService 需要 HealthProperties。容器先创建并绑定配置 Bean,再把它传给服务:

service-dependency.txt
1
application.yaml
2
-> 绑定 HealthProperties Bean
3
-> 注入 HealthService Bean
4
-> 注入 HealthController Bean

构造器参数同时完成了两件事:它让容器知道如何组装对象,也让阅读代码的人知道服务运行所需的最小依赖。字段使用 final,表示服务创建完成后不会把依赖替换为另一个对象。

只有一个构造方法时,不需要再添加 @Autowired。Spring 会自动使用它完成构造器注入。显式构造方法也让普通 Java 代码和单元测试可以直接创建服务,不必依赖容器提供隐藏的字段赋值。

Controller 只依赖 Service,不应该继续穿透到 Service 的内部依赖:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
private final HealthService healthService;
2
3
public HealthController(HealthService healthService) {
4
this.healthService = healthService;
5
}

HealthController 不需要同时注入 HealthProperties。配置怎样参与检查属于 Service 内部实现,控制器只需要知道 HealthService 能够返回心跳结果。

3. Service 方法

Service 的公共方法描述应用对外提供的业务动作。方法名、参数和返回值应该使用业务语言,而不是 HTTP 语言。

心跳服务提供两个入口:无参数版本检查默认服务,有参数版本检查指定服务。它们构成 Java 的方法重载,方法名相同,但参数列表不同:

src/main/java/cn/usehook/spring/health/HealthService.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import java.util.Locale;
05
import org.springframework.stereotype.Service;
06
07
@Service
08
public class HealthService {
09
10
private final HealthProperties properties;
11
12
public HealthService(HealthProperties properties) {
13
this.properties = properties;
14
}
15
16
public HealthStatus check() {
17
return check(properties.service());
18
}
19
20
public HealthStatus check(String service) {
21
String normalizedService = normalizeService(service);
22
return new HealthStatus(properties.status(), normalizedService);
23
}
24
25
private String normalizeService(String service) {
26
if (service == null || service.isBlank()) {
27
throw new IllegalArgumentException("Invalid service name: " + service);
28
}
29
return service.strip().toLowerCase(Locale.ROOT);
30
}
31
}

调用 check() 时,Java 根据无参数签名选择第一个方法;调用 check("postgresql") 时,则选择带 String 参数的方法。无参数 check() 没有复制检查过程,而是读取默认配置后继续调用 check(String)。真正的规则只保留一份,两个入口不会因为后续修改而产生不同结果。

normalizeService() 被声明为 private,因为它只是 Service 内部的一步,不是应用准备提供给其他组件的独立业务能力。条件 service == null || service.isBlank() 先处理 null,再拒绝空字符串和空白字符串;|| 具有短路行为,所以 service 为 null 时不会继续调用 isBlank()。通过校验后,strip() 去除首尾空白,Locale.ROOT 则让小写转换不受服务器默认地区影响。

公共方法接收 String service,不接收 Controller 的 HealthCheckRequest:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
2
HealthStatus status = healthService.check(request.service());
3
return new HealthResponse(0, "success", status);
4
}

HealthCheckRequest 描述 JSON 请求结构,只在 HTTP 边界有意义。Controller 从 DTO 中取出业务需要的数据,再调用 Service。这样 Service 不依赖 @RequestBody、@Valid 或某一种客户端数据格式。

返回值也遵守相同原则。Service 返回 HealthStatus,而不是 HealthResponse 或 ResponseEntity<HealthResponse>:

src/main/java/cn/usehook/spring/health/HealthStatus.java
1
package cn.usehook.spring.health;
2
3
public record HealthStatus(String status, String service) {}

HealthStatus 表达检查结果,Controller 再决定它如何出现在 HTTP 响应中。Service 因此不需要知道响应状态码、响应头和 JSON 外层结构。它也不需要成为 Spring Bean,因为每次检查都会创建一个新的结果对象,调用结束后由调用方继续使用。

4. 业务规则与异常

请求数据上的 @NotBlank 能拦截来自 HTTP 的空字符串,但 Service 仍然需要保护自己的业务边界。原因是 Service 不只服务于 Controller。例如,将来的定时任务也可以直接调用它:

src/main/java/cn/usehook/spring/health/HealthCheckJob.java
01
package cn.usehook.spring.health;
02
03
public class HealthCheckJob {
04
05
private final HealthService healthService;
06
07
public HealthCheckJob(HealthService healthService) {
08
this.healthService = healthService;
09
}
10
11
public HealthStatus run(String configuredService) {
12
return healthService.check(configuredService);
13
}
14
}

这里的 HealthCheckJob 只用来表示一个不经过 HTTP 的普通 Java 调用方。它没有注册为 Spring Bean,也没有配置定时注解,因此不会自动执行。

configuredService 可能来自配置文件,也可能是 null、空白字符串或包含大小写差异的名称。规则放在 Service 中,HTTP 请求、将来的定时任务和测试等所有调用入口才能得到一致行为:无效名称被拒绝,PostgreSQL 这样的有效名称则统一处理为 postgresql。

为失败原因创建业务异常,比在 Service 中创建 HTTP 响应更合适:

src/main/java/cn/usehook/spring/health/InvalidServiceNameException.java
1
package cn.usehook.spring.health;
2
3
public class InvalidServiceNameException extends IllegalArgumentException {
4
5
public InvalidServiceNameException(String service) {
6
super("Invalid service name: " + service);
7
}
8
}

InvalidServiceNameException 继承 IllegalArgumentException,仍然属于不需要在方法签名中显式声明的运行时异常,同时让调用方能够通过异常类型识别具体失败原因。创建它以后,把 normalizeService() 中原来的异常替换掉:

src/main/java/cn/usehook/spring/health/HealthService.java
1
private String normalizeService(String service) {
2
if (service == null || service.isBlank()) {
3
throw new InvalidServiceNameException(service);
4
}
5
return service.strip().toLowerCase(Locale.ROOT);
6
}

Service 只负责说明「服务名称无效」。Controller 层的异常处理器再把它转换为 400 Bad Request:

src/main/java/cn/usehook/spring/controller/ApiExceptionHandler.java
01
@ExceptionHandler(InvalidServiceNameException.class)
02
public ResponseEntity<ProblemDetail> handleInvalidServiceName(
03
InvalidServiceNameException exception
04
) {
05
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
06
HttpStatus.BAD_REQUEST,
07
exception.getMessage()
08
);
09
problem.setTitle("Invalid service name");
10
return ResponseEntity.badRequest().body(problem);
11
}

这样的分层保留了两个事实:Service 知道业务为何失败,Controller 知道这项失败在 HTTP 中应该如何表达。以后从定时任务调用 Service 时,同一个异常可以记录为任务失败,而不是被迫处理 ResponseEntity。

业务异常应该对应调用方可以理解并处理的预期失败。空指针、数组越界等编程错误不应该全部包装成业务异常,否则真正的缺陷会被隐藏成看似正常的业务分支。

5. 依赖与协作

Service 经常需要组合多个协作对象。当前 HealthService 只有 HealthProperties 一个依赖,调用链到这里就结束了:

依赖关系应该保持从业务流程指向具体能力:

service-collaboration.txt
1
HealthController
2
-> HealthService
3
-> HealthProperties

此时 check() 生成 HealthStatus 后立即返回,结果没有被保存。下一章需要解决的新问题是「如何保存并再次查询检查结果」,因此会先创建 HealthStatusRepository 接口和内存实现,再把这个依赖加入 HealthService:

service-with-repository.txt
1
HealthController
2
-> HealthService:决定检查与保存顺序
3
-> HealthProperties:提供默认配置
4
-> HealthStatusRepository:保存和查询结果

Controller 不应该绕过 Service 直接调用 Repository,否则名称校验、保存顺序等规则会分散到多个 HTTP 方法中。Service 也不应该从 ApplicationContext 主动调用 getBean() 查找协作对象,而应该继续通过构造方法明确声明依赖。

Service 方法可以协调多个步骤,但应该让每个协作组件承担自己的细节。例如 Repository 负责保存与查询,Service 负责确定何时保存以及查询不到结果时如何处理。这样的分工使数据来源以后可以从内存替换为数据库,而检查规则仍然留在 Service。

构造器依赖增多本身不一定有问题,它们可能恰好描述一项完整的业务协作。不过,如果一个 Service 的构造方法不断增长,而且不同方法只使用其中一小部分依赖,通常说明这个类同时承担了多项职责,可以按业务能力进一步拆分。

6. 状态与作用域

@Service 默认创建单例 Bean。Web 应用可能同时处理多个请求,因此同一个 HealthService 实例可能被多个线程并发调用。

当前服务把固定依赖保存在字段中,把每次调用的数据保存在局部变量中:

src/main/java/cn/usehook/spring/health/HealthService.java
1
private final HealthProperties properties;
2
3
public HealthStatus check(String service) {
4
String normalizedService = normalizeService(service);
5
return new HealthStatus(properties.status(), normalizedService);
6
}

properties 在构造后不会被替换,normalizedService 只属于本次方法调用。这种无状态设计适合单例 Service。

下面的数据不应该保存在单例服务的普通可变字段中:

  • 当前请求传入的服务名称;
  • 当前用户信息;
  • 上一次检查结果;
  • 为某个请求临时计算的中间值。

如果多个请求同时修改这些字段,一个请求可能读到另一个请求的数据。请求相关状态应该留在方法参数、局部变量或明确的请求作用域对象中。

Service 也是常见的事务边界,但 @Service 本身不会开启事务。接入数据库后,一个业务动作可能需要读取、修改并保存多项数据,那时可以根据实际原子性要求在 Service 方法上声明事务。当前案例还没有数据库操作,因此不需要为了体现分层而提前添加 @Transactional;事务的传播、代理和回滚规则应结合数据库单独学习。

7. 接口与测试

是否需要 Service 接口

Spring 可以直接通过具体类型注入 HealthService,所以每个 Service 都不需要机械地创建同名接口:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
private final HealthService healthService;
2
3
public HealthController(HealthService healthService) {
4
this.healthService = healthService;
5
}

只有一个稳定实现,而且服务只在当前应用内部使用时,直接使用具体类通常更简单。接口更适合下面这些情况:

场景接口的价值
确实存在多个实现调用方只依赖共同能力
需要替换不同供应商或策略实现可以根据配置选择
跨模块公开稳定边界隐藏实现细节,控制模块依赖
领域能力需要独立建模接口名称能够表达清晰的业务角色

不要只为了让文件数量看起来完整而创建 IHealthService 和唯一的 HealthServiceImpl。如果还没有抽象需求,接口只是把阅读入口拆成两个文件。

不启动容器的单元测试

清晰的 Service 只依赖构造参数和普通 Java 类型,因此可以直接创建并测试,不需要启动完整 Spring Boot 应用:

src/test/java/cn/usehook/spring/health/HealthServiceTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.config.HealthProperties;
04
import org.junit.jupiter.api.Test;
05
06
import static org.assertj.core.api.Assertions.assertThat;
07
import static org.assertj.core.api.Assertions.assertThatThrownBy;
08
09
class HealthServiceTests {
10
11
private final HealthProperties properties = new HealthProperties("UP", "spring");
12
private final HealthService healthService = new HealthService(properties);
13
14
@Test
15
void checksDefaultService() {
16
HealthStatus status = healthService.check();
17
18
assertThat(status).isEqualTo(new HealthStatus("UP", "spring"));
19
}
20
21
@Test
22
void normalizesServiceName() {
23
HealthStatus status = healthService.check(" PostgreSQL ");
24
25
assertThat(status.service()).isEqualTo("postgresql");
26
}
27
28
@Test
29
void rejectsBlankServiceName() {
30
assertThatThrownBy(() -> healthService.check(" "))
31
.isInstanceOf(InvalidServiceNameException.class);
32
}
33
}

测试直接传入 HealthProperties,说明构造器已经完整表达服务依赖。HealthStatus 是 record,自动按字段值实现相等比较,因此可以直接和预期结果比较。

这里测试的是业务行为,不需要验证组件扫描、HTTP 路径或 JSON 序列化。Controller 测试负责 HTTP 边界,Service 单元测试负责业务规则,两类测试各自覆盖一层,失败时更容易判断问题所在。

到这里,HealthService 已经能够处理默认服务和指定服务,并且所有调用入口都遵守相同的名称规则。检查结果仍然只作为返回值存在。下一章将从这个明确的问题出发,引入 HealthStatusRepository 保存和查询结果,再把它作为第二个构造器依赖交给 Service。