资讯动态

Java实战:电影信息管理系统的状态流转与集合存储设计

发布时间:2026/9/9 21:42:31 来源:尧图企业网站定制
做Java基础练习图书管理、学生管理这类系统大家早已写腻这次换一个电影信息管理系统的切入点反而有意思得多。它表面上是标准的增删改查但把“上架、下架、查询、修改、封杀”这几个动作拆开看你会发现它并不是简单的CRUD而是一套带状态管理的业务系统。这篇文章就从我实际写这套Java实现电影信息管理系统的过程出发把核心设计、代码实现、踩坑经历一起讲清楚。它适合刚学完Java集合、想用完整项目串一遍基础知识的同学也适合准备面试时想找一个“能讲出业务细节”的小项目的人。1. 项目整体设计与业务建模1.1 业务需求拆解五个动作背后的真实场景拿到题目先别急着写代码第一步要把业务想明白。“电影上架、下架、查询、修改、封杀”虽然听起来是五个动词但它们在真实系统里的地位完全不同。上架对应两个动作管理员录入一部新电影让它成为可被观众看到的在映影片或者把之前因为排期、版权等原因下架的片子重新放回放映列表。下架则是把已上架的电影从列表里拿掉常见原因是排期结束或者授权到期。查询的核心是按各种条件检索电影按名称、按导演、按评分、按状态都算。修改是调整电影基本信息比如片名打错了、导演信息要补充、评分有了新变化。封杀则属于惩罚性手段针对违规内容一旦封杀就视为这部电影永久退出平台不允许再上架。这个需求里“查询”和“修改”的逻辑相对直白真正的设计难点在上架、下架、封杀这三个操作之间的状态约束。举个例子一部封杀状态的电影能不能重新上架一部已经下架的电影能不能直接封杀一部刚录入但还没上架的电影修改的时候有没有限制这些规则如果不在动手编码前定义清楚代码就会变成一堆没有边界的if判断今天能跑明天改一个需求就会漏出各种洞。我的做法是引入“状态”概念把电影当前所处的位置抽象成一个状态字段上架、下架、封杀就是三种互斥状态。整个系统的业务逻辑全部围绕状态流转来展开谁可以流转到谁、谁不能流转到谁都提前定好规则。做完这个练习之后我最大的体会是它逼着我在写第一行代码之前先做业务建模而不是上来就new对象、写方法。1.2 技术选型为什么坚持用纯Java SE这套练习的目标很明确就是巩固Java基础所以我刻意没有引入Spring、MyBatis、MySQL这些重家伙而是用纯Java SE加集合存储来完成全部功能。选择HashMap作为电影数据的临时仓库配合Scanner做控制台交互一个Service类统一处理业务逻辑。很多同学会觉得不连数据库就没有成就感但我的看法恰恰相反数据库和框架会掩盖大量基础细节。在纯Java环境下你必须亲手处理集合选型、对象设计、状态判断、异常处理这些才是面试和日常开发中真正躲不开的东西。等工作几年之后再回头看你会发现最简单的能力往往最难补。基础够硬了往上加数据库和框架是顺水推舟反过来一上来就玩全家桶出了问题连该查哪一层都不知道。这个项目的包结构做了简单的三层划分entity层存放Movie实体类和MovieStatus枚举负责描述数据本身service层存放MovieService负责所有业务规则和状态流转ui层存放MovieConsole控制台交互负责接收用户输入并调用Service。这套分层并不复杂但它把“数据怎么存”“业务怎么处理”“界面怎么交互”三件事拆开了。好处是后续哪怕要扩展成Web项目Service层的业务逻辑基本可以原封不动搬走只要替换掉交互层即可。我在很多练习项目里看到的问题是所有代码堆在一个类里三百行写到底表面上是图省事实际上后面每次改动都在给自己挖坑。2. 核心类设计与状态管理2.1 用枚举管好三种状态Java里表示状态的方式很多最常见的两种是魔法数字0、1、2和字符串0代表下架、1代表上架这类。我一直推荐用枚举因为枚举能把状态收拢到一个类型里编译期就能发现拼写问题还方便在状态上挂描述信息。public enum MovieStatus { OFF_SALE(已下架), ON_SALE(已上架), BANNED(已封杀); private final String desc; MovieStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } }用枚举之后Movie实体里的状态字段就是MovieStatus类型而不是int或者String。这意味着业务层在switch或if判断时IDE会自动提示所有可选状态完全不用靠脑子记数字。而且枚举本身是类型安全的如果你写了个不存在状态编译直接报错这比字符串比较之类的方式安全得多。后面就算要新增一个“待审核”状态改动范围也完全可控不会出现牵一发而动全身的情况。2.2 Movie实体字段设计Movie实体是整套系统的数据核心字段设计要围绕业务需求来而不是越多越好。我最终保留的字段如下id主键自增生成用来唯一定位一部电影title电影名称director导演releaseYear上映年份rating评分status当前状态createTime记录首次录入时间。title和director属于基本信息查询和修改都离不开。releaseYear单独拎出来是为了以后可以按年份筛选。rating字段比较特殊录入时必须做范围校验评分不是你想填多少就填多少0到10是一个基本原则。createTime这个字段很多人容易忽略但保留它之后“按时间排序”“最近上架的电影”这类需求就有了支撑不会等到要用了再回头补。public class Movie { private Integer id; private String title; private String director; private Integer releaseYear; private Double rating; private MovieStatus status; private LocalDateTime createTime; public Movie() { } public Movie(Integer id, String title, String director, Integer releaseYear, Double rating, MovieStatus status) { this.id id; this.title title; this.director director; this.releaseYear releaseYear; this.rating rating; this.status status; this.createTime LocalDateTime.now(); } // getter / setter 方法在实际编码时用 IDE 生成即可 }构造方法里把createTime设置为当前时间这样new Movie()的那一刻就自动带上了录入时间不用每次手动指定减少遗漏。2.3 存储方案List还是Map这是练习项目里绕不开的一道选择题。ArrayList简单直观适合“按顺序展示全部电影”的场景但按id查找时要线性遍历数据量一大效率就明显下降。HashMap用id做key单部电影的增删改查都是O(1)复杂度比List强很多。这个项目里我选择的是MapInteger, Movie结构并在Service内部维护一个自增id。每次新增电影时id自动加1作为Map的key。展示列表时取出values()之后再转成ArrayList给交互层使用而修改、下架、封杀这些操作都需要先根据id找到目标电影Map天然适配这种“按主键定位”的场景。这里必须提一个实际开发中容易忽略的细节HashMap不保证迭代顺序。如果你的列表展示希望按录入先后顺序来排直接遍历HashMap的values()很可能得到乱序结果。解决办法很简单把HashMap换成LinkedHashMap它保留了插入顺序既继承Map的查找效率又让values()的输出顺序变得可控。针对当前这套系统如果用List做存储删除和修改操作都需要先遍历找到下标代码写起来会比较绕用Map之后这些操作的表达都简洁很多。如果后面要升级成数据库存储这个Map对应的就是数据库表的主键索引设计思路是一脉相承的。3. 功能实现与核心代码讲解3.1 上架新增与恢复两重语义“上架”这个动作在需求里其实包含两种含义一种是新增一部电影之后直接上架另一种是把之前下架的电影重新设为上架。我把它们拆成两个方法避免把语义混在一起。public Movie addMovie(String title, String director, Integer releaseYear, Double rating) { if (title null || title.trim().isEmpty()) { throw new IllegalArgumentException(电影名称不能为空); } validateRating(rating); if (releaseYear null || releaseYear 1888 || releaseYear 2100) { throw new IllegalArgumentException(上映年份不合法); } Movie movie new Movie(nextId, title.trim(), director, releaseYear, rating, MovieStatus.ON_SALE); movieStore.put(movie.getId(), movie); return movie; }addMovie的逻辑流程是先做参数校验名称非空、评分在0到10之间、年份合理再构造Movie对象并把初始状态设为ON_SALE最后放入Map并返回。这里注意校验必须放在构造对象之前不然脏数据一旦进了集合后面排查时非常痛苦。评分校验我单独抽了一个validateRating方法因为修改功能里同样要复用。public void onSale(Integer id) { Movie movie findById(id); if (movie null) { throw new IllegalArgumentException(电影不存在 id); } if (movie.getStatus() MovieStatus.BANNED) { throw new IllegalStateException(封杀状态的电影不能重新上架); } if (movie.getStatus() MovieStatus.ON_SALE) { throw new IllegalStateException(该电影已经处于上架状态请勿重复操作); } movie.setStatus(MovieStatus.ON_SALE); }onSale方法的核心在于状态判断。只有OFF_SALE状态才允许重新上架已经是ON_SALE的要提示重复操作BANNED的直接拒绝。这里面最关键的一条业务规则是封杀不可逆任何恢复操作都不能绕过这个限制。哪怕后续真的出现“误封杀”的场景也应该是走审批流程解封而不是在代码里随手放行。3.2 下架与封杀相近操作不同的业务强度下架和封杀在代码实现上都是做一次状态变更看起来像一对双胞胎但业务强度完全不同。下架是温和操作目标是结束本轮放映周期后续还可以重新上架封杀是惩罚措施状态一旦设置就不允许回到正常流程。public void offSale(Integer id) { Movie movie findById(id); if (movie null) { throw new IllegalArgumentException(电影不存在 id); } if (movie.getStatus() MovieStatus.BANNED) { throw new IllegalStateException(封杀状态的电影不能下架); } if (movie.getStatus() MovieStatus.OFF_SALE) { throw new IllegalStateException(该电影已经处于下架状态请勿重复操作); } movie.setStatus(MovieStatus.OFF_SALE); } public void banMovie(Integer id) { Movie movie findById(id); if (movie null) { throw new IllegalArgumentException(电影不存在 id); } if (movie.getStatus() MovieStatus.BANNED) { throw new IllegalStateException(该电影已经被封杀请勿重复操作); } movie.setStatus(MovieStatus.BANNED); }两个方法的结构相似但状态判断条件有明显差异。下架方法拒绝BANNED状态和OFF_SALE状态的操作只接受ON_SALE到OFF_SALE的流转。封杀方法则允许ON_SALE和OFF_SALE都能进入BANNED状态。为什么下架要拒绝BANNED因为封杀状态意味着这部电影连“正常下架”的资格都没有它已经被剥夺了参与正常业务流程的权利。这种细节如果不提前想清楚很容易写出“封杀后还能下架”这种业务上完全说不通的逻辑。3.3 查询与修改别让权限状态被绕过查询功能我做了三种形态按id精确查询、按名称模糊查询、按状态筛选列表。使用Stream处理集合过滤非常简洁一行代码就能表达意图。public ListMovie findByTitle(String keyword) { if (keyword null || keyword.trim().isEmpty()) { return new ArrayList(movieStore.values()); } String kw keyword.trim(); return movieStore.values().stream() .filter(movie - movie.getTitle().contains(kw)) .collect(Collectors.toList()); }这里用contains实现模糊匹配也就是说搜索“战狼”能匹配到“战狼2”“战狼3”。如果希望搜索更精确可以换成equals但一般情况下业务系统都会用模糊搜索所以contains更贴近真实使用场景。按状态筛选的写法也类似直接用filter判断status是否等于目标状态即可。修改功能相比查询要谨慎得多。核心逻辑是先根据id找到电影不存在就抛出异常再判断当前状态BANNED状态的电影不允许修改。public void updateMovie(Integer id, String title, String director, Integer releaseYear, Double rating) { Movie movie findById(id); if (movie null) { throw new IllegalArgumentException(电影不存在 id); } if (movie.getStatus() MovieStatus.BANNED) { throw new IllegalStateException(封杀状态的电影不能修改信息); } if (title ! null !title.trim().isEmpty()) { movie.setTitle(title.trim()); } if (director ! null !director.trim().isEmpty()) { movie.setDirector(director.trim()); } if (releaseYear ! null) { movie.setReleaseYear(releaseYear); } if (rating ! null) { validateRating(rating); movie.setRating(rating); } }修改接口设计成“传null的字段不更新”调用方可以只修改想改的字段不用每次把整条信息重新传一遍。这个设计在控制台程序里很好用也为将来接Web接口打下了良好基础。BANNED状态禁止修改这条看起来简单却是整个修改功能里最容易被忽略的一环很多初学版本会忘掉这个判断导致封杀电影可以通过修改名称和资料“原地洗白”。3.4 控制台交互层把服务串起来交互层是整套程序的入口我写了一个MovieConsole类核心结构是一个死循环每次循环先打印菜单、读取用户的数字选择然后调用Service的对应方法并打印结果。逻辑本身不难但有三个细节值得单独拿出来说。第一个细节是Scanner的nextInt()之后必须调用nextLine()把换行符吃掉否则下一次读取字符串时会直接读到一个空行。这个坑几乎每个初学者都会踩一次我早期做控制台程序时也为这个抓狂过。第二个细节是每次操作之后都要有明确的反馈成功打印成功信息失败打印失败原因不能让用户对着空屏幕猜。第三个细节是Service层抛出的业务异常要在交互层捕获用try-catch包住调用逻辑避免控制台直接刷出异常堆栈把普通用户吓一跳。public void start() { Scanner scanner new Scanner(System.in); while (true) { printMenu(); int choice scanner.nextInt(); scanner.nextLine(); try { switch (choice) { case 1 - addMovieMenu(scanner); case 2 - listMovies(); case 3 - findMovieMenu(scanner); case 4 - updateMovieMenu(scanner); case 5 - offSaleMenu(scanner); case 6 - onSaleMenu(scanner); case 7 - banMovieMenu(scanner); case 0 - { System.out.println(感谢使用再见); return; } default - System.out.println(无效选项请重新输入); } } catch (IllegalArgumentException | IllegalStateException e) { System.out.println(操作失败 e.getMessage()); } } }这段代码里的printMenu只是打印菜单文本addMovieMenu、findMovieMenu等方法负责读取对应参数并调用Service。菜单项我把控制台操作编号列得比较细查询、上架、下架、封杀都有独立入口实际操作时一目了然。4. 常见问题与排查技巧实录4.1 我踩过的高频异常与处理方案这套系统虽然小但我在调试过程中踩过的坑一点都不少。整理成表格大家可以直接对照排查。异常或问题出现场景处理方案ConcurrentModificationException遍历ArrayList/Menu时直接做增删操作使用Iterator的remove方法或者先收集需要操作的对象循环结束后再统一处理比较字符串失效用Scanner输入状态字符串后与常量比较字符串一律使用equals方法比较或者干脆用枚举彻底绕开这个问题HashMap展示顺序乱序调用values()打印列表时顺序与录入顺序不一致存储容器换成LinkedHashMap保留插入顺序Scanner读取不到字符串nextInt()后直接调用nextLine()在nextInt()后补一次nextLine()消费掉残留换行符封杀后仍能修改修改方法没有判断当前状态修改前先检查movie.getStatus()是否为BANNED是则抛出异常空指针异常查询不存在的id后直接调用getStatus()先判空找不到电影就提前抛出“电影不存在”这些坑里面我想单独说一下ConcurrentModificationException。我在写按条件批量下架功能时先遍历了一个列表然后在循环里直接调用movie.setStatus()结果因为修改的是对象属性而不是修改集合结构倒是没触发异常。但如果你在循环里直接调用movieStore.remove(id)就一定会炸。这是Java集合fail-fast机制在起作用迭代器发现集合结构被修改就会主动抛异常来避免不确定行为。解决办法是使用Iterator的remove方法或者先把要删除的id收集到另一个集合里循环结束后再统一删除。4.2 从练习到面试这个项目怎么讲出亮点单有代码还不够还要能讲清楚设计思路。这套系统在面试里其实非常好讲因为它麻雀虽小、五脏俱全。面试官看到电影管理系统最容易追问的点也就那么几个。第一个常见追问是HashMap和ArrayList到底怎么选。这时候你可以结合电影系统的场景说查询频繁、按id定位所以选Map如果要展示顺序且数据量不大List更直接。第二个追问是用枚举好还是用数字好答案是枚举类型安全、可读性强还能挂描述信息。第三个追问是上架、下架、封杀之间怎么保证不出现非法状态这就回到了状态流转设计把只有ON_SALE能下架、BANNED不可逆这条规则说清楚。第四个追问是数据量大了怎么办你可以顺理成章地说出升级到MySQL、引入MyBatis、再把存储层抽成接口的演进方案。能把这些设计理念讲明白的人哪怕项目本身很简单也给面试官留下“有业务建模能力”的印象。这比背八股文有用得多。八股文是死的项目是你一步步做出来的讲起来底气都不一样。4.3 后续可以怎么升级做完纯内存版代码的扩展空间其实已经留好了。升级路径很清晰建议按顺序来。第一层是文件持久化把movieStore的数据在程序退出时写入本地文件启动时再加载回来。用ObjectOutputStream做序列化最简单但要注意版本兼容用JSON格式则更通用后面接前端也不吃亏。第二层是接MySQL把MovieService里操作Map的地方换成JDBC或MyBatis操作数据库表。第三层是重构为Spring Boot项目把Service注册成Bean交互层换成REST接口。等这层做完你会发现当初的分层设计让迁移非常顺畅业务代码几乎不用动。再往后还有两个进阶方向一个是操作日志每次上架、下架、封杀都记录操作人和时间这块我最近在研究怎么用动态代理拦截Service方法来做通用日志这样业务代码不需要侵入式改动另一个是状态模式随着状态越来越多业务层的if判断会变得很臃肿把每种状态封装成一个对象、每个动作逻辑放进去代码的扩展性会再上一个台阶。这些都属于同样的业务模型上做延伸从练习到实战的跨度非常自然。这套练习做完之后我自己最大的感受是业务建模能力比单纯的语法熟练更重要。上架、下架、封杀这几个动作代码实现每一行都很简单但把它们放进一套有约束的状态流转规则里就构成了一个能讲清楚、能抗住追问的完整项目。如果正在做类似练习建议不要满足于“能跑就行”多问自己几个“如果”多补几个状态判断收获会完全不同。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价