简介:这份PDF是基于MVC设计模式的Java Web网上购书系统毕业设计论文,面向计算机专业毕业生、Java Web初学者及需要开展同类课题的开发者。论文从课题背景、MVC设计思想、系统总体设计到详细实现逐层推进,覆盖用户登录注册、图书查询、智能辨认、购书流程、操作过时管理等核心模块,并重点剖析Servlet、JDBC、JavaBean等关键技术的实际应用,可作为毕业设计写作框架、代码实现与答辩准备的参考资料。资源为单个PDF文档,共1个文件,大小3.16MB,内含完整目录、绪论、系统设计、实现细节及参考文献,结构清晰、章节完整,便于按需查阅。已有309人学习浏览,适合希望快速理解MVC分层架构在购书系统中落地方式的读者。
1. MVC在Java Web里从来不是目录结构问题
在网上购书系统这种题目里,MVC往往被一句话带过:"采用MVC设计模式,分成模型、视图、控制器三层。"真动手时,很多人把这句话理解成"把代码放进三个包":model包放实体类,controller包放Servlet,jsp目录放页面,交差就完成。这套做法让设计模式变成目录划分,系统该乱还是乱。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(实体+Service+DAO) | Service、DAO、领域对象 | Servlet API、JSP标签、SQL字符串 |
| View(JSP) | EL/JSTL、Controller设置的对象 | JDBC、业务条件判断 |
| Controller(Servlet) | Service、Model、请求/响应对象 | SQL、HTML拼接、业务规则 |
这个表就是整个系统的"交通规则"。违反规则的代码通常当场能跑,但改需求和排障时会变得非常贵。比如Controller里做业务判断,第一次没问题,第二次加一个"会员折扣"就要在多个Controller里复制;把判断下沉到Service,Controller就只是请求的翻译官,这两者在工程里差别很大。
2.2 反例:JSP里直接查库为什么必踩坑
网上购书系统最有"历史感"的错误是:图书列表直接在JSP里用JDBC查。代码示意:
<%-- 错误写法示意,不要抄 --%> <%@ page import="java.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 class='book'>" + rs.getString("title") + " ¥" + rs.getBigDecimal("price") + "</div>"); } } %>这段代码把请求参数、SQL、实体映射、HTML渲染全部揉进一个JSP里,问题不止"不好看"。request参数直接拼接进SQL,type传一个"1 or 1=1"就能把全站图书翻出来;JSP由前端维护时会改数据库代码,由后端维护时会改坏页面标签;这段逻辑永远没办法脱离Tomcat写单元测试。MVC对它的改造不是"把代码换个包",而是把Query变成BookDao.search()、把渲染交给JSTL、把参数解析放进Controller。
2.3 正向链路:一次请求在每个角色的产物不一样
一次"列出Java分类图书"的请求,按MVC切完后是这样的链路:
- 浏览器发GET /books?cate=java&page=1
- DispatcherServlet按路径找到BookController
- BookController取出cate、page,调用bookService.search("java", 1, 12)
- BookService把keyword处理成模糊查询的"%java%",转给bookDao.search
- BookDao执行参数化SQL,逐行转成List
- Controller把List 放进request属性,forward到books.jsp
- books.jsp用c:forEach渲染,返回HTML
每个步骤的产物,对应不同的角色:
| 步骤 | 产物 | 所在角色 |
|---|---|---|
| 3 | 参数与调用 | Controller |
| 4 | 查询条件整理 | Service |
| 5 | List | DAO |
| 6 | request属性 | Controller |
| 7 | HTML | View |
这段链路的价值在于:将来做手机端时,只需要加一个Controller方法复用第4、5步,返回JSON,Model和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 service,service可以import dao,model谁都不依赖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 xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>com.bookstore.controller.DispatcherServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app>与配置对应的DispatcherServlet不用处理复杂路由,维护一张Map就行:
public class DispatcherServlet extends HttpServlet { private final Map<String, 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-startup=1表示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.properties:
driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username=root password=123456MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,老文章里写的com.mysql.jdbc.Driver在MySQL 8下会抛ClassNotFoundException;serverTimezone是MySQL 8连接时的必填参数,不填会报时区错误。characterEncoding=utf8解决书名、作者名里的中文乱码。第二个里程碑是渲染首页:把最简单的index.jsp放到WEB-INF/jsp,通过一个Servlet转发,能打开就说明转发路径没有问题。第三个里程碑才是打通"请求→Service→DAO→页面"的完整链路,用一个参数最简单的/books接口验证分页和结果集映射。数据库表建议直接使用InnoDB:
create 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,也没判断"密码长度是否合法"。这些逻辑全部在Service:
public 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"); PageResult<Book> 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 PageResult<Book> search(String keyword, int page, int pageSize) { PageResult<Book> 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()) { List<Book> 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) * pageSize,page=3、pageSize=12表示跳过24条取12条,这条公式写错会导致分页永远重第一页或跳页。money字段在Book里用BigDecimal而不是double,Java的double做价格计算会出现0.1+0.2精度问题,数据库侧也一样用decimal(10,2)存储。
提示:pageSize是用户可改的参数,建议在Service或Controller里做个上限,比如超过50就强制设回12,防止一次请求把全表拖出来。
4.3 购物车:会话态数据为什么不该落库
下单前必须先把购物车定清楚。购物车、订单、库存是三种不同生命周期的数据,不能统一塞进数据库:
| 数据 | 载体 | 生命周期 | 是否需要事务 |
|---|---|---|---|
| 购物车 | HttpSession | 会话结束即失效 | 不需要 |
| 订单 | MySQL订单表 | 永久 | 必须 |
| 库存 | MySQL库存表 | 永久 | 必须并发控制 |
购物车放Session而不是数据库,是因为它在业务语义上就是"临时待确认列表",用户清掉浏览器就应该消失。把它落库会引入多一张表、一套序列化逻辑、一个清理过期购物车的定时任务,却换不来任何事务价值。购物车丢失用户会重新加,订单丢失无法弥补,两者在系统里的地位完全不同。Cart模型应该是一个不依赖Servlet API的纯Java对象:
public class Cart { private final Map<Integer, 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 API,add、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获取连接、关闭自动提交、把连接传给DAO:
public 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都使用外部传入的conn,DAO内部禁止自己new连接。conn.setAutoCommit(false)之后,所有DAO操作都在同一个数据库事务里,任何一步抛异常都会rollback,库存和订单保持同步。finally里把autoCommit恢复原状是为了防止连接池回收时,把"关闭自动提交"的状态泄漏给下一个请求。
5.3 并发扣减:把校验和扣减合成一条 SQL
最经典的并发bug在这里:先select stock,判断stock >= quantity,再执行update。两个请求同时读到stock=5,都认为库存够,各自扣减后库存变成3而不是1,这就是超卖。解决办法是把校验和扣减合并成一条原子SQL:
update 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-type>java.lang.RuntimeException</exception-type> <location>/jsp/error.jsp</location> </error-page> <error-page> <error-code>404</error-code> <location>/jsp/not-found.jsp</location> </error-page>这样用户看到的是"库存不足,请修改数量",而不是一串堆栈。堆栈记到日志里,方便排查真正的程序bug。
6. 用一场MVC体检检查购书系统的控制器健康度
代码写完不等于结构正确。教一个快速且可执行的验证方案:用grep扫一遍代码,Controller里不允许出现JDBC,JSP里不允许出现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 API:
public class BookController { public String list(RequestContext ctx) { int page = ctx.getInt("page", 1); String keyword = ctx.getString("keyword"); PageResult<Book> pageResult = bookService.search(keyword, page, 12); ctx.set("pageResult", pageResult); return "jsp:books/list"; // 返回视图描述,由Dispatcher转发 } }DispatcherServlet统一处理返回值前缀,"jsp:"开头的做forward,"redirect:"开头的做sendRedirect,转发逻辑从Controller里彻底消失。这一步做完,Controller里的方法不再依赖任何Servlet类,可以脱离Tomcat做单元测试;路由行为也能直接测试,而不必每次启动容器。这个写法是Spring MVC的ModelAndView理念在Servlet时代的极简版本,把Controller瘦到只剩"取参数、调Service、返回视图名"三件事。
本文还有配套的精品资源,点击获取