上一章的 HealthService 已经负责心跳检查规则,但检查结果只存在于方法返回值中。应用如果需要保存并再次查询结果,就要与内存、文件或数据库等数据来源协作。Repository 用来隔离这部分数据访问逻辑。
本章先实现一个内存 Repository,不提前引入数据库框架。这样可以先看清 Repository 的职责、接口与实现的关系,以及它怎样参与 Controller → Service → Repository 调用链。
1. Repository 的职责
Repository 是业务代码访问数据的边界。Service 描述需要保存或查询什么,Repository 决定怎样与具体存储交互:
1HealthController2-> HealthService:执行心跳检查3-> HealthStatusRepository:保存或查询检查结果4-> 当前使用内存5-> 以后可以替换为 PostgreSQL
Repository 通常承担以下工作:
| 工作 | 心跳案例中的含义 |
|---|---|
| 保存数据 | 保存某个服务最新的 HealthStatus |
| 按条件查询 | 根据服务名称查找检查结果 |
| 处理存储细节 | 操作内存集合,未来可以改为执行 SQL |
| 隔离数据来源 | Service 不需要知道数据来自内存还是数据库 |
| 转换持久化异常 | 把底层访问失败转换为应用可处理的异常体系 |
Repository 不负责判断服务名称是否合法,也不决定一次心跳检查应该执行哪些步骤。这些是 Service 的业务规则。Controller 同样不应该绕过 Service 直接访问 Repository,否则业务约束会分散在不同 HTTP 接口里。
下面是三个层次各自理解的语言:
| 层次 | 主要语言 |
|---|---|
| Controller | 路径、JSON、HTTP 状态码 |
| Service | 检查服务、业务规则、业务结果 |
| Repository | 保存、查询、删除、数据来源 |
Repository 也不等于数据库。当前实现使用内存集合,后面可以替换成关系数据库;只要上层看到的保存与查询契约不变,Service 就不需要跟着存储技术一起修改。外部 HTTP 服务通常由 Client 或 Gateway 封装,只有当它确实在提供同一组数据的存取能力时,才适合放在 Repository 实现背后。
2. Repository 接口
先用接口描述 Service 需要的数据能力。对 Service 来说,这个接口就像一个可以保存和查找 HealthStatus 的集合,它不暴露 Map、SQL 或数据库连接:
01package cn.usehook.spring.health;0203import java.util.List;04import java.util.Optional;0506public interface HealthStatusRepository {0708HealthStatus save(HealthStatus status);0910Optional<HealthStatus> findByService(String service);1112List<HealthStatus> findAll();13}
接口只有方法契约,没有说明数据保存在什么地方:
| 方法 | 输入 | 输出 | 含义 |
|---|---|---|---|
save | 一个检查结果 | 保存后的检查结果 | 新增或替换指定服务的最新状态 |
findByService | 服务名称 | Optional<HealthStatus> | 查询指定服务,结果可能不存在 |
findAll | 无 | List<HealthStatus> | 按服务名称升序获得当前保存的全部结果 |
方法名称应该表达业务需要的查询,而不是泄漏底层实现。例如 findByService 描述按服务查询,无论实现最终使用 Map.get() 还是 SQL,Service 的调用方式都不需要改变。
这里返回 Optional<HealthStatus>,明确告诉调用方「结果可能不存在」。如果直接返回 HealthStatus,读者无法从类型判断缺失时会返回 null、抛出异常,还是构造一个空对象。
上一章提到过,不必为了分层而给每个 Service 都创建接口。这里定义 Repository 接口有明确目的:本章先使用内存实现,后面还会替换成数据库实现,变化点已经存在。如果一个小项目确定只有一种简单实现,也可以直接注入具体 Repository 类。
接口本身不添加 @Repository,因为它没有方法实现,当前还不是一个可以执行保存与查询操作的对象。容器最终注入的是实现类。将来使用 Spring Data 时,框架能够为符合规则的 Repository 接口生成代理实现,那是另一种创建实现的方式。
3. 内存实现
使用 ConcurrentHashMap 保存每个服务最新的检查结果:
01package cn.usehook.spring.health;0203import java.util.Comparator;04import java.util.List;05import java.util.Map;06import java.util.Objects;07import java.util.Optional;08import java.util.concurrent.ConcurrentHashMap;09import org.springframework.stereotype.Repository;1011@Repository12public class InMemoryHealthStatusRepository implements HealthStatusRepository {1314private final Map<String, HealthStatus> statuses = new ConcurrentHashMap<>();1516@Override17public HealthStatus save(HealthStatus status) {18Objects.requireNonNull(status, "status must not be null");19Objects.requireNonNull(status.service(), "status.service must not be null");20statuses.put(status.service(), status);21return status;22}2324@Override25public Optional<HealthStatus> findByService(String service) {26Objects.requireNonNull(service, "service must not be null");27return Optional.ofNullable(statuses.get(service));28}2930@Override31public List<HealthStatus> findAll() {32return statuses.values()33.stream()34.sorted(Comparator.comparing(HealthStatus::service))35.toList();36}37}
@Repository 让组件扫描把实现类注册为 Bean,同时表达这个类属于数据访问层。使用数据库时,如果项目启用了 Spring 的持久化异常转换基础设施,这个注解还会让符合条件的底层异常被转换为统一的 DataAccessException 体系。当前内存实现不会产生数据库异常,因此这里主要使用它声明组件角色。
InMemoryHealthStatusRepository 默认是单例 Bean,多个 HTTP 请求可能同时调用它。普通 HashMap 不适合没有额外同步措施的并发读写,因此这里使用 ConcurrentHashMap。它能保证 put()、get() 等单次 Map 操作的线程安全,但不能自动把多次读写组成一个不可分割的业务操作;需要「先查询再更新」之类的复合逻辑时,仍要另外设计并发控制。
映射使用服务名称作为键。再次保存相同服务时,新结果会替换旧结果,所以这个实现保存的是「每个服务的最新状态」,而不是完整历史记录。Repository 的数据结构必须与方法契约保持一致。
Objects.requireNonNull() 保护的是 Repository 自己的数据结构:ConcurrentHashMap 不接受 null 键和值。服务名称是否为空、是否需要转成小写仍由 Service 判断,Repository 不重复业务校验,也不悄悄修改传入的数据。
findAll() 没有把内部集合直接交给调用方。流的 toList() 生成一个独立且不可修改的结果列表,并按照服务名称排序。需要注意,并发写入发生在遍历期间时,ConcurrentHashMap 只能提供弱一致性的遍历结果,它不保证这份列表对应某一个瞬间的完整快照。当前「查看最近状态」的场景可以接受这种语义;严格一致的读取需要由数据库事务、合适的隔离级别或额外同步机制保证。
这仍然不是持久化存储。应用重启后,内存数据会丢失;多个应用实例也不会共享各自的 Map。内存实现适合当前教学、单元测试或明确允许数据丢失的临时状态。
4. Service 协作
HealthService 依赖接口,而不是直接依赖 ConcurrentHashMap 或具体实现。相较上一章,Service 只发生三处变化:
| 位置 | 修改 | 目的 |
|---|---|---|
| 字段与构造方法 | 注入 HealthStatusRepository | 取得数据访问能力 |
check 方法 | 把新建的结果交给 save() | 保存每个服务的最新状态 |
| 查询方法 | 增加 find() 与 findAll() | 向上层提供查询入口 |
完整代码如下:
01package cn.usehook.spring.health;0203import cn.usehook.spring.config.HealthProperties;04import java.util.List;05import java.util.Locale;06import java.util.Optional;07import org.springframework.stereotype.Service;0809@Service10public class HealthService {1112private final HealthProperties properties;13private final HealthStatusRepository repository;1415public HealthService(HealthProperties properties, HealthStatusRepository repository) {16this.properties = properties;17this.repository = repository;18}1920public HealthStatus check() {21return check(properties.service());22}2324public HealthStatus check(String service) {25String normalizedService = normalizeService(service);26HealthStatus status = new HealthStatus(properties.status(), normalizedService);27return repository.save(status);28}2930public Optional<HealthStatus> find(String service) {31return repository.findByService(normalizeService(service));32}3334public List<HealthStatus> findAll() {35return repository.findAll();36}3738private String normalizeService(String service) {39if (service == null || service.isBlank()) {40throw new InvalidServiceNameException(service);41}42return service.strip().toLowerCase(Locale.ROOT);43}44}
容器发现 InMemoryHealthStatusRepository 实现了 HealthStatusRepository,于是可以把它注入 HealthService 的构造方法:
1InMemoryHealthStatusRepository Bean2-> 满足 HealthStatusRepository 类型依赖3-> 注入 HealthService4-> HealthService 注入 HealthController
Service 仍然控制业务顺序:先规范服务名称,再创建检查结果,最后要求 Repository 保存。check() 返回 save() 的结果,是因为数据库实现以后可能补齐数据库生成的标识或时间等字段;当前内存实现原样返回传入对象。
查询同样先经过 normalizeService()。这样无论 Controller 传入 " PostgreSQL " 还是 "postgresql",保存和查找使用的键都保持一致。Repository 不重复名称规范化,因为它不应该悄悄改变业务数据。
如果以后创建 PostgreSQL 实现,只要它继续遵守 HealthStatusRepository 接口,Service 的业务流程就不需要知道 SQL、表名和数据库连接。容器中同时存在多个实现时,则需要使用 @Primary、@Qualifier 或条件配置明确选择一个候选 Bean。
5. 查询语义
Repository 方法不仅是一段代码,还定义了调用方能够依赖的数据语义。同样叫作 find 或 save,项目需要明确结果不存在、重复保存和查询集合时分别发生什么。
查询不到数据
findByService 返回 Optional,表示 Repository 只负责报告「有结果」或「没有结果」。Service 再根据用例决定缺失是不是错误。
如果某个查询要求结果必须存在,可以先定义一个业务异常:
1package cn.usehook.spring.health;23public class HealthStatusNotFoundException extends RuntimeException {45public HealthStatusNotFoundException(String service) {6super("Health status not found: " + service);7}8}
然后在 Service 中把空的 Optional 转换成这个异常:
1public HealthStatus getRequired(String service) {2String normalizedService = normalizeService(service);3return repository.findByService(normalizedService)4.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));5}
Repository 只报告没有结果,Service 再根据具体业务决定返回空值、创建默认状态或抛出业务异常。Repository 不应该直接创建 404 Not Found,因为 HTTP 状态属于 Controller 边界。
find() 和 getRequired() 表达的是两种不同用例:前者允许结果不存在,后者要求结果存在。实际项目不必为了完整而同时公开两个方法,应根据接口语义选择一个。若使用 getRequired(),后续可以由 Controller 的异常处理把 HealthStatusNotFoundException 映射为 HTTP 404。
保存相同服务
当前 save 使用服务名称作为键,因此重复保存会覆盖旧值。如果应用需要保留完整检查历史,就不能继续用这一份契约假装已经实现;应该引入记录标识、检查时间,并提供查询历史的方法。
查询全部结果
数据规模很小时,findAll() 可以返回完整列表。当前契约还约定结果按服务名称升序排列,数据库实现也应该使用 ORDER BY 保持相同行为。接入真实数据库后,结果可能不断增长,此时应该增加分页和排序参数,而不是先读取整张表再在 Java 中截取。
异常边界
无效服务名称是业务输入错误,由 Service 判断;内存故障、SQL 执行失败或连接不可用属于数据访问失败,由 Repository 或底层框架报告。两类异常的含义不同,不应该统一转换成「数据不存在」。
方法契约越明确,更换实现时越不容易出现行为变化。接口屏蔽的是存储技术,不是存储语义。
6. @Repository 与 Spring Data
@Repository 是组件角色注解,不是自动生成数据访问代码的开关。给普通类添加它以后,Spring 会把类注册为 Bean,但不会凭空实现 save(),也不会自动连接数据库。
当前手写 Repository 的组成如下:
| 部分 | 当前实现 |
|---|---|
| 契约 | HealthStatusRepository 接口 |
| 实现 | InMemoryHealthStatusRepository 类 |
| 数据来源 | ConcurrentHashMap |
| Bean 注册 | 实现类上的 @Repository |
Spring Data 则提供另一种机制:接口继承对应模块的 Repository 基础接口后,框架在运行时生成代理实现,并根据方法签名、注解或显式查询完成数据访问。这样的接口通常不需要自己再编写实现类。
| 手写 Repository | Spring Data Repository |
|---|---|
| 项目自己编写实现类 | 框架为符合规则的接口创建实现 |
| 实现代码显式访问内存或数据库 | 由 JPA、JDBC、MongoDB 等具体模块支持 |
| 查询逻辑完全显式 | 可以使用派生查询或显式查询 |
| 当前不需要数据库依赖 | 需要相应 Starter、连接配置和映射模型 |
findByService 在当前普通接口中只是一个方法名,Spring 不会解析它。只有接口被相应 Spring Data 模块识别时,派生查询方法名才具有生成查询的含义。
因此,Repository 模式、@Repository 注解和 Spring Data Repository 是三个相关但不同的概念:模式定义职责边界,注解声明组件角色,Spring Data 则提供特定数据技术的实现机制。
7. 边界与测试
Repository 的公开方法应该围绕上层需要的数据能力设计,并避免让存储细节向外扩散:
| 适合进入 Repository | 不适合进入 Repository |
|---|---|
| 数据保存和条件查询 | HTTP 请求与响应 DTO |
| 数据来源需要的转换 | Controller 的状态码 |
| 分页、排序和并发访问 | Service 的业务流程顺序 |
| 持久化异常处理 | 当前用户的请求状态 |
| 数据模型与存储结构映射 | 与数据访问无关的复杂计算 |
测试当前内存实现时不需要启动 Spring 容器,可以像普通 Java 类一样直接创建对象:
01package cn.usehook.spring.health;0203import org.junit.jupiter.api.Test;0405import static org.assertj.core.api.Assertions.assertThat;0607class InMemoryHealthStatusRepositoryTests {0809private final HealthStatusRepository repository = new InMemoryHealthStatusRepository();1011@Test12void savesAndFindsLatestStatus() {13repository.save(new HealthStatus("UP", "postgresql"));14repository.save(new HealthStatus("DOWN", "postgresql"));1516assertThat(repository.findByService("postgresql"))17.contains(new HealthStatus("DOWN", "postgresql"));18}1920@Test21void returnsEmptyWhenServiceDoesNotExist() {22assertThat(repository.findByService("redis")).isEmpty();23}2425@Test26void returnsStatusesSortedByService() {27repository.save(new HealthStatus("UP", "redis"));28repository.save(new HealthStatus("UP", "postgresql"));2930assertThat(repository.findAll())31.extracting(HealthStatus::service)32.containsExactly("postgresql", "redis");33}34}
这个测试验证的是 Repository 契约:相同服务保存新状态后能读到最新值,不存在的服务返回空结果,查询全部状态时顺序稳定。它不需要启动 Spring 容器,也不测试 Service 的名称规范化规则。
接入数据库以后,Repository 测试还需要验证 SQL、映射和真实数据库行为,那时应使用针对数据层的集成测试。无论数据来源怎样变化,Controller 继续只依赖 Service,Repository 也不会接收下一章要讲的 HTTP DTO。