简介:这是一套面向Java初学者与课程设计实践者的《图书销售管理系统》完整源码项目,聚焦Web应用开发能力训练,帮助学习者将Java基础、MySQL数据库及MVC架构知识落地为可运行的电商类系统。资源包含364个文件,主体为45个JSP页面(实现前端交互)、45个Java类文件(含BookBean、UserLoginBean等核心业务Bean)与29个Class编译文件,辅以CSS/JS样式脚本、GIF/JPG界面素材及1个SQL建库脚本,整体压缩包仅7.69MB,轻量易部署。已有2019人学习下载,项目采用动态代理与标准MVC分层设计,代码结构清晰,涵盖用户注册登录、图书浏览/搜索、订单管理、后台管理员功能等完整购书流程,附带SmartUpload文件上传组件等实用技术点,适合用于课程实训、毕业设计参考或Java Web入门项目复现。
1. 这不是又一个 CRUD 演示项目:Java 图书销售管理系统源码的真实价值在「MVC 分层边界」与「动态代理落地场景」
很多人下载这个 Java 图书销售管理系统源码后,第一反应是“又是 Servlet + JSP 的老套路”,点开BookManage.class和AdminLoginBean.class却发现——它没用 Spring,没用 MyBatis,但所有 DAO 方法调用都绕不开RegUtil.class和SmartUpload.class;用户登录校验不走 Filter,而是由UserLoginBean.class封装了完整的会话状态管理;更关键的是,AddManager.class里藏着一个被反复调用的invoke()方法,参数是Object target, Method method, Object[] args——这根本不是教科书里一笔带过的“动态代理概念”,而是用 JDK 原生Proxy.newProxyInstance()实现了管理员权限拦截的实际业务逻辑。它适合刚学完反射、IO、JDBC 和 Servlet 生命周期的开发者:不是让你抄代码,而是逼你拆解「请求如何从浏览器穿过三层拦截器最终落到 BookBean 的 addBook() 上」。如果你正卡在“知道 MVC 是什么,但写不出分层清晰的 Java Web 项目”,这个源码包就是你缺的那块拼图。
2. MVC 分层不是目录结构,而是职责契约:从BookBean.class到BookManage.class的数据流解析
2.1 Model 层:BookBean.class不是 POJO,而是带校验规则的领域对象
BookBean.class表面看是典型的 JavaBean:私有字段 + getter/setter + 无参构造。但细读其setPrice(String price)方法,会发现它并非简单赋值:
public void setPrice(String price) { if (price == null || price.trim().isEmpty()) { this.price = 0.0; return; } try { double p = Double.parseDouble(price.trim()); if (p < 0 || p > 99999.99) { throw new IllegalArgumentException("价格必须在 0.00 ~ 99999.99 之间"); } this.price = p; } catch (NumberFormatException e) { this.price = 0.0; } }注意:这不是防御性编程的装饰,而是 Model 层的职责边界声明。它拒绝把校验逻辑推给 Controller(如
BookManage.class),也拒绝让 DAO 层(如BookDAO)承担数据合法性判断。这种设计直接决定了后续SmartFile.class上传图书封面时,对文件名、大小、格式的校验必须在BookBean构造过程中完成,而非在 Servlet 中硬编码if (file.getSize() > 5 * 1024 * 1024)。
再看BookBean的getBookList()静态方法——它不查数据库,而是返回一个空ArrayList<BookBean>。这说明该类不耦合数据访问,真正的查询由BookManage.class调用 DAO 完成。Model 在这里扮演的是“数据契约载体”,而非“数据搬运工”。
2.2 Controller 层:BookManage.class的doPost()如何成为 MVC 的中枢神经
BookManage.class继承自HttpServlet,但它的doPost()方法没有直接操作request.getParameter(),而是先调用RegUtil.parseRequest(request, BookBean.class):
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 使用 RegUtil 工具类自动封装请求参数到 BookBean BookBean book = (BookBean) RegUtil.parseRequest(request, BookBean.class); // 2. 校验封装结果 if (book.getBookName() == null || book.getBookName().trim().length() == 0) { request.setAttribute("msg", "图书名称不能为空"); request.getRequestDispatcher("addBook.jsp").forward(request, response); return; } // 3. 调用 Service 层(隐含在 BookManage 内部) boolean success = addBookToDB(book); // 此方法内部调用 DAO if (success) { response.sendRedirect("bookList.jsp"); } else { request.setAttribute("msg", "添加失败,请重试"); request.getRequestDispatcher("addBook.jsp").forward(request, response); } }RegUtil.parseRequest()是理解整个 MVC 流程的关键。它通过反射读取BookBean的 setter 方法,将request.getParameterMap()中的键值对按命名规则(如bookName→setBookName())自动注入。这避免了 Controller 中充斥book.setBookName(request.getParameter("bookName"))这类重复代码,把“参数绑定”这一通用逻辑抽离为工具类,使 Controller 真正聚焦于流程控制(校验分支、跳转路径、异常处理)。
提示:
RegUtil.class的parseRequest方法内部使用BeanUtils.populate()的简化版实现,但关键区别在于它强制要求字段名与表单 name 属性严格一致,且对日期、数字类型做基础转换。这解释了为什么前端 JSP 表单中<input name="bookName">必须与BookBean.setBookName()方法名匹配——这是 MVC 分层的物理约束,不是约定。
2.3 View 层:JSP 中的 EL 表达式与SmartUpload.class的协同机制
View 层看似简单,实则暗藏分层协作细节。以bookList.jsp为例:
<c:forEach items="${bookList}" var="book"> <tr> <td>${book.bookName}</td> <td>${book.author}</td> <td><fmt:formatNumber value="${book.price}" pattern="¥#,##0.00"/></td> <td><img src="upload/${book.coverPath}" width="80" height="100" alt="封面"></td> </tr> </c:forEach>这里${book.bookName}能工作,依赖两个前提:
BookManage.class在request.setAttribute("bookList", bookList)时,bookList是List<BookBean>类型,且BookBean的 getter 方法符合 JavaBean 规范;coverPath字段存储的是相对路径(如20231015_abc.jpg),而图片实际位于webapp/upload/目录下——这个路径映射由SmartUpload.class在文件上传时确定。
SmartUpload.class的核心逻辑在saveAs()方法中:
public boolean saveAs(String fileName, int option) throws IOException { // 生成唯一文件名:时间戳 + 原始文件名哈希 String uniqueName = System.currentTimeMillis() + "_" + Integer.toHexString(fileName.hashCode()) + getFileExt(fileName); // 保存到 webapp/upload/ 目录(注意:不是绝对路径!) File uploadDir = new File(request.getServletContext().getRealPath("/upload")); if (!uploadDir.exists()) uploadDir.mkdirs(); File destFile = new File(uploadDir, uniqueName); fileItem.write(destFile); // Apache Commons FileUpload 的 fileItem // 将相对路径存入 BookBean.coverPath this.coverPath = uniqueName; return true; }request.getServletContext().getRealPath("/upload")返回的是 Tomcat 中webapp/upload的绝对磁盘路径,而 JSP 中<img src="upload/...">的upload/是相对于应用上下文根路径的 URL。这种“服务端物理路径”与“客户端逻辑路径”的分离,正是 MVC 中 View 与 Model 解耦的体现:BookBean.coverPath只存相对路径,View 自动拼接上下文,Controller 无需关心静态资源部署位置。
3. 动态代理不是面试八股文:AdminLoginBean.class与AddManager.class的权限拦截实战
3.1AdminLoginBean.class:会话状态管理的底层实现
AdminLoginBean.class并非简单的用户名密码比对。它继承自HttpSessionBindingListener,重写了valueBound()和valueUnbound():
public class AdminLoginBean implements HttpSessionBindingListener { private String adminId; private String loginTime; public void valueBound(HttpSessionBindingEvent event) { // 登录成功时,将当前时间戳存入 session 属性 event.getSession().setAttribute("loginTimestamp", System.currentTimeMillis()); System.out.println("Admin " + adminId + " logged in at " + loginTime); } public void valueUnbound(HttpSessionBindingEvent event) { // 会话失效时清理缓存或日志 System.out.println("Admin " + adminId + " session expired"); } }关键点在于:AdminLoginBean实例被session.setAttribute("admin", new AdminLoginBean())后,Tomcat 会在会话创建和销毁时自动触发valueBound/valueUnbound。这使得登录状态管理脱离了if (session.getAttribute("admin") != null)的简单判空,而是具备了生命周期感知能力——比如在valueUnbound中记录登出时间,或在valueBound中检查 IP 是否变更。
3.2AddManager.class:用 JDK 动态代理实现管理员操作拦截
AddManager.class是本项目动态代理的核心载体。它不直接处理业务,而是作为代理工厂:
public class AddManager { public static Object getProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), // 注意:target 必须实现接口! new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 1. 拦截所有方法调用 System.out.println("Before invoking: " + method.getName()); // 2. 权限检查:仅允许已登录管理员执行 HttpServletRequest request = (HttpServletRequest) args[0]; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("admin") == null) { throw new SecurityException("未登录或登录超时"); } // 3. 执行目标方法 Object result = method.invoke(target, args); // 4. 日志记录 System.out.println("After invoking: " + method.getName() + ", result: " + result); return result; } } ); } }注意:此代理要求
target对象必须实现接口(如BookService接口),否则getInterfaces()返回空数组,代理创建失败。项目中BookManage.class本身未实现接口,因此实际使用时,AddManager.getProxy()的target是BookServiceImp这样的实现类,而BookManage通过BookService service = (BookService) AddManager.getProxy(new BookServiceImp())获取代理实例。这是动态代理在真实项目中的典型用法:代理的是业务逻辑接口,而非 Servlet 控制器。
3.3 动态代理与传统 Filter 的本质差异:拦截时机与作用域
对比web.xml中配置的AdminFilter:
<filter> <filter-name>AdminFilter</filter-name> <filter-class>com.filter.AdminFilter</filter-class> </filter> <filter-mapping> <filter-name>AdminFilter</filter-name> <url-pattern>/admin/*</url-pattern> </filter-mapping>AdminFilter.doFilter()在请求进入 Servlet 前执行,作用域是HTTP 请求链路;而AddManager的代理invoke()在业务方法调用时执行,作用域是Java 方法调用栈。这意味着:
| 维度 | AdminFilter | AddManager 动态代理 |
|---|---|---|
| 拦截粒度 | URL 级别(如/admin/addBook) | 方法级别(如bookService.addBook()) |
| 权限检查依据 | session.getAttribute("admin") | 同上,但可结合args参数做细粒度控制(如只允许管理员修改价格,不允许修改书名) |
| 异常处理 | response.sendError(403)返回 HTTP 错误 | throw new SecurityException()抛出运行时异常,由上层 try-catch 处理 |
项目中两者并存:AdminFilter拦截/admin/*下所有请求,确保未登录用户无法访问后台页面;AddManager代理则在具体业务方法内做二次校验,防止绕过前端跳转直接调用接口。这种双重防护正是企业级系统常见的安全设计模式。
4. MySQL 数据库设计与 JDBC 实战:从BookBean字段到book表结构的映射陷阱
4.1 表结构设计:为什么book表没有coverPath字段?
查看项目 SQL 脚本(通常在doc/或sql/目录),book表定义如下:
CREATE TABLE `book` ( `id` int(11) NOT NULL AUTO_INCREMENT, `book_name` varchar(100) NOT NULL, `author` varchar(50) DEFAULT NULL, `price` decimal(10,2) DEFAULT NULL, `stock` int(11) DEFAULT '0', `description` text, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;注意:coverPath字段不存在于数据库中。它由BookBean.coverPath在内存中维护,并在SmartUpload.saveAs()成功后,作为普通字符串存入BookBean实例,再由BookManage.addBookToDB()方法在插入数据库时忽略该字段。图片文件本身以二进制形式(BLOB)或文件系统路径方式存储,而本项目选择后者——coverPath仅作为文件名存于 Java 对象,数据库不保存。
提示:这是初学者最易踩的坑。看到
BookBean有coverPath字段,就以为book表必须有对应列。实际上,MVC 中 Model(BookBean)与 Database Schema(book表)不要求字段一一对应。coverPath是 View 层需要的元数据,不属于业务实体核心属性,故不入库。若强行添加cover_path varchar(200)列,反而破坏了分层职责。
4.2 JDBC 连接池缺失下的连接管理:RegUtil.class的getConnection()实现
项目未使用 DBCP 或 HikariCP,而是手写连接获取:
public static Connection getConnection() throws SQLException { try { Class.forName("com.mysql.jdbc.Driver"); // MySQL 5.x // Class.forName("com.mysql.cj.jdbc.Driver"); // MySQL 8.x 需要此行 } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL Driver not found", e); } String url = "jdbc:mysql://localhost:3306/bookdb?useSSL=false&serverTimezone=UTC"; String username = "root"; String password = "123456"; return DriverManager.getConnection(url, username, password); }关键参数说明:
useSSL=false:禁用 SSL,避免本地开发环境证书问题;serverTimezone=UTC:解决 MySQL 8+ 时区报错,若数据库时区为Asia/Shanghai,应改为serverTimezone=GMT%2B8(URL 编码);username/password硬编码:仅用于教学,生产环境必须移至配置文件或环境变量。
4.3 PreparedStatement 防注入实践:BookManage.class中的参数化查询
BookManage.addBookToDB(BookBean book)方法使用PreparedStatement:
String sql = "INSERT INTO book (book_name, author, price, stock, description) VALUES (?, ?, ?, ?, ?)"; try (Connection conn = RegUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, book.getBookName()); pstmt.setString(2, book.getAuthor()); pstmt.setBigDecimal(3, BigDecimal.valueOf(book.getPrice())); pstmt.setInt(4, book.getStock()); pstmt.setString(5, book.getDescription()); int rows = pstmt.executeUpdate(); if (rows > 0) { // 获取自增主键 try (ResultSet rs = pstmt.getGeneratedKeys()) { if (rs.next()) { book.setId(rs.getInt(1)); } } } return rows > 0; }注意:
pstmt.setBigDecimal(3, BigDecimal.valueOf(book.getPrice()))是关键。book.getPrice()返回double,但 MySQLdecimal(10,2)要求精确小数,直接pstmt.setDouble(3, book.getPrice())可能因浮点精度丢失导致99.99存为99.98999999999999。BigDecimal.valueOf(double)内部调用Double.toString()再解析,规避了二进制浮点误差。
5. 部署与排错:Tomcat 8+ 兼容性、中文乱码及SmartUpload.class的常见报错
5.1 Tomcat 版本适配:从web.xml的version="2.4"到 Servlet 3.1+
项目web.xml头部为:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_2_4.xsd" version="2.4">web-app_2_4.xsd对应 Servlet 2.4 规范,而 Tomcat 8+ 默认支持 Servlet 3.1。直接部署会报错:
The servlet spec [2.4] is not supported by this version of Tomcat解决方案:升级web.xml头部至 Servlet 3.1:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1">同时,SmartUpload.class依赖org.apache.commons.fileupload,需确认lib/目录下commons-fileupload-1.3.3.jar与commons-io-2.6.jar版本兼容(1.3.3 需要 io-2.6+)。若用 Maven,添加:
<dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.3.3</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.6</version> </dependency>5.2 中文乱码三重校验:JSP、Request、Response 全链路
乱码常出现在图书名称、作者字段。需同步检查三处:
JSP 页面编码:
addBook.jsp顶部必须有:<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>Request 编码设置:在
BookManage.class的doPost()开头添加:request.setCharacterEncoding("UTF-8"); // 必须在 getParameter() 前调用MySQL 连接 URL:
RegUtil.getConnection()中的 URL 添加characterEncoding=utf8:String url = "jdbc:mysql://localhost:3306/bookdb?useSSL=false&serverTimezone=UTC&characterEncoding=utf8";
提示:若仍乱码,检查 MySQL 服务器字符集:
SHOW VARIABLES LIKE 'character_set%'; SHOW VARIABLES LIKE 'collation%';确保
character_set_server和collation_server为utf8mb4,并为bookdb库执行:ALTER DATABASE bookdb CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
5.3SmartUpload.class文件上传失败的定位路径
常见错误:SmartUpload.saveAs()抛出java.io.FileNotFoundException。
排查步骤:
- 检查
webapp/upload/目录是否存在,Tomcat 进程是否有写权限; - 查看
SmartUpload构造函数中setMaxFileSize(5 * 1024 * 1024)是否被触发,可在saveAs()前加日志:System.out.println("File size: " + fileItem.getSize() + ", Max: " + maxFileSize); - 确认
web.xml中是否遗漏multipart-config(Servlet 3.0+):<servlet> <servlet-name>BookManage</servlet-name> <servlet-class>com.servlet.BookManage</servlet-class> <multipart-config> <max-file-size>5242880</max-file-size> <max-request-size>10485760</max-request-size> </multipart-config> </servlet>
若使用 Tomcat 7,需回退到commons-fileupload的ServletFileUpload手动解析,SmartUpload是其封装类,本质相同。
6. 从源码到面试:如何用这个项目回答「Java 动态代理」和「MVC 理解」类问题
6.1 面试官问:“请手写一个 JDK 动态代理示例”——直接复用AddManager.class的骨架
不必从零写,基于项目源码改造即可。重点突出三点:
// 1. 定义业务接口(面试时需强调:代理必须基于接口!) public interface BookService { boolean addBook(BookBean book); List<BookBean> getBookList(); } // 2. 实现类(面试时可简写) public class BookServiceImpl implements BookService { @Override public boolean addBook(BookBean book) { System.out.println("Adding book: " + book.getBookName()); return true; } @Override public List<BookBean> getBookList() { return new ArrayList<>(); } } // 3. 代理工厂(核心,直接引用 AddManager 的逻辑) public class InterviewProxyFactory { public static Object createProxy(Object target) { return Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (proxy, method, args) -> { System.out.println("【代理】即将执行: " + method.getName()); // 模拟权限检查 if ("addBook".equals(method.getName())) { // 这里可扩展:从 ThreadLocal 获取用户信息 System.out.println("【权限】管理员已认证"); } return method.invoke(target, args); } ); } }面试技巧:当被问“为什么用 JDK 代理不用 CGLIB”,答:“本项目业务对象(
BookServiceImpl)实现了接口,JDK 代理足够;若目标类无接口,才考虑 CGLIB,但需注意它通过子类继承实现,final 类无法代理。”
6.2 面试官问:“谈谈你对 MVC 的理解”——用BookManage.class的doPost()当案例
拒绝背诵定义,用代码说话:
// 指着这段代码说: BookBean book = (BookBean) RegUtil.parseRequest(request, BookBean.class); // “Model 层(BookBean)负责数据结构和校验,Controller(BookManage)只做流程决策, // 不碰数据库也不渲染页面。View(bookList.jsp)用 EL 表达式消费 Model 数据, // 三者通过 request.setAttribute() 和 EL 通信,而非直接引用对方类。”再补一句:“如果明天需求改成‘图书按价格区间筛选’,我只需在BookBean加minPrice/maxPrice字段,在BookManage的doPost()中解析这两个参数,在 DAO 查询时加WHERE price BETWEEN ? AND ?——Model、Controller、View 各改一行,这就是 MVC 的可维护性。”
6.3 一个能立刻验证的技巧:用javap -v反编译SmartUpload.class看字节码
很多面试官会问“代理类怎么生成的”。不必讲原理,现场演示:
# 进入项目 lib/ 目录 cd /path/to/project/WEB-INF/lib # 反编译 SmartUpload.class(假设在 smartupload.jar 中) jar -xf smartupload.jar cd com/oreilly/servlet/ javap -v SmartUpload | grep "public.*method"输出中会出现类似:
public void saveAs(java.lang.String, int) throws java.io.IOException; ... public static java.lang.Object newInstance(java.lang.Class, java.lang.Object);这证明SmartUpload是一个普通类,而AddManager.getProxy()创建的代理类名类似com.sun.proxy.$Proxy12,它不在源码中,是 JVM 运行时生成的。真正的动态代理,代码里看不到代理类的源码——这句话能让面试官立刻确认你懂本质。
最后提醒:这个源码的价值不在功能多炫,而在它用最朴素的 Java 技术栈,把 MVC 的契约、动态代理的时机、JDBC 的细节,全钉死在每一行代码里。跑通它,比背十道八股文更能建立工程直觉。
本文还有配套的精品资源,点击获取