上一章把 Controller 定义为应用的 HTTP 边界。HealthController 已经能够从路径或 JSON 中取得服务名称,并调用下面两个方法:
1healthService.check();2healthService.check("postgresql");
Controller 只知道自己需要发起哪一种检查,具体怎样处理默认配置、校验服务名称和生成结果,都应该由 HealthService 完成。本章沿着这两个方法继续向内实现 Service,并说明它怎样组织业务规则、依赖与状态。
1. Service 的职责
Service 表示应用能够完成的一项业务能力。Controller 解决「客户端怎样发起调用」,Service 解决「应用收到调用后应该做什么」。
在心跳案例中,两者的分工如下:
01HealthController02-> 读取 HTTP 请求中的 service03-> 调用 HealthService.check(service)0405HealthService06-> 校验服务名称是否符合业务要求07-> 读取心跳配置08-> 执行检查规则09-> 返回 HealthStatus1011HealthController12-> 把 HealthStatus 包装成 HTTP 响应
| Controller 关心的内容 | Service 关心的内容 |
|---|---|
| URL 和 HTTP 方法 | 可以执行哪些业务操作 |
| 路径、查询参数和 JSON | 方法需要哪些业务输入 |
@Valid 请求格式校验 | 业务约束是否满足 |
| HTTP 状态码和响应头 | 业务结果或业务失败 |
| 请求与响应 DTO | 领域对象和协作组件 |
Service 不只是为了让 Controller 变短。它建立了独立于 HTTP 的业务入口,同一项能力以后可以被另一个 Controller、定时任务、消息消费者或测试代码直接调用。
不过,并非每个 Controller 方法都必须机械地对应一个 Service。如果接口只返回固定文本,没有业务规则、协作对象或复用需求,直接在 Controller 中完成也可以。随着心跳接口开始读取配置、规范服务名称并需要保存结果,独立的 HealthService 才真正承担了业务职责,而不是为了凑齐分层结构增加一次无意义转发。
以方法调用来观察边界会更清楚:
1@GetMapping("/health/{service}")2public HealthResponse healthByService(@PathVariable String service) {3HealthStatus status = healthService.check(service);4return new HealthResponse(0, "success", status);5}
@PathVariable 和 HealthResponse 属于 Controller,check(service) 和 HealthStatus 属于 Service 提供的业务能力。
2. 声明与注入
使用 @Service 可以把业务类注册为 Spring Bean,并表达它在项目中的职责:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import org.springframework.stereotype.Service;0506@Service07public class HealthService {0809private final HealthProperties properties;1011public HealthService(HealthProperties properties) {12this.properties = properties;13}14}
@Service 建立在 @Component 之上。当前类位于 cn.usehook.spring 根包之下,所以组件扫描会发现它。默认 Bean 名称是 healthService,默认作用域是 singleton。
@Service 主要表达组件角色,并让类能够参与组件扫描。它不会因为名称是 Service 就自动获得事务、缓存、重试或异步执行能力;这些行为需要相应依赖、配置和注解单独启用。
构造方法声明 HealthService 需要 HealthProperties。容器先创建并绑定配置 Bean,再把它传给服务:
1application.yaml2-> 绑定 HealthProperties Bean3-> 注入 HealthService Bean4-> 注入 HealthController Bean
构造器参数同时完成了两件事:它让容器知道如何组装对象,也让阅读代码的人知道服务运行所需的最小依赖。字段使用 final,表示服务创建完成后不会把依赖替换为另一个对象。
只有一个构造方法时,不需要再添加 @Autowired。Spring 会自动使用它完成构造器注入。显式构造方法也让普通 Java 代码和单元测试可以直接创建服务,不必依赖容器提供隐藏的字段赋值。
Controller 只依赖 Service,不应该继续穿透到 Service 的内部依赖:
1private final HealthService healthService;23public HealthController(HealthService healthService) {4this.healthService = healthService;5}
HealthController 不需要同时注入 HealthProperties。配置怎样参与检查属于 Service 内部实现,控制器只需要知道 HealthService 能够返回心跳结果。
3. Service 方法
Service 的公共方法描述应用对外提供的业务动作。方法名、参数和返回值应该使用业务语言,而不是 HTTP 语言。
心跳服务提供两个入口:无参数版本检查默认服务,有参数版本检查指定服务。它们构成 Java 的方法重载,方法名相同,但参数列表不同:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import java.util.Locale;05import org.springframework.stereotype.Service;0607@Service08public class HealthService {0910private final HealthProperties properties;1112public HealthService(HealthProperties properties) {13this.properties = properties;14}1516public HealthStatus check() {17return check(properties.service());18}1920public HealthStatus check(String service) {21String normalizedService = normalizeService(service);22return new HealthStatus(properties.status(), normalizedService);23}2425private String normalizeService(String service) {26if (service == null || service.isBlank()) {27throw new IllegalArgumentException("Invalid service name: " + service);28}29return 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:
1public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {2HealthStatus status = healthService.check(request.service());3return new HealthResponse(0, "success", status);4}
HealthCheckRequest 描述 JSON 请求结构,只在 HTTP 边界有意义。Controller 从 DTO 中取出业务需要的数据,再调用 Service。这样 Service 不依赖 @RequestBody、@Valid 或某一种客户端数据格式。
返回值也遵守相同原则。Service 返回 HealthStatus,而不是 HealthResponse 或 ResponseEntity<HealthResponse>:
1package cn.usehook.spring.health;23public record HealthStatus(String status, String service) {}
HealthStatus 表达检查结果,Controller 再决定它如何出现在 HTTP 响应中。Service 因此不需要知道响应状态码、响应头和 JSON 外层结构。它也不需要成为 Spring Bean,因为每次检查都会创建一个新的结果对象,调用结束后由调用方继续使用。
4. 业务规则与异常
请求数据上的 @NotBlank 能拦截来自 HTTP 的空字符串,但 Service 仍然需要保护自己的业务边界。原因是 Service 不只服务于 Controller。例如,将来的定时任务也可以直接调用它:
01package cn.usehook.spring.health;0203public class HealthCheckJob {0405private final HealthService healthService;0607public HealthCheckJob(HealthService healthService) {08this.healthService = healthService;09}1011public HealthStatus run(String configuredService) {12return healthService.check(configuredService);13}14}
这里的 HealthCheckJob 只用来表示一个不经过 HTTP 的普通 Java 调用方。它没有注册为 Spring Bean,也没有配置定时注解,因此不会自动执行。
configuredService 可能来自配置文件,也可能是 null、空白字符串或包含大小写差异的名称。规则放在 Service 中,HTTP 请求、将来的定时任务和测试等所有调用入口才能得到一致行为:无效名称被拒绝,PostgreSQL 这样的有效名称则统一处理为 postgresql。
为失败原因创建业务异常,比在 Service 中创建 HTTP 响应更合适:
1package cn.usehook.spring.health;23public class InvalidServiceNameException extends IllegalArgumentException {45public InvalidServiceNameException(String service) {6super("Invalid service name: " + service);7}8}
InvalidServiceNameException 继承 IllegalArgumentException,仍然属于不需要在方法签名中显式声明的运行时异常,同时让调用方能够通过异常类型识别具体失败原因。创建它以后,把 normalizeService() 中原来的异常替换掉:
1private String normalizeService(String service) {2if (service == null || service.isBlank()) {3throw new InvalidServiceNameException(service);4}5return service.strip().toLowerCase(Locale.ROOT);6}
Service 只负责说明「服务名称无效」。Controller 层的异常处理器再把它转换为 400 Bad Request:
01@ExceptionHandler(InvalidServiceNameException.class)02public ResponseEntity<ProblemDetail> handleInvalidServiceName(03InvalidServiceNameException exception04) {05ProblemDetail problem = ProblemDetail.forStatusAndDetail(06HttpStatus.BAD_REQUEST,07exception.getMessage()08);09problem.setTitle("Invalid service name");10return ResponseEntity.badRequest().body(problem);11}
这样的分层保留了两个事实:Service 知道业务为何失败,Controller 知道这项失败在 HTTP 中应该如何表达。以后从定时任务调用 Service 时,同一个异常可以记录为任务失败,而不是被迫处理 ResponseEntity。
业务异常应该对应调用方可以理解并处理的预期失败。空指针、数组越界等编程错误不应该全部包装成业务异常,否则真正的缺陷会被隐藏成看似正常的业务分支。
5. 依赖与协作
Service 经常需要组合多个协作对象。当前 HealthService 只有 HealthProperties 一个依赖,调用链到这里就结束了:
依赖关系应该保持从业务流程指向具体能力:
1HealthController2-> HealthService3-> HealthProperties
此时 check() 生成 HealthStatus 后立即返回,结果没有被保存。下一章需要解决的新问题是「如何保存并再次查询检查结果」,因此会先创建 HealthStatusRepository 接口和内存实现,再把这个依赖加入 HealthService:
1HealthController2-> HealthService:决定检查与保存顺序3-> HealthProperties:提供默认配置4-> HealthStatusRepository:保存和查询结果
Controller 不应该绕过 Service 直接调用 Repository,否则名称校验、保存顺序等规则会分散到多个 HTTP 方法中。Service 也不应该从 ApplicationContext 主动调用 getBean() 查找协作对象,而应该继续通过构造方法明确声明依赖。
Service 方法可以协调多个步骤,但应该让每个协作组件承担自己的细节。例如 Repository 负责保存与查询,Service 负责确定何时保存以及查询不到结果时如何处理。这样的分工使数据来源以后可以从内存替换为数据库,而检查规则仍然留在 Service。
构造器依赖增多本身不一定有问题,它们可能恰好描述一项完整的业务协作。不过,如果一个 Service 的构造方法不断增长,而且不同方法只使用其中一小部分依赖,通常说明这个类同时承担了多项职责,可以按业务能力进一步拆分。
6. 状态与作用域
@Service 默认创建单例 Bean。Web 应用可能同时处理多个请求,因此同一个 HealthService 实例可能被多个线程并发调用。
当前服务把固定依赖保存在字段中,把每次调用的数据保存在局部变量中:
1private final HealthProperties properties;23public HealthStatus check(String service) {4String normalizedService = normalizeService(service);5return new HealthStatus(properties.status(), normalizedService);6}
properties 在构造后不会被替换,normalizedService 只属于本次方法调用。这种无状态设计适合单例 Service。
下面的数据不应该保存在单例服务的普通可变字段中:
- 当前请求传入的服务名称;
- 当前用户信息;
- 上一次检查结果;
- 为某个请求临时计算的中间值。
如果多个请求同时修改这些字段,一个请求可能读到另一个请求的数据。请求相关状态应该留在方法参数、局部变量或明确的请求作用域对象中。
Service 也是常见的事务边界,但 @Service 本身不会开启事务。接入数据库后,一个业务动作可能需要读取、修改并保存多项数据,那时可以根据实际原子性要求在 Service 方法上声明事务。当前案例还没有数据库操作,因此不需要为了体现分层而提前添加 @Transactional;事务的传播、代理和回滚规则应结合数据库单独学习。
7. 接口与测试
是否需要 Service 接口
Spring 可以直接通过具体类型注入 HealthService,所以每个 Service 都不需要机械地创建同名接口:
1private final HealthService healthService;23public HealthController(HealthService healthService) {4this.healthService = healthService;5}
只有一个稳定实现,而且服务只在当前应用内部使用时,直接使用具体类通常更简单。接口更适合下面这些情况:
| 场景 | 接口的价值 |
|---|---|
| 确实存在多个实现 | 调用方只依赖共同能力 |
| 需要替换不同供应商或策略 | 实现可以根据配置选择 |
| 跨模块公开稳定边界 | 隐藏实现细节,控制模块依赖 |
| 领域能力需要独立建模 | 接口名称能够表达清晰的业务角色 |
不要只为了让文件数量看起来完整而创建 IHealthService 和唯一的 HealthServiceImpl。如果还没有抽象需求,接口只是把阅读入口拆成两个文件。
不启动容器的单元测试
清晰的 Service 只依赖构造参数和普通 Java 类型,因此可以直接创建并测试,不需要启动完整 Spring Boot 应用:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import org.junit.jupiter.api.Test;0506import static org.assertj.core.api.Assertions.assertThat;07import static org.assertj.core.api.Assertions.assertThatThrownBy;0809class HealthServiceTests {1011private final HealthProperties properties = new HealthProperties("UP", "spring");12private final HealthService healthService = new HealthService(properties);1314@Test15void checksDefaultService() {16HealthStatus status = healthService.check();1718assertThat(status).isEqualTo(new HealthStatus("UP", "spring"));19}2021@Test22void normalizesServiceName() {23HealthStatus status = healthService.check(" PostgreSQL ");2425assertThat(status.service()).isEqualTo("postgresql");26}2728@Test29void rejectsBlankServiceName() {30assertThatThrownBy(() -> healthService.check(" "))31.isInstanceOf(InvalidServiceNameException.class);32}33}
测试直接传入 HealthProperties,说明构造器已经完整表达服务依赖。HealthStatus 是 record,自动按字段值实现相等比较,因此可以直接和预期结果比较。
这里测试的是业务行为,不需要验证组件扫描、HTTP 路径或 JSON 序列化。Controller 测试负责 HTTP 边界,Service 单元测试负责业务规则,两类测试各自覆盖一层,失败时更容易判断问题所在。
到这里,HealthService 已经能够处理默认服务和指定服务,并且所有调用入口都遵守相同的名称规则。检查结果仍然只作为返回值存在。下一章将从这个明确的问题出发,引入 HealthStatusRepository 保存和查询结果,再把它作为第二个构造器依赖交给 Service。