Java实战:手把手带你从零实现用户管理系统

Java实战:手把手带你从零实现用户管理系统 1. 为什么第一个实战项目要选用户管理系统如果你正在学Java学完了变量、循环、集合、面向对象这些基础语法然后突然不知道该干什么了那么用户管理系统几乎是你能遇到的性价比最高的练手项目。先说一个很多初学者容易陷入的误区有人一上来就想做电商系统、做秒杀系统、做微服务中台结果写了一周连项目结构都搭不明白最后自信心直接被击穿。还有人反过来整天对着网上的八股文刷题Java集合源码倒背如流但真让他写一个从控制台录入用户信息、存下来、再查出来的程序他反而无从下手。这两种情况我都见过不少。用户管理系统好就好在它把一个真实项目里最核心的东西都包含进去了数据怎么建模、数据往哪存、怎么对数据做增删改查、多个功能模块之间怎么分层解耦、用户输错了怎么办、程序怎么提示才友好。这些能力不是靠背知识点能获得的必须亲手写一遍才有体感。而且这个项目的复杂度刚好卡在再简单一点就没意思再复杂一点就学不动的位置上。这篇文章我会从零开始带你写一个运行在控制台的用户管理系统。里面包含用户注册、登录、用户信息的增删改查、数据持久化到本地文件、简单的输入校验。技术栈只用Java标准库不引入任何框架保证你照着敲一遍就能跑起来。我会把每个类的设计思路、每段代码为什么这么写、运行过程中最容易踩的坑全部讲清楚。适合的人群是已经学完Java基础语法想通过一个完整项目把知识串起来的人以及准备面试但缺少项目经历的应届生——虽然这个项目不大但它足以成为你面试里第一个能讲清楚我自己独立完成的项目。顺便说一句项目里我用到的几个关键点——集合操作、文件读写、异常处理、面向对象设计——恰恰是热搜词里那些java面试题java基础java八股文最爱考的东西。后面我会在对应的地方逐个给你点出来让你不仅会写还能知道面试官会怎么问。2. 开发前的准备JDK配置和项目结构规划2.1 JDK安装和环境变量配置90%的人在这一步卡住写代码之前得先把环境装好。如果你还没装JDK去官网下载当前稳定的JDK版本17或21都可以别追最新版安装的时候记住安装路径。装完之后必须配环境变量这是初学者第一个大型翻车现场。我见过太多人在网上搜教程照着配JAVA_HOME、PATH、CLASS_PATH结果命令窗口一敲java -version要么提示不是内部或外部命令要么版本不对。这里给你一套经过验证的配置思路直接照着做就行新建系统变量JAVA_HOME值填JDK安装目录比如C:\Program Files\Java\jdk-17。注意不要带bin子目录。编辑系统变量Path新增一行%JAVA_HOME%\bin。不要设置CLASS_PATH。网上大量旧教程会让你配这个东西但JDK 1.5之后它就不再是必需的了配了反而可能给后面引入第三方库埋坑。从命令行验证。这一步很关键——配置完一定要重新打开命令提示符窗口再执行java -version和javac -version因为新开的窗口才会加载最新的环境变量。验证看到版本号输出环境就没问题了。如果你用的是IDE推荐IntelliJ IDEA社区版免费够用IDE里其实也可以设置Project SDK有时候你在命令行配好了环境变量但IDEA里没选对SDK一样会编译报错。所以记住一句话命令行和IDE里的JDK是两套配置。2.2 项目结构怎么设计才不是玩具代码很多初学者写项目最容易犯的毛病是一个Main类写三百行所有代码堆在main方法里。这样写确实能跑但它完全没法扩展。比如你后面想改存储方式、想加新功能就得伤筋动骨重写。我做这个项目的时候结构是这样划分的src/ com/example/usermanager/ entity/ -- 实体类User.java dao/ -- 数据访问层UserDao.java UserDaoFileImpl.java service/ -- 业务逻辑层UserService.java controller/ -- 交互控制层UserController.java Main.java -- 程序入口只负责启动 util/ -- 工具类CheckUtils.java输入校验每个包职责单一实体类只描述用户长什么样DAO层只管数据存到哪、怎么取Service层只处理业务规则Controller层负责跟用户交互Main只负责启动。这套分层思路其实就是Web开发里MVC的简化版。所以当你以后学Spring Boot、写真正的Web项目时会发现用户管理系统的分层经验可以直接迁移过去。这也是为什么我说这个项目小归小含金量不低——它帮你建立了分层思维这是区分写代码的人和做工程的人的分水岭。2.3 一个常见的编译警告源发行版 17 需要目标发行版 17搜索热词里有一条高频报错java: 警告: 源发行版 17 需要目标发行版 17。这个错误几乎每个初学者都会遇到我直接给你解释清楚它的意思是编译器要求源文件和编译产物的Java版本必须一致。比如你在IDEA里项目的Project Structure设置成了JDK 17但有一个地方的Language level被设置成了11编译时就会冒出这个警告甚至报错。解决办法也很简单File→Project Structure→Project把SDK和Language level都统一为17。Settings→Build, Execution, Deployment→Compiler→Java Compiler查看Per-module bytecode version是否也是17。三个地方版本对齐重新编译问题就消失了。这个坑的背后其实是对编译器版本参数的理解。--release、-source、-target这几个参数的关系面试题偶尔也会问到。简单理解就是source指定源码的Java版本target指定编译后class文件的目标版本release同时指定两者。日常开发中直接保持版本一致就行不要去玩什么高版本源码编出低版本class的花活给自己找不痛快。3. 手把手实现核心功能从实体类到控制台交互3.1 第一步定义User实体类用户管理系统核心数据就是用户。所以我先定义一个User类它的职责只有一个描述用户这个对象长什么样。package com.example.usermanager.entity; import java.io.Serializable; import java.util.Objects; /** * 用户实体类对应一条用户记录 */ public class User implements Serializable { // 序列化版本号文件读写时需要 private static final long serialVersionUID 1L; private Integer id; // 用户ID唯一标识 private String username; // 用户名 private String password; // 密码 private String nickname; // 昵称 private Integer age; // 年龄 private String email; // 邮箱 // 无参构造 public User() { } // 带参构造方便创建对象 public User(Integer id, String username, String password, String nickname, Integer age, String email) { this.id id; this.username username; this.password password; this.nickname nickname; this.age age; this.email email; } // 每个字段都有标准的getter和setter方法 // 比如getId() / setId(Integer id) // 这里为了篇幅省略实际写的时候用IDEA快捷键AltInsert一键生成 Override public String toString() { return User{ id id , username username \ , nickname nickname \ , age age , email email \ }; } Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return Objects.equals(id, user.id) Objects.equals(username, user.username); } Override public int hashCode() { return Objects.hash(id, username); } }为什么实体类要实现Serializable因为后面要把用户数据写到文件里Java做对象序列化时需要这个接口做标记。在实际Web项目里实体类一般还会加各种注解比如TableName、TableId但现在不用管那些标准Java类就够了。还有一点toString方法必须重写否则你打印对象输出的是com.example.usermanager.entity.User1b6d3586这种内存地址完全没法看。这里顺嘴一提IDEA有个Lombok插件能用Data注解自动生成getter/setter很多公司项目都在用。但我的建议是第一个项目别用Lombok手动写一遍构造器、getter/setter你才能知道这些水代码到底在干嘛。搜索引擎热词里那条you arent using a compiler supported by lombok的报错等以后用Lombok再研究也来得及。一上来就上注解害处远大于好处。3.2 第二步数据存储层——先用Map再落盘到文件数据要存哪这是设计数据库之前的经典问题。初级阶段有两条路存内存里——用ArrayList或HashMap。永久保存——存文件、存数据库。我建议两者结合运行时用MapInteger, User作为临时数据库承载操作然后通过文件读写把每个用户的完整信息持久化到磁盘。这样程序关闭再打开数据还在体验上就接近真实系统了。思路如下Map的key是用户ID这样通过ID查询用户是O(1)操作value是对应的User对象。用户注册时ID自增保证唯一。所有用户操作之前先从文件加载数据到Map每次操作完之后再把Map写回文件。代码里我通过接口加实现类的方式来做先定义一个UserDao接口再写一个实现类UserDaoFileImpl。这样做的好处是以后你想改数据存储方式再写一个UserDaoDbImpl实现同样的接口上层的业务逻辑一行都不用动。这就是面向接口编程的意义。package com.example.usermanager.dao; import com.example.usermanager.entity.User; import java.util.List; /** * 用户数据访问接口 * 定义对用户数据的所有操作不关心数据怎么存 */ public interface UserDao { boolean addUser(User user); // 新增用户 boolean deleteUser(Integer id); // 根据ID删除用户 boolean updateUser(User user); // 修改用户信息 User findById(Integer id); // 根据ID查用户 User findByUsername(String username); // 根据用户名查用户 ListUser findAll(); // 查询所有用户 }实现类里核心是loadFromFile()和saveToFile()两个私有方法。保存时把所有用户对象放进List一次性序列化写入文件读取时反序列化回List再转存到Map里。这里面有一个性能取舍每操作一次就整表写文件。用户量小几百个以内根本无所谓但如果你想优化可以改成操作一次写一条或者批量异步写。不过对当前这个项目简单可靠是最重要的别过早优化。还有一个序列化方式的坑Java原生序列化写出来的文件是二进制乱码没法直接用记事本看。你要是想方便调试可以改用JSON格式存储。标准库没有JSON处理能力需要引入第三方库比如fastjson或Jackson。我的建议是先用Java原生序列化把项目跑通再尝试引入Jackson做JSON存储这是一个很好的升级练手点。3.3 第三步业务逻辑层——注册、登录、增删改查的规则都在这业务层是系统的裁判员。它不管数据是怎么存的只管这件事允不允许做。举个例子用户注册时Service层要校验用户名不能为空、不能重复。密码长度不能少于6位。年龄必须在1到150之间。邮箱格式要基本正确。这些规则如果散落在Controller层代码结构会很快腐化。放在Service层Controller就只需要做一件事接收用户输入调用Service把结果反馈给用户。package com.example.usermanager.service; import com.example.usermanager.dao.UserDao; import com.example.usermanager.dao.UserDaoFileImpl; import com.example.usermanager.entity.User; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; public class UserService { private final UserDao userDao new UserDaoFileImpl(); // 记录当前用户ID的最大值新增用户时自增 // 使用AtomicInteger是为了演示多线程安全的概念单线程下用Integer也行 private final AtomicInteger idGenerator new AtomicInteger(1); public boolean register(String username, String password, String nickname, Integer age, String email) { // 校验用户名是否已存在 if (userDao.findByUsername(username) ! null) { throw new RuntimeException(用户名已存在请直接登录或更换用户名); } int id idGenerator.getAndIncrement(); User user new User(id, username, password, nickname, age, email); return userDao.addUser(user); } public boolean login(String username, String password) { User user userDao.findByUsername(username); if (user null) { return false; } // 实际项目中密码必须加密存储这里演示用明文 return user.getPassword().equals(password); } public ListUser listAll() { return userDao.findAll(); } public User findById(Integer id) { return userDao.findById(id); } public boolean update(User user) { return userDao.updateUser(user); } public boolean delete(Integer id) { return userDao.deleteUser(id); } }这里有几个细节值得说道说道第一idGenerator为什么从1开始自增因为用户的主键标识必须唯一且稳定。如果你用List的下标当ID删除第2个用户后后面的用户下标全变了程序分分钟出逻辑错误。用独立的自增序列做主键是数据库设计里的基本功。第二注册和登录方法里抛了RuntimeException。为什么不直接返回false因为用户名已存在是一个需要用户看到的明确提示。返回false只能让上层知道注册失败但不知道失败原因。如果你用int作返回值0表示成功、1表示用户名重复、2表示密码太短这样行不行行但代码可读性会很差。异常不是洪水猛兽合理使用反而能让错误信息精确传递。第三登录这里没做任何密码加密我明确标注了这是演示代码。真实项目里密码至少得用BCrypt加盐哈希否则数据库一泄露用户密码全裸奔。你可以在系统跑通后自己尝试引入加密算法这也是一个很好的进阶训练。3.4 第四步交互控制层和主菜单到这一步系统已经具备底层能力了但用户怎么用呢我写一个简单的控制台菜单帮助未知用户操作系统。package com.example.usermanager.controller; import com.example.usermanager.entity.User; import com.example.usermanager.service.UserService; import java.util.List; import java.util.Scanner; public class UserController { private final UserService userService new UserService(); private final Scanner scanner new Scanner(System.in); public void start() { // 主循环保证用户退出前程序一直在运行 while (true) { System.out.println( 用户管理系统 ); System.out.println(1. 注册); System.out.println(2. 登录); System.out.println(3. 查看所有用户); System.out.println(4. 按ID查找用户); System.out.println(5. 修改用户信息); System.out.println(6. 删除用户); System.out.println(7. 退出系统); System.out.print(请选择操作序号); String input scanner.nextLine(); switch (input) { case 1 - register(); case 2 - login(); case 3 - listAll(); case 4 - findById(); case 5 - updateUser(); case 6 - deleteUser(); case 7 - { System.out.println(感谢使用再见); return; } default - System.out.println(输入有误请输入1~7之间的数字); } } } private void register() { System.out.print(请输入用户名); String username scanner.nextLine(); System.out.print(请输入密码不少于6位); String password scanner.nextLine(); // 密码输入控制台不可见需要额外处理这里简化 System.out.print(请输入昵称); String nickname scanner.nextLine(); System.out.print(请输入年龄); int age Integer.parseInt(scanner.nextLine()); System.out.print(请输入邮箱); String email scanner.nextLine(); try { userService.register(username, password, nickname, age, email); System.out.println(注册成功); } catch (RuntimeException e) { System.out.println(注册失败 e.getMessage()); } } private void login() { System.out.print(请输入用户名); String username scanner.nextLine(); System.out.print(请输入密码); String password scanner.nextLine(); boolean success userService.login(username, password); if (success) { System.out.println(登录成功欢迎回来 username); } else { System.out.println(登录失败用户名或密码错误); } } // findById, updateUser, deleteUser, listAll 的实现逻辑类似 // 先读取输入再调用service层最后输出结果 }这是我特别想提醒你的一个点用nextLine()而不是nextInt()。Scanner的nextInt()在你输入数字后按回车会把换行符留在缓冲区。下一次调用nextLine()时读到的就是一个空串然后你发现怎么还没输入程序就跳过去了。这个坑在网上被问了一遍又一遍方案很简单所有输入都用nextLine()需要整数时用Integer.parseInt()转换顺手还能做输入格式检查。3.5 Main入口整个程序的启动开关package com.example.usermanager; import com.example.usermanager.controller.UserController; public class Main { public static void main(String[] args) { UserController controller new UserController(); controller.start(); } }main方法里只有三行代码干净利落。很多初学者爱在main方法里直接写所有逻辑但main方法的定位应该是启动器——把运行环境准备好然后把控制权交给真正干活的类。养成这个习惯后后面学Spring Boot的时候你会觉得一切都是那么自然因为SpringApplication.run()也是这么干的。到这里你已经在IDE里一个文件一个文件地建好了一个基本的、可运行的用户登录和增删改查系统。代码不多但每一层各司其职完整跑通的那一瞬间你会第一次对编程产生那种我能造东西了的感觉。4. 运行中那些绕不开的坑亲测问题与排查思路4.1 数组越界异常最经典的初学者翻车点写作过程中我一直提醒自己运行代码时不把坑踩一遍光看教程等于白学。热搜词里有一条java中数组越界异常我就在这个项目里故意演示一下它是怎么发生的以及怎么排查。假设你在用户列表功能里写了这样的代码ListUser userList userService.listAll(); for (int i 0; i userList.size(); i) { System.out.println(userList.get(i)); }注意i userList.size()这里多了一个等于号。假设系统里只有3个用户size()返回3索引只有0、1、2但循环会访问到索引3于是运行时报出Exception in thread main java.lang.IndexOutOfBoundsException: Index 3 out of bounds for length 3这个报错信息其实已经说得很清楚了访问了索引3但列表长度只有3。报错虽然直接但如果你用的是foreach循环或者Stream通常不会有这个问题。这也引出一个小经验能用增强for或List.forEach就不要手写for(int i...)手写循环时边界条件一多越界是早晚的事。排查这类问题的方法也很固定看at com.example.usermanager.controller.UserController.listAll(UserController.java:42)这样的堆栈信息它会精确告诉你哪个类哪个方法第几行出了问题。顺着堆栈往上翻基本十拿九稳。4.2 空指针异常业务校验缺失的教材级案例空指针NullPointerException是Java开发者的老朋友尤其是在处理按ID查找用户时。假设你写了这样的代码User user userService.findById(100); System.out.println(用户年龄 user.getAge());如果ID为100的用户不存在findById返回的是null第二行直接炸。报错是at xxx.UserController.findByxxx(UserController.java:58)方法名直接暴露问题。解决思路任何一个能从外部传进来的ID都要假设它有可能查无此人。查完空必须判空。养成防御性编程的习惯。同样地用户在注册时年龄输入了-5如果你的Service层没有校验那系统里就会出现一个活-5年的用户。这种垃圾数据一旦进入存储层后面统计、展示全都会出各种诡异问题。所以校验必须在入口处拦住这也是我之前强调Service层要做校验的原因。4.3 关于内存不足报错的一个定心丸版解释热词里有一条java: outofmemoryerror: insufficient memory。初学者见到这个报错常常手足无措以为电脑坏了。其实大部分情况下它只是JVM默认堆内存不够用了。比如你在程序里写了一个死循环不断往List里加对象或者一次性读了个超大文件堆内存被占满JVM没办法再分配新对象就抛出OutOfMemoryError。看到这个报错时处理思路是这样的先检查代码里有没有无限循环或无限添加对象的逻辑这是最常见的原因。如果代码没问题再用-Xmx参数增大JVM堆内存上限比如java -Xmx512m Main表示把最大堆内存调到512MB。但要注意增大内存只是治标如果你程序本身有内存泄漏堆多大都不够用。用户管理系统这种级别的项目正常操作基本不会触发这个错。所以遇到它不用慌先把代码排查一遍——多半是文件读取时while循环条件写错了文件读完了还在read。4.4 Lombok与编译器版本不匹配的知名烂梗热词里有一条很长的关于Lombok的报错you arent using a compiler supported by lombok, so lombok will not work。我在前面建议第一个项目手写getter/setter但我知道很多人忍不住想用Lombok。如果你用了这个注解库然后发现它在编译时罢工了通常是因为Lombok版本和JDK版本不匹配。Lombok通过注解处理器在编译期修改AST新版JDK一出旧版Lombok可能就不认了。IDE里的Annotation Processing被关闭了。在IDEA的Settings→Build, Execution, Deployment→Compiler→Annotation Processors里把Enable annotation processing勾上。我的态度是新手期认真手写一遍访问器比省那几行代码价值大得多。等到你写了几百个实体类觉得重复劳动太烦了再上Lombok你会真心感激它。而那时候你就算遇到这种版本兼容报错也不会再发怵因为你已经理解它到底在做什么了。4.5 排查问题的一个通用心法很多初学者遇到报错的第一反应是把报错粘到搜索引擎里然后照着搜到的答案一顿乱改。这样运气好能解决但运气不好会越改越乱。我个人的经验是先读懂报错再动手改代码。Java的报错信息90%以上会告诉你具体是什么问题、发生在哪个文件的哪一行。哪怕一时看不懂也要先自己读一遍再决定要不要去搜。这个习惯会成为你后续开发中最重要的能力之一。至于搜索报错本身也要有个正确姿势把完整堆栈信息贴进搜索引擎而不是只贴Exception这种单词。搜的时候带引号把整个异常信息包起来匹配度会高很多。另外别只盯着解决办法看多看几篇讲产生原因的文章你会发现很多坑之间是有共性规律的。注意本文提到的所有报错分析和排查思路都是Java标准运行环境中的常见做法。遇到具体报错时以你所用JDK版本和实际报错堆栈为准。5. 从控制台到Web沉淀下来再学Spring Boot你会感谢这个项目5.1 把当前项目跑通后先别急着学新框架很多人写完控制台版用户管理系统觉得这也太简陋了转头就去学Spring Boot。但我的建议是先把这个项目本身改造几轮再上Spring Boot不迟。改造方向有很多把数据存储从文件改成MySQL用JDBC连接数据库手写INSERT、SELECT、UPDATE、DELETE。这能让你理解SQL和Java代码是怎么交互的。把输入方式从控制台改成Swing图形界面体验一下前端和后端的边界在哪。在Service层加上分页查询模糊搜索功能理解为什么数据库查询要带条件和限制。尝试引入JSON存储感受一下通用数据格式和序列化协议的意义。每改一轮你对系统的理解都会加深一层。比如改成MySQL存储后你才会真正理解UserDao接口的价值——切换存储方式时上层代码一行不用动只换了实现类。5.2 面试时这个项目怎么讲才有含金量如果你是为了面试准备这个项目我提几个建议第一不要只说我做了个用户管理系统要说清楚系统里包含哪些模块、哪些功能以及每层之间的调用关系。第二准备一个问题清单比如用户ID为什么用自增而不是用名字密码为什么不能明文存储文件存储和数据库存储各自的优缺点数据量大了之后这个架构会暴露什么问题这些问题即使面试官不问你自己也要想明白。想明白了项目才有深度。第三项目里尽量体现几个亮点用接口隔离数据访问和业务逻辑用异常统一处理错误信息用AtomicInteger保证ID生成的线程安全哪怕目前是单线程但体现了你的知识面文件存储虽然简单但提到了序列化和IO流的使用。这些细节比项目用了三层架构这种大话更能让面试官信服。5.3 下一个里程碑用Spring Boot重构一遍等到你对Java基础语法和这个项目已经烂熟于心强烈建议你用Spring Boot重写一版RestController替代控制台菜单用HTTP接口暴露增删改查功能。Spring Data JPA或MyBatis替代文件存储操作MySQL数据库。Thymeleaf或Vue写个简单页面让用户能在浏览器里操作。加上登录校验、密码加密、异常统一处理。你会发现Spring Boot本身并不神秘它就是把你在这个项目里手动做的分层和管理工作以更规范、更自动化的方式帮你完成。有了用户管理系统的基础你在学Spring Boot时就不会有空中楼阁的感觉每一层概念都能映射回你已经写过的代码上。另外分享一个我自己的体会写第一个项目的时候速度不重要重要的是把每行代码都弄懂。能跑和懂为什么能跑之间的差距将在几个月后的面试和实际开发中体现得淋漓尽致。// 最后补一个你在面试里可能被追问的经典问题演示 // 为什么用 MapInteger, User 而不是 ListUser // 因为当用户量很大时按ID查找用户Map的时间复杂度是O(1) // List即便在排序状态下用二分查找也要O(log n)何况无序List是O(n)。 // 这个选择本身就是数据结构知识在工程里的落地。 MapInteger, User userMap new HashMap(); userMap.put(1, new User(1, admin, 123456, 管理员, 30, adminexample.com)); userMap.put(2, new User(2, zhangsan, abc123, 张三, 25, zhangsanexample.com)); User target userMap.get(1); // O(1)直接命中5.4 这个项目可以继续生长的地方最后总结一下这个项目自身还能长出的分支你可以当成一张经验地图来用安全方向引入BCrypt做密码加密引入JWT做登录令牌。性能方向引入缓存比如Caffeine减少文件IO操作次数。工程化方向用Maven/Gradle管理依赖引入Log4j2做日志记录。测试方向给Service层写单元测试用JUnit5跑通核心业务逻辑。架构方向把单机文件存储替换为Redis或数据库集群。每走一个分支你都会接触一个新的技术生态。但万变不离其宗你在用户管理系统里建立起的对实体、分层、数据操作、校验、异常处理的理解会一直跟随你成为后续所有项目的骨架。所以如果你正处在学完了语法但不知道写什么的阶段别犹豫就从这个用户管理系统开始。准备好JDK建好项目结构从User实体类写起一步步来。写完运行起来那一刻你学到的东西会比看十篇教程都多。