跳转到正文
← 返回文章
设备运维工单开发 · 第 2 篇

Map 集合与工单存档恢复

文章目录

这是“设备运维工单开发”系列第 2 篇,承接上一篇的工单封装、Git 与派单规则。项目对应博客的Java 实习学习路线,最新功能说明见项目文档。学生管理系统的旧笔记仍保留在原系列。

今天从按状态筛选继续,先把工单集合改为按编号管理,再完成单张工单的文件保存、读取、类型转换与对象恢复。

最终跑通的过程是:

Ticket 对象 → Properties 字符串字段 → UTF-8 文件
UTF-8 文件 → Properties 字符串字段 → 类型转换 → Ticket.restore → 新的 Ticket 对象

代码是在逐步提示下编写、经检查和运行验证完成的。下面记录实现过程与需要继续巩固的知识,不把跟写通过当作已经能独立设计完整的持久化模块。

1 按状态筛选返回什么 #

上一课只留下了 findByStatus 的方法骨架。今天补齐了参数检查、遍历和返回值:

public List<Ticket> findByStatus(TicketStatus status) {
    if (status == null) {
        throw new IllegalArgumentException("工单状态不能为null");
    }
    List<Ticket> result = new ArrayList<>();
    for (Ticket ticket : ticketsById.values()) {
        if (ticket.getStatus() == status) {
            result.add(ticket);
        }
    }
    return result;
}

这里展示的是完成 Map 改造后的版本。没有匹配项就返回空列表;return 放在循环结束后,才能收集所有匹配的工单。TicketStatus 是枚举,可以直接用 == 比较枚举值。

写出方法还不等于运行时会看到结果。Demo 需要调用方法、接收结果,并明确打印或检查;Java 不会自动把返回值显示在控制台。

2 新列表和新工单是两回事 #

我一度想把引用关系概括为“没有 new 就指向同一个对象”,后来发现这个判断不够准确:调用处没有写 new,方法内部仍可能创建并返回新对象。

在 findByStatus 中,每次调用都会执行:

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

所以两次查询得到两个不同的列表。但是加入列表时:

result.add(ticket);

保存的是已有工单的引用,没有复制工单。

操作结果
清空查询结果列表不会清空服务内部的集合
再次调用查询方法会重新创建列表,重新收集匹配项
对列表里的工单调用 rename服务中同一张工单的标题也会改变
比较列表里的工单与编号查询结果可以是同一个对象,引用比较为 true

Demo 中的实际输出包括:

清空前:1
结果列表:0
重新查询:1
重新查询标题:打印机卡纸
是否同一对象:true

这也不代表结果列表会自动更新:如果之后改变工单状态,列表中的对象能显示新状态,但列表不会自动重新筛选成员。需要再次调用 findByStatus。

3 用 Map 按工单编号管理 #

工单编号是业务标识,不是列表下标。今天把原来的列表管理改成:

private final Map<Long, Ticket> ticketsById = new LinkedHashMap<>();

Map 是接口,描述键值映射的操作;LinkedHashMap 是具体实现。这里的键是工单编号,值是工单对象。泛型类型参数不能直接写基本类型 long,所以使用 Long;传入 long 编号时 Java 会自动装箱。

方法当前用途
get(id)按编号取工单;当前服务中找不到就返回 null
containsKey(id)判断编号是否已存在
put(id, ticket)保存编号与工单的对应关系
values()取得值的集合视图,供状态筛选遍历

findById 不再手动循环:

public Ticket findById(long id) {
    return ticketsById.get(id);
}

新增时仍然必须先检查:

public void add(Ticket ticket) {
    if (ticket == null) {
        throw new IllegalArgumentException("工单不能为null");
    }
    if (ticketsById.containsKey(ticket.getId())) {
        throw new IllegalArgumentException("工单编号已存在");
    }
    ticketsById.put(ticket.getId(), ticket);
}

put 本身允许覆盖同一个键的旧值,拒绝重复编号是项目的业务规则。

当前使用默认的 LinkedHashMap,遍历按插入顺序进行。例如依次加入 3003、1001、2002,遍历也按这个顺序,不会自动按编号大小排序。values() 是关联原 Map 的视图,不能把它当作独立副本随意 clear。

final 限制字段重新指向另一个 Map,不禁止向当前 Map 添加数据。当前实现仍是单线程练习,containsKey 再 put 不构成并发安全的新增操作。

4 Properties 保存的是字符串字段 #

Properties 可以用键值对组织数据。当前练习只使用 setProperty 和 getProperty,将键和值都按字符串处理:

Properties data = new Properties();
data.setProperty("count", "1");
data.setProperty("ticket.0.id", Long.toString(ticket.getId()));
data.setProperty("ticket.0.title", ticket.getTitle());
data.setProperty("ticket.0.status", ticket.getStatus().name());

