jsp+servlet网上书店实战:从分层设计到避坑指南
发布时间:2026/10/8 2:59:16 锦皓数字建站

简介这是一套基于Java Web技术栈的网上书店项目源码面向正在学习JSP、Servlet与DAO分层开发的学生和初级开发者帮助理解MVC架构在真实电商场景中的落地方式。压缩包共161个文件约768KB以gif图片、java源文件、class字节码、jsp页面为主另含少量js、xml、html及sql脚本覆盖视图渲染、控制器逻辑、数据持久化与数据库建表等环节。项目完整实现了用户登录验证、图书搜索、分页浏览、购物车增删改与结算等核心功能并借助JNDI配置数据库连接池通过Session跟踪购物车状态前端结合JavaScript与CSS优化交互与布局。已有162人学习下载适合作为课程设计或毕业设计的参考模板也可用于对照理解Servlet请求处理、DAO数据库操作与分页标签的实现思路。1. jspservlet网上书店为什么“老技术栈”反而更适合练手全栈如果你正在搜“jspservlet网上书店”大概率不是想听人说这套技术过时了而是想找一个能跑通、能看懂、能改的完整项目把 Java Web 的请求流转、会话管理、数据库交互一次性串起来。我见过太多新手直接上 Spring Boot结果连HttpServletRequest和HttpServletResponse到底谁先谁后都说不清出了问题只能靠猜。jspservlet 这套组合虽然老但它把 HTTP 请求响应模型、MVC 分层、Session 机制全部暴露在你面前没有自动配置帮你藏黑匣子。网上书店这个场景又天然覆盖了用户注册登录、图书列表展示、购物车增删改、订单提交这几个典型模块正好把 servlet 的doGet/doPost、jsp 的 EL 表达式和 JSTL 标签、JDBC 事务控制全练一遍。适合谁适合刚学完 Java SE、想找一个能写进简历、面试能讲清楚请求链路的在校生或转行初期开发者。这一章不聊虚的先把这套技术栈在书店项目里到底怎么分工讲透。2. 从请求到页面jspservlet 网上书店的分层与数据流转2.1 为什么不用 JSP 直接写业务逻辑很多教程为了省事在 JSP 页面顶部写% Class.forName(com.mysql.cj.jdbc.Driver); %然后直接查数据库、直接输出 HTML。这种写法在“jsp个人信息展示页面”这类小练习里能跑但放到网上书店项目里就是灾难。原因很简单JSP 本质是一个被容器编译成 Servlet 的模板文件它的职责应该是渲染视图而不是处理业务。一旦你在 JSP 里写数据库连接就会出现三个问题第一数据库连接无法复用每个请求都新建连接并发一上来连接池直接爆第二事务边界模糊下单扣库存和写订单明细如果不在同一个 service 方法里中途异常就会导致数据不一致第三调试困难报错栈里全是_jspService的行号你根本不知道是哪次请求触发的。我一般会强制分层Servlet 只做三件事——取参数、调 Service、转发或重定向Service 负责业务逻辑和事务DAO 只负责 SQL 执行和结果集映射JSP 只负责用 EL 和 JSTL 把 request 域里的数据展示出来。这样分层之后网上书店的“加入购物车”动作就变成CartServlet拿到bookId和quantity调用CartService.addItem()Service 里先查库存再更新 Session 中的购物车对象最后 Servlet 重定向到购物车页面。每一步职责清晰出问题看日志就能定位到具体层。2.2 一个最小可跑的图书列表 Servlet 与 JSP先看 Servlet 侧。假设你已经建好了book表字段有id, title, author, price, stock。下面这个BookListServlet是最小可跑版本直接映射到/books// BookListServlet.java WebServlet(/books) public class BookListServlet extends HttpServlet { private BookDao bookDao new BookDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 调用 DAO 获取全部图书实际项目应加分页参数 ListBook books bookDao.findAll(); // 2. 把数据放入 request 域供 JSP 读取 req.setAttribute(bookList, books); // 3. 转发到 /WEB-INF/views/book_list.jsp避免直接访问 JSP req.getRequestDispatcher(/WEB-INF/views/book_list.jsp) .forward(req, resp); } }逻辑说明doGet里没有写任何 SQL也没有拼 HTML只做了“取数据-存域-转发”三件事。参数说明WebServlet(/books)是 Servlet 3.0 注解方式不需要再改web.xmlfindAll()返回ListBookBook 是标准 JavaBean字段名和表字段对应。注意转发路径放在/WEB-INF/下这样浏览器无法直接访问 JSP必须经过 Servlet保证了 MVC 入口唯一。对应的 JSP 用 JSTL 遍历%-- book_list.jsp --% % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table c:forEach items${bookList} varbook tr td${book.title}/td td${book.author}/td td${book.price}/td tda hrefcart?actionaddbookId${book.id}加入购物车/a/td /tr /c:forEach /table这里${bookList}就是 Servlet 里setAttribute存进去的EL 表达式会自动调用 getter。注意cart?actionaddbookId这种 URL 传参方式在“jsp图片如何对坐标定位”这类需求里也会用到——比如图片热区跳转不同链接本质都是拼 query string。2.3 购物车 Session 设计与库存扣减的时序网上书店最核心的交互是购物车。我见过有人把购物车存数据库每次增删改都写库结果用户没登录时根本没法用。常见做法是未登录时购物车放HttpSession登录后合并到数据库cart表。Session 里存一个MapInteger, CartItemkey 是 bookIdvalue 是数量和图书快照。// CartService.java 片段 public void addItem(HttpSession session, int bookId, int qty) { MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } CartItem item cart.get(bookId); if (item null) { Book book bookDao.findById(bookId); item new CartItem(book, qty); cart.put(bookId, item); } else { item.setQuantity(item.getQuantity() qty); } }参数说明qty默认传 1如果用户手动输入数量Servlet 里要做Integer.parseInt并捕获NumberFormatException。库存扣减必须放在订单提交的 Service 方法里用synchronized或数据库行锁SELECT ... FOR UPDATE否则两个用户同时买最后一本书就会超卖。这是血泪经验Session 购物车只做展示真实库存以数据库为准下单时再校验一次。3. 数据库表设计与 JDBC 连接池别让连接数成为瓶颈3.1 网上书店五张核心表与字段取舍表设计直接决定后面代码好不好写。我一般会建五张表user、book、category、orders、order_item。注意orders不要用order因为ORDER是 SQL 关键字MySQL 里虽然可以用反引号但 JDBC 拼接时容易翻车。表名关键字段说明userid, username, password, email, phonepassword 存 MD5 加盐不要明文bookid, title, author, price, stock, category_id, cover_imgcover_img 存相对路径不存二进制categoryid, name分类和图书一对多ordersid, user_id, total_price, status, create_timestatus 用 0 待支付 1 已支付 2 已发货order_itemid, order_id, book_id, quantity, unit_priceunit_price 下单时快照防止图书改价影响历史订单order_item里的unit_price必须冗余存一份不能只关联book表查价格。因为图书价格会变历史订单要保留成交价。这个坑我在早期项目里踩过后来对账时发现金额对不上查了半天才发现是图书调价导致。3.2 用 Druid 还是手写连接池给一个可抄的配置新手常问JDBC 要不要用连接池答案是必须用。不用连接池每个请求DriverManager.getConnection创建物理连接MySQL 默认最大连接数 151稍微压测就报Too many connections。常见做法是用 Druid 或 HikariCP但如果你在练手阶段不想引入额外依赖也可以手写一个极简连接池但生产绝对不要这么干。下面给一个 Druid 的druid.properties配置放在src/main/resources下driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/bookstore?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot passwordyourpassword initialSize5 maxActive20 maxWait3000 validationQuerySELECT 1 testWhileIdletrue参数说明initialSize是启动时创建连接数maxActive是最大活跃连接数maxWait是获取连接超时毫秒数。validationQuery用来检测连接是否有效testWhileIdle表示空闲时检测。注意serverTimezone必须设否则 MySQL 8 驱动会报时区错误。加载方式// DBUtil.java public class DBUtil { private static DataSource dataSource; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这样 DAO 里只需要try (Connection conn DBUtil.getConnection())连接用完自动归还不会泄漏。3.3 DAO 层增删改查的模板写法与 SQL 注入防护DAO 层最容易被写乱。我一般固定一个模板findById、findAll、insert、update、delete五个方法全部用PreparedStatement。下面以BookDao的findByCategory为例public ListBook findByCategory(int categoryId, int page, int size) { String sql SELECT * FROM book WHERE category_id ? LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, categoryId); ps.setInt(2, (page - 1) * size); ps.setInt(3, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setId(rs.getInt(id)); b.setTitle(rs.getString(title)); b.setPrice(rs.getBigDecimal(price)); list.add(b); } } } catch (SQLException e) { throw new RuntimeException(查询图书失败, e); } return list; }逻辑说明用?占位符而不是字符串拼接彻底杜绝 SQL 注入。参数说明page从 1 开始size是每页条数LIMIT的偏移量是(page-1)*size。注意price用BigDecimal而不是double金额计算不能用浮点数否则 0.10.2 不等于 0.3 的问题会在订单总额里出现。4. 避坑与排查jspservlet 网上书店最常见的 5 个翻车现场4.1 中文乱码为什么 POST 和 GET 处理方式不一样现象表单提交中文书名数据库里显示????。原因Tomcat 8 之后 GET 请求默认 URI 编码是 UTF-8但 POST 请求体默认是 ISO-8859-1。解决在 Servlet 的doPost第一行加req.setCharacterEncoding(UTF-8)并且resp.setContentType(text/html;charsetUTF-8)。如果 GET 也乱码检查 Tomcat 的server.xml里Connector是否加了URIEncodingUTF-8。注意setCharacterEncoding必须在getParameter之前调用否则无效。4.2 404JSP 放在 WEB-INF 下为什么访问不到现象浏览器直接访问/WEB-INF/views/book_list.jsp报 404。原因WEB-INF目录是受保护的容器不允许外部直接访问。解决这是故意设计的必须通过 Servlet 转发。如果你确实需要直接访问某个 JSP把它放在webapp根目录下但那样就绕过了 MVC 入口不推荐。排查时看 Tomcat 日志里有没有File not found同时确认WebServlet的 URL 映射和浏览器地址一致。4.3 空指针Session 过期后购物车操作为什么崩现象用户停留太久Session 超时后再点“加入购物车”后台报NullPointerException。原因session.getAttribute(cart)返回 null代码没判空就直接cart.put。解决在CartService.addItem里先判空再初始化同时给 Session 设置合理的超时时间在web.xml里配session-configsession-timeout30/session-timeout/session-config。另外下单前要检查用户是否登录未登录跳转登录页并带上redirect参数登录后跳回。4.4 重复提交刷新页面为什么多出一条订单现象用户提交订单后按 F5订单表多了一条重复记录。原因表单提交后直接forward到订单详情页浏览器地址栏还是submitOrder刷新就会重新提交。解决提交成功后用resp.sendRedirect(orderDetail?id orderId)重定向这是 PRG 模式。更严谨的做法是在 Session 里放一个 token提交时校验并删除。这个坑在“基于jsp的毕业论文管理过程系统”里同样常见凡是写操作都要防重。4.5 连接泄漏为什么跑一会儿就报连接池耗尽现象压测几分钟后报GetConnectionTimeoutException。原因DAO 里用了Connection但没在finally里关闭或者ResultSet、PreparedStatement没关。解决全部用 try-with-resources像上面 DAO 示例那样把Connection、PreparedStatement、ResultSet都放在 try 的括号里。另外检查 Druid 的removeAbandoned配置可以设removeAbandonedtrue和removeAbandonedTimeout180作为兜底但根本解决还是代码规范。5. 进阶技巧用 Filter 统一编码、用监听器预热缓存、用分页插件收尾5.1 一个 Filter 解决全站编码不用每个 Servlet 写 setCharacterEncoding每个 Servlet 都写req.setCharacterEncoding太啰嗦而且容易漏。我一般写一个EncodingFilter在web.xml里配/*拦截所有请求WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }这样所有请求进来先过 Filter编码统一设好。注意 Filter 的执行顺序按web.xml里filter-mapping的先后注解方式则按类名排序所以如果有多个 Filter最好用web.xml显式控制顺序。5.2 用 ServletContextListener 在启动时加载分类数据网上书店的分类列表几乎每个页面都要用每次查数据库浪费。常见做法是在ServletContextListener的contextInitialized里查一次放到ServletContext域WebListener public class InitListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { CategoryDao dao new CategoryDao(); sce.getServletContext().setAttribute(categories, dao.findAll()); } }JSP 里直接用${categories}遍历。注意ServletContext域是全局的数据量不能太大否则占内存。分类这种几十条的数据正合适。如果后台改了分类需要重新加载可以加一个管理端接口手动刷新。5.3 分页别自己拼 SQL用 PageHelper 或手写一个 PageBean分页是网上书店图书列表的刚需。手写分页要查两次一次COUNT(*)算总数一次LIMIT查当前页。我一般封装一个PageBeanTpublic class PageBeanT { private int page; // 当前页 private int size; // 每页条数 private int total; // 总记录数 private int totalPages; // 总页数 private ListT list; // 当前页数据 // getter/setter 省略 }Service 里先count再findByPage把结果塞进 PageBeanJSP 里渲染上一页/下一页链接。参数说明page从请求参数取默认 1size可以固定 10 或 20。注意totalPages用(total size - 1) / size计算不要用total / size否则最后一页会丢。如果不想手写引入 PageHelper 插件也能用但练手阶段建议自己写一遍理解分页的本质。5.4 最后说一个我自己的习惯每次写完一个模块我会把web.xml、WebServlet、WebFilter、WebListener的映射关系画在一张纸上标清楚哪些 URL 经过哪些 Filter、最终到哪个 Servlet、转发到哪个 JSP。这个习惯帮我省了很多“为什么 404”“为什么编码没生效”的排查时间。jspservlet 网上书店这个项目代码量不大但请求链路完整把这张图跑通后面学 Spring MVC 就是换了个写法而已。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。