跳转到正文
← 返回文章

设备运维工单开发03 多张工单存取与 Demo 自测

文章目录

这是“设备运维工单开发”系列第 3 篇,承接上一篇的 Map 集合与单张工单恢复。本文记录Java 学习路线中当天实际完成的练习,当前项目说明见项目文档。此前的学生管理系统仍保留在原系列。

今天把文件读写从 Demo 中提取出来,再从一张工单扩展到多张工单。另一个变化是:我开始在 Demo 里写明确的检查条件,自己判断结果是否正确,而不只是看几行输出或等助手检查。

这些代码是在逐步提示下编写、根据反馈修正的。正常读取已运行通过,我也实际试过把预期编号改错,确认测试会报错;最后布置的“第二张工单缺标题”练习还没有完成,留到下次。

1 把文件操作交给 TicketFileStore #

昨天,文件转换、读写和打印都挤在 Demo 中。今天把存取细节提取到 TicketFileStore:

类现在负责什么
Ticket一张工单的数据、业务校验和对象恢复
TicketService管理内存里的多张工单,添加、查询和筛选
TicketFileStore工单与 Properties 文件之间的转换和读写
TicketFileDemo准备示例数据,调用保存并显示结果
TicketLoadDemo调用读取,检查恢复结果并打印

我先提取了单张工单的 save 和 load,随后把保存参数改为列表,并实现返回列表的 loadAll。收尾时源码中使用的是:

public void save(List<Ticket> tickets, Path path) throws IOException
public List<Ticket> loadAll(Path path) throws IOException

TicketFileStore 不再负责打印“读取失败”。它把结果返回给 Demo,或者把异常传给调用方,让调用方决定如何反馈。

2 throws 声明与 throw 执行不是一回事 #

我一开始把方法后面的 throws IOException 和方法体里的 throw 混在一起。

public void save(List<Ticket> tickets, Path path) throws IOException {
    if (path == null) {
        throw new IllegalArgumentException("路径不能为null");
    }
    // 后面执行文件写入
}
  • throws IOException 写在方法声明里,表示这个方法可能把文件读写异常传给调用方;写了声明不代表每次执行都会抛异常。
  • throw new ... 是实际执行的语句。条件满足、执行到它时,当前正常执行路径就被中断。
  • 即使方法里没有自己写 throw new IOException(...),调用的文件 API 也可能抛出 IOException,再由本方法向外传递。

IOException 是受检异常,调用方通常需要捕获它,或者继续声明抛出。IllegalArgumentException 是运行时异常,不要求在方法声明里列出。

Demo 用两个分支区分故障:

try {
    List<Ticket> tickets = store.loadAll(path);
    // 检查并打印读取结果
} catch (IOException e) {
    System.out.println("读取失败:" + e.getMessage());
} catch (IllegalArgumentException e) {
    System.out.println("存档内容不合法:" + e.getMessage());
}

例如文件不存在会进入第一种;必要字段缺失、编号无法转换或业务校验不通过,会进入第二种。NumberFormatException 也是 IllegalArgumentException 的子类。

3 Path、Reader 和两个 load 各做什么 #

今天我问过:“path 自带的 load 和我写的是两个不一样的吗?”实际需要先看调用点左边的对象是什么。

Properties loaded = new Properties();

try (BufferedReader reader =
         Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    loaded.load(reader);
}
名称含义
pathPath 对象,描述文件位置;它没有这里所说的 load 方法
reader局部变量,引用一个 BufferedReader,按 UTF-8 将文件字节解码成字符
loadedProperties 对象,用来容纳解析出的字符串键值
loaded.load(reader)Java 提供的 Properties 方法,从 Reader 读取并解析键值
store.loadAll(path)自己写的方法,还要检查字段、转换类型并恢复工单列表

reader 只是变量名,不是 Java 关键字。try (...) 的资源管理写法会在结束时关闭 Reader,包括读取过程中发生异常的情况。

文件里有字符串,并不等于内存里已经有 Ticket。数据还要经过后续的解析和校验。

4 用列表保存多张工单 #

为了拿到服务中的全部工单,我增加了:

public List<Ticket> findAll() {
    return new ArrayList<>(ticketsById.values());
}

它返回一个新列表,清空这个列表不会删除服务中的记录;列表里的 Ticket 仍引用原来的对象。这和前一天的状态筛选结果一样,不是把每张工单都复制了一遍。

保存 Demo 创建两张工单,加入服务后调用:

Path path = Paths.get("tickets.properties");
store.save(service.findAll(), path);

在 save 内部,先检查列表和路径,再按序号生成键:

Properties data = new Properties();
data.setProperty("count", Integer.toString(tickets.size()));

for (int i = 0; i < tickets.size(); i++) {
    Ticket ticket = tickets.get(i);
    if (ticket == null) {
        throw new IllegalArgumentException("工单列表中不能为null");
    }

    String prefix = "ticket." + i + ".";
    data.setProperty(prefix + "id", Long.toString(ticket.getId()));
    data.setProperty(prefix + "title", ticket.getTitle());
    data.setProperty(prefix + "status", ticket.getStatus().name());
    if (ticket.getAssigneeId() == null) {
        data.setProperty(prefix + "assigneeId", "");
    } else {
        data.setProperty(prefix + "assigneeId",
                Long.toString(ticket.getAssigneeId()));
    }
}

try (BufferedWriter writer =
         Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    data.store(writer, "Ticket demo");
}

这里的 ticket.0、ticket.1 是记录序号,不是工单编号。若每轮都使用固定的 ticket.0.id,后面的值就会覆盖前面的值。

我还问过保存时是否需要再检查 title 为 null。在当前 Ticket 的公开接口下,创建和修改标题已经统一校验,所以这里检查列表、列表元素和路径;不重复实现标题规则。读取文件时的数据来自外部,仍必须检查缺字段并通过恢复入口校验。

所有字段准备好后才打开 Writer,能避免列表中途发现 null 时已经覆盖文件。但真正开始写入后的 I/O 失败保护尚未实现,不能把这点当作完整的原子保存。

5 count 现在真正参与读取了 #

单张 Demo 时只读 ticket.0,count 没有参与控制流程。扩展到多张后,它决定循环次数:

String countText = loaded.getProperty("count");
if (countText == null) {
    throw new IllegalArgumentException("存档缺少工单数量");
}

int count = Integer.parseInt(countText);
if (count < 0) {
    throw new IllegalArgumentException("工单数量不能为负数");
}

count 为 0 时循环不执行,方法返回空列表。缺少 count、负数或无法解析的文本则被拒绝。这些规则已经写入源码,但今天没有逐一完成对应的存档测试。

循环恢复的关键代码是:

List<Ticket> result = new ArrayList<>();

for (int i = 0; i < count; i++) {
    String prefix = "ticket." + i + ".";
    String idText = loaded.getProperty(prefix + "id");
    String title = loaded.getProperty(prefix + "title");
    String statusText = loaded.getProperty(prefix + "status");
    String assigneeText = loaded.getProperty(prefix + "assigneeId");

    if (idText == null || title == null
            || statusText == null || assigneeText == null) {
        throw new IllegalArgumentException("存档缺少必要字段");
    }

    long id = Long.parseLong(idText);
    TicketStatus status = TicketStatus.valueOf(statusText);
    Long assigneeId;
    if (assigneeText.isEmpty()) {
        assigneeId = null;
    } else {
        assigneeId = Long.valueOf(assigneeText);
    }

    result.add(Ticket.restore(id, title, status, assigneeId));
}

return result;

我问“restore 存的是什么”时,进一步分清了两件事:Ticket.restore(...) 创建并还原一个内存对象;result.add(...) 把这个对象的引用加入列表。restore 本身不写文件。

恢复入口内部先检查状态与处理人组合,再通过 new Ticket(id, title) 复用编号和标题校验,设置合法的状态和处理人,最后返回对象。保存前后字段相同,不代表它们是同一个内存对象。

如果第二张工单出错,循环里的异常会让方法在 return result 之前结束,调用者拿不到只包含第一张工单的正常返回值。当前仍未将这个列表整体装入 TicketService,服务替换和失败保护要继续实现。

6 Demo 报错的两个原因 #