if (ticket.getAssigneeId() == null) {
    data.setProperty("ticket.0.assigneeId", "");
} else {
    data.setProperty("ticket.0.assigneeId",
            Long.toString(ticket.getAssigneeId()));
}

这里的编号要转换为字符串,枚举用 name() 保存名称。未派单时处理人保存为空字符串,不能直接给 setProperty 传 null。

约定的逻辑内容如下,实际保存时键的顺序可能不同:

count=1
ticket.0.id=1001
ticket.0.title=投影仪无信号
ticket.0.status=IN_PROGRESS
ticket.0.assigneeId=20

ticket.0 中的 0 是第 0 条记录的序号,真正的工单编号在对应的 id 值中。带点的键只是命名约定,不会自动创建嵌套对象或数组。

setProperty 只修改内存中的 Properties,此时还没有写入文件。它保存的是字段当时的字符串值,后续修改 Ticket 也不会自动更新这些值。

5 用 Writer 把字段写入文件 #

TicketFileDemo 先创建编号 1001 的工单,再派给工程师 20。字段准备好后执行:

Path path = Paths.get("ticket-demo.properties");

try (BufferedWriter writer =
             Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    data.store(writer, "Ticket demo");
} catch (IOException e) {
    System.out.println("保存失败:" + e.getMessage());
    return;
}

System.out.println("保存成功:" + path.toAbsolutePath());

Path 表示文件路径;BufferedWriter 负责写字符;store 才真正把 Properties 的内容写出去。第二个参数是文件注释,不是文件名。

try 后面括号中声明的资源会在离开代码块时自动关闭。catch 中的 return 则保证写入失败后不会继续打印“保存成功”。

相对路径根据程序的工作目录解析,不是根据 Java 源文件的位置。运行保存和读取 Demo 时,要确认它们使用同一工作目录。

目前保存会覆盖同名文件,且没有临时文件替换或备份机制。这是单张工单的演示代码,后续保存整个集合时还需要处理写入失败对旧存档的影响。

6 中文乱码要先判断发生在哪一层 #

今天遇到标题中文显示乱码。检查发现实际文件已经是正确的 UTF-8,问题出在 IDEA 查看 Properties 文件时使用的编码。将 Properties 文件编码设置为 UTF-8,并按正确编码重新加载后,显示恢复正常。

这次经验是先区分:

  • Java 源文件是否正确保存。
  • 写文件时使用什么编码。
  • 读文件时使用什么编码。
  • IDE 或终端用什么方式显示内容。

不要看到乱码就直接把乱码文字重新保存。当前代码通过 Reader 和 Writer 显式指定 UTF-8;不能把它与 Properties 的字节流重载混为一谈。

7 用 Reader 读回字段 #

TicketLoadDemo 中先加载文件:

Path path = Paths.get("ticket-demo.properties");
Properties loaded = new Properties();

System.out.println("读取文件:" + path.toAbsolutePath());

try (BufferedReader reader =
             Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    loaded.load(reader);
} catch (IOException e) {
    System.out.println("读取失败:" + e.getMessage());
    return;
}

load 把文件里的键值读入 loaded,不会自动生成 Ticket。

随后取出必要字段:

String idText = loaded.getProperty("ticket.0.id");
String title = loaded.getProperty("ticket.0.title");
String statusText = loaded.getProperty("ticket.0.status");
String assigneeText = loaded.getProperty("ticket.0.assigneeId");

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

这里不能把 null 和空字符串当作同一回事:

文件情况getProperty 的结果本项目含义
没有处理人这个键null缺少必要字段
写着 ticket.0.assigneeId=空字符串未派单,没有处理人
写着 ticket.0.assigneeId=20字符串 “20”需要转换并校验的处理人编号

只有确认 assigneeText 不为 null,后面调用 isEmpty() 才安全。

8 字符串还需要转换成类型 #

getProperty 读到的 “1001” 是 String。转换代码如下:

long id = Long.parseLong(idText);
TicketStatus status = TicketStatus.valueOf(statusText);

Long assigneeId;
if (assigneeText.isEmpty()) {
    assigneeId = null;
} else {
    assigneeId = Long.valueOf(assigneeText);
}

Long.parseLong 返回基本类型 long;Long.valueOf(String) 返回包装类型 Long。处理人允许为 null,因此使用 Long。

TicketStatus.valueOf 要求名称匹配枚举定义,包括大小写。非法数字会导致 NumberFormatException,不存在的枚举名称会导致 IllegalArgumentException。

类型转换通过不代表业务合法。例如 “-1” 可以解析为 long,但不能作为合法工单编号。

