简介一份基于Java的控制台宠物管理系统源码包面向Java初学者、课程设计学生及希望在命令行场景中练习面向对象编程的开发者。系统以标准输入输出实现交互涵盖用户登录、宠物信息增删改查、异常处理与文件持久化等核心模块可帮助理解类、对象、集合框架ArrayList/LinkedList及JDBC/序列化等知识在实际项目中的运用适合作为实训作业或入门级项目管理参考。资源压缩包总大小24KB共包含22个文件其中8个java源码文件为主干逻辑8个class为编译后产物5个xml文件用于项目配置或数据归档1个iml文件为IDEA模块描述整体结构清晰便于直接导入开发环境阅读改造。目前已有986人学习下载源码模块划分明确并涉及登录校验、数据库或文件存储、查询策略等设计思路值得参考和复用。1. 从需求到控制台宠物管理系统到底在管什么一个没有图形界面、不接数据库的Java控制台程序能覆盖多少Java基础的关键点宠物管理系统恰恰是这类“小而完整”的题目它既要维护宠物的档案又要支持喂食、治疗、领养等状态变化还必须把数据保存到文件下一次启动能原样加载。正因为没有框架兜底类的边界怎么划、集合怎么选、键盘输入怎么处理、文件读写怎么容错全部要自己拍板这些也正是Java基础面试八股里反复出现的考点。这套系统的受众很明确吃透它等于把对象建模、字符串解析、IO流、异常处理四件事串成一条线无论做课程设计、应付面试还是补Java语法都值得完整过一遍。2. 数据模型与面向对象设计宠物实体与类的边界宠物管理系统里的“宠物”不能只是一个HashMapString, String否则增删改查写起来轻松等到要统一处理“喂食影响健康”、“不同物种叫声不同”时代码会迅速腐化成大段if。方向应该是先把领域模型立住宠物有哪些公共属性哪些行为按物种变化哪些状态变更需要业务规则约束。面向对象在这里不是加分项而是降低后续章节编写成本的地基。2.1 抽象出 Pet 基类与子类继承先定义Pet抽象类把 id、name、age、health、hunger 这些公共字段收进去。id 一旦创建就不允许修改所以用final健康值和饱食度有明确取值范围setter 里做边界校验不让脏数据在内存里流通。public abstract class Pet { private final String id; // 全局唯一编号创建后不可变 private String name; // 宠物名 private int monthsAge; // 月龄 private int health; // 健康值0~100 private int hunger; // 饱食度0~100数值越低越饿 public Pet(String id, String name, int monthsAge) { this(id, name, monthsAge, 80, 70); } public Pet(String id, String name, int monthsAge, int health, int hunger) { this.id id; setName(name); setMonthsAge(monthsAge); setHealth(health); setHunger(hunger); } public abstract String getSpecies(); public abstract void voice(); // 以下为 gettersetter 中的校验逻辑见 2.2 public String getId() { return id; } public String getName() { return name; } public int getMonthsAge() { return monthsAge; } public int getHealth() { return health; } public int getHunger() { return hunger; } }这里用了一个重载技巧Pet(String, String, int)把默认健康值 80、饱食度 70 委托给全参构造器避免两个构造器里重复赋值。子类传入自己的物种标识和叫声行为public class Cat extends Pet { public Cat(String id, String name, int monthsAge) { super(id, name, monthsAge); } Override public String getSpecies() { return cat; } Override public void voice() { System.out.println(getName() 喵了一声); } }这样写的好处是后头要新增Bird或Turtle只需要新加一个类主流程的集合类型依然是ListPet完全不用改。多态把“不同物种”的差异隔离在voice和getSpecies两个方法里这正是 Java 基础里抽象类与继承的典型落地场景。2.2 参数校验与构造器重载防止脏数据进入内存实体类最容易犯的毛病是把校验散落在 main 方法的十几处if里。常见做法是让 setter 成为唯一的校验出口任何入口进来的数据都要过同一道闸门public void setHealth(int health) { if (health 0 || health 100) { throw new IllegalArgumentException(健康值需在0~100之间); } this.health health; } public void setMonthsAge(int monthsAge) { if (monthsAge 0 || monthsAge 240) { throw new IllegalArgumentException(月龄范围应为0~240); } this.monthsAge monthsAge; }字段类型取值范围校验失败时的处理idString非空、全系统唯一构造时就抛出 IllegalArgumentExceptionnameString1~10 个字符中英文或数字setter 统一拦截空串和超长值monthsAgeint0~240setter 拦截负数与超大值healthint0~100setter 拦截越界值hungerint0~100setter 拦截越界值把校验放在 setter 里而不是在各处业务代码里逐个判断目的在于单一职责业务方法只负责“改成多少”不负责“合不合理”。后续第 5 章还会在交互层做更细的输入校验但那层校验防的是“用户乱输”setter 这一层防的是“程序逻辑错误”两者职责不同不能互相替代。2.3 业务操作放在服务类而不是宠物实体里喂食、治疗、领养这些操作表面上是“宠物状态变化”实际上还牵扯到查找宠物、校验状态、记录日志一类系统级逻辑。把它们塞进Pet实体会让实体类既管状态又管业务流程违背单一职责。我更习惯把这类行为放到PetService里public class PetService { private final MapString, Pet petStore new LinkedHashMap(); public void addPet(Pet pet) { if (petStore.containsKey(pet.getId())) { throw new IllegalArgumentException(宠物编号已存在: pet.getId()); } petStore.put(pet.getId(), pet); } public void feed(String id) { Pet pet petStore.get(id); if (pet null) { throw new IllegalArgumentException(找不到编号为 id 的宠物); } pet.setHunger(Math.min(100, pet.getHunger() 20)); pet.setHealth(Math.min(100, pet.getHealth() 5)); } public ListPet listAll() { return new ArrayList(petStore.values()); } }选择LinkedHashMap而不是HashMap是有意的前者保持插入顺序listAll()返回的列表在多次运行之间顺序稳定控制台打印时不会出现“每次启动顺序都不一样”的诡异现象键用String类型的 id查找复杂度是 O(1)比ArrayList里遍历下标稳定得多。feed方法里用到Math.min做上限封顶这是控制台管理系统里最常见的防溢出写法目的是不让饱食度在反复喂食后突破 100。业务方法放在服务类里带来的直接收益是实体只保留状态和自描述行为控制台菜单层只做输入输出数据校验集中到 setter查找与状态流转集中到 Service。三个层次各管一摊后续要加“宠物驯化”“宠物医疗”等功能只需要在 Service 增加方法不必回头改实体。3. 控制台交互层菜单循环与输入解析控制台程序的核心不是把菜单打印得多漂亮而是稳定地从键盘读到命令并给出反馈。很多课程设计在数据模型上没出问题却在交互层翻车按回车后菜单无响应、输入非数字直接抛异常、菜单项多了以后代码层层嵌套。这一章要解决的是把“读输入、解析输入、分发命令”这件重复劳动做扎实。3.1 用 do-while 做主循环避免递归造成栈溢出菜单循环最朴素也最可靠的写法是while boolean 标志位。有人为了“返回菜单”方便直接递归调用showMenu()这在小次数操作时没问题但控制台程序往往要连续运行几百次操作递归深度持续累积栈溢出只是时间问题。迭代循环没有这个隐患public void run() { boolean running true; while (running) { showMenu(); int choice readInt(); switch (choice) { case 1 - addPet(); case 2 - listPets(); case 3 - feedPet(); case 4 - removePet(); case 0 - running false; // 唯一退出出口 default - System.out.println(无效选项请重新输入); } } }while (running)每次进入循环前检查标志位退出条件是用户选择 0不会再从switch外部横跳。箭头写法case 1 - addPet()是 Java 14 起的 switch 表达式风格省掉了每个分支的break可读性更好如果你的编译环境固定在 Java 8把箭头改成冒号加break即可逻辑完全等价。3.2 nextInt 与 nextLine 混用是新手第一个坑控制台输入最经典的坑是用Scanner.nextInt()读数字后紧接着用nextLine()读字符串。nextInt()只消费数字本身不消费回车换行符换行符留在输入缓冲区里下一个nextLine()读到的是空字符串表现为“明明输入了名字却被跳过”。这个问题在 Java 版本升级后依然存在因为它根植于 Scanner 的标记解析机制。我的做法是不混用统一用nextLine()读整行再自己做类型转换private int readInt() { while (true) { try { return Integer.parseInt(scanner.nextLine().trim()); } catch (NumberFormatException e) { System.out.print(只能输入数字请重试); } } }scanner是系统唯一的Scanner实例菜单和业务方法共用避免多处创建导致输入流状态混乱。trim()去掉用户误敲的前后空格Integer.parseInt只接受纯数字字符串1.5、abc都会触发NumberFormatException由循环重新询问而不是让程序崩溃退出。读字符串用另一个方法private String readName() { String line scanner.nextLine().trim(); while (line.isEmpty()) { System.out.print(名称不能为空请重新输入); line scanner.nextLine().trim(); } return line; }输入方式读数字读字符串混用风险nextInt() nextLine()直接返回 int直接返回 String数字后的换行符被 nextLine 吞掉全用 nextLine() parseInt需自行捕获异常直接返回 String无混用问题3.3 数字菜单与命令词用 Map 做可读映射纯数字菜单对新手友好但熟练用户更希望直接输入feed、list这类命令词减少连续按数字的繁琐。兼容两种输入并不难用一张不可变的Map把命令词映射到数字编号即可private static final MapString, Integer COMMAND_MAP Map.of( add, 1, list, 2, feed, 3, remove, 4, exit, 0 ); private int parseChoice(String line) { if (COMMAND_MAP.containsKey(line)) { return COMMAND_MAP.get(line); } try { return Integer.parseInt(line); } catch (NumberFormatException e) { return -1; // 非命令词、非数字交给 switch 的 default 处理 } }Map.of创建的是一个不可修改的键值对集合适合常量映射。解析顺序是先查命令词再尝试转数字返回 -1 表示这条输入既不是已知命令词也不是合法编号由主循环打印“无效选项”。注意COMMAND_MAP.containsKey的键区分大小写如果你想支持List和list都生效可以在传入parseChoice前统一toLowerCase()。这一层解析逻辑独立成方法主循环只关心拿到一个整数菜单内容和解析策略可以独立调整是控制台交互层里值得保留的边界。4. 持久化方案文件读写与内存模型的取舍自定义对象、集合、循环都跑通后系统还有一个明显的隐患所有宠物都躺在内存里进程一结束数据就归零。课程设计的评分老师几乎必问“数据怎么保存”因此持久化不是可选项而是必需项。这一章说清楚选型理由、写文件、读文件三个层面中间穿插一个隐藏地雷。4.1 三种落盘格式怎么选控制台项目做持久化常见选项是 CSV 文本、Java 原生序列化、Properties 文件。三者的取舍并非越高级越好而是要权衡可读性、实现成本和跨版本安全。方案人类可读跨版本安全实现成本适用场景CSV 文本好Excel 可直接打开好字段顺序固定即可低课程设计、轻量数据Java 序列化差二进制乱码差类结构一变就废最低快速原型Properties中适合键值对好低系统配置不适合列表数据我的选择很明确业务数据用 CSV。原因有两条第一CSV 是纯文本出问题了能用记事本直接检查宠物店场景下人工排查成本最低第二Java 序列化把类结构耦合进文件一旦在开发中改字段名旧数据文件全部作废。这里唯一需要注意的是 CSV 没有标准转义规则宠物名里出现逗号时要统一替换或禁止输入上文第 2 章的名字校验只允许中英文和数字正好规避了逗号问题。4.2 写文件Files.write 比手写 FileWriter 更稳把petStore里的对象逐行转成 CSV现代 Java 标准库提供了非常简洁的写法public void save(String filePath) throws IOException { ListString lines new ArrayList(); for (Pet pet : petStore.values()) { lines.add(String.join(,, pet.getId(), pet.getName(), String.valueOf(pet.getMonthsAge()), String.valueOf(pet.getHealth()), String.valueOf(pet.getHunger()), pet.getSpecies())); } Files.write(Path.of(filePath), lines, StandardCharsets.UTF_8); }Files.write内部会完成打开、写入、关闭的全过程比手写FileWriter加try-finally少了一半代码。路径用Path.of创建第三个参数指定UTF-8字符集避免在中文系统上读写乱码。每行字段用String.join拼装顺序与第 2 章Pet类的字段顺序保持一致这是 CSV 方案里唯一的隐性约定必须在代码注释里写明。文件写入异常通过throws IOException抛给上层控制台统一定位不在 save 方法内部吞掉。4.3 读文件逐行解析与损坏行跳过读回数据是持久化的反向操作逐行读取、按逗号切分、恢复成对象。文件可能被手工改动所以解析必须对坏行有容忍度public void load(String filePath) throws IOException { if (Files.notExists(Path.of(filePath))) { return; // 首次运行没有数据文件直接返回空列表 } try (BufferedReader reader Files.newBufferedReader(Path.of(filePath), StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { if (line.isBlank()) { continue; // 跳过空行容忍文件末尾多余换行 } String[] parts line.split(,); if (parts.length 6) { System.err.println(忽略格式错误的行: line); continue; } Pet pet buildPet(parts); if (pet ! null) { petStore.put(pet.getId(), pet); } } } }BufferedReader.readLine()读取到文件末尾时返回null这是循环终止条件: auto close由 try-with-resources 保证不需要手动调用 close。split(,)产生字段数组parts.length 6判断字段数不足表明该行被截断或格式漂移直接跳过比抛出异常更合理因为一行坏数据不该让整个系统启动失败。buildPet方法里根据parts[5]的物种字符串去构造对应子类实际写的时候用if (cat.equals(...))或者switch都行这一层是恢复对象的唯一工厂。4.4 序列化方案的一个隐藏地雷如果你图省事选了 Java 原生序列化有一个坑必须在开发早期就堵上Pet类必须显式声明private static final long serialVersionUID 1L;。没有这个声明编译器会根据类结构自动生成序列化 ID类里新增一个字段、改一个方法签名自动生成的 ID 就变了结果就是旧文件里的ObjectInputStream.readObject()抛出InvalidClassException。相比之下CSV 方案把字段顺序变成了唯一约定字段名随意改只要顺序不变就能读回这正是我坚持用文本格式的原因。提示无论选哪种落盘方案都建议在启动时先load()在用户选择退出时再save()。频繁写文件会拖慢菜单响应而只在退出时落盘则要求程序必须提供干净的退出路径不能直接关窗口否则数据丢失很难追溯。5. 进阶验证模拟键盘输入与统一参数校验系统功能齐了剩下的问题是怎么证明它可靠。手动启动程序、一遍遍敲菜单来验证效率太低且容易漏测。更专业的做法是让控制台程序具备“输入脚本化”的能力同时把散落在各处的参数规则收拢到一个出口。5.1 把 System.in 换成字符串让控制台程序可自动化验证System.in本质是一个InputStream可以在 main 方法启动阶段替换成字符串构造的ByteArrayInputStream。这样一段模拟键盘输入的脚本就能覆盖一次完整的用户操作路径public static void main(String[] args) throws IOException { // 模拟输入新增一只猫、查看列表、退出 String fakeInput String.join(System.lineSeparator(), 1, A001, 柴柴, 5, 2, 0); System.setIn(new ByteArrayInputStream(fakeInput.getBytes(StandardCharsets.UTF_8))); PetSystem system new PetSystem(); system.load(pets.csv); system.run(); system.save(pets.csv); }这个技巧的核心价值是把手工测试变成了可重复执行的脚本。String.join用系统换行符分隔每一行输入模拟用户依次回车System.setIn替换的是 JVM 全局输入流所以Scanner必须是在这之后创建的实例不能在类加载阶段静态初始化否则捕获的还是旧输入流。配合一个临时数据文件就可以断言save之后文件里出现了预期内容这已经是轻量级冒烟测试的雏形比对着控制台手工点点点可靠得多。5.2 参数校验的统一出口把校验规则从 setter 里再抽象一层抽出静态方法可以让交互层在构造对象之前就拦截非法输入。典型的是宠物名校验private static String validName(String name) { if (name null || !name.matches([\\u4e00-\\u9fa5A-Za-z0-9]{1,10})) { throw new IllegalArgumentException(宠物名需为1~10位中文、英文或数字); } return name; }matches用正则表达式做整体匹配这里的字符类限定了中文字符、英文大小写和数字量词{1,10}限制长度。注意String.matches是全文匹配不是部分查找所以不要在前面加.*。交互层读取用户输入后先调用validName通过后再构造Pet对象setter 里的范围校验依然保留两者形成内外两道防线分别防用户乱输和防代码逻辑错误。正则表达式集中在一个方法里后续想调整命名规则只改这一处即可。本文还有配套的精品资源点击获取