改造 TicketLoadDemo 时,我把读取和循环输出放进去,却遗漏了外层 try {,留下了跟在 for 后面的 catch。

正确嵌套关系是:

try {
    List<Ticket> tickets = store.loadAll(path);
    for (Ticket ticket : tickets) {
        // 输出每张工单
    } // 结束 for
} catch (IOException e) { // 结束 try 后处理异常
    System.out.println("读取失败:" + e.getMessage());
} catch (IllegalArgumentException e) {
    System.out.println("存档内容不合法:" + e.getMessage());
}

另一个问题是文件名:保存用 tickets.properties,读取却写成了 ticket.properties,少一个 s。前一个问题属于编译错误;文件名不一致则可能导致运行时找不到文件或读错文件。

修正后,读回了两张工单:

读回数量:2
1001 | 投影仪无信号 | IN_PROGRESS | 20
1002 | 打印机卡纸 | OPEN | null

7 Demo 也能测试,但要明确谁判断结果 #

今天讨论了一个问题:既然每次写完都要测试,那之前写的 Demo 不就是测试代码吗?

Demo 确实可以用来测试。最初的方式是准备数据、调用方法、打印输出,再由我对照预期判断。现在增加明确的判断条件,让代码自己发现结果不一致。

数量先检查,再访问下标,避免没读到两张工单时直接 get(1):

if (tickets.size() != 2) {
    throw new AssertionError("应该读回2张工单,实际为:" + tickets.size());
}

接着检查字段,下面是正常验收应使用的预期值:

Ticket first = tickets.get(0);
if (first.getId() != 1001L
        || !"投影仪无信号".equals(first.getTitle())
        || first.getStatus() != TicketStatus.IN_PROGRESS
        || !Long.valueOf(20L).equals(first.getAssigneeId())) {
    throw new AssertionError("第一张工单的数据恢复不正确");
}

Ticket second = tickets.get(1);
if (second.getId() != 1002L
        || !"打印机卡纸".equals(second.getTitle())
        || second.getStatus() != TicketStatus.OPEN
        || second.getAssigneeId() != null) {
    throw new AssertionError("第二张工单的数据恢复不正确");
}

System.out.println("测试通过:两张工单的所有字段都正确");

|| 表示任意一项不符合预期就失败。编号是 long,可以直接比较;处理人是允许 null 的 Long,已派单时用确定非空的 Long.valueOf(20L).equals(...) 比较数值,未派单时直接检查 != null。

null 不加引号;"null" 是字符串,也不是空字符串 ""。第二张工单预期没有处理人,所以“处理人不为 null”才是失败条件。

AssertionError 不会被现有的 IOException 和 IllegalArgumentException 两个 catch 捕获。因此检查失败时程序会报错,后面的“测试通过”不会执行。这里是显式抛出 AssertionError,不依赖 Java assert 关键字,也不需要启用 -ea。

正常场景在检查时重新编译运行通过。我还临时修改了预期编号,确认出现“第一张工单的数据恢复不正确”。这一步是在验证测试确实能发现错误。

收尾核对时,磁盘上的预期编号仍是用于故意失败的 999L。上面的示例使用正确的 1001L;下次先确认改回并保存,再运行正常验收,不能把故意失败版当作当前全绿的状态。

当前没有引入 JUnit 或 Maven。Demo 已有可重复执行的正常场景断言,但 IOException 和 IllegalArgumentException 仍只是被打印,并没有统一转成失败退出状态,所以还不是可直接用于 CI 的完整测试套件。

8 Git 与学习记录的小结 #

提取文件类和完善加载异常提示后,我完成了本地提交“提取工单文件存储类并完善加载异常提示”。后续的多张工单和断言修改仍有未提交内容,不能把前一次 commit 当作保存了全部新进度。

git add src README.md docs 时出现过 LF 将替换成 CRLF 的警告。这是换行符转换提示,不是中文内容乱码,也不代表暂存失败。可以继续用 git status 核对暂存区内容。

博客学习栏目今天增加了按实际练习记录的学习时间线。我最终选择主动提出整理请求,再按固定规则同步笔记、时间线和项目文档,没有启用定时自动发文。时间线的“当日已复盘”表示已整理当天记录,不等于全部内容都能独立完成。

9 今天完成到哪里,下次从哪里继续 #

内容实际进度
文件存储类提取按步骤完成,理解调用方与文件类的职责
throw、throws、Reader结合调用过程讲解,并在代码中使用
findAll 与多张保存已实现,服务列表中的两张工单已保存
count 与 loadAll按提示实现,多张工单正常读取通过
正常场景断言数量、顺序和字段检查通过;错预期值能触发失败
缺字段坏文件测试已讲解操作步骤,尚未创建并验收对应 Demo
空列表、异常 count 的专项测试代码已有相关行为,专项测试待补
重复编号、整体恢复服务、写入失败保护待实现

下一次先确认正常 Demo 的预期编号已经恢复为 1001,再复制一份 tickets.properties 为 tickets-invalid.properties,删除副本中的整行 ticket.1.title。不要只清空标题,也不要改坏正常存档。

计划编写 TicketLoadFailureDemo:缺字段时应该抛出“存档缺少必要字段”;如果正常返回,测试反而失败;找不到文件的 IOException 不能算作成功拦截坏内容。还要临时补回标题,确认这份异常测试也能识别“本应失败却成功”的情况。

这个练习尚未完成,记录下来是为了下次接着做。之后再测试空列表和异常 count,最后把恢复出的工单完整交给服务管理。

← 浏览全部文章回到顶部 ↑