简介:这是一套面向JavaWeb初学者与课程设计者的简易图书订阅管理系统实战项目,基于Servlet+JSP+MySQL技术栈构建,聚焦用户登录注册、图书浏览/购买、后台图书CRUD及基础安全防护等核心功能,适用于高校JavaWeb实验课、毕业设计原型开发或自学练手。资源包共76个文件,含37个Java源码(涵盖Servlet控制层与JDBC数据访问逻辑)、20个JSP页面(实现前后端交互与动态展示)、9个运行依赖JAR包、1个book.sql数据库脚本(含建表与初始数据),以及CSS/JS/图片等静态资源,整体5.16MB,结构清晰,模块划分明确。已有676人学习下载,提供开箱即用的完整工程结构(含src、web、WEB-INF、pages等标准目录),配套jdbc.properties配置与Tomcat部署说明,便于快速导入IDE调试运行,是理解JavaWeb分层开发与MVC实践的典型入门案例。
1. 项目概述与核心价值
最近在带新人或者自己回顾基础的时候,发现一个现象:很多朋友学完了Java基础、Servlet、JSP,甚至框架也接触了一些,但一提到“做个完整的项目”,脑子里还是乱的,不知道从哪里下手,各个模块怎么串起来。这其实挺正常的,因为学习是点状的,而项目是线性和网状的。今天,我就以一个非常经典且实用的练手项目——“简易的JavaWeb图书订阅管理系统”为例,给大家彻底拆解一遍。这个项目麻雀虽小,五脏俱全,涵盖了从需求分析、数据库设计、后端逻辑到前端展示的完整流程,特别适合用来巩固JavaWeb核心技术和理解MVC模式的实际应用。
简单说,这个系统就是模拟一个图书馆或者个人对图书订阅的管理。核心功能包括:用户(可能是读者或管理员)的注册登录、浏览图书信息、订阅(借阅)图书、查看自己的订阅记录、管理员对图书和用户信息进行增删改查等。你别看它“简易”,里面涉及的技术点非常密集:JDBC操作数据库、Servlet处理请求、JSP展示页面、会话管理、简单的权限控制、还有前后端数据交互。通过亲手实现它,你能把之前学的那一堆零散的知识点,像拼图一样完整地拼接起来,真正理解一个Web应用是怎么跑起来的。无论你是正在找工作的应届生,还是想夯实基础的在职开发者,这个项目都是一个绝佳的起点。
2. 项目整体设计与技术栈选型
2.1 为什么选择这个技术组合?
提到JavaWeb,现在可能大家第一反应是Spring Boot。但对于深入理解底层原理,从Servlet和JSP入手是无可替代的。我们这个“简易”系统,就采用最经典、最纯粹的JavaWeb技术栈:Servlet + JSP + JDBC + MySQL + Tomcat,开发工具选用目前最主流的IntelliJ IDEA。
这么选型的核心考量是:
- 聚焦核心原理:Spring Boot做了大量封装,虽然开发效率高,但容易让人变成“配置工程师”,不清楚底层发生了什么。直接用Servlet,你能清晰地看到HTTP请求如何被接收、如何被分发、如何被处理,这对理解Web容器(如Tomcat)的工作机制至关重要。
- 强化基础能力:手写JDBC,能让你深刻理解数据库连接池的意义、SQL注入的风险与防范(比如使用
PreparedStatement)、事务管理的基本概念。这些是无论用任何ORM框架(如MyBatis, Hibernate)都绕不开的根基。 - 理解MVC的雏形:虽然我们没有用Spring MVC,但我们可以自己模拟MVC架构。Servlet充当Controller(控制器),负责处理请求和业务逻辑;JSP充当View(视图),负责展示数据;JavaBean(实体类)和Service/DAO层充当Model(模型),负责数据和业务规则。自己动手实现一遍,对MVC的理解会比直接使用框架深刻十倍。
- 环境简单,排除干扰:这套环境搭建简单,几乎不会在环境配置上卡住,可以让我们把全部精力集中在业务逻辑和代码编写上。
注意:有同学可能会问,为什么不用Thymeleaf或FreeMarker而用JSP?JSP虽然看起来“老”,但它将Java代码嵌入HTML的模式,对于初学者理解服务器端渲染(SSR)和EL表达式、JSTL标签库来说,非常直观。先理解JSP,再看现代模板引擎,你会明白它们解决了JSP的哪些痛点。
2.2 系统功能模块拆解
在动手写代码之前,我们必须把系统要做什么想清楚。基于“图书订阅管理”这个核心,我们可以拆解出以下核心功能模块:
1. 用户模块
- 核心功能:用户注册、登录、退出。
- 细节:登录时需要验证用户名和密码;密码绝不能明文存储,需要MD5或SHA等哈希算法加密后存库;使用Session来记录用户的登录状态和基本信息(如用户ID、角色)。
- 权限雏形:我们可以设计一个
role字段(如0-普通用户,1-管理员),为后续的权限控制打下基础。
2. 图书模块
- 核心功能:图书信息的增、删、改、查(CRUD)。
- 细节:图书属性至少包括:图书ID、ISBN、书名、作者、出版社、出版日期、简介、总数量、可订阅数量。注意“总数量”和“可订阅数量”的区别,这是实现订阅业务的关键。
3. 订阅(借阅)模块
- 核心功能:用户订阅图书、归还图书、查看个人订阅记录。
- 细节:这是系统的业务核心。用户订阅时,需要检查该书“可订阅数量”是否大于0,然后生成一条订阅记录,并减少该书的“可订阅数量”。归还时,更新订阅记录状态为“已归还”,并增加书的“可订阅数量”。这里涉及到数据库事务,要保证“更新库存”和“生成记录”两步要么都成功,要么都失败。
4. 后台管理模块(基于权限)
- 核心功能:管理员可以管理所有图书和用户信息。
- 细节:在Servlet中,可以通过判断Session中的用户角色,来决定是否允许访问某些特定的管理页面(如
/admin/*路径下的请求)。
根据以上模块,我们可以规划出大致的页面流:首页 -> (登录/注册) -> 图书列表页 -> 图书详情页 -> (订阅操作) -> 个人中心(我的订阅)。管理员则有额外的入口进入管理后台。
3. 数据库设计与核心表结构
数据库是项目的基石,设计得好,后面编程事半功倍。我们使用MySQL,至少需要三张核心表。
3.1 表结构详解与字段设计
用户表 (user)这张表存储系统用户信息。
CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键', `username` varchar(50) NOT NULL UNIQUE COMMENT '用户名,唯一', `password` varchar(255) NOT NULL COMMENT '密码(加密后存储)', `email` varchar(100) DEFAULT NULL COMMENT '邮箱', `role` int(11) DEFAULT 0 COMMENT '角色:0-普通用户,1-管理员', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';设计要点:
password字段长度要给足,因为经过哈希加密(如MD5产生32位字符串)后的密码会比明文长。role字段用整数表示角色,便于程序判断。扩展时也可以使用字符串如‘USER‘,’ADMIN‘。create_time设置默认值,避免插入时遗漏。
图书表 (book)这张表存储图书信息。
CREATE TABLE `book` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '图书ID,主键', `isbn` varchar(20) DEFAULT NULL COMMENT 'ISBN号', `name` varchar(200) NOT NULL COMMENT '书名', `author` varchar(100) DEFAULT NULL COMMENT '作者', `publisher` varchar(100) DEFAULT NULL COMMENT '出版社', `publish_date` date DEFAULT NULL COMMENT '出版日期', `description` text COMMENT '简介', `total_quantity` int(11) NOT NULL DEFAULT 0 COMMENT '总数量', `available_quantity` int(11) NOT NULL DEFAULT 0 COMMENT '可订阅数量', PRIMARY KEY (`id`), KEY `idx_name` (`name`) COMMENT '书名索引,方便搜索' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表';设计要点:
total_quantity和available_quantity是核心字段。初始时,两者相等。每当成功订阅一次,available_quantity减1;归还时加1。total_quantity一般不变(除非图书新增或销毁)。- 为
name字段添加普通索引,是因为我们很可能需要根据书名进行模糊查询。
订阅记录表 (subscription)这张表记录每一次订阅行为。
CREATE TABLE `subscription` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '记录ID,主键', `user_id` int(11) NOT NULL COMMENT '用户ID,外键关联user.id', `book_id` int(11) NOT NULL COMMENT '图书ID,外键关联book.id', `borrow_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '订阅时间', `expected_return_time` datetime DEFAULT NULL COMMENT '应归还时间', `actual_return_time` datetime DEFAULT NULL COMMENT '实际归还时间', `status` int(11) DEFAULT 0 COMMENT '状态:0-订阅中,1-已归还,2-超期未还', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_book_id` (`book_id`), CONSTRAINT `fk_sub_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_sub_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅记录表';设计要点:
- 使用了外键约束(
FOREIGN KEY),确保user_id和book_id的引用完整性。ON DELETE CASCADE表示当用户或图书被删除时,其相关的订阅记录也会自动删除,这符合业务逻辑。 status字段用整数表示状态,便于程序处理。通过expected_return_time和actual_return_time可以计算是否超期。- 为
user_id和book_id建立了索引,因为这是高频查询条件(如“查询我的订阅”、“查询某本书的订阅记录”)。
3.2 数据库连接与工具类封装
在Java中操作数据库,我们使用JDBC。但直接在每个Servlet里写连接代码会导致大量重复和难以维护。最佳实践是封装一个数据库工具类。
DBUtil.java (数据库连接与关闭工具类)
import java.sql.*; import javax.sql.DataSource; import com.mchange.v2.c3p0.ComboPooledDataSource; // 使用C3P0连接池 public class DBUtil { private static DataSource dataSource = new ComboPooledDataSource(); // 静态数据源 // 获取数据库连接(从连接池) public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } // 关闭资源 public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace();} try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace();} try { if (conn != null) conn.close(); } // 注意:这里是归还连接到池,不是真正关闭 } }为什么要用连接池(C3P0/DBCP/HikariCP)?每次操作数据库都新建一个物理连接,开销巨大(TCP三次握手、数据库认证等)。连接池预先创建一些连接放在“池”里,程序使用时从池中取,用完后归还,避免了频繁创建和销毁的开销,极大提升性能。这是企业级应用的标配。
实操心得:在
src目录下创建一个c3p0-config.xml配置文件,在里面配置连接池的初始连接数、最大连接数、超时时间等参数。这样,ComboPooledDataSource()会自动加载该配置。把数据库的URL、用户名、密码等敏感信息放在配置文件中,而不是硬编码在Java代码里,是更安全、更灵活的做法。
4. 后端核心:Servlet设计与业务逻辑实现
Servlet是项目的控制器,是大脑。我们按照功能模块来创建Servlet。
4.1 用户相关Servlet:登录、注册与会话管理
LoginServlet.java (处理登录请求)
@WebServlet("/login") // 使用注解配置Servlet映射路径,替代web.xml public class LoginServlet extends HttpServlet { private UserService userService = new UserServiceImpl(); // 依赖业务层 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); // 1. 基本参数校验 if(username == null || password == null || username.trim().isEmpty()){ request.setAttribute("msg", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 2. 调用业务层验证用户 User user = userService.login(username, password); if(user != null){ // 3. 登录成功,将用户信息存入Session HttpSession session = request.getSession(); session.setAttribute("user", user); // 4. 重定向到首页(防止表单重复提交) response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { // 5. 登录失败,返回错误信息 request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }关键点解析:
@WebServlet注解:简化配置,/login对应表单的action="login"。- 请求转发(
forward)vs重定向(redirect):这是初学者易混淆点。登录失败时,我们用forward,将请求和request对象(包含错误信息msg)一起转发到login.jsp页面显示。登录成功时,我们用redirect(发送302响应让浏览器重新请求index.jsp),这样浏览器地址栏会变,且刷新页面不会重复提交登录表单。 - Session的使用:
HttpSession是服务器为每个用户创建的会话对象。登录成功后,我们将整个User对象存入Session。在其他页面,可以通过session.getAttribute("user")来判断用户是否登录以及获取用户信息。
UserService接口及其实现类是典型的面向接口编程,将业务逻辑与Servlet解耦。
// UserService.java public interface UserService { User login(String username, String password); boolean register(User user); } // UserServiceImpl.java public class UserServiceImpl implements UserService { private UserDao userDao = new UserDaoImpl(); @Override public User login(String username, String password) { // 1. 对传入的密码进行同样的加密 String encryptedPwd = MD5Util.encrypt(password); // 2. 调用DAO层查询用户 User user = userDao.findUserByUsernameAndPassword(username, encryptedPwd); return user; } }密码加密:绝对不要在数据库存明文密码!我们使用MD5工具类进行单向加密。
// MD5Util.java import java.security.MessageDigest; public class MD5Util { public static String encrypt(String str) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] bytes = md.digest(str.getBytes("UTF-8")); // 将byte数组转换为16进制字符串 StringBuilder result = new StringBuilder(); for (byte b : bytes) { String hex = Integer.toHexString(b & 0xFF); if (hex.length() == 1) { result.append("0"); } result.append(hex); } return result.toString(); } catch (Exception e) { throw new RuntimeException(e); } } }重要安全提示:单纯MD5现在已不够安全(可通过彩虹表破解)。生产环境应使用加盐(Salt)的哈希算法,如
BCrypt或PBKDF2。但对于学习项目,理解加密的必要性和基本流程更为关键。
4.2 图书管理Servlet:CRUD与分页查询
BookListServlet.java (展示图书列表,通常支持分页)
@WebServlet("/book/list") public class BookListServlet extends HttpServlet { private BookService bookService = new BookServiceImpl(); protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取分页参数 String pageNumStr = request.getParameter("pageNum"); String pageSizeStr = request.getParameter("pageSize"); int pageNum = 1; // 默认第一页 int pageSize = 10; // 默认每页10条 try { pageNum = Integer.parseInt(pageNumStr); } catch (NumberFormatException e) { /* 使用默认值 */ } try { pageSize = Integer.parseInt(pageSizeStr); } catch (NumberFormatException e) { /* 使用默认值 */ } // 2. 获取查询条件(如按书名搜索) String keyword = request.getParameter("keyword"); // 3. 调用业务层获取分页数据 PageInfo<Book> pageInfo = bookService.getBooksByPage(keyword, pageNum, pageSize); // 4. 将数据放入request域,转发给JSP展示 request.setAttribute("pageInfo", pageInfo); request.getRequestDispatcher("/book_list.jsp").forward(request, response); } }分页逻辑的实现:分页是Web应用的必备技能。核心是计算limit start, pageSize中的start。start = (pageNum - 1) * pageSize。我们需要查询两次数据库:一次用count(*)查总记录数(用于计算总页数),一次用limit查当前页的数据。这些逻辑封装在BookService和BookDao中。
PageInfo.java (分页信息封装类)这是一个通用的分页包装类,包含当前页数据列表、当前页码、每页条数、总记录数、总页数等属性,方便在JSP页面中展示分页导航栏。
4.3 订阅业务Servlet:事务控制的典型场景
BorrowServlet.java (处理订阅图书请求)这是整个系统业务逻辑最复杂的地方,因为它涉及多个表的更新,必须保证数据一致性。
@WebServlet("/book/borrow") public class BorrowServlet extends HttpServlet { private SubscriptionService subService = new SubscriptionServiceImpl(); protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 检查用户是否登录 HttpSession session = request.getSession(); User user = (User) session.getAttribute("user"); if(user == null){ response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 2. 获取图书ID String bookIdStr = request.getParameter("bookId"); int bookId = 0; try { bookId = Integer.parseInt(bookIdStr); } catch (NumberFormatException e) { // 参数错误处理 response.sendRedirect(request.getContextPath() + "/error.jsp"); return; } // 3. 调用业务层执行订阅 boolean success = false; try { success = subService.borrowBook(user.getId(), bookId); } catch (Exception e) { e.printStackTrace(); // 记录日志 } // 4. 根据结果跳转 if(success){ // 订阅成功,重定向到“我的订阅”页面 response.sendRedirect(request.getContextPath() + "/subscription/my"); } else { // 订阅失败(可能库存不足),返回错误信息 request.setAttribute("msg", "订阅失败,图书可能已被借完或不存在"); request.getRequestDispatcher("/book_detail.jsp").forward(request, response); } } }SubscriptionServiceImpl.borrowBook 方法(事务核心)
@Override public boolean borrowBook(int userId, int bookId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 开启事务 // 2. 检查图书库存(使用行级锁 FOR UPDATE,防止超订) BookDao bookDao = new BookDaoImpl(); Book book = bookDao.getBookByIdForUpdate(conn, bookId); if(book == null || book.getAvailableQuantity() <= 0){ conn.rollback(); return false; } // 3. 减少图书可订阅数量 bookDao.updateAvailableQuantity(conn, bookId, book.getAvailableQuantity() - 1); // 4. 创建订阅记录 SubscriptionDao subDao = new SubscriptionDaoImpl(); Subscription sub = new Subscription(); sub.setUserId(userId); sub.setBookId(bookId); sub.setBorrowTime(new Date()); // 计算应归还时间,例如30天后 Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DAY_OF_MONTH, 30); sub.setExpectedReturnTime(calendar.getTime()); sub.setStatus(0); subDao.insert(conn, sub); conn.commit(); // 5. 提交事务 return true; } catch (SQLException e) { try { if(conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace();} e.printStackTrace(); return false; } finally { DBUtil.close(conn, null, null); } }事务与锁的深度解析:
conn.setAutoCommit(false):这是手动管理事务的开始。默认情况下,JDBC每条SQL都是一个独立事务。这里我们将多条SQL(检查库存、更新库存、插入记录)捆绑成一个原子操作。FOR UPDATE:在getBookByIdForUpdate方法对应的SQL中,我们使用SELECT ... FROM book WHERE id = ? FOR UPDATE。这会在查询时对这条记录加上行级排他锁。在高并发场景下,如果没有这个锁,两个用户可能同时查询到库存为1,然后都成功下单,导致超订(库存变为-1)。FOR UPDATE锁确保在第一个事务提交或回滚前,其他事务无法读取或修改这条记录。commit()与rollback():所有操作成功则提交,使更改永久生效。任何一步失败则回滚,所有更改撤销,数据库恢复到事务开始前的状态。- 资源关闭:在
finally块中关闭连接,确保无论成功失败,连接都能归还到连接池。
踩坑记录:务必在
catch块中进行回滚(rollback),并在finally块中关闭连接。我曾因为忘记回滚,导致在更新库存失败后,订阅记录却插入了,数据严重不一致。另外,关闭连接前,最好将连接的自动提交状态恢复为true,避免影响连接池中其他代码的使用。
5. 前端视图:JSP页面与数据展示
JSP负责将后端处理好的数据渲染成HTML。我们应尽量让JSP只做展示,逻辑交给Servlet和JavaBean。
5.1 使用JSTL与EL表达式简化JSP
在JSP中直接写Java代码(<% ... %>)是过时且难以维护的做法。我们应该使用EL表达式(${})来获取数据,使用JSTL标签库来进行循环、判断等逻辑控制。
引入JSTL:在JSP页面头部添加:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>book_list.jsp (图书列表页示例)
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>图书列表</title></head> <body> <h1>图书列表</h1> <!-- 搜索框 --> <form action="${pageContext.request.contextPath}/book/list" method="get"> <input type="text" name="keyword" value="${param.keyword}" placeholder="输入书名搜索"> <button type="submit">搜索</button> </form> <!-- 图书表格 --> <table border="1"> <tr> <th>ID</th><th>书名</th><th>作者</th><th>出版社</th><th>可订阅数量</th><th>操作</th> </tr> <c:forEach items="${pageInfo.list}" var="book"> <tr> <td>${book.id}</td> <td><a href="${pageContext.request.contextPath}/book/detail?id=${book.id}">${book.name}</a></td> <td>${book.author}</td> <td>${book.publisher}</td> <td> <c:choose> <c:when test="${book.availableQuantity > 0}"> ${book.availableQuantity} (可订阅) </c:when> <c:otherwise> <span style="color:red;">已售罄</span> </c:otherwise> </c:choose> </td> <td> <c:if test="${sessionScope.user != null && book.availableQuantity > 0}"> <form action="${pageContext.request.contextPath}/book/borrow" method="post" style="display:inline;"> <input type="hidden" name="bookId" value="${book.id}"> <button type="submit">订阅</button> </form> </c:if> <c:if test="${sessionScope.user.role == 1}"> <!-- 管理员才显示编辑删除 --> <a href="${pageContext.request.contextPath}/admin/book/edit?id=${book.id}">编辑</a> <a href="#" onclick="deleteBook(${book.id})">删除</a> </c:if> </td> </tr> </c:forEach> </table> <!-- 分页导航 --> <div> <c:if test="${pageInfo.hasPreviousPage}"> <a href="${pageContext.request.contextPath}/book/list?pageNum=${pageInfo.prePage}&keyword=${param.keyword}">上一页</a> </c:if> <c:forEach begin="${pageInfo.navigateFirstPage}" end="${pageInfo.navigateLastPage}" var="pn"> <c:choose> <c:when test="${pageInfo.pageNum == pn}"> <strong>[${pn}]</strong> </c:when> <c:otherwise> <a href="${pageContext.request.contextPath}/book/list?pageNum=${pn}&keyword=${param.keyword}">${pn}</a> </c:otherwise> </c:choose> </c:forEach> <c:if test="${pageInfo.hasNextPage}"> <a href="${pageContext.request.contextPath}/book/list?pageNum=${pageInfo.nextPage}&keyword=${param.keyword}">下一页</a> </c:if> </div> <script> function deleteBook(id) { if(confirm('确定要删除这本图书吗?')){ location.href = '${pageContext.request.contextPath}/admin/book/delete?id=' + id; } } </script> </body> </html>关键点解析:
${pageContext.request.contextPath}:动态获取项目的上下文路径,这样无论项目部署在什么路径下,链接都不会错。这是写JSP链接的最佳实践。<c:forEach>:循环遍历pageInfo.list这个图书列表。<c:choose> / <c:when> / <c:otherwise>:相当于Java的switch-case,这里用于根据库存数量显示不同文本。<c:if>:条件判断。这里用于判断用户是否登录、是否是管理员,从而控制“订阅”、“编辑”、“删除”按钮的显示。${sessionScope.user}就是从Session中取出的用户对象。${param.keyword}:获取请求参数中的keyword值,并回填到搜索框,提升用户体验。- 分页逻辑:使用
pageInfo对象中的各种属性(如hasPreviousPage,navigateFirstPage等)来动态生成分页链接,并将搜索关键词keyword也作为参数传递,确保翻页后搜索条件不丢失。
5.2 页面复用:使用静态包含分离公共部分
每个页面都有共同的头部(导航栏)、尾部(版权信息)。我们可以把它们抽成独立的JSP文件,然后在其他页面中包含进来,避免重复代码。
common/header.jsp
<div style="background: #eee; padding: 10px;"> <a href="${pageContext.request.contextPath}/index.jsp">首页</a> | <c:choose> <c:when test="${empty sessionScope.user}"> <a href="${pageContext.request.contextPath}/login.jsp">登录</a> | <a href="${pageContext.request.contextPath}/register.jsp">注册</a> </c:when> <c:otherwise> 欢迎你,${sessionScope.user.username} <c:if test="${sessionScope.user.role == 1}">(管理员)</c:if> | <a href="${pageContext.request.contextPath}/subscription/my">我的订阅</a> | <a href="${pageContext.request.contextPath}/logout">退出</a> </c:otherwise> </c:choose> </div>在其他页面中,通过<%@ include file="common/header.jsp"%>静态包含进来。这样,修改导航栏只需要改一个文件。
6. 项目部署、调试与常见问题排查
6.1 在IntelliJ IDEA中配置与运行
- 创建项目:新建一个Java Enterprise项目,选择
Web Application模板,确保勾选Create web.xml(虽然我们用注解,但保留它更规范)。 - 配置Tomcat:点击
Run -> Edit Configurations,添加Tomcat Server -> Local。在Deployment选项卡中,添加Artifact(你的项目war包)。 - 添加依赖库:将MySQL驱动jar包、JSTL的jar包(
jstl.jar和standard.jar)复制到WEB-INF/lib目录下。IDEA会自动将其添加到项目库中。 - 运行:点击绿色三角运行,IDEA会自动启动Tomcat并部署你的应用。访问
http://localhost:8080/你的项目名/即可。
6.2 常见问题与解决方案实录
在开发这个项目的过程中,我遇到了不少典型问题,这里总结一下,希望能帮你提前避坑。
问题1:点击提交按钮后,出现404错误,提示“The requested resource is not available”。
- 可能原因A:Servlet映射路径错误。检查
@WebServlet("/login")中的路径是否与表单action="login"或链接href="login"完全一致。注意路径是相对于项目上下文根目录的。使用${pageContext.request.contextPath}/login是更稳妥的写法。 - 可能原因B:Servlet类没有被Tomcat加载。确保你的Servlet类在
src目录下,并且有正确的@WebServlet注解或web.xml配置。重启Tomcat有时能解决。 - 排查技巧:在IDEA的Tomcat控制台查看启动日志,看是否有“Servlet [XxxServlet] mapped to [/xxx]”这样的信息,确认Servlet已成功注册。
问题2:JSP页面显示乱码。
- 解决方案:这是一个经典问题,需要多管齐下。
- 确保JSP文件本身保存为UTF-8编码(IDEA中:File -> File Properties -> File Encoding)。
- 在JSP页面顶部添加:
<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8"%>。 - 对于POST请求(如表单提交),在对应的Servlet的
doPost方法最前面,添加:request.setCharacterEncoding("UTF-8");。 - 对于GET请求的乱码(参数在URL中),需要修改Tomcat的
server.xml配置文件,在<Connector>标签中添加URIEncoding="UTF-8"属性。或者在后端用new String(param.getBytes("ISO-8859-1"), "UTF-8")进行转换(不推荐,麻烦)。 - 设置数据库连接字符串时指定编码:
jdbc:mysql://localhost:3306/db_name?useUnicode=true&characterEncoding=UTF-8。
问题3:登录成功后,Session中存了用户,但刷新页面后用户信息就没了。
- 可能原因:浏览器禁用了Cookie。Session ID默认依靠Cookie(
JSESSIONID)在浏览器和服务器间传递。如果Cookie被禁用,每次请求都会被认为是新会话。 - 解决方案:使用URL重写。在所有需要保持会话的链接和表单action中,使用
response.encodeURL(url)或response.encodeRedirectURL(url)。但这样会让URL变得很长且难看。对于学习项目,确保浏览器启用Cookie即可。在实际项目中,这也是需要考虑的兼容性问题。
问题4:订阅图书时,出现“库存超卖”,即库存减为负数。
- 根本原因:高并发下的线程安全问题。两个线程同时查询到库存为1,然后都去执行减1操作。
- 解决方案:如4.3节所述,在查询库存的SQL语句中使用
SELECT ... FOR UPDATE进行加锁,将“查询”和“更新”放在同一个数据库事务中。这是最根本的解决方案。仅仅在Java代码中用synchronized关键字是不够的,因为你的应用可能部署在多台服务器上。
问题5:使用JSTL标签库,页面报错“The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved”。
- 原因:缺少JSTL的jar包,或者jar包没有放在正确的位置。
- 解决:从Maven仓库或官网下载
jstl.jar和standard.jar(或使用javax.servlet.jsp.jstl-api和javax.servlet.jsp.jstl),将其放入WEB-INF/lib目录下,并右键 -> Add as Library。同时,确保web.xml头部的版本是2.4或以上(支持JSTL)。
6.3 项目扩展思路
完成这个基础版本后,你可以尝试添加以下功能,让项目更丰满,写到简历里也更有分量:
- 前端美化:引入Bootstrap、Layui等前端UI框架,替换原始的表格和按钮,让界面现代化。
- 异步交互:使用jQuery或原生JavaScript的AJAX技术,实现“订阅”按钮的无刷新操作,提升用户体验。
- 过滤器(Filter):编写一个
AuthFilter,过滤所有需要登录才能访问的路径(如/subscription/*,/user/center),统一检查Session,实现权限控制的解耦。 - 文件上传:实现图书封面图片的上传功能。
- 邮件提醒:在图书到期前,发送邮件提醒用户归还(可以使用JavaMail API)。
- 图表统计:使用ECharts等库,为管理员后台增加数据统计图表,如“热门图书借阅榜”、“月度借阅趋势”。
这个“简易”的图书订阅管理系统,就像一把钥匙,帮你打开了JavaWeb应用开发的大门。它串联起了JavaSE、数据库、前端、网络协议等多个领域的知识。我强烈建议你不要只看,而是打开IDEA,从头到尾敲一遍。过程中遇到的每一个错误,都是你进步的阶梯。当你最终看到浏览器上跑起自己写的完整应用时,那种成就感是无与伦比的。编程,终究是一门实践的技艺。
本文还有配套的精品资源,点击获取