当前代码只在文件读取处捕获 IOException;缺字段、非法数字、非法枚举或恢复校验失败的异常仍会向外抛出。友好地报告坏存档并保留原服务数据,是后续加载器要完成的工作。

9 用恢复入口保留原状态 #

只执行 new Ticket(id, title),对象会使用初始的 OPEN 状态和空处理人。这适合新建工单,却不能完整恢复已经派单的存档。

在 Ticket 类中增加:

public static Ticket restore(long id, String title,
                             TicketStatus status, Long assigneeId) {
    if (status == null) {
        throw new IllegalArgumentException("工单状态不能为null");
    }

    if (status == TicketStatus.OPEN) {
        if (assigneeId != null) {
            throw new IllegalArgumentException("待派单工单不能有处理人");
        }
    } else {
        if (assigneeId == null || assigneeId <= 0) {
            throw new IllegalArgumentException("已派单工单必须有有效处理人");
        }
    }

    Ticket ticket = new Ticket(id, title);
    ticket.status = status;
    ticket.assigneeId = assigneeId;
    return ticket;
}

这是一个静态工厂方法,可以通过 Ticket.restore(…) 调用。方法内部的 new 才创建对象,最后 return 把新对象的引用交给调用方。

new Ticket(id, title) 复用构造器的编号和标题检查;恢复方法额外检查状态和处理人是否匹配。它位于 Ticket 类内部,所以能访问该类对象的 private 字段。static 方法没有 this,这里通过局部变量 ticket 指明要操作的对象。

assigneeId == null || assigneeId <= 0 使用短路求值:左边成立时不执行右边,避免对 null 自动拆箱。

当前约定 OPEN 必须没有处理人,另外三种状态必须有正数处理人编号。恢复 RESOLVED 或 CLOSED 只是在重建存档状态,不表示项目已经实现“提交处理结果”或“验收关闭”的业务方法。恢复入口将来用于持久化层,不应直接暴露为可任意设置状态的业务接口。

10 从恢复后的对象读取结果 #

最后在 TicketLoadDemo 中调用:

Ticket restored = Ticket.restore(id, title, status, assigneeId);

System.out.println("编号:" + restored.getId());
System.out.println("标题:" + restored.getTitle());
System.out.println("状态:" + restored.getStatus());
System.out.println("处理人:" + restored.getAssigneeId());

实际输出:

编号:1001
标题:投影仪无信号
状态:IN_PROGRESS
处理人:20

这次输出取自恢复后的对象。它和保存前的工单具有相同字段,但属于重新创建的对象;文件没有保存 Java 对象在内存中的引用。

本次检查编译了全部源码,并在助手创建的临时验证程序中完成 21 项恢复检查,全部通过:

  • 四种状态与合法处理人组合能够恢复,编号和标题保持预期。
  • null 状态、OPEN 带处理人被拒绝。
  • 其他三种状态的处理人为 null、0 或负数时被拒绝。
  • 0 和负数工单编号、null 或空白标题被拒绝。
  • 50 长度标题可以恢复,51 长度标题被拒绝;标题首尾空格会被清除。

读取前后的示例存档哈希一致,读取没有修改原文件。这些是助手运行的临时检查,当前仓库还没有 JUnit 测试套件,也没有逐项修改坏文件来验证整个加载流程。

11 把项目文档跟上代码 #

今天给项目补齐了 README 和三份 docs 文档,并同步到了博客学习板块:

学习笔记保存当天的过程和理解;项目文档随当前代码更新。以后读到历史文章中的旧实现,要结合当天阶段理解,不要求把过去的笔记全部改成最新代码。

12 今天完成到哪里 #

内容实际进度
状态筛选按步骤完成,参数、匹配结果和列表独立性经检查通过
Map 改造按步骤完成,编号查询、重复编号拒绝与插入顺序经检查通过
Properties 字段转换经提示修正,支持已派单和未派单的字段表达
UTF-8 文件写入与读取单张示例工单运行通过,中文显示问题已解决
类型转换和对象恢复按示例完成,实际读取与 21 项恢复检查通过
项目文档已补齐并发布,结束时更新到对象恢复阶段
多张工单保存与加载尚未实现,count 目前写入但没有读取使用
完整存档异常处理尚未实现,解析异常仍向外抛出

下一次先把 Demo 中的文件读写抽取成专门的持久化类,再逐步接入 TicketService 和多张工单。需要继续练习的是独立组织“读字段、转类型、校验、恢复对象”的顺序,以及加载失败时不污染原有数据。

复习时可以先合上代码回答三个问题:为什么清空结果列表不影响服务集合?为什么空处理人和缺字段要分开处理?为什么恢复入口仍然要调用构造器?

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