ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java集合框架实战:用HashMap和Lombok实现明星管理CRUD模块

Java集合框架实战:用HashMap和Lombok实现明星管理CRUD模块 做 Java 的集合框架练习最怕的就是概念背了一堆写代码时还是不知道用 List 还是 Map。我最近整理了一个特别适合练手的小案例用集合框架实现一个电影明星管理模块覆盖增删改查和“封杀”状态控制再配合 Lombok 的 Data 注解把实体类写得非常清爽。整个过程不需要数据库打开 IDE 建个 Maven 工程就能跟着跑起来适合刚学完集合框架、想练手 CRUD 和注解用法的同学也适合那些想快速复习 HashMap 和 Lombok 的开发者。这个案例的代码量不大但是涉及的知识点很密集合选型、注解原理、遍历时的坑、逻辑删除设计一个都不少。1. 这个需求为什么值得动手写一遍明星管理模块的业务拆解1.1 从需求说起增删改查和封杀到底在说什么先说业务背景。假设我们要给一个电影资料系统做明星管理模块用户需要一个页面或命令行接口能够完成这些操作把一位新明星的信息加进去看某位明星的信息修改明星的艺名、代表作品、类型等把某位明星的信息移除以及把某位明星从可选名单中封杀。听起来很简单但和纯背 API 不一样的地方在于这里每一步操作都在“对数据做处理”你会被迫去思考数据的组织方式。比如新增时要不要校验这位明星是否已经存在删除时是真的把这个对象丢掉还是只改变它的状态修改时是直接覆盖原先的数据还是要保留修改记录这些思考恰恰是集合框架学习中缺失的部分。我把需求列成一个表格方便你对照后面的代码功能业务含义对应集合操作新增登记一位新明星map.put(id, star)按ID查询查看指定明星map.get(id)按名字搜索模糊匹配明星遍历 values 再判断修改更新明星信息覆盖 key 对应 value删除物理移除明星map.remove(id)封杀逻辑禁用明星star.setStatus(0)这个表格一列出来你就会发现除了“按名字搜索”和“封杀”稍微需要动点脑子其他操作几乎就是对 Map 的 put、get、remove 三个方法做包装。这就是我推荐它当练习案例的原因范围小但是把核心 API 的用法都串起来了。1.2 为什么选集合框架而不是数据库先内存后持久化的练习路线有些同学看到增删改查第一反应是“这不就是数据库 CRUD 吗为什么不直接用 MySQL MyBatis”问得好但这里的关键是学习阶段和练习成本。如果你已经会 JDBC那直接去写数据库增删改查当然更有用。但对很多刚刚学完 Java 基础、还没接触数据库框架的人来说用集合框架做内存版的增删改查能把注意力集中在集合 API 本身而不是被 JDBC 的驱动加载、连接管理、预编译参数这些额外细节干扰。内存版还有一个好处程序重启后数据虽然会丢失但这也正好逼你思考一个问题——如果数据要跨重启保留应该怎么设计这个思考会为后面引入文件存储或数据库做铺垫。我在教学时经常强调一个观点先在一个没有外部依赖的环境里把业务逻辑写清楚再引入持久化层排错会容易很多。1.3 一个小模块能把哪些知识点串起来别小看这个“小案例”完整写下来涉及的知识点包括HashMap 的常用方法、Map.Entry 的遍历方式、Lombok 注解的编译期处理机制、对象的 equals 和 hashCode、迭代器遍历时的快速失败机制、逻辑删除 vs 物理删除的设计。这些知识点分散在很多教程里平时一个个看容易忘放在这个案例里却是一条完整的链路。尤其是 Data 注解。很多人写实体类习惯手动写 getter/setter用了 Data 之后确实“手变懒了”但懒归懒你得知道它在背后生成什么否则后面会遇到各种莫名其妙的问题。下一节就专门来讲这个。2. 一行 Data 背后Lombok 到底替你写了什么2.1 Lombok 的注解处理机制编译期生成代码Data 是 Lombok 库提供的一个注解全名是lombok.Data作用在类上。核心机制说穿了并不神奇Lombok 在 javac 编译阶段通过注解处理器读取当前类的抽象语法树然后在内存中生成对应的 getter、setter、toString、equals、hashCode 方法再让编译器把它们一起编译进字节码。这个机制意味着两件事。第一你写的源码里确实没有这些方法所以 IDE 里如果没安装 Lombok 插件会一直提示找不到 getter/setter。第二编译后的 .class 文件里是有这些方法的运行时表现和手写完全一样。搞清楚这一点以后遇到“为什么源码里没有 setXxx 但代码却能用”这类问题就有底了。对比一下手写版本。一个 Star 类通常至少有 id、name、work、genre、status 五个字段手写的时候需要 5 个 getter、5 个 setter、1 个 toString、1 个 equals、1 个 hashCode大概十几行代码。用 Data 写就是一行注解加字段声明代码量直接少一半还多。2.2 Data 展开后的完整方法清单很多人以为 Data 就只是“帮我生成 getter 和 setter”其实不止。它实际涵盖的是这几个方法所有非 final 字段的 getter 方法。所有非 final 字段的 setter 方法。toString()方法输出类名加全部字段值。equals(Object)方法比较所有非静态、非 transient 字段。hashCode()方法同样基于这些字段计算。如果类里有 final 字段或 NonNull 字段还会生成一个 requiredArgsConstructor。所以当你查看编译后的 class 时会发现类里方法非常多。一个只写了 Data 的 Star 类在反编译工具里展开方法列表大概有十几个。这对平时开发很友好但也带来了一个隐患一旦你的实体类里有关联对象字段equals/hashCode/toString 可能会把关联对象一起卷进来导致性能问题甚至栈溢出。这一点我在后面的坑里会专门讲。2.3 什么时候不适合用 Data虽然 Data 很香但看到“注解一键生成”就无脑加并不是好习惯。至少有两种场景我不建议用它。第一种是类中包含集合字段且需要深比较的业务对象。Data 生成的 equals 会直接调用集合字段的 equals而集合的 equals 是逐元素比较的数据量大时开销不小。第二种是继承体系里的类。父类和子类都用 Data 时equals 和 hashCode 容易因为字段不同而出现不对称的问题——比如父类 equals 判断子类对象用 instanceof子类却只比较自己的字段两个对象互相比较会出现一边 true 一边 false 的现象。在明星管理这个案例里Star 类没有继承关系字段也简单用 Data 完全没问题。但如果你是写一个涉及关联、继承的复杂项目建议还是手动设计 equals/hashCode 策略。3. 用 HashMap 存明星是我在这个场景下的首选3.1 List、Set、Map 的选型对比回到集合选型问题。同样是存一组 Star 对象有三种基础选择ArrayList、HashSet、HashMap。我为什么选了 HashMap答案要从业务需求反推。这个模块的核心操作是“给一个明星 ID我要快速找到对应的 Star 对象”。ArrayList 也能做这件事但只能通过遍历来查找时间复杂度是 O(n)如果还要保证不重复List 本身就没有语义约束需要额外判断。HashSet 虽然能用 contains 判断是否存在某个对象但它不保存键值对应关系拿到一个 ID 之后没法直接取出对象。HashMap 则是最合适的key 放明星 IDvalue 放 Star 对象get 方法在理想情况下时间复杂度 O(1)。而且 Map 接口天然表达了“从 ID 到实体对象”的映射关系代码读起来语义清晰。我做选型时有一个习惯先写出一行伪代码看看这个操作在哪种数据结构里最直观。比如“通过 id 拿一个 star”直接写 map.get(id)这就说明 Map 是合适的选择。3.2 为什么 HashMap 特别适合“按 ID 增删改查”HashMap 的核心是基于哈希表实现的。调用 put(key, value) 时它会先计算 key 的 hashCode再用哈希值定位到桶如果出现哈希冲突会用链表或红黑树挂在同一个桶下面。对于 Long 类型的 ID 来说哈希值的分布非常均匀实际冲突概率很低所以 get 基本都能在常数时间内完成。我在这个案例里还用了 AtomicLong 作为 ID 生成器。每次新增明星时idGenerator.incrementAndGet()生成一个自增 ID这样可以保证每个 key 唯一。为什么不用手动传 ID因为手工赋值容易出现两条数据 id 相同的尴尬用自增 ID等于把“唯一性”从源头控制住了。当然HashMap 也有限制它是线程不安全的。多线程环境下如果同时修改 map可能引发死循环或数据错乱。在这个练习案例里我们只有单线程操作所以没问题。如果你以后把这个模块接到 Web 后端记得要么把 service 设计成单例并用线程安全的 ConcurrentHashMap要么加锁控制。3.3 hash 相关注意事项和 null 值处理用 Map 的时候还有几个细节值得注意。第一HashMap 允许 null 作为 key也允许 null 作为 value。但在“明星管理”这种场景里我建议你不要用 null key。因为 key 是用来定位的如果 key 是 null后续想通过某个 ID 查找就查不到而且 null key 的存在会让代码的逻辑分支变复杂。最好的做法是入口处做参数校验新增强制要求传入的实体不带 idID 统一由服务层生成。第二当 key 是自定义对象时必须正确实现 equals 和 hashCode否则 get、containsKey 都会失效。好在我们用了 Long 作为 keyLong 内部已经正确重写了这两个方法不需要额外操心。这个细节反过来也说明 Data 为什么重要如果 Star 自身要作为 key 放入 SetData 生成的 equals/hashCode 能保证“字段相同就是同一个对象”省了我不少事。4. 增删改查和封杀的完整代码实现4.1 实体类 Star搭配 Data 后的清爽写法代码部分从实体类开始。我习惯先用一个简单的 Star 类把字段定义清楚再交给 Data 去生成样板方法import lombok.Data; Data public class Star { private Long id; private String name; private String work; // 代表作品 private String genre; // 可选项动作、科幻、文艺等 private Integer status; // 1正常, 0封杀 }这里我没有用自动生成 ID只保留了一个普通 Long id。原因很简单实体类只负责描述数据ID 的生成策略属于服务层的职责。如果你把 id 生成逻辑放到实体类里后面换成数据库自增主键时反而别扭。status 字段很关键它决定了一位明星是否可用。1 代表正常0 代表封杀。这个字段的存在让我们可以不做物理删除只做状态变更。后面你会发现这个设计让“封杀”和“删除”成了两个完全不同的概念。4.2 服务层 StarServiceMap 的增删改查核心方法接下来是核心的服务类。我用一个 HashMap 作为存储容器配合 AtomicLong 生成 ID把这几个操作都包成方法import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; public class StarService { private final MapLong, Star starStore new HashMap(); private final AtomicLong idGenerator new AtomicLong(1); // 新增 public Star add(Star star) { star.setId(idGenerator.getAndIncrement()); star.setStatus(1); // 新增默认正常 starStore.put(star.getId(), star); return star; } // 按ID查询 public Star findById(Long id) { return starStore.get(id); } // 修改用新对象覆盖旧对象 public Star update(Star star) { Long id star.getId(); if (id null || !starStore.containsKey(id)) { throw new IllegalArgumentException(更新失败ID不存在: id); } starStore.put(id, star); return star; } // 物理删除 public boolean delete(Long id) { return starStore.remove(id) ! null; } // 封杀状态置为0 public void ban(Long id) { Star star starStore.get(id); if (star ! null) { star.setStatus(0); } } // 解封状态置为1 public void unban(Long id) { Star star starStore.get(id); if (star ! null) { star.setStatus(1); } } }这段代码的每一个方法都很短但有几处细节我想单独解释一下。新增里的setStatus(1)是为了避免调用方传入一个 status 是 null 或 0 的对象。既然走新增流程默认就应该是正常状态你不需要信任外部传入的参数。这里体现了一个很实用的原则服务层负责兜底数据的合法性不要把状态的正确性完全寄托在调用方身上。update 方法里我先判断 id 是否存在是因为 put 的语义是“有则覆盖无则新增”如果在修改时不小心把一个新的 id 传进来就会变成“静默新增”这是很多 bug 的来源。更稳妥的做法是把不存在的情况直接抛异常让调用方感知到错误。4.3 封杀状态逻辑删除和物理删除的区别我在这个模块里把 delete 和 ban 分开设计是有意为之。delete 是map.remove(id)把整个对象从容器里移除属于物理删除ban 只是把对象的 status 改成 0对象仍然留在 map 里属于逻辑删除。为什么现实中更强调逻辑删除因为业务数据往往有保留价值直接删了就找不回来了。你在热门视频平台看到“某作品被下架”大概率不是删除数据库记录而是把状态改成“不可用”未来审核通过还能恢复。这一点用集合框架来模拟再简单不过ban 对应setStatus(0)unban 对应setStatus(1)。你可能要问那查询的时候要不要自动过滤被封杀的明星要的。但过滤逻辑放在哪里是一个可以讨论的点。最粗暴的方式是在业务方法里遍历时判断 status好一点的做法是提供一个可以被控制器、页面统一调用的“可见列表”方法。一般来说搜索结果中默认只展示正常状态的明星而“按 ID 查询”则保留查看被封杀明星的能力——因为系统后台需要能查到所有数据。4.4 模糊查询遍历 contains 的简单方案模糊搜索名字是集合框架一个很有意思的应用。SQL 里可以用 LIKE 关键字集合里就得自己遍历了import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; public ListStar searchByName(String keyword) { if (keyword null || keyword.trim().isEmpty()) { return new ArrayList(); } String key keyword.trim(); return starStore.values().stream() .filter(star - star.getName() ! null star.getName().contains(key)) .collect(Collectors.toList()); }这里我用了 String 的 contains 而非 startsWith 或 endsWith是因为用户输入明星名字时只会输入一部分包含关系是最合适的匹配方式。如果你还想连带匹配代表作品只需要再加一个条件.filter(star - (star.getName() ! null star.getName().contains(key)) || (star.getWork() ! null star.getWork().contains(key)))想匹配多个字段时这种写法的好处就体现出来了。但我提醒一句集合里的模糊查询只适合练习和小规模数据真到了海量数据就该把这项工作交给数据库索引和全文搜索引擎别在内存里硬扛。4.5 测试输出让结果直观可见写完代码后我建议不要急着写单元测试框架先用一个简单的 main 方法把全流程跑一遍看到输出比什么都重要public class StarModuleDemo { public static void main(String[] args) { StarService service new StarService(); Star star1 new Star(); star1.setName(张三); star1.setWork(《城市之光》); star1.setGenre(文艺); service.add(star1); Star star2 new Star(); star2.setName(李四); star2.setWork(《极速行动》); star2.setGenre(动作); service.add(star2); System.out.println(新增后数量 service.searchByName().size()); // 根据ID查询 Long id1 star1.getId(); System.out.println(按ID查询 service.findById(id1)); // 模糊搜索 service.searchByName(张).forEach(System.out::println); // 修改 star1.setWork(《城市之光2》); service.update(star1); System.out.println(修改后 service.findById(id1)); // 封杀 service.ban(id1); System.out.println(封杀后状态 service.findById(id1).getStatus()); // 删除 service.delete(star2.getId()); System.out.println(删除后查找李四 service.findById(star2.getId())); } }运行后你会发现因为 Star 类上有 Dataprintln 输出的就是“字段值”的易读文本这正是 Data 的 toString 带来的便利。没有它的话你看到的多半是内存地址那调试起来就痛苦了。5. 实测中踩过的几个坑每一个都能写进避坑手册5.1 遍历时删除引发的 ConcurrentModificationException我第一次写搜索 删除集成逻辑时习惯性地用了 for-each 循环逐个判断然后在循环体里调用了starStore.remove(id)。结果程序运行到一半直接抛出 ConcurrentModificationException。这个异常的根源在于 HashMap 内部有一个 modCount 字段记录结构被修改的次数。迭代器创建时会保存一个 expectedModCount每次访问下一个元素都会检查 modCount 是否等于 expectedModCount不一致就立刻抛异常。这属于 Fail-Fast 机制目的是防止迭代过程中数据被外部修改后迭代器还在基于过期状态做判断导致不可预期的结果。正确的做法有两种。一种是使用迭代器自身的 removeIteratorMap.EntryLong, Star it starStore.entrySet().iterator(); while (it.hasNext()) { Map.EntryLong, Star entry it.next(); if (entry.getValue().getStatus() 0) { it.remove(); } }另一种是先收集要删除的 ID循环结束后统一 removeListLong needDelete new ArrayList(); starStore.forEach((id, star) - { if (star.getStatus() 0) { needDelete.add(id); } }); needDelete.forEach(starStore::remove);第二种在数据量不大时更直观。注意不要在 forEach 里直接调用 remove哪怕只是提前判断 key 存不存在也不行因为 forEach 隐含着迭代器语义。5.2 Data 的 equals/hashCode 在去重场景中的坑前面说了 Data 会生成基于所有字段的 equals 和 hashCode。这在单一场景下很好用但如果你的对象里有一个字段是“临时状态”去重逻辑就会出问题。举个例子如果我想把两位名字相同但 ID 不同的明星放进 HashSet按 Data 生成的 equalsid、name、work 都相等所以它们被认为是同一个对象导致第二次 add 失败。可是业务上两位明星撞名是完全可能发生的ID 才是真正能区分是否同一个人的主键。遇到这种需求就不能靠 Data 的默认实现了而是要手动重写 equals 和 hashCode只基于 id 判断Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Star)) return false; Star star (Star) o; return Objects.equals(id, star.id); } Override public int hashCode() { return Objects.hash(id); }这个案例给我留下的教训是注解省事但省的是没争议的样板代码一旦业务规则有特殊要求还是要回头自己写。这也是我建议初学者“先用 Data但务必理解它生成了什么”的原因。5.3 IDE 不支持 Lombok 导致的编译失败这是入门阶段极其常见的坑。你满心欢喜地写完代码一编译IDE 报错找不到 getter、setter甚至找不到符号。原因在于IDE 的解析和编译是两个环节编译靠的是 Maven/Gradle 引入的 Lombok 依赖而 IDE 里的代码检查则需要插件来理解 Lombok 注解。我记得刚开始用 Lombok 的时候忘了在 IDEA 的插件市场安装 Lombok plugin导致编辑器里一片红色波浪线。后面安装了插件并同时开启了 Annotation Processing 设置才恢复正常。如果你用的是 Mavenpom.xml 里需要加dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId version1.18.30/version scopeprovided/scope /dependency记住 scope 一定是 provided因为 Lombok 只在编译期起作用运行时并不需要它出现在 classpath 里。很多人刚学的时候用了 compile 或者省略 scope虽然也能跑但是把不必要的依赖塞进了产物里没必要。5.4 对象引用修改的诡异现象明明改了为什么查出来没变还有一个很隐蔽的坑来自对象引用。比如我写了这样一个“修改”方法public void updateName(Long id, String newName) { Star star starStore.get(id); if (star ! null) { star new Star(); star.setName(newName); } }看起来没什么问题但实际运行时会发现原对象根本没变。原因是 star 局部变量指向了 map 里的那个对象可一旦执行star new Star()它就不再指向 map 里的对象了之后 set 到新对象上的值自然没有写入原对象。正确的姿势是直接修改从 map 中拿到的对象或者重新 put 一个新构建的对象Star star starStore.get(id); star.setName(newName);这其实是个 Java 基础问题Java 方法传递对象引用时复制的是引用本身而不是对象内容。一旦你让局部变量指向新的对象改动就丢失了。我把它写在这里是因为它特别容易和集合框架混淆初学者经常把锅甩给 HashMap其实是引用语义没吃透。6. 从内存集合到数据库这个模块还能怎么演进6.1 给 StarService 加一层接口隔离数据源这个模块练完之后最自然的扩展方向就是接数据库。我的建议是先定义一个接口再把当前基于 HashMap 的实现收进去public interface StarRepository { Star insert(Star star); Star update(Star star); boolean delete(Long id); Star findById(Long id); ListStar searchByName(String keyword); }接着写一个InMemoryStarRepository把前面 StarService 里的 Map 逻辑搬进去。之后你再想接 JDBC就写一个JdbcStarRepository用连接数据库、PreparedStatement 执行 SQL 的方式实现同样的接口。这样调用方完全感受不到底层变化这就是面向接口设计的价值。很多人在初学阶段忽略接口觉得“多写一个接口多此一举”等数据源真的切换时才后悔没有隔离。6.2 分页、排序和条件查询要用 Stream 和 Comparator纯集合的增删改查练完后可以继续在内存版上加需求按类型筛选、按作品数量排序、分页输出。这些用 Stream API 写起来非常顺手。排序可以这样写ListStar sorted starStore.values().stream() .sorted(Comparator.comparing(Star::getName)) .collect(Collectors.toList());分页可以结合 skip 和 limit 实现int page 2; int size 5; ListStar pageList starStore.values().stream() .skip((long) (page - 1) * size) .limit(size) .collect(Collectors.toList());这个练习能帮你把 Stream 玩熟而且因为这还是在操作内存集合调试起来比在 SQL 里调参数直观得多。等你把内存版的分页排序搞明白再看数据库分页 SQL思路是互通的。6.3 把这套练习模板迁移到其他业务场景最后再分享一个我个人的体会这种“实体类 内存集合 服务层 CRUD 状态字段”的组合其实是一个可以无限复用的练习模板。今天你用 Star 练手明天换成商品管理就是增了一个 price 字段和一个“下架”状态换成学生选课就是一个“选课/退课”的逻辑删除场景。每次迁移时你可以有意去改一两个条件这次用 ArrayList 存数据下次试试 ConcurrentHashMap这次用 Data 生成 equals下次试试自定义 equals 只比较 ID这次用 for-each 遍历下次试试 Stream API。同样的骨架不同的细节做十遍集合框架相关的知识就彻底是你的了。我自己带新人的时候也是推荐他们用这类小案例反复打磨而不是急着去啃框架源码。先把小例子的每一条路径都走熟后面接触真实项目时才不会被基础问题绊住。
返回列表