简介:这是一套面向Java Web初学者与课程设计学习者的网上购书系统完整源码,采用JSP+JavaBean+Servlet的经典B/S架构实现,适合用于课程综合训练、毕业设计参考或Java Web入门练手。系统实现了用户注册、用户登录、图书信息查询与浏览等核心功能,覆盖了从页面展示到业务处理再到数据访问的典型分层流程,能帮助读者理解Servlet控制流程、JavaBean封装数据以及JSP页面渲染之间的协作方式。压缩包共55个文件,约1.21MB,包含15个java源文件与15个class编译文件、6个jsp页面、2个sql脚本、2个xml配置、2个properties资源文件,以及jar依赖、项目配置与说明文档等,结构完整、便于导入运行。目前已有173人学习下载。读者可据此快速搭建可运行的购书系统原型,对照源码梳理注册登录与查询浏览的实现思路,并借助SQL脚本与配置文件完成数据库初始化,是Java Web入门阶段较为实用的参考案例。
1. 从零手搓一个网上购书系统:Jsp+Bean+Servlet 到底能撑起多大的项目
很多人第一次接触 Java Web,都是从「网上购书系统」这个题目开始的。它几乎是高校课程设计和初级面试的标配:有用户注册登录、有图书列表、有购物车、有订单,业务闭环完整,又不至于复杂到劝退。但真正动手时你会发现,网上搜到的代码要么是半成品,要么把 Jsp 当模板引擎用、把 Java 代码全塞进<% %>里,跑起来能看,改起来要命。这套基于 Jsp+Bean+Servlet 的实现思路,核心价值就在于把「页面展示」「业务数据」「请求调度」三层拆开:Jsp 只负责渲染,Bean 承载数据与业务,Servlet 接管所有请求入口。它适合刚学完 Servlet 生命周期、想找一个完整项目练手的人,也适合需要交付课程设计、又不想被框架黑匣子绕晕的开发者。下面我按实际搭建顺序,把选型理由、目录结构、每个模块的代码和参数都摊开讲。
2. 三层架构怎么落地:Jsp 只做展示,Bean 管数据,Servlet 管调度
2.1 为什么不用 Jsp 直接连数据库
新手最容易翻车的地方,就是在 Jsp 页面顶部写<%@ page import="java.sql.*" %>,然后一路Connection、Statement、ResultSet写到底。这样做在单页面测试时能跑通,但一旦页面超过五个,数据库连接代码就会散落在各处,改一个表名要全局搜索替换,连接忘记关闭还会导致连接池耗尽。更关键的是,Jsp 本质是一个 Servlet,它在第一次被访问时编译成 class 文件,如果里面混入大量 Java 逻辑,编译报错时你看到的堆栈会指向_jspService方法,排查成本极高。
常见做法是把数据库访问收敛到 DAO 层,由 Bean 承载查询结果。Servlet 收到请求后调用 DAO,把 Bean 放进request作用域,再转发给 Jsp。Jsp 里只用 EL 表达式${book.title}取值,不出现任何import和数据库代码。这样做的直接好处是:Jsp 可以交给前端同学改样式,Servlet 和 DAO 可以单独写单元测试,三层各司其职。
2.2 项目目录结构与依赖准备
一个能跑起来的 Web 工程,目录结构必须符合 Servlet 规范,否则 Tomcat 找不到类。我一般会按下面这样组织:
bookstore/ ├── src/ │ └── com/bookstore/ │ ├── bean/ # 实体类:Book、User、Order、CartItem │ ├── dao/ # 数据访问:BookDao、UserDao、OrderDao │ ├── servlet/ # 控制器:LoginServlet、BookListServlet 等 │ └── util/ # 工具类:DBUtil 数据库连接 ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet 映射配置 │ │ └── lib/ # 第三方 jar:mysql-connector-java │ ├── css/ │ ├── js/ │ ├── index.jsp # 首页 │ ├── login.jsp │ ├── bookList.jsp │ └── cart.jsp依赖只需要一个 MySQL 驱动 jar,放进WEB-INF/lib下,Tomcat 启动时会自动加载。如果你用 Maven,在pom.xml里加mysql-connector-java和javax.servlet-api两个坐标即可,注意servlet-api的 scope 要设为provided,因为 Tomcat 自带了这个包,重复打包会导致类冲突。
2.3 数据库连接工具类的三个必调参数
数据库连接是整套系统的命脉,写一个DBUtil类集中管理连接,比在每个 DAO 里重复写DriverManager.getConnection要可靠得多。下面是我常用的写法:
public class DBUtil { // 数据库驱动类名,MySQL 8.x 用 com.mysql.cj.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; // 连接地址,必须带上时区和字符集参数,否则中文乱码 private static final String URL = "jdbc:mysql://localhost:3306/bookstore" + "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new RuntimeException("数据库驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 关闭顺序:ResultSet -> Statement -> Connection,反了会报错 try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (stmt != null) stmt.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }这里有三个参数必须调对。第一,serverTimezone不写会报The server time zone value is unrecognized,MySQL 8 之后尤其明显。第二,characterEncoding=utf8要配合数据库建表时的CHARSET=utf8mb4,否则图书标题里的特殊符号会变成问号。第三,Class.forName在静态代码块里执行一次即可,不要每次获取连接都反射加载,那样会拖慢响应。关闭顺序也要注意,先关结果集再关连接,反过来会抛异常。
2.4 Bean 的实例化方式与作用域选择
Bean 在这个系统里承担两种角色:一种是数据载体,比如Book类只有属性和 getter/setter;另一种是业务对象,比如Cart类里带addItem、removeItem方法。数据载体类的实例化通常由 DAO 在查询时new出来,逐行填充;业务对象则要看作用域。
购物车 Bean 就是一个典型例子。如果你在 Servlet 里new Cart()然后放进request,那每次请求购物车都是空的,用户加购后刷新页面就丢。正确做法是放进session:request.getSession().setAttribute("cart", cart)。这样同一个浏览器会话共享一个购物车实例。但要注意,session里的 Bean 如果被多个线程同时修改,会有并发问题。简单系统里可以在 Servlet 的doPost方法上加synchronized,或者用ConcurrentHashMap存购物项。我一般会提醒:Bean 放request还是session,取决于数据要不要跨请求存活,购物车、登录用户信息放session,图书列表、订单详情放request。
3. 核心模块编码:从登录到下单的完整链路
3.1 用户登录 Servlet 的请求校验与转发
登录是第一个要跑通的闭环。前端login.jsp提交表单到LoginServlet,Servlet 取参数、查数据库、判断结果、决定跳转。代码骨架如下:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置编码,必须在取参数之前,否则中文用户名乱码 req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // 空值校验,前端校验可以被绕过,后端必须再查一次 if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { req.setAttribute("msg", "用户名或密码不能为空"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { req.getSession().setAttribute("user", user); resp.sendRedirect(req.getContextPath() + "/bookList"); } else { req.setAttribute("msg", "账号或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }这段代码里有两个容易忽略的点。req.setCharacterEncoding("UTF-8")必须放在getParameter之前,放在之后无效。转发用forward,浏览器地址栏不变;跳转用sendRedirect,地址栏会变。登录成功后用重定向,是为了防止用户刷新页面时重复提交表单。失败时用转发,是为了把错误信息带回原页面显示。
3.2 图书列表分页查询的 SQL 与参数计算
图书列表如果一次查全表,数据量大了页面会卡。分页是必须做的。核心 SQL 用LIMIT实现:
SELECT id, title, author, price, stock, cover FROM book WHERE title LIKE ? ORDER BY id DESC LIMIT ?, ?;三个占位符分别对应:模糊查询关键词、起始行号、每页条数。起始行号的计算公式是(当前页码 - 1) * 每页条数。比如每页 8 条,第 3 页的起始行号是 16。Servlet 里这样处理:
int page = 1; String pageParam = req.getParameter("page"); if (pageParam != null && pageParam.matches("\\d+")) { page = Integer.parseInt(pageParam); } int pageSize = 8; int offset = (page - 1) * pageSize; List<Book> books = bookDao.findByPage(keyword, offset, pageSize); int total = bookDao.countByKeyword(keyword); int totalPages = (total + pageSize - 1) / pageSize; req.setAttribute("books", books); req.setAttribute("page", page); req.setAttribute("totalPages", totalPages); req.getRequestDispatcher("/bookList.jsp").forward(req, resp);pageParam.matches("\\d+")是防止用户手动改 URL 传入负数或字母导致 SQL 异常。totalPages用向上取整,避免最后一页数据被截断。Jsp 里用${books}配合 JSTL 的<c:forEach>遍历,不要用脚本片段。
3.3 购物车的 Bean 设计与 session 同步
购物车 Bean 我一般设计成Map<Integer, CartItem>结构,key 是图书 ID,value 是购物项。这样加购时判断是否已存在,存在就数量加一,不存在就新建。关键方法:
public class Cart { private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Book book, int quantity) { CartItem item = items.get(book.getId()); if (item == null) { item = new CartItem(); item.setBook(book); item.setQuantity(quantity); items.put(book.getId(), item); } else { item.setQuantity(item.getQuantity() + quantity); } } public void remove(int bookId) { items.remove(bookId); } public double getTotalPrice() { double total = 0; for (CartItem item : items.values()) { total += item.getBook().getPrice() * item.getQuantity(); } return total; } }用LinkedHashMap而不是HashMap,是为了让购物车里的图书按加入顺序展示,用户体验更自然。getTotalPrice每次实时计算,不缓存总价,避免数量修改后总价不同步。Servlet 里加购时先从 session 取购物车,取不到就新建再放回去,这个「取-判-建-存」四步是 session 操作的标准套路。
3.4 下单流程的事务控制与库存扣减
下单是整个系统里唯一需要事务的地方:插入订单、插入订单明细、扣减库存,三步必须同时成功或同时失败。DAO 层不能各自获取连接,否则事务无法控制。正确做法是把Connection从 Servlet 传到 DAO:
public class OrderService { public boolean createOrder(int userId, Cart cart) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 OrderDao orderDao = new OrderDao(conn); int orderId = orderDao.insertOrder(userId, cart.getTotalPrice()); for (CartItem item : cart.getItems().values()) { orderDao.insertOrderItem(orderId, item); // 扣库存时加 stock >= ? 条件,防止超卖 int affected = orderDao.reduceStock(item.getBook().getId(), item.getQuantity()); if (affected == 0) { conn.rollback(); return false; } } conn.commit(); return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ignored) {} return false; } finally { DBUtil.close(conn, null, null); } } }扣库存的 SQL 要写成UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?,第三个参数是购买数量。如果库存不足,affected返回 0,立即回滚。这个条件判断是防止并发下单超卖的关键,比先查库存再扣减要可靠得多。
4. 避坑与排查:那些让系统跑不起来的细节
4.1 中文乱码:现象是页面显示问号,原因是编码链没对齐
现象:登录时输入中文用户名,数据库里存进去变成???,或者 Jsp 页面显示乱码。原因通常有三处:Jsp 页面头部没写<%@ page contentType="text/html;charset=UTF-8" %>;Servlet 里没调req.setCharacterEncoding("UTF-8");数据库连接 URL 缺characterEncoding=utf8。解决方法是三处同时检查,缺一不可。另外 Tomcat 8 之后 GET 请求默认用 UTF-8,但 POST 请求仍要手动设置,不要只改一处就以为好了。
4.2 404 错误:现象是访问 Servlet 报找不到,原因是映射路径写错
现象:浏览器访问/login返回 404,控制台没有报错。原因可能是web.xml里<url-pattern>写成了/LoginServlet而表单提交的是/login,或者用了@WebServlet注解但web.xml的metadata-complete="true"导致注解被忽略。解决方法是先确认访问路径和映射路径完全一致,再检查web.xml的版本声明,Servlet 3.0 以上才支持注解。如果两个都配了,以web.xml为准。
4.3 数据库连接泄漏:现象是跑一会儿就报 Too many connections
现象:系统刚启动正常,操作几次后报Too many connections。原因是 DAO 里获取了Connection但异常路径下没关闭,或者close方法只关了ResultSet忘了关Connection。解决方法是在finally块里统一关闭,并且关闭顺序不能反。更稳妥的做法是用 try-with-resources,但老项目里手动关闭更常见。每次写完 DAO 方法,我都会扫一眼有没有finally。
4.4 session 失效导致购物车清空:现象是加购后跳转页面购物车空了
现象:用户加购后点击结算,购物车显示为空。原因是加购的 Servlet 把Cart放进了request而不是session,或者 Jsp 页面里用了session="false"。解决方法是确认加购、查看购物车、结算三个环节都从session取同一个Cart对象。另外 session 默认 30 分钟过期,测试时如果间隔太久也会丢,可以在web.xml里调session-timeout。
4.5 Jsp 页面 EL 表达式不解析:现象是页面直接显示${book.title}
现象:Jsp 页面把${book.title}原样输出,没有替换成实际值。原因是web.xml的版本低于 2.4,或者 Jsp 页面isELIgnored="true"。解决方法是把web.xml声明改成 Servlet 3.0 以上的版本,或者在页面头部加<%@ page isELIgnored="false" %>。这个问题在新版 Tomcat 里少见,但用老版本 Tomcat 6 时会遇到。
5. 进阶技巧:用过滤器统一编码和登录拦截
系统跑通之后,你会发现每个 Servlet 里都写一遍setCharacterEncoding很啰嗦,而且有些页面没登录也能直接访问。这时候该上过滤器了。写一个EncodingFilter实现Filter接口,在doFilter里统一设置请求和响应编码,再写一个LoginFilter判断 session 里有没有 user,没有就重定向到登录页。两个过滤器在web.xml里配置,EncodingFilter放前面,LoginFilter放后面,顺序不能反。
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); // 放行,交给下一个过滤器或 Servlet } }LoginFilter里要放行登录页、登录 Servlet 和静态资源,否则用户永远进不了登录页。判断逻辑是:先看请求路径是否在白名单里,不在就检查 session,session 没有 user 就重定向。白名单我一般用String[]存/login.jsp、/login、/css/、/js/这几个前缀,用startsWith匹配。
验证过滤器是否生效,有个简单办法:在doFilter里打印请求 URL 和 session ID,观察控制台输出顺序。如果EncodingFilter的日志在LoginFilter之前,说明顺序对了。另外注意过滤器只对匹配的 URL 生效,@WebFilter("/*")会拦截所有请求包括图片,如果图片也走过滤器会拖慢加载,可以在白名单里加上.css、.js、.png后缀判断。
最后说一个我自己的习惯:每写完一个模块,先不急着写页面,用curl或 Postman 直接打 Servlet 的 URL,确认返回数据正确,再写 Jsp 渲染。这样能把「后端逻辑错」和「前端展示错」分开排查,省掉很多来回改的时间。这套 Jsp+Bean+Servlet 的结构虽然老,但把三层边界划清楚之后,后面换 Spring 或换前端框架,迁移成本比想象中低得多。希望帮到你。
本文还有配套的精品资源,点击获取