接口可以把调用方与具体实现分开,但接口的方法仍然需要准确表达输入和返回值。后面的 Repository 会保存多条 HealthStatus,按服务名称查找一条记录,并返回分页结果,这些能力离不开集合与泛型。
本章继续使用普通 Java 对象,依次理解 List、Map、泛型和 Optional。最后再学习最常见的 Stream 转换,让后续数据访问代码中的方法签名不再只是需要记忆的写法。
1. 多个对象
上一章中的变量一次只引用一个对象:
1HealthStatus status = new HealthStatus("UP", "spring");
当程序需要保存多次检查结果时,可以使用数组:
1HealthStatus[] statuses = {2new HealthStatus("UP", "spring"),3new HealthStatus("UP", "postgresql")4};
数组的长度在创建后不能改变。应用数据通常会持续增加、删除或查询,因此 Java 集合框架提供了更适合动态数据的接口与实现。
当前项目会遇到三种典型需求:
| 需求 | 合适的类型 | 原因 |
|---|---|---|
| 按顺序返回多条状态 | List<HealthStatus> | 保留元素顺序,可以按索引访问 |
| 按服务名称保存最新状态 | Map<String, HealthStatus> | 使用服务名称作为键快速查找 |
| 查询结果可能不存在 | Optional<HealthStatus> | 在返回类型中明确表达缺失 |
List 和 Map 是接口,ArrayList、HashMap、ConcurrentHashMap 等类提供不同实现。变量通常声明为接口类型,让调用代码只依赖需要的集合能力。
2. List
List<E> 表示有顺序、允许重复的元素序列,其中 E 是元素类型。创建一份不可修改的示例列表:
1List<HealthStatus> statuses = List.of(2new HealthStatus("UP", "spring"),3new HealthStatus("DOWN", "postgresql")4);56HealthStatus first = statuses.get(0);7int count = statuses.size();
代码需要引入 java.util.List。索引从 0 开始,因此 get(0) 取得第一项。访问小于 0 或大于等于 size() 的位置会抛出 IndexOutOfBoundsException。
List.of() 创建的列表不能增加、删除或替换元素。需要在内存中修改列表时,可以使用 ArrayList:
1List<HealthStatus> statuses = new ArrayList<>();2statuses.add(new HealthStatus("UP", "spring"));3statuses.add(new HealthStatus("UP", "postgresql"));4statuses.remove(0);
这段代码还要引入 java.util.ArrayList。左侧仍然声明为 List<HealthStatus>,右侧才选择具体实现,调用方因此不会依赖 ArrayList 特有细节。
可以使用增强 for 循环依次读取元素:
1for (HealthStatus status : statuses) {2System.out.println(status.service());3}
集合自身可变与集合中对象可变是两件事。List.of() 不允许替换元素,但如果元素本身是可变对象,它的字段仍可能改变。当前 HealthStatus 使用 record,因此对象创建后状态不会被重新赋值。
3. Map
Map<K, V> 保存键和值之间的对应关系。当前项目希望每个服务只保留一条最新状态,因此可以让服务名称成为键:
1Map<String, HealthStatus> statuses = new HashMap<>();23statuses.put("spring", new HealthStatus("UP", "spring"));4statuses.put("postgresql", new HealthStatus("UP", "postgresql"));56HealthStatus springStatus = statuses.get("spring");
代码需要引入 java.util.Map 和 java.util.HashMap。put() 使用相同键时会替换旧值,所以这份 Map 天然表达「每个服务的最新状态」,而不是完整历史记录。
get() 返回 null 可能表示键不存在,也可能表示键存在但值就是 null。当前项目不保存 null,但 Repository 的公开查询仍会使用 Optional 明确告诉调用方结果可能缺失,而不是把 null 的含义留给调用方猜测。
普通 HashMap 不能保证多个线程同时读写时仍然正确。Spring Web 应用可能并发处理请求,后面的内存 Repository 会使用 ConcurrentHashMap。它仍然实现 Map 接口,但提供适合并发访问的实现策略。
需要返回 Map 中的全部状态时,可以取得值集合,再复制成列表:
1List<HealthStatus> allStatuses = List.copyOf(statuses.values());
复制后的列表不会随着原 Map 的后续修改而变化,也不会把 Map 的可修改视图直接暴露给调用方。
4. 泛型
尖括号中的类型就是泛型参数。List<HealthStatus> 表示这个列表只接受 HealthStatus,Map<String, HealthStatus> 则表示键是 String、值是 HealthStatus。
泛型让错误在编译阶段暴露:
1List<HealthStatus> statuses = new ArrayList<>();2statuses.add(new HealthStatus("UP", "spring"));3// statuses.add("UP"); // 编译失败:String 不是 HealthStatus
读取元素时也不需要手动把 Object 转回具体类型。编译器已经知道 statuses.get(0) 的结果是 HealthStatus。
类和 record 也可以声明自己的类型参数。分页结果不需要固定只能保存心跳记录:
01package cn.usehook.spring.health;0203import java.util.List;0405public record PageResult<T>(06List<T> items,07int page,08int size,09long totalElements10) {}
T 是由使用方决定的占位类型:
1PageResult<HealthStatus> result = new PageResult<>(2statuses,30,420,5statuses.size()6);
创建对象时,T 被确定为 HealthStatus,所以 items() 的返回类型就是 List<HealthStatus>。菱形语法 <> 允许编译器从左侧和构造参数推断重复的类型信息。
5. Optional
按服务名称查询时,数据库或 Map 中可能没有对应记录。Optional<T> 用一个对象表达「存在一个 T」或「没有结果」:
1HealthStatus status = statuses.get("redis");2Optional<HealthStatus> result = Optional.ofNullable(status);
代码需要引入 java.util.Optional。ofNullable() 在参数为 null 时返回空 Optional,否则包装该对象。已经确定非空时可以使用 Optional.of(),明确没有结果时可以使用 Optional.empty()。
调用方可以转换存在的结果,并为缺失提供默认值:
1String service = result2.map(HealthStatus::service)3.orElse("unknown");
map() 只在值存在时执行转换,空 Optional 会继续保持为空;orElse() 再提供回退值。后面 Repository 会返回 Optional<HealthStatus>,Service 根据具体用例决定缺失时返回默认值、继续保持为空,还是抛出业务异常。
不要把 Optional 用作所有字段的通用包装,也不要调用 get() 后期待值一定存在。它最适合用作可能没有单个查询结果的方法返回值;集合查询没有结果时直接返回空列表,通常比 Optional<List<T>> 更清楚。
6. Stream
集合可以通过 Stream 描述一连串读取与转换操作。理解 Stream 以前,需要先认识函数式接口和 Lambda。
函数式接口是只有一个抽象方法的接口。Java 标准库已经提供了常见类型:
| 函数式接口 | 抽象方法的输入与输出 | 常见用途 |
|---|---|---|
Predicate<T> | 接收 T,返回 boolean | 判断元素是否满足条件 |
Function<T, R> | 接收 T,返回 R | 把一种值转换为另一种值 |
Consumer<T> | 接收 T,没有返回值 | 消费元素,例如输出或写入 |
Supplier<T> | 没有参数,返回 T | 延迟提供一个值 |
Lambda 是函数式接口的一种紧凑实现方式。它没有脱离类型系统独立存在,必须由赋值位置、方法参数或返回类型提供目标函数式接口:
1Predicate<HealthStatus> healthy = status -> "UP".equals(status.status());2Function<HealthStatus, String> serviceName = status -> status.service();34boolean springIsHealthy = healthy.test(new HealthStatus("UP", "spring"));5String name = serviceName.apply(new HealthStatus("UP", "postgresql"));
这段代码需要引入 java.util.function.Predicate 和 java.util.function.Function。箭头左侧是参数,右侧是表达式或代码块。表达式结果会成为返回值;需要多条语句时使用代码块,并显式编写 return:
1Function<HealthStatus, String> description = status -> {2String normalized = status.service().trim();3return status.status() + ":" + normalized;4};
Lambda 可以读取外部局部变量,但该变量必须是 final 或事实不可变,也就是赋值以后不再改变:
1String requiredStatus = "UP";2Predicate<HealthStatus> matches = status -> requiredStatus.equals(status.status());
这个限制避免同一段 Lambda 在稍后执行时面对不断变化的局部变量。对象字段和集合本身仍可能可变,所以「可以捕获引用」不表示并发读写自动安全。
当 Lambda 只是把参数原样交给一个已有方法时,可以使用方法引用。HealthStatus::service 等价于 status -> status.service();System.out::println 则等价于 value -> System.out.println(value)。方法引用没有改变执行逻辑,只减少重复的参数转发。
现在从所有状态中筛选可用服务,再只保留服务名称:
1List<String> availableServices = statuses.stream()2.filter(status -> "UP".equals(status.status()))3.map(HealthStatus::service)4.toList();
stream() 创建处理序列,filter() 保留满足条件的元素,map() 把元素转换成另一种值,toList() 才收集最终结果。
filter() 的参数类型是 Predicate<HealthStatus>,因此编译器知道 status -> ... 必须返回布尔值。map() 需要把 HealthStatus 转换为另一种类型,方法引用返回的 String 使后续 Stream 成为 Stream<String>。
Stream 的中间操作通常是惰性的。只调用 filter() 和 map() 时只是描述处理步骤,直到 toList()、forEach()、count() 等终止操作出现,元素才真正流过处理链。一个 Stream 终止后不能再次使用;需要重新处理时,应从集合创建新的 Stream。
Stream 不会自动让查询更高效。对内存列表调用 stream(),筛选仍然发生在 Java 进程中;数据库数据应尽量通过 SQL 条件筛选后再返回。后面的分页文章会继续保持这个边界。
现在已经可以读懂 Repository 中的 List<HealthStatus>、Map<String, HealthStatus> 和 Optional<HealthStatus>,也能理解分页结果为什么使用泛型。下一篇补上 Java 异常传播,解释方法失败时怎样中断正常返回路径;随后再学习注解与反射,最后进入 IoC 容器与 Spring Bean。