迭代器模式-在区域健康档案导出中的实战应用

0 阅读14分钟

迭代器模式-在区域健康档案导出中的实战应用

今天聊一聊迭代器模式。前面我们从 if-else 视角聊了策略、状态、职责链和工厂模式;从这一篇开始,换一个视角:for 循环背后,到底藏着什么样的对象关系?

还是先回顾一下经典设计模式中迭代器模式的内容。

一、先看经典实现

1.1 概念和代码结构

迭代器模式(Iterator Pattern)属于对象行为型模式。

GoF 对它的经典定义是:

提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。

迭代器模式主要解决两个问题:

  1. 封装遍历过程:把当前位置、是否结束、如何取得下一个元素等逻辑从客户端拿走;
  2. 隔离聚合内部结构:客户端不需要知道聚合底层是数组、链表、树,还是数据库分页结果。

经典迭代器模式通常包含四个角色:

角色职责
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 介绍一下业务背景

区域卫生平台需要支持用户导出某个区域内的居民健康档案,导出要求如下:

  1. 数据量较大,可能达到几十万甚至更多记录;
  2. 需要采用游标分页查询,不能一次性把全部记录加载到内存;
  3. 导出结果需要写入 Excel 文件;
  4. Excel 使用 EasyExcel 分批写入;
  5. 文件生成后保存到服务端或文件存储,用户再通过下载地址获取;
  6. 导出耗时较长时,不应一直占用浏览器请求,宜通过异步任务执行。

导出字段包括:档案编号、居民姓名、证件号码、性别、民族、血型、联系电话、疾病史、过敏史、所属区域和最近就诊时间。

完整流程如下:

用户创建导出任务
        ↓
后台异步执行导出
        ↓
游标分页读取区域健康档案
        ↓
逐条遍历、分批写入 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 社区卫生服务中心

四、与经典角色的对照

经典角色本文实现说明
Iteratorjava.util.Iterator<HealthArchiveRecord>JDK 标准遍历接口
ConcreteIteratorHealthArchiveCursorIterator实现跨页加载与游标推进
Aggregatejava.lang.Iterable<HealthArchiveRecord>声明可以创建迭代器
ConcreteAggregateRegionalHealthArchive每次创建独立的游标迭代器
内部表示分页、游标和终止条件对文件写入方隐藏
迭代器客户端HealthArchiveExportService逐条消费并生成 Excel

五、实战中的注意事项

  • 普通 List 不要再包一层:如果数据已经全部在内存中,直接增强 for 即可,没有必要制造一个自定义迭代器;
  • 遍历位置不要共享:每个迭代器都应持有自己的当前页和游标,不能把遍历位置放在单例 Service 中;
  • 游标查询要有稳定排序:数据库应按唯一字段排序,例如 archive_id,下一页使用上一页最后一条记录的游标,避免导出期间新增或修改数据造成重复、遗漏;
  • 分页与写入批次要受控:pageSize 和 batchSize 应结合数据库负载、单行大小和堆内存测试确定,不是越大越好;
  • 避免长事务包住整个导出:按需迭代可能跨越多次数据库查询,不要让几十万行导出一直占用同一个数据库事务;
  • 导出任务需要失败处理:文件写入异常时应删除不完整文件、记录失败原因并允许重试,不能把半成品标记为成功;
  • 注意权限与隐私:证件号码、疾病史和过敏史均属于敏感信息,导出前应校验区域数据权限,根据业务要求进行脱敏,并记录导出审计日志;
  • 文件要有生命周期:设置下载有效期和定期清理策略;对象存储场景可改用临时签名地址,而不是由应用服务器长期保存文件;
  • EasyExcel 也需要控制内存:分批写入解决的是数据集合规模问题,实际内存仍受 EasyExcel 配置、文件格式和单行内容影响,需要进行大数据量压测。

六、总结

迭代器模式并不是把:

for (Item item : items) {
}

换成另一套更复杂的写法。它真正有价值的地方是:

遍历方式比循环本身复杂时,把遍历方式从业务代码中拿走。

本文中,导出服务仍然是普通的 for 循环,但分页、游标、空页和终止条件已经封装进迭代器;组织机构树同样如此,递归和栈被收进迭代器,客户端只剩一个增强 for。文件写入方只需要关心一件事:拿到下一条健康档案以后,怎样把它写入当前文件。

最终形成清晰分工:

RegionalHealthArchive:提供可遍历的区域健康档案
HealthArchiveCursorIterator:负责跨页取数
HealthArchiveExportService:负责生成 Excel
ExportTaskService:负责任务状态与下载权限
ExportDownloadController:负责提供已完成文件下载