拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java Web购书系统MVC分层与事务边界实践

简介一份基于MVC设计模式的Java Web网上购书系统设计与实现毕业论文定位为高校计算机相关专业学生的毕业设计参考也适合正在学习Java Web分层架构、Servlet与JDBC开发的入门及进阶开发者。论文从课题背景、MVC组件关系出发系统阐述网上书店的业务需求与功能设计并围绕用户登录注册、图书查询、智能辨认、购书流程、会话管理等模块给出详细实现思路。内容还对Servlet生命周期、JDBC工作机制、JavaBean封装等关键技术做了归纳目录结构清晰章节安排完整便于读者按需检索其中的设计思路与核心代码逻辑。包体为1个PDF文件大小3.16MB方便直接阅读、批注与打印。目前已有309人学习下载说明该资料在毕业设计选题和Java Web课程设计场景中具有一定参考价值。1. 做网上购书系统最先暴露问题的是“规则该放哪一层”做网上购书系统在一开始就能感觉到普通CRUD和可迭代项目的区别图书列表、详情页、购物车谁都能写但一旦出现“满300减50”“下单后扣减库存”这类规则逻辑该放哪里就成了问题。有人把所有计算写在JSP里加一个活动就要改十几个页面有人用MVC把规则收敛到模型层改一个方法加一条测试就能覆盖一个业务规则。Java Web里做网上购书系统最成熟的落地方式是把Servlet当控制器、JSP当视图、Service加DAO当模型。下面沿这条线往下拆讲清楚每一层在系统里具体管什么再落到购物车、订单、连接池配置这些一定会碰到的细节。正拿这项目做毕设或刚接手老代码的人都能按这套拆法对应到要写的类。2. MVC三层职责边界在购书系统里的落地MVC严格说是架构模式跟GoF那23种设计模式分类不同但解决的是同一类问题——职责分配。购书系统里最容易看出这一点的就是订单和购物车不规定好哪一层拥有数据访问权、哪一层拥有业务规则权后续每次加活动、改促销都等于重新翻一遍全项目。2.1 模型层再细分实体、DAO、Service各管一摊在购书系统里用户、图书、购物车、订单、订单项这五类数据就能撑起主流程。很多人把模型层设计成“实体类加一个DAOService汇合所有查询”结果事务和业务规则全部堆在一个类里。我一般会拆成三层粒度实体类只表示一份数据没有行为DAO只做数据访问一个方法对应一条可审计的SQLService负责组合DAO和业务规则是事务边界的终点。// 实体只承载数据不写业务方法 public class Book { private Long id; private String title; private String isbn; private BigDecimal price; private Integer stock; // getter / setter 省略 } // DAO每个方法对应一个明确的 SQL public interface BookDao { ListBook findPage(int offset, int limit); int countAll(); int deductStock(Long bookId, int amount); }实体类在MVC里是跨层传输数据的载体尤其在Java Web常见的贫血模型约定下不要在Book里写hasStock()这种业务方法更不要把HttpServletRequest引用传进DAO。DAO的方法名要能让读代码的人直接想到SQL比如deductStock对应的就是UPDATE book SET stock stock - ? WHERE id ? AND stock ?条件带上stock ?是防超卖的关键后面订单章节还会再提到。至于一个DAO里该有几个方法我的标准是按业务需要的查询来定不为“通用CRUD”写模板因为模板方法在购书系统里容易演变成全表查询。Service层是模型层对控制器唯一的出口。购书系统里下单这个动作在Service里至少要完成校验库存、生成订单、扣减库存三件事控制器只调用一个submit(orderInput)不需要知道里面是先查库还是先写库。这样设计之后单元测试可以直接构造OrderService并注入内存版DAO验证提交失败时会回滚而不需要启动Tomcat和数据库。为什么坚持在Service层收口而不在DAO层收口因为一次下单要动多张表DAO方法天然只访问一个表跨表的一致性必须由上一层来管。事务放进DAO层会让一个DAO方法操作多个表职责就乱了事务放进Servlet控制器则同一个业务动作可以被多个入口以不同顺序调用规则会散。所以“Service是事务边界”不是设计偏好而是MVC下唯一能把业务变动集中在单点的约束。2.2 视图层里能出现的代码决定项目能活几年JSP是Java Web里最容易写脏的一块。常见问题是页面上到处是% %脚本片段循环、判断、价格计算都写在片段里% ListBook books (ListBook) request.getAttribute(books); % % if (books ! null) { for (Book b : books) { % tr td% b.getTitle() %/td td% b.getPrice() %/td /tr % } } %这段代码的阅读者被迫同时理解Java语法和页面结构更关键的是逻辑无法复用优惠活动要改的话得把全站的% %片段找一遍。改用EL表达式和JSTL之后页面里只留展示逻辑数据全部来自控制器放入request作用域的对象% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach items${books} varbook tr td${book.title}/td tdyen;${book.price}/td /tr /c:forEach这里还有一个容易误用的点在页面里做大量空值防御。${books ! null}、${book.price ! null}写多了视图会变得比业务方法还啰嗦。数据健壮性应该在控制器或Service层保证视图只需要处理“有数据就渲染”和“没数据就提示”两种状态对应一个c:if test${empty books}就足够。视图层可接受的判断包括根据登录状态显示不同按钮、根据订单状态显示不同文案这些属于展示分支不算业务规则。判断逻辑留在页面里是否合理标准只有一个如果这个判断需要依赖数据库或另一个对象的状态它就不该出现在视图层。2.3 控制器层请求入口的翻译官控制器在Java Web里的传统实现是Servlet。一种风格是一个模块一个Servlet图书、购物车、订单各建一个类另一种是统一入口用一个DispatcherServlet接收所有请求按action参数分发。模块化Servlet直观但公共逻辑难收敛统一入口可控方便统一做鉴权和日志前提是分发粒度要细不要在一个方法里又加购又删项。WebServlet(/dispatcher.do) public class DispatcherServlet extends HttpServlet { private final MapString, Command commands new HashMap(); Override public void init() throws ServletException { // 正式项目建议在启动时注入数据源这里用 ServletContext 简化示意 DataSource ds (DataSource) getServletContext().getAttribute(ds); commands.put(book.list, new BookListCommand(ds)); commands.put(cart.add, new CartAddCommand(ds)); commands.put(order.submit, new OrderSubmitCommand(ds)); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action req.getParameter(action); Command cmd commands.get(action); if (cmd null) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST); return; } String view cmd.execute(req, resp); if (view ! null) { req.getRequestDispatcher(view).forward(req, resp); } } }Command接口的execute要么直接写出响应要么返回视图路径由DispatcherServlet统一转发。Command在init()里注册是启动期只做一次的操作请求进来只做查表和调用具体参数解析回到各自Command里控制器本身不写业务。action必须有白名单校验cmd null时返回400而不是转发到异常页避免把内部路径暴露出去。控制器还有一个最容易忽略的职责对参数做第一层清洗。把String转成Long、把非数字页码回退为1、判断用户是否登录做完这层之后Service可以默认收到的是合法入参不用每个方法再重复校验。层应该负责的事不应该做的事视图 JSP渲染request作用域的数据、控制展示分支写SQL、调DAO、计算价格控制器 Servlet解析参数、校验登录、调用Service、决定视图处理事务、拼SQL、做业务判断模型 ServiceDAO组合业务规则、管理事务边界、访问数据接收HttpServletRequest、输出HTML这张表可以作为代码评审时的分层检查清单任何跨层动作出现直接指出该改在哪一层。3. 购物车与订单购书系统里两处最见MVC功力的流程3.1 图书列表分页控制器只解析参数模型层只返回一页购书系统的图书列表是流量最集中的页面分页必须在内层做掉不能把全部图书findAll出来到视图里截取。写的时候不难关键是参数边界。控制器从request取出page参数并给默认值然后交给DAO执行带LIMIT的查询把总页数算好放进requestOverride public String execute(HttpServletRequest req, HttpServletResponse resp) { int page 1; if (req.getParameter(page) ! null) { try { page Integer.parseInt(req.getParameter(page)); } catch (NumberFormatException e) { page 1; } } if (page 1) { page 1; } int pageSize 12; int offset (page - 1) * pageSize; ListBook books bookDao.findPage(offset, pageSize); int total bookDao.countAll(); int pages (total pageSize - 1) / pageSize; req.setAttribute(books, books); req.setAttribute(currentPage, page); req.setAttribute(totalPages, pages); return /WEB-INF/views/book/list.jsp; }这里bookDao由构造器注入具体绑定在DispatcherServlet初始化时完成。DAO里的findPage对应SQL要写成LIMIT ? OFFSET ?并用PreparedStatement绑定。有两个坑第一LIMIT的值不能拼进SQL数字过了参数校验不意味着没有注入风险第二countAll()和findPage()是两条SQL如果不在同一事务里查询期间有新书插入会让总页数和列表短暂不一致购书场景接受这种读偏差不必为分页跨表加锁。分页条在JSP里渲染时当前页要显示为纯文本而不是链接。页面里循环输出1到totalPages配合/dispatcher.do?actionbook.listpage${i}即可。为了后面做页面缓存方便分页参数最好只保留page和可选keyword两个其他条件不要塞进URL。3.2 购物车放Session还是落库购物车是Java Web系统里很典型的跨请求状态。最常见的流程是未登录用户也可以加购状态放Session结算前要求登录登录后再把Session里的购物车合并到数据库。关键代码是购物车对象本身public class Cart { private MapLong, CartItem items new LinkedHashMap(); public void add(Book book, int qty) { CartItem item items.get(book.getId()); if (item null) { // 只拷贝结算需要的字段不持有整个 Book 引用 items.put(book.getId(), CartItem.from(book, qty)); } else { item.setQty(Math.min(item.getQty() qty, 99)); } } public void remove(Long bookId) { items.remove(bookId); } }LinkedHashMap保持插入顺序购物车页面可以按加入顺序展示。CartItem.from里只拷贝bookId、title、price、qty、stock这些结算时要用的字段不要塞整个Book实体否则Session序列化体积失控库存快照过期策略也没法做。数量上限99是上线前就该确定的参数不设上限的话一个恶意请求可以通过不断累加qty把Session撑大。Session购物车在订单提交成功后必须清空否则刷新页面可能重复下单。这个清理动作属于会话管理放在控制器层做不需要进Service。Session购物车的缺点是换设备就丢。要支持跨设备同步就要在每次加购时把user_id book_id qty落库登录后先合并Session与库里的数据再展示。此时购物车落库的表只做三列即可简单直接。提示Session里购物车超时丢失应按产品预期处理页面上给“购物车已过期”的提示技术上不要为了保留购物车无限拉长session超时。3.3 订单提交一个方法里完成三张表的原子写入提交订单是整个购书系统里MVC价值最集中的场景。控制器只把用户意图翻译成Service调用真实事务边界在Service的submit方法里public Long submit(Long userId, Cart cart) { try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); try { Long orderId saveOrder(conn, userId); saveOrderItems(conn, orderId, cart); updateStock(conn, cart); conn.commit(); return orderId; } catch (SQLException e) { conn.rollback(); throw new OrderException(订单提交失败全部回滚, e); } } catch (SQLException e) { throw new OrderException(数据库连接不可用, e); } }saveOrder插入订单主表saveOrderItems批量插入订单明细updateStock对每本图书执行带库存条件的更新。同一个Connection贯穿三个方法setAutoCommit(false)之后只有commit()生效任何一步抛异常都走rollback()。事务放在Service而不是DAO因为这三步不在同一个DAO职责内只有Service才能形成原子操作。控制器里不能出现这类事务代码否则两个不同的控制器调用同一业务时会走完全不同的提交逻辑。控制器在调用前从Session取出Cart代码里submit(Long userId, Cart cart)这个签名也保证Service不碰HttpSession。如果哪个session引用穿透到了DAO层说明分层已经被破坏后面所有测试都很难写。updateStock对应的SQL值得再看一次UPDATE book SET stock stock - ? WHERE id ? AND stock ?stock ?利用数据库行锁把“检查库存”和“扣减库存”合并为一条原子SQL而不是先SELECT再UPDATE。购书系统并发下单时先查再改会出现超卖带条件的UPDATE受影响行数为0就说明库存不足直接抛异常回滚。4. 从跑通到并发不大崩购书系统的配置与参数4.1 servlet映射与WEB-INF路径里的隐藏前提用WebServlet(/dispatcher.do)可以少写一点web.xml但静态资源放行和受保护目录这两件事还是要显式处理。/dispatcher.do这种带后缀的路径天然避开CSS和JS但如果某个环境里DispatcherServlet被映射到/就必须补静态资源放行配置。常见做法是把静态文件统一放在/static/下让默认Servlet处理它们servlet-mapping servlet-namedefault/servlet-name url-pattern/static/*/url-pattern /servlet-mapping session-config session-timeout40/session-timeout /session-configdefault是Servlet容器内置的静态资源Servlet这个映射只对/static/*生效业务请求仍走DispatcherServlet。视图文件放进/WEB-INF/views/这个目录对浏览器直接访问不可见天然保证所有页面都必须通过forward到达。加上这条约束后“所有入口经过控制器”就从开发自觉变成了物理限制。JSP本身也是Servlet如果视图文件直接放在webapp根目录用户可以直接请求.jsp地址页面里的数据全是nullMVC分层就形同虚设。这条在部署检查时最容易被忽略很多“能打开但没数据”的问题都是这么来的。4.2 连接池、事务隔离级别先改这几个参数购书系统如果每个请求新建数据库连接压测到几十并发就会出现连接拒绝。接入连接池是第一步在Tomcat的context.xml里可以声明一个数据源资源Resource namejdbc/bookstoreDS authContainer typejavax.sql.DataSource maxTotal50 maxIdle10 maxWaitMillis5000 defaultTransactionIsolation2/参数建议值理由maxTotal40~60结合Tomcat线程数设定给数据库留余量连接数翻倍未必更快maxIdle10~15空闲连接不用保留太多省内存maxWaitMillis3000~5000连接耗尽时快速失败不让请求无限排队defaultTransactionIsolation22对应JDBC的READ_COMMITTED读多写少场景锁竞争比REPEATABLE_READ小maxTotal不是越大越好数据库连接同时占用数据库端线程和内存。没有压测数据时按Tomcat线程数的一半左右起步比较稳之后再根据监控调整不要一上来就设200。defaultTransactionIsolation2对应Connection.TRANSACTION_READ_COMMITTED按字符串常量名配置容易因连接池实现不同而解析失败写成数字最稳。这个隔离级别下同一个事务里两次读同一行可能看到不同值这对购书系统符合预期价格和库存以提交那一刻为准不允许脏读才是硬要求。不要以为配置了连接池就自动获得事务管理。JDBC的默认行为是每条SQL自动提交若拿到的连接没调setAutoCommit(false)前面订单代码里的rollback就完全不生效。调试时可以打开数据库通用日志看订单请求是否真的只出现一次commit一眼就能确认事务边界。4.3 Session超时与购物车恢复策略Tomcat默认session超时30分钟。购书场景里“用户挑书到一半去开会回来继续下单”是正常行为session-timeout调到40分钟比较合理但不要一味加长。每个session都住在Tomcat堆里用户量上来后会变成明显的存量消耗Session属性里应尽量少放对象购物车尤其要注意。要支持“关掉浏览器购物车还在”正确做法是把购物车同步到数据库。我会在每次加购和改数量的请求里调用cartDao.saveOrUpdate(userId, items)登录时按user_id载入。Session里的Cart仍保留作为短生命周期的工作副本避免每次渲染都查库。这样Session超时、用户换设备、浏览器清cookie购物车数据都不会丢代价是每个写操作多一条SQL购物车写入频率不高这个成本值得。还要处理整单提交后的清理订单创建成功后购物车对应的cart_item要删除否则下次登录时购物车又恢复成已下单的旧状态。这个清理动作放在Service层的submit方法内部和订单创建、库存扣减在同一事务里。5. 用Filter给MVC请求计时再用curl串起会话排查页面慢的时候MVC项目最难判断的是慢在哪一层——JSP渲染、Controller转发、还是Service查询。给整个应用加一个AccessLogFilter是最省事的做法它在所有请求出口记录总耗时WebFilter(/*) public class AccessLogFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq (HttpServletRequest) req; long start System.currentTimeMillis(); try { chain.doFilter(req, resp); } finally { long cost System.currentTimeMillis() - start; if (cost 300) { String uri httpReq.getRequestURI(); String query httpReq.getQueryString(); System.out.printf([SLOW] %s%s %dms%n, uri, query null ? : ? query, cost); } } } }Filter是Servlet规范里最外层的拦截器总耗时覆盖Controller和View的完整链路。只靠这个还是分不出层次可以在Service入口用同样的方式打点把请求对应的Service耗时记下来。对比两份日志就能定位Service耗时接近总耗时就查SQL、查连接池等待差得多就查JSP渲染或转发前后的IO。这个技巧对老项目特别好用不用改动现有业务代码只加一个Filter和几行日志。示例里直接输出到stdout线上换成项目里的日志框架列为一条独立的访问日志。排查跨请求的状态流转问题时浏览器页面不够用用curl带上cookie把“浏览→加购→看车→下单”串起来curl -i -c cookie.jar http://localhost:8080/bookstore/dispatcher.do?actionbook.listpage1 curl -b cookie.jar http://localhost:8080/bookstore/dispatcher.do?actioncart.addbookId3qty1 curl -b cookie.jar http://localhost:8080/bookstore/dispatcher.do?actioncart.view curl -b cookie.jar -X POST -d actionorder.submitaddressId8 \ http://localhost:8080/bookstore/dispatcher.do-c cookie.jar是把服务端下发的Cookie写入文件-b cookie.jar是每次请求带上这个文件里的Cookie正好模拟一个浏览器里连续的Session。连着执行四条命令后对照AccessLogFilter和Service打点日志能看到哪一步慢、哪一步丢了会话、哪一步回滚。这一套流程当作发布前的冒烟测试脚本也够用。Filter里的300ms只是示例阈值线上可以放宽到1000ms只捞慢请求如果慢请求集中在几个action上再进Service层打点如果集中在静态资源上先检查是不是静态资源没有走默认Servlet而是挤了业务请求的通道。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门