Java Web网上购书系统MVC实战:从Servlet职责到并发扣库存

发布时间:2026/9/19 12:17:24
Java Web网上购书系统MVC实战:从Servlet职责到并发扣库存 简介这份PDF是基于MVC设计模式的Java Web网上购书系统毕业设计论文面向计算机专业毕业生、Java Web初学者及需要开展同类课题的开发者。论文从课题背景、MVC设计思想、系统总体设计到详细实现逐层推进覆盖用户登录注册、图书查询、智能辨认、购书流程、操作过时管理等核心模块并重点剖析Servlet、JDBC、JavaBean等关键技术的实际应用可作为毕业设计写作框架、代码实现与答辩准备的参考资料。资源为单个PDF文档共1个文件大小3.16MB内含完整目录、绪论、系统设计、实现细节及参考文献结构清晰、章节完整便于按需查阅。已有309人学习浏览适合希望快速理解MVC分层架构在购书系统中落地方式的读者。1. MVC在Java Web里从来不是目录结构问题在网上购书系统这种题目里MVC往往被一句话带过采用MVC设计模式分成模型、视图、控制器三层。真动手时很多人把这句话理解成把代码放进三个包model包放实体类controller包放Servletjsp目录放页面交差就完成。这套做法让设计模式变成目录划分系统该乱还是乱。MVC的本意是请求链路里的每种角色只做一件事Controller解析输入Model完成业务View渲染输出依赖方向全程单向。购书系统恰好能把这件事讲透——它同时有Session购物车这种会话态、订单库存这种持久态还有并发扣减这种常见书里碰不到的坑。你的选题要回答的不是我用了三层而是请求从URL到数据库再到页面每一步的职责边界为什么这么切。2. Servlet、JSP与POJO在MVC里的职责先把三层这个说法放下很多Java Web论文把MVC直接等同于MVC三层架构表示层、业务层、持久层。这个说法没有错但它把两个不同维度的问题混在一起了。三层架构解决的是系统分区MVC解决的是单次请求内三种角色怎么协作。购书系统可以同时有三层架构和MVC也可以明明分了三层JSP里还是写SQL。所以先把三层放下看MVC的三角色约束究竟是什么。2.1 角色边界不是类放哪个包而是能不能看见对方MVC的三个角色由一组依赖规则定义。Model在购书系统里不是单个类而是领域对象和业务行为的集合Book、Cart、Order、库存扣减逻辑、订单校验逻辑。它不关心数据来自MySQL还是来自内存一个纯Java的Book类里不会出现import javax.servlet.*。View在Java Web里默认是JSP它的工作是把Controller塞给它的现成数据渲染成HTML能读${book.price}但不能自己去查一张表。Controller是Servlet负责把请求参数解析成方法入参、调用Model方法、再决定转发哪个JSP或重定向到哪个URL。把这段话落到依赖表格里角色允许依赖禁止依赖Model实体ServiceDAOService、DAO、领域对象Servlet API、JSP标签、SQL字符串ViewJSPEL/JSTL、Controller设置的对象JDBC、业务条件判断ControllerServletService、Model、请求/响应对象SQL、HTML拼接、业务规则这个表就是整个系统的交通规则。违反规则的代码通常当场能跑但改需求和排障时会变得非常贵。比如Controller里做业务判断第一次没问题第二次加一个会员折扣就要在多个Controller里复制把判断下沉到ServiceController就只是请求的翻译官这两者在工程里差别很大。2.2 反例JSP里直接查库为什么必踩坑网上购书系统最有历史感的错误是图书列表直接在JSP里用JDBC查。代码示意%-- 错误写法示意不要抄 --% % page importjava.sql.* % % // 省略连接参数的实例化代码 String sql select id, title, price from book where type_id request.getParameter(type); try (Connection conn DriverManager.getConnection(url, user, pwd); Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(sql)) { while (rs.next()) { out.println(div classbook rs.getString(title) ¥ rs.getBigDecimal(price) /div); } } %这段代码把请求参数、SQL、实体映射、HTML渲染全部揉进一个JSP里问题不止不好看。request参数直接拼接进SQLtype传一个1 or 11就能把全站图书翻出来JSP由前端维护时会改数据库代码由后端维护时会改坏页面标签这段逻辑永远没办法脱离Tomcat写单元测试。MVC对它的改造不是把代码换个包而是把Query变成BookDao.search()、把渲染交给JSTL、把参数解析放进Controller。2.3 正向链路一次请求在每个角色的产物不一样一次列出Java分类图书的请求按MVC切完后是这样的链路浏览器发GET /books?catejavapage1DispatcherServlet按路径找到BookControllerBookController取出cate、page调用bookService.search(java, 1, 12)BookService把keyword处理成模糊查询的%java%转给bookDao.searchBookDao执行参数化SQL逐行转成ListController把List 放进request属性forward到books.jspbooks.jsp用c:forEach渲染返回HTML每个步骤的产物对应不同的角色步骤产物所在角色3参数与调用Controller4查询条件整理Service5ListDAO6request属性Controller7HTMLView这段链路的价值在于将来做手机端时只需要加一个Controller方法复用第4、5步返回JSONModel和DAO一行不改。看一个系统有没有MVC不是看包名而是看同一段图书查询逻辑能不能同时渲染成HTML和JSON——能Model就是独立的不能说明业务逻辑还黏在Controller或JSP里。3. 网上购书系统的标准目录结构与最小启动顺序Java Web项目最容易被结构没有标准答案带偏。网上购书系统属于中规中矩的Servlet时代项目我一般会用下面这个目录它对应Servlet规范的war包也同时满足Controller、Service、DAO、Model四条清晰依赖线。先看结构再看为什么这么放。3.1 Java Web标准目录结构一套能直接放的包划分bookstore/ ├── pom.xml └── src/main/ ├── java/com/bookstore/ │ ├── controller/ # 只做参数解析 选择视图 │ │ ├── DispatcherServlet.java │ │ ├── BookController.java │ │ ├── CartController.java │ │ └── OrderController.java │ ├── service/ # 业务规则与事务边界 │ │ ├── BookService.java │ │ ├── CartService.java │ │ └── OrderService.java │ ├── dao/ # 只负责数据存取 │ │ ├── BookDao.java │ │ └── OrderDao.java │ ├── model/ # 无Servlet依赖的纯Java对象 │ │ ├── Book.java │ │ ├── Cart.java │ │ └── Order.java │ └── util/ │ ├── DBUtil.java │ └── PageResult.java ├── resources/ │ ├── db.properties │ └── log4j2.xml └── webapp/ ├── WEB-INF/web.xml ├── jsp/ # 不能通过URL直连 │ ├── books/list.jsp │ ├── cart/cart.jsp │ └── order/confirm.jsp └── static/ ├── css/site.css └── js/cart.js这个结构的硬规则是依赖方向controller可以import serviceservice可以import daomodel谁都不依赖Web容器。最容易跑偏的是在service里import controller或者model类里出现HttpSession——只要出现这种import整个层级就失去意义。JSP放WEB-INF下是刻意为之用户无法用URL直接访问jsp文件所有页面必须经Controller的转发进入这既防止JSP被绕过控制器直连也是MVC里View必须由Controller选择的物理保证。3.2 web.xml 收口一个 DispatcherServlet 管住所有请求Servlet 3.0 之后可以用注解注册Servlet但对购书系统来讲我仍建议保留一个web.xml来定义入口因为评审和后续维护的人一眼就能看到所有请求从哪里进来。关键配置web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 servlet servlet-namedispatcher/servlet-name servlet-classcom.bookstore.controller.DispatcherServlet/servlet-class load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping session-config session-timeout30/session-timeout /session-config /web-app与配置对应的DispatcherServlet不用处理复杂路由维护一张Map就行public class DispatcherServlet extends HttpServlet { private final MapString, ControllerHandler routes new HashMap(); Override public void init() { routes.put(/books, new BookController()); routes.put(/login, new LoginController()); routes.put(/cart, new CartController()); routes.put(/orders, new OrderController()); } Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String path request.getRequestURI().substring(request.getContextPath().length()); ControllerHandler handler routes.get(path); if (handler null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } handler.handle(request, response); } }load-on-startup1表示Tomcat启动时立即初始化路由注册失败会马上暴露而不是等第一个请求打进来。url-pattern /会接管所有没有明确匹配的请求JSP仍由容器自带的JspServlet处理但static目录里的CSS/JS默认会被这个匹配挡住。如果出现页面正常但css加载404常见做法是给static单独加一个servlet-mapping指向默认DefaultServlet或者把静态资源放进CDN/nginx层。这个坑不填经常成为为什么我的购书系统没有样式的排查起点。3.3 三个最小验证点先能连库再出页面再通链路不先把依赖关系清零就堆功能排障时很难分清问题在SQL、在转发路径还是在JSP。我一般按三个里程碑来启动这个项目第一个里程碑是数据库连通。写一个DBUtil用main方法单独跑一次getConnection确认驱动和地址都对。public final class DBUtil { private static final Properties props new Properties(); static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { props.load(in); Class.forName(props.getProperty(driver)); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection( props.getProperty(url), props.getProperty(username), props.getProperty(password)); } }db.propertiesdrivercom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/bookstore?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai usernameroot password123456MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver老文章里写的com.mysql.jdbc.Driver在MySQL 8下会抛ClassNotFoundExceptionserverTimezone是MySQL 8连接时的必填参数不填会报时区错误。characterEncodingutf8解决书名、作者名里的中文乱码。第二个里程碑是渲染首页把最简单的index.jsp放到WEB-INF/jsp通过一个Servlet转发能打开就说明转发路径没有问题。第三个里程碑才是打通请求→Service→DAO→页面的完整链路用一个参数最简单的/books接口验证分页和结果集映射。数据库表建议直接使用InnoDBcreate table book ( id int primary key auto_increment, title varchar(128) not null, author varchar(64) not null, price decimal(10, 2) not null, stock int not null default 0, type_id int not null, create_time datetime default current_timestamp, index idx_type (type_id) ) engine InnoDB default charset utf8mb4;engine设成InnoDB不是随手写的订单、库存、购物车明细这些数据天然需要事务和行锁MyISAM不支持行锁出现并发扣库存时会遇到完全不可控的写覆盖。表结构可以先只建book、user、orders、order_item四张购物车不进库原因放到第4章说。再补充一点上面的DBUtil每调用一次就新建一条TCP连接只适合学习环境要真正上线把getConnection内部换成Druid或HikariCP的DataSource即可Controller到DAO的代码不需要跟着改这正是分层带来的替换空间。4. 从登录到购物车网上购书系统三层协作的完整代码三层结构搭好以后最怕的是结构长对了代码还是Servlet里写业务。这一章用三个最常见的购书场景把Controller、Service、DAO各自该有的样子写出来。4.1 登录购书系统里MVC边界的第一道验收登录是第一个能检验职责划分的功能。用户提交用户名和密码Controller要做的只有两件事从request取参数调用Service后决定去哪个页面。密码校验、加盐、查用户是Service的职责。WebServlet(/login) public class LoginController extends HttpServlet { private final AuthService authService new AuthService(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); User user authService.login(username, password); if (user ! null) { req.getSession().setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /books); return; } req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/jsp/login.jsp).forward(req, resp); } }Controller里没有出现对UserDao的直接调用没写MD5也没判断密码长度是否合法。这些逻辑全部在Servicepublic class AuthService { private static final String SALT bookstore:salt; public User login(String username, String password) { if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { throw new BizException(用户名或密码不能为空); } String hashed DigestUtils.md5Hex(password SALT); User user userDao.findByUsername(username); if (user null || !user.getPassword().equals(hashed)) { return null; } return user; } }AuthService完全不感知HttpServletRequest因此它在别的入口比如管理员后台、手机接口都能直接复用。两个细节值得注意一是密码不能明文存库这里用MD5加固定盐只是演示生产环境应升级为BCrypt二是登录成功后用sendRedirect而不是forward这是PRG模式——重定向让浏览器地址栏变成/books用户刷新时不会把表单再提交一次避免产生重复会话。4.2 图书列表分页count 与 list 分开查参数装进 PageResult图书列表一定是分页的。Controller把page、size、keyword解析成业务参数Service负责把keyword转换成模糊查询条件DAO负责执行SQL并组装PageResult。WebServlet(/books) public class BookController extends HttpServlet { private final BookService bookService new BookService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page parseInt(req.getParameter(page), 1); int pageSize parseInt(req.getParameter(size), 12); String keyword req.getParameter(keyword); PageResultBook pageResult bookService.search(keyword, page, pageSize); req.setAttribute(pageResult, pageResult); req.getRequestDispatcher(/jsp/books/list.jsp).forward(req, resp); } private int parseInt(String raw, int defaultValue) { if (raw null) { return defaultValue; } try { return Integer.parseInt(raw); } catch (NumberFormatException e) { return defaultValue; } } }PageResult是一个只存放page、pageSize、total、list四个字段的POJO它不属于任何特定页面DAO和Service都能用。BookDao里的分页查询是这样实现的public PageResultBook search(String keyword, int page, int pageSize) { PageResultBook result new PageResult(page, pageSize); String like % keyword %; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(COUNT_SQL)) { ps.setString(1, like); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { result.setTotal(rs.getInt(1)); } } try (PreparedStatement ps2 conn.prepareStatement(PAGE_SQL)) { ps2.setString(1, like); ps2.setInt(2, (page - 1) * pageSize); ps2.setInt(3, pageSize); try (ResultSet rs ps2.executeQuery()) { ListBook list new ArrayList(); while (rs.next()) { list.add(mapToBook(rs)); } result.setList(list); } } } catch (SQLException e) { throw new DataAccessException(分页查询失败, e); } return result; }COUNT_SQL和PAGE_SQL都用?占位符而不是字符串拼接keyword由用户输入直接拼进去会产生SQL注入。limit的offset计算是(page - 1) * pageSizepage3、pageSize12表示跳过24条取12条这条公式写错会导致分页永远重第一页或跳页。money字段在Book里用BigDecimal而不是doubleJava的double做价格计算会出现0.10.2精度问题数据库侧也一样用decimal(10,2)存储。提示pageSize是用户可改的参数建议在Service或Controller里做个上限比如超过50就强制设回12防止一次请求把全表拖出来。4.3 购物车会话态数据为什么不该落库下单前必须先把购物车定清楚。购物车、订单、库存是三种不同生命周期的数据不能统一塞进数据库数据载体生命周期是否需要事务购物车HttpSession会话结束即失效不需要订单MySQL订单表永久必须库存MySQL库存表永久必须并发控制购物车放Session而不是数据库是因为它在业务语义上就是临时待确认列表用户清掉浏览器就应该消失。把它落库会引入多一张表、一套序列化逻辑、一个清理过期购物车的定时任务却换不来任何事务价值。购物车丢失用户会重新加订单丢失无法弥补两者在系统里的地位完全不同。Cart模型应该是一个不依赖Servlet API的纯Java对象public class Cart { private final MapInteger, CartItem items new LinkedHashMap(); public void add(Book book, int quantity) { CartItem item items.get(book.getId()); if (item null) { items.put(book.getId(), new CartItem(book, quantity)); } else { item.increase(quantity); } } public BigDecimal getTotal() { BigDecimal total BigDecimal.ZERO; for (CartItem item : items.values()) { total total.add(item.getTotalPrice()); } return total; } public void clear() { items.clear(); } }Cart里没有一行Servlet APIadd、getTotal、clear就是它的完整业务。Controller的职责只是把它从Session里取出来操作完再放回去Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); }采用这种设计后登录后购物车跨设备同步这个需求一旦出现只需要把Controller里存取Session的代码替换成Redis存取Cart本身不用改。Model不依赖HttpSession换存储介质时才不会牵连业务逻辑。5. 下单与扣库存并发控制在购书系统里该放在哪一层购物车只是开始真正的坑在下单这个动作上。下单不是一个方法而是一串必须同时成功或同时失败的操作还要处理两个用户同时买最后一本书的情况。很多论文在这里只写了顺序代码一压测就暴露问题。5.1 一次下单要经过四个不可拆散的步骤把一次下单拆开看至少有四步第一步校验购物车非空第二步扣减每本图书的库存第三步生成订单头和订单明细第四步清空购物车。这四步不是相关是要么全成要么全不成。如果没有事务第2步成功、第3步失败时库存已经扣了用户却没有订单记录对账时会永远差一本书。网上购书系统的订单和库存天然是强一致需求不能靠定时任务去补偿。5.2 编程式事务Connection 怎么穿过 DAO 传递不引入Spring的情况下事务边界要放在Service层并在整个流程里复用同一个Connection。这是最常见也最容易被写错的地方很多人把getConnection写在DAO里导致每个DAO方法各用各的连接事务形同虚设。正确的做法是Service获取连接、关闭自动提交、把连接传给DAOpublic class OrderService { public Order createOrder(Cart cart, User user) throws SQLException { if (cart null || cart.getItems().isEmpty()) { throw new BizException(购物车是空的); } Connection conn DBUtil.getConnection(); boolean oldAutoCommit conn.getAutoCommit(); conn.setAutoCommit(false); try { Order order orderDao.insert(conn, user, cart); for (CartItem item : cart.getItems()) { int changed bookDao.deductStock(conn, item.getBookId(), item.getQuantity()); if (changed 0) { throw new BizException(图书[ item.getBook().getTitle() ]库存不足); } } cart.clear(); conn.commit(); return order; } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(oldAutoCommit); conn.close(); } } }这里的关键是连接从一个方法传进另一个方法orderDao.insert和bookDao.deductStock都使用外部传入的connDAO内部禁止自己new连接。conn.setAutoCommit(false)之后所有DAO操作都在同一个数据库事务里任何一步抛异常都会rollback库存和订单保持同步。finally里把autoCommit恢复原状是为了防止连接池回收时把关闭自动提交的状态泄漏给下一个请求。5.3 并发扣减把校验和扣减合成一条 SQL最经典的并发bug在这里先select stock判断stock quantity再执行update。两个请求同时读到stock5都认为库存够各自扣减后库存变成3而不是1这就是超卖。解决办法是把校验和扣减合并成一条原子SQLupdate book set stock stock - ? , version version 1 where id ? and stock ?这条update自带行锁数据库会串行执行对同一行的更新。affected rows为0说明库存不足此时Service抛出BizException整个事务回滚。关键点在于where条件里的stock ?它把校验放进了条件里而不是放在应用程序里。public int deductStock(Connection conn, Integer bookId, Integer quantity) throws SQLException { String sql update book set stock stock - ?, version version 1 where id ? and stock ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, quantity); ps.setInt(2, bookId); ps.setInt(3, quantity); return ps.executeUpdate(); } }返回0就抛业务异常抛出后由Service的catch统一rollback。这里不需要再查一次库存也不需要select ... for update一条update既完成了条件判断也完成了写操作是扣减类场景里最省事的并发控制方案。事务边界放在Service还是Controller从下面这张表能看得很清楚方案多个入口复用单元测试职责清晰度Service层开启事务可以直接复用无需容器即可测业务层自洽Controller层开启事务每个入口复制一遍必须启动Tomcat控制层被异常逻辑污染5.4 异常分流BizException 与 500 分开处理下单失败不是系统异常是业务失败不应该返回500页面。Controller捕获BizException后把错误信息设置到request属性转发到订单失败页其他RuntimeException则抛给容器由web.xml的error-page统一处理。error-page exception-typejava.lang.RuntimeException/exception-type location/jsp/error.jsp/location /error-page error-page error-code404/error-code location/jsp/not-found.jsp/location /error-page这样用户看到的是库存不足请修改数量而不是一串堆栈。堆栈记到日志里方便排查真正的程序bug。6. 用一场MVC体检检查购书系统的控制器健康度代码写完不等于结构正确。教一个快速且可执行的验证方案用grep扫一遍代码Controller里不允许出现JDBCJSP里不允许出现Java脚本。6.1 一条命令找出越界代码cd bookstore # 1. Controller里不该出现JDBC和SQL grep -rn DriverManager\|createStatement\|select src/main/java/com/bookstore/controller/ # 2. JSP里不该出现% %脚本块 grep -rn %[^] src/main/webapp/jsp/ # 两条命令如果没有任何输出说明层与层之间没有越界这个检查简单但有说服力。第一类输出对应Controller太厚第二类输出对应JSP里写了业务。两种情况只要出现一个就说明MVC边界已经破了。6.2 把Servlet依赖从Controller里剥掉还有一个更结构化的做法让Controller方法接收RequestContext而不是HttpServletRequest。RequestContext把参数解析封装成方法DispatcherServlet负责适配public class RequestContext { private final HttpServletRequest req; public RequestContext(HttpServletRequest req) { this.req req; } public String getString(String name) { return req.getParameter(name); } public int getInt(String name, int defaultValue) { String v req.getParameter(name); return v null ? defaultValue : Integer.parseInt(v); } public void set(String key, Object value) { req.setAttribute(key, value); } }改造后的BookController不再碰Servlet APIpublic class BookController { public String list(RequestContext ctx) { int page ctx.getInt(page, 1); String keyword ctx.getString(keyword); PageResultBook pageResult bookService.search(keyword, page, 12); ctx.set(pageResult, pageResult); return jsp:books/list; // 返回视图描述由Dispatcher转发 } }DispatcherServlet统一处理返回值前缀jsp:开头的做forwardredirect:开头的做sendRedirect转发逻辑从Controller里彻底消失。这一步做完Controller里的方法不再依赖任何Servlet类可以脱离Tomcat做单元测试路由行为也能直接测试而不必每次启动容器。这个写法是Spring MVC的ModelAndView理念在Servlet时代的极简版本把Controller瘦到只剩取参数、调Service、返回视图名三件事。本文还有配套的精品资源点击获取