
smbms 这套项目全称 Super Market Billing Management System也就是超市订单管理系统网上传得很广的那套 JavaWeb 视频系列的配套项目就是它。很多人学完 Servlet、JSP、Filter、JDBC 之后面对的完整体项目就是这套。我当年也是一集一集跟着敲敲完发现一个很现实的问题跟着视频能在本地跑起来合上视频让我自己从零写一遍连包结构都理不清。后来我把视频丢到一边只留题目和功能要求完整地复现了一遍。这篇文章就是那次复现的完整记录环境、数据库、分层代码、登录权限、分页查询、增删改查、部署验证每一步我都尽量把背后的原因说清楚。1. 项目定位与拆解这个经典项目到底在训练什么1.1 业务模型超市、供应商和订单先理解业务。这个系统模拟的是一家超市的采购订单管理流程超市会从多家供应商进货每笔进货对应一张订单订单上记录商品名、规格、数量、金额、是否已付款同时还要记录这张订单对应哪家供应商。管理人员打开系统能看到所有订单也能按商品名、供应商、付款状态筛选还要能新增、修改、删除订单。供应商和用户则是辅助管理模块供应商要维护联系人、电话、地址、传真用户要区分角色普通员工和管理员看到的界面、能做的操作不一样。业务不复杂但刚好能把一套入门级管理系统的完整链路覆盖到从数据库表设计到后台接口再到前端页面展示和交互每一环都不缺。对于刚学完 JavaWeb 基础的人来说它不像电商系统那样庞大也不像课后作业那样单薄是那种跳一跳正好够得着的项目。1.2 功能清单与学习目标登录、退出登录登录后根据角色显示对应菜单用户管理新增、修改、删除、模糊查询、角色筛选、分页供应商管理新增、修改、删除、查看订单管理新增、修改、删除、组合条件查询、分页密码修改校验旧密码更新新密码对应到技术点清单就是下面这张表技术点在项目里的落点Maven依赖管理、war 打包、目录规范Servlet所有请求入口处理参数、转发/重定向JSP EL JSTL页面展示、列表循环、分页条Filter登录拦截、字符编码统一处理JDBC 封装BaseDao、数据库连接、增删改查MySQL四张核心表、多表联查这套项目最有价值的地方就在这里它没有引入任何框架你能看到 Servlet 最原始的工作状态也能自己动手写出一个能用的 JDBC 封装层。之后再学 Spring Boot 或 MyBatis你会更容易理解框架到底帮你省掉了什么。2. 环境与开局版本组合选对了后面少踩一半坑我复现时用的环境组合是这批项目最常见的一档JDK 8、Maven 3.6.3、Tomcat 8.5、MySQL 8.0。说实话现在装 JDK 17 的人很多但老项目代码大多是给 JDK 8 设计的如果直接上 JDK 17Tomcat 的版本、编译级别、依赖的旧 jar 都可能出兼容问题第一轮排查就能耗掉半天。所以第一次复现建议直接按 JDK 8 来跑通以后再考虑升级。2.1 我用的那套稳定组合JDK 1.8和主流的 Web 容器、视频教程里的配置完全对得上Maven 3.6.xIDEA 自带版本通常也能用注意配好本地仓库和镜像源Tomcat 8.5支持 Servlet 3.1对老项目兼容性好MySQL 8.0需要注意驱动类和时区参数下文会细说IDEA社区版就能跑 Web 项目专业版只是多了框架支持有个细节我想提一下如果本机已经装了更高版本的 Tomcat比如 Tomcat 10那要特别注意它默认使用的是 jakarta.servlet 命名空间老项目里的 javax.servlet 全部要改包名。对初学者来说这是个大坑不建议在第一步就挑战它。2.2 pom.xml 依赖与两个容易出错的点先看依赖。Maven 工程的第一步是创建 war 包结构的 Web 项目然后在 pom.xml 里加上核心依赖dependencies !-- Servlet APITomcat 已提供所以用 provided -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTLJSP 页面里做列表循环和条件判断 -- dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency /dependencies依赖里最容易出问题的就是 MySQL 驱动。MySQL 5.x 用 com.mysql.jdbc.Driver而 MySQL 8.x 必须改成 com.mysql.cj.jdbc.Driver否则启动时会报 ClassNotFoundException。第二个坑是连接串MySQL 8 默认要求带上时区参数所以连接地址通常写成这样jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/smbms?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.password123456characterEncodingutf8 这个参数也不能漏否则后面插入的中文数据到数据库里全是问号。2.3 IDEA 建 Web 工程时的 Artifact 问题这一步是新手最容易卡住的地方而且卡得很莫名其妙工程建好了代码写完了一点运行浏览器直接 404控制台没报错。原因多半是 IDEA 里没有配置好 Web Artifact。正确流程是这样的用 Maven 创建工程时选 webapp 骨架没有骨架也可以后面手动加 Web 模块然后在 Project Structure 里把模块的 Web Facet 关联上再在 Artifacts 里新增一个 war exploded 类型的启动产物最后在 Tomcat Run Configuration 的 Deployment 页面把这个 Artifact 添加进去设置 Application context 为 /smbms。只要这几步对齐Tomcat 启动后能直接访问 http://localhost:8080/smbms/。实测下来社区版和新版 IDEA 的界面长得不太一样但核心套路没变。如果启动后 index.jsp 都访问不了优先检查Artifacts 是否为空、context 路径是否写对、Tomcat 的 application server 是否已经配置。3. 数据先行四张表的字段设计和容易忽略的小细节代码还没写先把数据表建好。smbms 的核心是四张表角色表、用户表、供应商表和订单表。我复现时按项目常见的字段结构来建这也是整个系统最底层的部分。3.1 四张表的字段设计用户表是最复杂的字段也比较多字段类型说明idint主键自增userCodevarchar用户编码登录时用userNamevarchar真实姓名userPasswordvarchar密码建议存 MD5genderint性别1 男 0 女birthdaydate出生日期phonevarchar电话addressvarchar地址userRoleint角色 id对应 smbms_role 表createdByint创建人 idcreationDatedatetime创建时间modifyByint最后修改人 idmodifyDatedatetime最后修改时间角色表很简单id、roleCode、roleName一般就是管理员和普通员工。供应商表 smbms_provider 包含供应商编码、名称、描述、联系人、电话、地址、传真以及创建人、创建时间。订单表 smbms_bill 是核心业务表包含订单编码、商品名、商品描述、计量单位、数量、总价、是否付款、供应商 id以及创建和修改相关字段。字段命名看起来琐碎但很有必要。createdBy、modifyBy 这些字段在后面的修改记录功能里要用很多网上代码为了省事删掉了它们结果后来要追踪数据变更就只能干瞪眼。3.2 外键不建物理外键的原因四张表之间有明显的关联关系用户表 userRole 指向角色表 id订单表 providerId 指向供应商表 id。很多新手会想既然有关联那就加上外键约束吧。实际项目里这套项目的常见做法是不建物理外键只做逻辑外键也就是字段值存在对应关系但数据库层面不强制约束。原因有二第一物理外键在删除和更新时会带来额外的约束判断项目里我们删除供应商前要先检查有没有关联订单这个逻辑放在 Service 层控制更好提示也更友好第二逻辑外键让拆库、迁移、分表都更容易教学中也能更自然地引出 left join 联查。如果你是在自己练手想加物理外键学习约束概念也没问题但在 smbms 这个项目里跟着主流做法走逻辑外键更省事。3.3 初始化数据与密码存储建完表一定要准备初始化数据不然后面联调全是空列表分页、筛选都看不出效果。我放了 3 个用户、2 家供应商和 8 条订单数据量刚好覆盖两页方便测分页边界。密码存储这里要单独说一句。网上很多版本的 smbms 代码里密码是明文存的演示而已。但你自己做项目时建议至少封装一个 MD5Util把密码加密后入库登录时先把用户输入的密码 MD5 化再比对。这不是 smbms 要求的功能但这是一道安全底线练手项目也不能把明文密码写死在数据库里。4. 骨架落地分层目录、BaseDao 和基础件先搭好环境就绪、表也建好了接下来是最重要的一步把代码骨架搭起来。很多新手写项目不讲究包结构所有类全堆在一起前期没问题写到订单模块自己都找不到 Service 在哪。我建议从一开始就按分层结构来后面每个模块的代码都能精确落到对应位置。4.1 包结构与请求走向我用的包结构是这样的com.xxx.smbms ├── pojo // 实体类User、Role、Provider、Bill、PageSupport ├── dao // 数据访问接口 impl 实现 ├── service // 业务接口 impl 实现 ├── servlet // 控制层LoginServlet、UserServlet、ProviderServlet、BillServlet ├── filter // 过滤器UniversalFilter、LoginFilter └── util // 工具类JDBCUtil、MD5Util、常量类请求走向也很直白浏览器请求某个 Servlet → Servlet 接收参数、调 Service → Service 调 DAO → DAO 操作 MySQL → 结果一层层返回 → Servlet 把数据塞进 request 或 session → 转发到 JSP 页面渲染。一开始可能觉得分这么多层太绕但你要理解每一层的职责Servlet 不写 SQLDAO 不写业务判断Service 不直接处理参数。好处是以后换数据库、换前端、加缓存只需要动对应的一层。这套结构其实就是后面 Spring 分层思想的雏形。4.2 数据库工具类和 BaseDao 的作用JDBC 最麻烦的就是重复的加载驱动、建连接、释放资源。我参考网上常见写法先写一个 BaseDao把通用逻辑收敛起来public class BaseDao { // 加载驱动 获取连接 public static Connection getConnection() throws Exception { return JDBCUtil.getConnection(); } // 通用增删改支持可变参数 public static int executeUpdate(Connection conn, String sql, Object... params) throws Exception { PreparedStatement ps conn.prepareStatement(sql); for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } int result ps.executeUpdate(); closeAll(conn, ps, null); return result; } // 通用查询返回 ResultSet 由 DAO 层自行封装 public static ResultSet executeQuery(Connection conn, String sql, Object... params) throws Exception { PreparedStatement ps conn.prepareStatement(sql); for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeQuery(); } // 释放资源 public static void closeAll(Connection conn, PreparedStatement ps, ResultSet rs) throws Exception { if (rs ! null) rs.close(); if (ps ! null) ps.close(); if (conn ! null) conn.close(); } }这里有一个容易被忽略的点查询返回的 ResultSet 不能立刻关闭连接和 PreparedStatement因为封装 ResultSet 还需要时间。所以我一般不在 executeQuery 方法里直接 closeAll而是让 DAO 层在拿到结果、封装成对象之后再调用 closeAll 关闭。很多新手在 BaseDao 里统一关闭资源结果一跑就报ResultSet already closed这个坑我踩过。4.3 接口加实现这个习惯值得坚持虽然项目很小但我建议 DAO 和 Service 都写成接口 实现类的形式比如 UserDao 接口下有 UserDaoImpl。理由不是规范这种空话而是等你想把 JDBC 换成 MyBatis 的时候只需要重新写一套 DAO 实现类Servlet 和 Service 层完全不用动。我后面把 smbms 迁移到 MyBatis 时深刻体会到这个设计有多省事。前端 JSP 页面里取值依赖实体类的 getter/setter 规范pojo 类里字段名和页面调用名要保持一致这里踩过坑的人不少。5. 登录主链路Session、Filter 和角色控制登录模块是整个系统最先要跑的链路也是后面所有模块的入口。只要登录和拦截做好整个系统的骨架就稳了。5.1 登录流程与密码比对登录页收集 userCode 和 userPassword提交到 UserServlet 的 login 方法。Servlet 先做简单的空值校验然后调用 Service 层按 userCode 查询用户拿回 user 对象后比对密码。这里要注意如果数据库里存的是 MD5那么比对前要把用户输入密码也 MD5 化一次不能拿明文直接和密文比较。登录成功的处理也有讲究。我把 user 对象放进 session而不是只放一个 userId这样 JSP 页面里可以直接用 ${sessionScope.user.userName} 显示当前登录人也能随时通过 user.userRole 判断角色。登录失败时向前端返回错误信息转发回 login.jsp在页面用 EL 表达式回显错误内容。5.2 Filter 拦截和静态资源放行如果登录流程写完了但没有加 Filter那么只要知道接口地址不登录也能直接访问所有页面。smbms 里最常见的写法是建一个 LoginFilter作用于所有请求在 doFilter 里判断public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); if (uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.jpg) || uri.endsWith(.png) || uri.endsWith(login.jsp) || uri.endsWith(login)) { chain.doFilter(request, response); return; } HttpSession session request.getSession(false); if (session ! null session.getAttribute(user) ! null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() /login.jsp); } }静态资源放行这个细节特别容易漏如果你直接拦所有请求登录页的 css、js 全部加载不出来页面会变成光秃秃的 HTML。判断关键词可以再完整一点比如还要放行登录时的验证码接口等。在 smbms 里没有注册所以放行 login 相关的路径就够了。如果你用的是注解方式配置 Filter比如 WebFilter(/*)注意要和 Servlet 的 URL 映射区分开。Filter 拦的是请求路径Servlet 的 WebServlet 注解定义的是功能入口两者配合使用不要互相覆盖。5.3 退出登录与角色权限退出登录比较简单调用 session.invalidate() 把整个会话废掉然后重定向到 login.jsp。这里不要去手动 removeAttribute因为 session 里可能还存了分页参数、条件参数不清理干净容易残留。角色权限这块smbms 的常见实现是JSP 导航菜单里用 JSTL 判断当前登录用户的 userRole如果 roleId 是管理员显示用户管理入口普通员工则只显示订单和供应商模块。后台也需要做一个简单的校验比如删除用户这个操作如果不是管理员登录直接拒绝。虽然在教学项目里前端隐藏菜单已经够用但补上后台校验会让你对权限控制有更完整的理解。6. 列表页与分页查询用户、订单共用的一套套路看完登录接下来是整个项目里最肥的一块列表和分页。用户管理、供应商管理、订单管理都有列表页而且都支持条件查询和分页。这一套逻辑想明白了后面三个模块可以复制粘贴着写。6.1 分页参数和 Page 对象分页本身不复杂但参数多理清关系很重要。需要四个数totalCount 总记录数、pageSize 每页条数、currentPageNo 当前页、totalPageCount 总页数。int totalCount userDao.getUserCount(userName, roleId); int pageSize 5; int totalPageCount (totalCount pageSize - 1) / pageSize; int currentPageNo 1; String pageIndex request.getParameter(pageIndex); if (pageIndex ! null !.equals(pageIndex)) { currentPageNo Integer.parseInt(pageIndex); } if (currentPageNo 1) currentPageNo 1; if (currentPageNo totalPageCount totalPageCount 0) currentPageNo totalPageCount; int begin (currentPageNo - 1) * pageSize;总页数的计算公式 (totalCount pageSize - 1) / pageSize 是向上取整的意思这样 8 条数据每页 5 条算出来就是 2 页而不是 1 页。这个边界细节很多人会在写代码的时候栽一跤。我把这四个参数连同查询列表一起封装成一个 PageSupport 对象放回 request 里JSP 页面统一取 pageSupport.totalPageCount、pageSupport.currentPageNo 这样的属性。这种对象化封装的好处是不同模块的页面模板长得一样改样式只需改一处。6.2 动态 SQL 拼接与条件回显列表条件查询的核心在 DAO 层。以用户管理为例支持按姓名模糊查询、按角色筛选SQL 要动态拼接StringBuilder sql new StringBuilder( select u.*, r.roleName from smbms_user u left join smbms_role r on u.userRole r.id where 11); ListObject params new ArrayList(); if (userName ! null !.equals(userName)) { sql.append( and u.userName like ?); params.add(% userName %); } if (roleId 0) { sql.append( and u.userRole ?); params.add(roleId); } sql.append( order by u.creationDate desc limit ?, ?); params.add(begin); params.add(pageSize);where 11 这种写法老项目里很常见好处是不管有没有条件后面的 and 都能直接接上去不用费心去判断是否拼接了第一个条件。条件回显是另一个坑。翻页的时候如果只把 pageIndex 传给后端userName 和 roleId 没有传那么第二页的数据就变成了没有查询条件的全部数据。我最初的实现就是直接在超链接里拼 URL结果中文姓名在地址栏里会乱码。后来我改成在 JSP 里放一个隐藏表单分页按钮先用 JS 往隐藏字段里写入当前筛选条件再提交这样既不会乱码也保住了条件。这是一个很典型的页面上没报错数据却不对的隐蔽 bug排查起来很费神。6.3 订单模块的组合查询订单管理比用户管理多两个查询条件商品名、供应商、是否付款。逻辑和用户管理一样只是 SQL 里多几个判断if (productName ! null !.equals(productName)) { sql.append( and b.productName like ?); params.add(% productName %); } if (providerId 0) { sql.append( and b.providerId ?); params.add(providerId); } if (isPayment 0) { sql.append( and b.isPayment ?); params.add(isPayment); }订单列表还会带出供应商名称所以要用 left join smbms_provider。写到这里想提醒一句查询列表和查询总条数这两条 SQL 一定要保持一致。常见情况是列表 SQL 里加了一个条件统计总数的 SQL 忘加结果就是页面顶部显示共 20 条翻页却只有 10 条。我在复现时真真切切踩过后来把查 count 和查 list 的两个方法放在一起维护每次改条件都同步改两个方法。7. 供应商与订单的增删改那些容易漏掉的细节列表和分页打通之后剩下的就是新增、修改、删除这几个动作。这几个动作看起来是 CRUD 的机械重复但里面藏着不少不写一遍就不会注意的细节。7.1 先查关联再删除供应商数据的删除是最容易破坏数据完整性的操作。如果某个供应商下已经存在订单直接 delete provider那么订单表里这个 providerId 就变成孤数据了。所以删除前一定要先查订单表int billCount billDao.getBillCountByProviderId(providerId); if (billCount 0) { // 返回该供应商下存在订单不能删除 } else { providerDao.deleteProviderById(providerId); }这个逻辑看起来简单但它触及了一个非常本质的问题业务层面的完整性校验不能完全依赖数据库约束。外部系统、批处理脚本、管理后台都可能绕过页面直接操作数据所以在 Service 层主动做约束校验是最通用的兜底方案。7.2 修改时的 created/modify 字段网上很多版本的表单修改只更新业务字段比如用户名、电话、地址完全不碰 createdBy、modifyBy、creationDate、modifyDate。这会导致一个问题数据明明被改过了却没有任何痕迹。我建议修改操作至少做到两点更新 modifyBy 为当前登录用户 id更新 modifyDate 为当前时间新增操作则要填 createdBy 和 creationDate。这可以说是把表设计的价值真正用起来。用户表还有一个特别容易翻车的字段birthday。前端日期选择器提交的格式是 yyyy-MM-dd后端如果直接往 DATE 字段里 setString有些驱动版本会报类型转换错误。稳妥做法是在 Servlet 里先 SimpleDateFormat 解析成 java.util.Date再通过 PreparedStatement.setDate 转成 java.sql.Date 传入。字符串和日期类型的转换是 CRUD 模块里新手报错最多的地方之一。7.3 Ajax 删除订单一个值得尝试验证的前后端交互smbms 的订单删除环节很适合用来演示最简单的 Ajax。页面点击删除按钮后用 jQuery 发 POST 请求到 BillServlet 的 del 方法后端删除成功后返回字符串 success前端根据返回结果刷新页面。function deleteBill(id) { if (confirm(确定删除这条订单吗)) { $.post(${pageContext.request.contextPath}/bill.do, { method: del, id: id }, function (res) { if (res success) { location.reload(); } else { alert(res); } } ); } }对应 Servlet 端最后就是response.getWriter().write(success)。这个动作本身不难但它是前端异步请求、后端返回字符串、前端局部刷新的雏形。如果你后面要做前后端分离这个小小的 Ajax 交互就是第一个热身。我遇到的坑是Servlet 跳转和返回普通文本的边界。删除成功后如果代码里不小心写了response.sendRedirectAjax 拿到的就不是 success 而是页面内容回调函数永远走不到正确分支。所以后端在处理 Ajax 请求时只写数据不做页面跳转。8. 部署到 Tomcat从本地到浏览器首访的验证步骤代码写完最后一步是把项目部署到 Tomcat 上真正通过浏览器访问。这一步对新手来说往往比写代码更折磨因为出问题的时候控制台不一定会报错页面就给你一个 404 或 500。8.1 war 包与 IDEA 集成 Tomcat有两种部署方式可以选。第一种是直接用 Maven 构建 war 包扔到 Tomcat 的 webapps 目录下启动 Tomcat 后会自动解压部署。第二种是在 IDEA 里配置 Tomcat Servlet用 war exploded 部署开发时改代码能更快见效果。我建议先学会方式二配合 IDEA 调试断点能直接打在 Servlet 代码上排查问题效率高。具体配置路径前面已经说过关键点在于 Artifacts 要和 Web Facet 关联缺了这一步Tomcat 能启动但找不到页面。8.2 常见部署报错对照表把我在复现过程中遇到的几类高频问题整理成了一张表现象可能原因处理方式ClassNotFoundException: com.mysql.cj.jdbc.DriverMySQL 驱动没引入或版本旧检查 pom.xml 和 war 的 lib 目录Access denied for user rootlocalhost账号密码不对或未授权检查 jdbc.properties页面 404Artifact 配置缺失/context 路径不对检查 IDEA 部署配置和访问路径中文乱码缺少字符集过滤器或数据库连接串没配编码加 Filter检查 url 的 characterEncodingPort 8080 already in use端口被占用改端口或结束占用进程ResultSet already closed查询后提前关闭了连接按 BaseDao 的关闭顺序调整字符集这个问题值得展开一下。除了数据库连接串的编码参数代码里通常还会有一个字符集过滤器在 doFilter 里执行request.setCharacterEncoding(UTF-8)和response.setCharacterEncoding(UTF-8)。如果漏掉这个 Filter表单提交的中文数据会在进入 Servlet 之前就乱掉后面再怎么处理都救不回来所以它必须放在请求处理的最前面。8.3 每次启动前的小检查清单部署次数多了之后我养成了一个习惯每次启动前花十秒钟过一遍这三项。第一jdbc.properties 里的账号密码是否正确MySQL 服务有没有启动第二Tomcat 端口是否被占用第三数据库里有没有初始化数据。八成的问题都出在这三件事上。如果你访问的是 login.jsp但页面里引用的 css、js 路径全是 /css/xxx 这种绝对路径就会出现样式丢失。因为部署后项目名是 smbms正确的路径应该是 /smbms/css/xxx。最稳妥的写法是在所有 JSP 页面统一用${pageContext.request.contextPath}拼路径这样不管部署在哪个 context 下都不会错。9. 跑通之后还能怎么走往 MyBatis 与 Spring 生态迁移smbms 完全跑通以后等于你亲手徒手搭了一个 JavaWeb 应用。这时候千万别急着把它丢到一边它是最好的进化素材。9.1 替换 DAO把 JDBC 换成 MyBatis我做的第一个改造是把原生 JDBC 的 DAO 实现替换成 MyBatis。因为前面留了 DAO 接口所以 Servlet 和 Service 层几乎不用改只需要写新的 mapper 文件和实现类然后把 Service 里 new 出来的 DAO 实现类换成 MyBatis 版本。这个改造过程会让你真正理解 ORM 框架省掉的到底是什么连接管理、SQL 执行、结果集到对象的映射。写一遍之后你再去看 MyBatis 的动态 SQL会发现和你在 smbms 里手写的 if 拼接逻辑一模一样。当然迁移过程中也有几个容易翻车的点。MyBatis 的if testuserName ! null and userName ! 判断逻辑要和原 JDBC 版本保持一致比如模糊查询里是否拼接 like、% 放在哪一边这些细节不对就会查出完全不同的结果。另外分页的 limit 参数在 MyBatis 里要显式标注类型不然某些驱动会报参数类型不明确这类问题排查起来往往要靠翻日志。9.2 前后端分离与 Spring 化smbms 作为脚手架的价值再进一步可以把 JSP 全部换成纯前端页面后端只提供 JSON 接口。登录、分页、模糊查询这些功能你已经知道怎么做只是把返回格式从 JSP 页面变成 JSON 字符串。这个练习做完了Spring Boot 里最基础的 Controller 写法对你来说就没有任何神秘感。如果还不满足后面也可以把 Servlet 替换成 Spring MVC把 Service 交给 Spring 容器管理。这样一套迁移走下来你等于用 smbms 这个骨架把整个 JavaWeb 进化史重走了一遍。我后来把这次复现的代码给过几个新人参考他们问得最多的其实不是登录或者分页而是动态 SQL 拼接那段到底为什么这么写。遇到这种情况我的建议永远只有一个别急着背把拼接出来的 SQL 打印到控制台对照日志看一遍比对着屏幕猜十遍都有用。这一步做完smbms 对你就不是一个视频里跑过的项目而是真正属于你自己的第一份 JavaWeb 工程了。