迭代器模式-在区域健康档案导出中的实战应用
今天聊一聊迭代器模式。前面我们从 if-else 视角聊了策略、状态、职责链和工厂模式;从这一篇开始,换一个视角:for 循环背后,到底藏着什么样的对象关系?
还是先回顾一下经典设计模式中迭代器模式的内容。
一、先看经典实现
1.1 概念和代码结构
迭代器模式(Iterator Pattern)属于对象行为型模式。
GoF 对它的经典定义是:
提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。
迭代器模式主要解决两个问题:
- 封装遍历过程:把当前位置、是否结束、如何取得下一个元素等逻辑从客户端拿走;
- 隔离聚合内部结构:客户端不需要知道聚合底层是数组、链表、树,还是数据库分页结果。
经典迭代器模式通常包含四个角色:
| 角色 | 职责 |
|---|---|
| Iterator | 定义 hasNext()、next() 等遍历接口 |
| ConcreteIterator | 保存当前遍历位置,实现具体遍历算法 |
| Aggregate | 定义创建迭代器的接口 |
| ConcreteAggregate | 创建与自身内部结构匹配的具体迭代器 |
Client
│ 使用
▼
Iterator ◀──── 创建 ──── Aggregate
▲ ▲
│ │
ConcreteIterator ConcreteAggregate
│ │
└──── 根据内部结构进行遍历 ─┘
再看看示例代码:
import java.util.NoSuchElementException;
/**
* 抽象迭代器:定义统一遍历方式。
*/
public interface Iterator<T> {
boolean hasNext();
T next();
}
/**
* 抽象聚合:声明创建迭代器的方法。
*/
public interface Aggregate<T> {
Iterator<T> iterator();
}
/**
* 具体聚合:内部使用数组保存元素。
*/
public class StringCollection implements Aggregate<String> {
private final String[] items;
public StringCollection(String[] items) {
this.items = items == null ? new String[0] : items.clone();
}
@Override
public Iterator<String> iterator() {
// 每次创建新的迭代器,遍历位置互不共享
return new StringIterator();
}
/**
* 具体迭代器:作为内部类访问聚合的私有数组。
*/
private class StringIterator implements Iterator<String> {
private int index;
@Override
public boolean hasNext() {
return index < items.length;
}
@Override
public String next() {
if (!hasNext()) {
throw new NoSuchElementException("没有更多元素");
}
return items[index++];
}
}
}
StringCollection 是 ConcreteAggregate,内部的 StringIterator 是 ConcreteIterator。客户端不需要访问 items 数组,也不需要自己维护 index。
客户端调用:
public class ClassicIteratorDemo {
public static void main(String[] args) {
StringCollection collection = new StringCollection(
new String[]{"语文", "数学", "英语"});
Iterator<String> iterator = collection.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
}
运行结果:
语文
数学
英语
1.2 啥时候用迭代器模式
看完上面经典实现的示例,我们发现 JDK 中默认的 Iterator 已经具备这些能力,Java 中常用的集合类也都已经实现了迭代器,那么我们还有什么场景需要自己实现迭代器?其实还是有很多场景的,我列举几个例子:
- 普通的 Java 集合遍历都是在内存中进行的。如果要遍历数据库中的大数据集,我们就会考虑分页查询,这时就可以用迭代器方式来实现数据的遍历。另外,数据来源于多数据源、大文件等等,Java 自带的迭代器无法直接满足这类数据的遍历需求,用迭代器模式可以很好地封装数据迭代的过程。
- 数据集是树形结构或自定义结构时,也需要自己定义数据集的迭代方式。
下面我们就用实际案例来说明一下迭代器模式在实际项目中的应用。
二、健康档案数据批量导出
2.1 介绍一下业务背景
区域卫生平台需要支持用户导出某个区域内的居民健康档案,导出要求如下:
- 数据量较大,可能达到几十万甚至更多记录;
- 需要采用游标分页查询,不能一次性把全部记录加载到内存;
- 导出结果需要写入 Excel 文件;
- Excel 使用 EasyExcel 分批写入;
- 文件生成后保存到服务端或文件存储,用户再通过下载地址获取;
- 导出耗时较长时,不应一直占用浏览器请求,宜通过异步任务执行。
导出字段包括:档案编号、居民姓名、证件号码、性别、民族、血型、联系电话、疾病史、过敏史、所属区域和最近就诊时间。
完整流程如下:
用户创建导出任务
↓
后台异步执行导出
↓
游标分页读取区域健康档案
↓
逐条遍历、分批写入 Excel
↓
保存文件并更新任务状态
↓
用户下载已生成的文件
如果导出服务一次性查询全部数据,代码可能写成:
public void export(String regionCode, OutputStream outputStream) {
// 数据量大时,这里会一次性占用大量堆内存
List<HealthArchiveExportRow> rows =
healthArchiveMapper.selectExportRows(regionCode);
EasyExcel.write(outputStream, HealthArchiveExportRow.class)
.sheet("区域健康档案")
.doWrite(rows);
}
这段代码实现简单,但不适合大数据量导出:
- 查询阶段内存过高:全部记录先进入 List;
- 遍历细节侵入业务方法:如果改为分页导出,也能实现,但是需要在业务代码中自己管理 cursor、pageSize 和 hasNext(现实中很多人就是这么做的);
- 其他调用方容易重复:数据同步、质量分析等场景如果也要遍历全部档案,就会再写一遍分页循环;
- 遍历方式无法独立测试:分页遍历和 Excel 写入混在一起,难以单独验证。
这时就可以考虑:把跨页遍历封装成一个聚合对象,文件写入方仍然只使用普通的 for 循环。
2.2 先定迭代器相关对象
2.2.1 区域健康档案记录
import lombok.Data;
import java.time.LocalDateTime;
/**
* 区域健康档案记录(只列出基本信息)。
*/
@Data
public class HealthArchiveRecord {
private String archiveId;
private String residentName;
private String idNumber;
private String gender;
private String nation;
private String bloodType;
private String phone;
private String diseaseHistory;
private String allergyHistory;
private String regionName;
private LocalDateTime lastVisitTime;
}
2.2.2 通用分页结果
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class PageSlice<T> {
private final List<T> records;
private final String nextCursor;
private final boolean hasNext;
public PageSlice(List<T> records, String nextCursor, boolean hasNext) {
// 和 record 紧凑构造器逻辑完全一致
this.records = records == null ? Collections.emptyList() : List.copyOf(records);
this.nextCursor = nextCursor;
this.hasNext = hasNext;
if (hasNext && (nextCursor == null || nextCursor.isBlank())) {
throw new IllegalArgumentException("存在下一页时,nextCursor 不能为空");
}
}
public List<T> records() {
return records;
}
public String nextCursor() {
return nextCursor;
}
public boolean hasNext() {
return hasNext;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PageSlice<?> pageSlice = (PageSlice<?>) o;
return hasNext == pageSlice.hasNext
&& Objects.equals(records, pageSlice.records)
&& Objects.equals(nextCursor, pageSlice.nextCursor);
}
@Override
public int hashCode() {
return Objects.hash(records, nextCursor, hasNext);
}
@Override
public String toString() {
return "PageSlice[" +
"records=" + records +
", nextCursor=" + nextCursor +
", hasNext=" + hasNext +
']';
}
}
PageSlice 只表达数据库分页结果,不负责迭代。
2.2.3 分页加载器
/**
* 健康档案分页加载器:隔离数据库 Mapper 或远程接口。
*/
@FunctionalInterface
public interface HealthArchivePageLoader {
PageSlice<HealthArchiveRecord> load(
String regionCode, String cursor, int pageSize);
}
迭代器只依赖这个接口,不直接依赖 Mapper,因此可以使用内存数据进行单元测试。
2.2.4 具体迭代器
import java.util.Collections;
import java.util.Iterator;
import java.util.NoSuchElementException;
/**
* 健康档案游标分页迭代器。
*
* 对外只暴露 hasNext()/next();
* 对内负责按需加载下一页、跳过空页、推进游标和判断终止。
*/
public class HealthArchiveCursorIterator implements Iterator<HealthArchiveRecord> {
private final String regionCode;
private final int pageSize;
private final HealthArchivePageLoader pageLoader;
private Iterator<HealthArchiveRecord> currentBatch = Collections.emptyIterator();
private String cursor;
private boolean finished;
public HealthArchiveCursorIterator(String regionCode, int pageSize,
HealthArchivePageLoader pageLoader) {
this.regionCode = regionCode;
this.pageSize = pageSize;
this.pageLoader = pageLoader;
this.cursor = null; // 第一页游标为 null
this.finished = false;
}
@Override
public boolean hasNext() {
// 当前页已经消费完,但后面还有数据时,继续加载下一页。
// 使用 while 是为了跳过“有下一页游标,但当前页没有记录”的空页。
while (!currentBatch.hasNext() && !finished) {
loadNextPage();
}
return currentBatch.hasNext();
}
@Override
public HealthArchiveRecord next() {
if (!hasNext()) {
throw new NoSuchElementException("没有更多健康档案记录");
}
return currentBatch.next();
}
private void loadNextPage() {
PageSlice<HealthArchiveRecord> page =
pageLoader.load(regionCode, cursor, pageSize);
if (page == null) {
throw new IllegalStateException("分页加载结果不能为 null");
}
currentBatch = page.records().iterator();
if (!page.hasNext()) {
finished = true;
return;
}
// 有下一页时,游标必须向前推进,否则可能反复查询同一页。
if (page.nextCursor().equals(cursor)) {
throw new IllegalStateException("分页游标未推进,请检查数据访问实现");
}
cursor = page.nextCursor();
}
}
这段实现体现了迭代器模式的本质:
hasNext()不只是看当前 List 是否还有元素,还可能触发下一页加载;next()遵守Iterator约定,没有元素时抛出NoSuchElementException;- 文件写入方完全不知道分页的存在;
- 空页和游标不推进等异常集中在迭代器中处理。
2.2.5 聚合对象:区域健康档案
import java.util.Iterator;
/**
* 区域健康档案集合(聚合对象)。
*
* 对外实现 Iterable,调用方可以直接使用增强 for;
* 每次 iterator() 都创建一个全新的迭代器,遍历位置互不共享。
*/
public class RegionalHealthArchive implements Iterable<HealthArchiveRecord> {
private final String regionCode;
private final int pageSize;
private final HealthArchivePageLoader pageLoader;
public RegionalHealthArchive(String regionCode, int pageSize,
HealthArchivePageLoader pageLoader) {
if (regionCode == null || regionCode.isBlank()) {
throw new IllegalArgumentException("区域编码不能为空");
}
if (pageSize <= 0) {
throw new IllegalArgumentException("pageSize 必须大于 0");
}
if (pageLoader == null) {
throw new IllegalArgumentException("pageLoader 不能为空");
}
this.regionCode = regionCode;
this.pageSize = pageSize;
this.pageLoader = pageLoader;
}
@Override
public Iterator<HealthArchiveRecord> iterator() {
return new HealthArchiveCursorIterator(regionCode, pageSize, pageLoader);
}
}
这里有一个重要细节:聚合对象不保存遍历位置。每次调用 iterator() 都创建新对象,两个导出任务的遍历过程互不干扰。
2.3 分批生成 Excel 文件
2.3.1 引入 EasyExcel
Excel 工具使用 EasyExcel。在大数据量导出到 Excel 这个场景下,EasyExcel 比 POI 更适合,当然用 POI 也没问题。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
2.3.2 定义 Excel 导出行
其实就是定义 Excel 结构,和业务模型分开,避免导出格式变化影响领域对象。
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 健康档案 Excel 导出行。
*/
@Data
public class HealthArchiveExportRow {
@ExcelProperty("档案编号")
private String archiveId;
@ExcelProperty("居民姓名")
private String residentName;
@ExcelProperty("证件号码")
private String idNumber;
@ExcelProperty("性别")
private String gender;
@ExcelProperty("民族")
private String nation;
@ExcelProperty("血型")
private String bloodType;
@ExcelProperty("联系电话")
private String phone;
@ExcelProperty("疾病史")
private String diseaseHistory;
@ExcelProperty("过敏史")
private String allergyHistory;
@ExcelProperty("所属区域")
private String regionName;
@ExcelProperty("最近就诊时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime lastVisitTime;
public HealthArchiveExportRow() {
// 供 EasyExcel 读取时使用
}
public static HealthArchiveExportRow from(HealthArchiveRecord record) {
HealthArchiveExportRow row = new HealthArchiveExportRow();
row.archiveId = record.getArchiveId();
row.residentName = record.getResidentName();
row.idNumber = record.getIdNumber();
row.gender = record.getGender();
row.nation = record.getNation();
row.bloodType = record.getBloodType();
row.phone = record.getPhone();
row.diseaseHistory = record.getDiseaseHistory();
row.allergyHistory = record.getAllergyHistory();
row.regionName = record.getRegionName();
row.lastVisitTime = record.getLastVisitTime();
return row;
}
}
2.3.3 定义导出文件描述
import java.nio.file.Path;
/**
* 已生成的导出文件。
*
* @param fileName 用户下载时看到的文件名
* @param path 服务端文件路径
* @param contentType 下载响应的媒体类型
*/
public record ExportFileDescriptor(
String fileName, Path path, String contentType) {
}
2.3.4 实现 Excel 导出服务
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 区域健康档案文件导出服务。
*
* 数据库分页由迭代器负责;本服务只负责逐条消费并写入文件。
*/
public class HealthArchiveExportService {
private static final String XLSX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private final HealthArchivePageLoader pageLoader;
private final Path exportRoot;
private final int pageSize;
private final int batchSize;
public HealthArchiveExportService(HealthArchivePageLoader pageLoader,
Path exportRoot,
int pageSize,
int batchSize) {
if (pageLoader == null || exportRoot == null) {
throw new IllegalArgumentException("pageLoader 和 exportRoot 不能为空");
}
if (pageSize <= 0 || batchSize <= 0) {
throw new IllegalArgumentException("pageSize 和 batchSize 必须大于 0");
}
this.pageLoader = pageLoader;
this.exportRoot = exportRoot.toAbsolutePath().normalize();
this.pageSize = pageSize;
this.batchSize = batchSize;
try {
Files.createDirectories(this.exportRoot);
} catch (IOException e) {
throw new UncheckedIOException("创建导出目录失败", e);
}
}
public ExportFileDescriptor exportExcel(String regionCode, String exportId) {
Path target = targetPath(exportId, ".xlsx");
RegionalHealthArchive archives = archives(regionCode);
writeExcel(archives, target);
return new ExportFileDescriptor(
"区域健康档案-" + exportId + ".xlsx", target, XLSX_CONTENT_TYPE);
}
private RegionalHealthArchive archives(String regionCode) {
return new RegionalHealthArchive(regionCode, pageSize, pageLoader);
}
private Path targetPath(String exportId, String suffix) {
// exportId 由服务端生成;这里同时限制字符,避免路径穿越。
if (exportId == null || !exportId.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException("exportId 只能包含字母、数字、下划线和连字符");
}
return exportRoot.resolve(exportId + suffix).normalize();
}
private void writeExcel(Iterable<HealthArchiveRecord> archives, Path target) {
try (OutputStream outputStream = Files.newOutputStream(target)) {
ExcelWriter excelWriter = EasyExcel
.write(outputStream, HealthArchiveExportRow.class)
.build();
try {
WriteSheet writeSheet = EasyExcel
.writerSheet("区域健康档案")
.build();
List<HealthArchiveExportRow> batch = new ArrayList<>(batchSize);
for (HealthArchiveRecord record : archives) {
batch.add(HealthArchiveExportRow.from(record));
if (batch.size() >= batchSize) {
excelWriter.write(batch, writeSheet);
batch.clear();
}
}
if (!batch.isEmpty()) {
excelWriter.write(batch, writeSheet);
}
} finally {
// 无论是否发生异常,都要完成或释放 Excel 写入资源。
excelWriter.finish();
}
} catch (IOException e) {
throw new UncheckedIOException("生成健康档案 Excel 文件失败", e);
}
}
}
这段代码中有两个不同层次的“批量”:
| 批量方式 | 本文参数 | 作用 |
|---|---|---|
| 数据库游标分页 | pageSize | 控制每次从数据库读取多少条记录 |
| 文件分批写入 | batchSize | 控制每次交给 EasyExcel 多少行数据 |
内存中通常只保留当前数据库页、当前文件写入批次以及迭代器状态,而不是整个区域的健康档案。
三、扩展场景:组织机构迭代器
树形结构也适合使用迭代器模式。客户端往往只关心“按某种顺序访问所有节点”,并不关心递归、栈或子节点加载方式。最常见的树形结构就是组织机构,下面我们简单实现一个组织机构的迭代器。
3.1 定义组织机构节点与树形聚合
import java.util.List;
/**
* 组织机构节点。
*/
public class OrgNode {
private final String orgCode;
private final String orgName;
private final List<OrgNode> children;
public OrgNode(String orgCode, String orgName, List<OrgNode> children) {
if (orgCode == null || orgCode.isBlank()) {
throw new IllegalArgumentException("机构编码不能为空");
}
if (orgName == null || orgName.isBlank()) {
throw new IllegalArgumentException("机构名称不能为空");
}
this.orgCode = orgCode;
this.orgName = orgName;
this.children = children == null ? List.of() : List.copyOf(children);
}
public static OrgNode leaf(String orgCode, String orgName) {
return new OrgNode(orgCode, orgName, List.of());
}
public String getOrgCode() {
return orgCode;
}
public String getOrgName() {
return orgName;
}
public List<OrgNode> getChildren() {
return children;
}
}
import java.util.Iterator;
/**
* 组织机构树:向客户端提供统一的前序遍历入口。
*/
public class OrgTree implements Iterable<OrgNode> {
private final OrgNode root;
public OrgTree(OrgNode root) {
this.root = root;
}
@Override
public Iterator<OrgNode> iterator() {
return new PreOrderOrgIterator(root);
}
}
3.2 使用栈实现前序遍历迭代器
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
/**
* 组织机构前序遍历迭代器。
*
* 使用显式栈保存待访问节点,客户端不需要编写递归。
*/
public class PreOrderOrgIterator implements Iterator<OrgNode> {
private final Deque<OrgNode> stack = new ArrayDeque<>();
public PreOrderOrgIterator(OrgNode root) {
if (root != null) {
stack.push(root);
}
}
@Override
public boolean hasNext() {
return !stack.isEmpty();
}
@Override
public OrgNode next() {
if (!hasNext()) {
throw new NoSuchElementException("没有更多组织机构节点");
}
OrgNode current = stack.pop();
List<OrgNode> children = current.getChildren();
// 栈是后进先出,因此子节点需要倒序入栈,才能保持原有顺序出栈
for (int i = children.size() - 1; i >= 0; i--) {
stack.push(children.get(i));
}
return current;
}
}
3.3 使用示例
import java.util.List;
public class OrgTreeDemo {
public static void main(String[] args) {
OrgNode root = new OrgNode("01", "区域卫健中心", List.of(
new OrgNode("0101", "中心医院", List.of(
OrgNode.leaf("010101", "门诊部"),
OrgNode.leaf("010102", "住院部")
)),
OrgNode.leaf("0102", "社区卫生服务中心")
));
for (OrgNode node : new OrgTree(root)) {
System.out.println(node.getOrgCode() + " " + node.getOrgName());
}
}
}
运行结果:
01 区域卫健中心
0101 中心医院
010101 门诊部
010102 住院部
0102 社区卫生服务中心
四、与经典角色的对照
| 经典角色 | 本文实现 | 说明 |
|---|---|---|
| Iterator | java.util.Iterator<HealthArchiveRecord> | JDK 标准遍历接口 |
| ConcreteIterator | HealthArchiveCursorIterator | 实现跨页加载与游标推进 |
| Aggregate | java.lang.Iterable<HealthArchiveRecord> | 声明可以创建迭代器 |
| ConcreteAggregate | RegionalHealthArchive | 每次创建独立的游标迭代器 |
| 内部表示 | 分页、游标和终止条件 | 对文件写入方隐藏 |
| 迭代器客户端 | HealthArchiveExportService | 逐条消费并生成 Excel |
五、实战中的注意事项
- 普通 List 不要再包一层:如果数据已经全部在内存中,直接增强 for 即可,没有必要制造一个自定义迭代器;
- 遍历位置不要共享:每个迭代器都应持有自己的当前页和游标,不能把遍历位置放在单例 Service 中;
- 游标查询要有稳定排序:数据库应按唯一字段排序,例如
archive_id,下一页使用上一页最后一条记录的游标,避免导出期间新增或修改数据造成重复、遗漏; - 分页与写入批次要受控:
pageSize和batchSize应结合数据库负载、单行大小和堆内存测试确定,不是越大越好; - 避免长事务包住整个导出:按需迭代可能跨越多次数据库查询,不要让几十万行导出一直占用同一个数据库事务;
- 导出任务需要失败处理:文件写入异常时应删除不完整文件、记录失败原因并允许重试,不能把半成品标记为成功;
- 注意权限与隐私:证件号码、疾病史和过敏史均属于敏感信息,导出前应校验区域数据权限,根据业务要求进行脱敏,并记录导出审计日志;
- 文件要有生命周期:设置下载有效期和定期清理策略;对象存储场景可改用临时签名地址,而不是由应用服务器长期保存文件;
- EasyExcel 也需要控制内存:分批写入解决的是数据集合规模问题,实际内存仍受 EasyExcel 配置、文件格式和单行内容影响,需要进行大数据量压测。
六、总结
迭代器模式并不是把:
for (Item item : items) {
}
换成另一套更复杂的写法。它真正有价值的地方是:
遍历方式比循环本身复杂时,把遍历方式从业务代码中拿走。
本文中,导出服务仍然是普通的 for 循环,但分页、游标、空页和终止条件已经封装进迭代器;组织机构树同样如此,递归和栈被收进迭代器,客户端只剩一个增强 for。文件写入方只需要关心一件事:拿到下一条健康档案以后,怎样把它写入当前文件。
最终形成清晰分工:
RegionalHealthArchive:提供可遍历的区域健康档案
HealthArchiveCursorIterator:负责跨页取数
HealthArchiveExportService:负责生成 Excel
ExportTaskService:负责任务状态与下载权限
ExportDownloadController:负责提供已完成文件下载