第11章 综合实战:把十章拼成一个能跑的系统
难度:★★★★★ | 前置知识:第4章 HTTP、第5章 Servlet、第6章 Request 与 Response、第7章 会话技术、第8章 JSP/EL/JSTL、第9章 Filter 与 Ajax、第10章 JDBC | 本章解决:把散落在十章里的零件按正确顺序装成一台"能登录、能增删改查、能扛住乱码和未登录访问"的系统,并且学会在装错的时候怎么查
本章导读
- 前面十章,每一章都在教你造一个零件:第5章造 Servlet,第7章造 Session,第8章造 JSP 与 EL,第9章造 Filter 与 Ajax,第10章造 JDBC。零件单独拿出来都能跑,可是把它们放进同一个工程、让它们互相调用,会出现一批只在装配时才会出现的问题:包放错了、路径对不上、参数是
null、中文变成问号、页面把<c:forEach>原样打印出来、连接池被耗尽。这一章讲的就是这些"接缝"。 - 所以本章不会再把前几章的代码重抄一遍。你已经会写
selectAll(),也已经会写forward;本章关心的是:先写哪一层、后写哪一层,层与层之间传什么,某一条线断了会出现什么现象、从哪儿开始查。同样的代码,按正确顺序搭是两小时,按错误顺序搭是两天。 - 全章只有一条主线:tb_brand 品牌管理。围绕它把登录、列表、模糊查询、新增、修改、删除、注册查重串成一条完整流程。每一个功能都给出"做到什么程度算完成"的验收描述,因为工程能力的检验标准落在"能证明它能跑"上,系统能运行只是起点。
- 有一件事必须先交代清楚。原始资料里的品牌管理案例,DAO 层是用 MyBatis 写的(
BrandMapper接口 +@Select注解 +resultMap)。而本讲义第10章讲的是 JDBC,全书没有介绍过 MyBatis。为了让知识贯通、不引入没讲过的框架,本章统一改用第10章的 JDBC 写法实现 DAO 层。原文的 MyBatis 版本仍然保留:11.4 节末尾有一个【对比】块,专门讲"把BrandDao换成 MyBatis 的BrandMapper到底要改哪三处、哪些文件一行都不用动",这恰好是检验你有没有理解分层的最好题目。 - 最后提醒一句心态:这一章你会遇到大量"明明照着写了却报错"的情况。这不是你笨,这是 Web 开发的常态:一个请求要穿过浏览器、HTTP、Tomcat、过滤器链、Servlet、Service、DAO、数据库八道关卡,任何一道不对,最终表现都只是浏览器上一行白字。11.8 节给了一张 16 条的故障速查表,建议你先通读一遍再动手,能省下大量瞎试的时间。
【考点】综合题在卷子上通常这样出:给一段需求("实现品牌的新增,要求未登录不能访问、中文不能乱码"),让你写出涉及的类名、包名、调用顺序,并指出"参数校验放在哪一层""事务开在哪一层"。阅卷看的是分层意识,不是代码能不能编译。答题模板固定为四句:Web 层收参数并做格式校验 → Service 层做业务规则与事务 → DAO 层只发一条 SQL → 结果经 request 域交给 JSP 渲染。把这四句背下来,综合题至少拿七成分。
11.1 需求与验收标准
11.1.1 一句话需求
做一个带登录的品牌管理系统:管理员登录后可以查看品牌列表、按品牌名搜索、新增品牌、修改品牌、删除品牌;未登录的人访问任何业务页面都会被送回登录页;注册新用户时,用户名重复要当场提示。
这句话里有三个层次的信息,缺一不可:
| 层次 | 内容 | 缺了会怎样 |
|---|---|---|
| 功能 | 登录、列表、搜索、增、改、删、注册查重 | 系统不完整,验收过不了 |
| 约束 | 未登录不能访问业务资源 | 功能全对,但这是个人人可改数据的安全漏洞 |
| 质量 | 中文不乱码、刷新不重复提交、SQL 不拼接 | 演示时看着能跑,一用就出事 |
初学者最容易只盯第一层。但工程里出问题的,八成在第二层和第三层。
11.1.2 功能清单与验收标准
"做完了"和"做好了"是两件事。下表左半边是功能,右半边是可验收的描述,每一条都能在浏览器里当场验证,不需要看代码。
| 编号 | 功能 | 入口 | 做到什么程度算完成 |
|---|---|---|---|
| F1 | 用户登录 | login.jsp 表单 → POST /login | 输入正确用户名密码 → 跳转到品牌列表,页面右上角显示当前用户名;输错 → 留在登录页并显示"用户名或密码错误";验证码输错 → 显示"验证码错误",且同一验证码不能用第二次 |
| F2 | 登录校验 | 任意业务 URL | 清空浏览器 Cookie 后直接访问 /brand/selectAll,会被送到 login.jsp;登录成功后自动回到刚才想去的那个页面,而不是一律回首页 |
| F3 | 品牌列表 | GET /brand/selectAll | 表格列出全部品牌,序号从 1 开始连续;状态列显示"启用/禁用"而不是 0/1;一条数据都没有时显示"没有查询到品牌数据"而不是一片空白 |
| F4 | 按品牌名模糊查询 | 列表页搜索框 → GET /brand/selectAll?brandName=华 | 输入"华"能查到"华为";输入不存在的名字 → 空列表 + 提示语;搜索后输入框里还留着刚才搜的关键词;关键词里的单引号、百分号不会让 SQL 报错或越权 |
| F5 | 新增品牌 | addBrand.jsp → POST /brand/add | 提交后列表里出现新记录,且排序值大的排在前面;提交"香飘飘"入库后中文正常显示,不是问号;从新增页刷新浏览器,不会重复插入一条 |
| F6 | 修改回显 | 列表页"修改" → GET /brand/selectById?id=3 | 表单里已经填好这条记录原来的五个字段;状态单选钮停在原来的选项上;地址栏里能看到 id=3,但页面上看不到 id |
| F7 | 修改保存 | updateBrand.jsp → POST /brand/update | 改完提交,列表里那一条变成新值,其他记录不受影响;如果把隐藏域 id 删掉再提交,系统报错而不是把全表更新一遍 |
| F8 | 删除品牌 | 列表页"删除" → GET /brand/delete?id=3 | 弹确认框,点"取消"不发请求;点"确定"后该条消失、列表刷新;删除不存在的 id 不报 500,只是列表不变 |
| F9 | 注册与验证码 | register.jsp → POST /register | 验证码错误 → 注册被拒绝且已填的用户名不清空;注册成功 → 可以直接用新账号登录 |
| F10 | 注册用户名查重 | 用户名输入框失焦 → GET /checkUsername?username=zs | 输入已存在的用户名,光标移开立刻提示"用户名已存在",页面不刷新;改成没被占用的名字,提示消失 |
| F11 | 编码统一 | 所有 POST 表单 | 新增一条品牌名为"三只松鼠旗舰店"的记录,数据库里、列表页上、修改页回显里三处都是正确中文 |
| F12 | 分页(可选) | 列表页底部 | 每页 5 条,显示"共 N 条 / 第 x 页",首页时"上一页"置灰,末页时"下一页"置灰;翻页后搜索关键词不丢 |
11.1.3 明确不做什么
范围边界和技术选型一样重要。本章不做下面三件事。这三件事都重要,只是它们会把注意力从"接缝"上引开:
- 不做权限分级。系统里只有"登录/未登录"两种状态,没有管理员与普通用户之分。真实项目里这一步是
tb_user加一个role字段,再在过滤器里判断。 - 不做前端美化。CSS 只到"表格有边框、按钮排得下"的程度。第2章讲过的内容不再重复。
- 不引入框架。不用 Spring、不用 MyBatis、不用 Vue。理由在导读里说过:本章要让第5~10章的知识自行贯通,多引入一个框架,就多一个"这一章没讲过"的黑盒。
【补充】关于"可选"两个字。F12 分页被标成可选,原因不在难度。它一次会牵动三层:DAO 的 SQL 要加
limit ?, ?、Service 要多返回一个"总条数"、JSP 要多渲染一组页码链接。它是一次完整的"改一个需求、动三个层"的演练,所以 11.9 节把它列成第一个扩展任务。如果你只想拿及格,跳过它;如果你想弄懂分层的价值,它比前面十一个功能加起来都值。【易错点】 验收标准里最容易被忽略的是 F4 的最后半句:"关键词里的单引号不会让 SQL 报错或越权"。很多人做模糊查询时写成
"like '%" + brandName + "%'"拼字符串,自己在输入框里打"华"当然没事;一旦打个'进来,SQL 直接语法错误 500。这就是 SQL 注入的入口,11.8 节第 8 条专门讲。
11.2 系统设计与建库建表
11.2.1 三层架构长什么样
先把结论摆出来:MVC 是一种分层开发的模式,其中 M(Model,业务模型)负责处理业务,V(View,视图)负责界面展示,C(Controller,控制器)负责处理请求、调用模型和视图。控制器(Servlet)接收浏览器发来的请求,调用模型(JavaBean)获取数据,比如从数据库查询数据;控制器拿到数据后再交由视图(JSP)进行展示。MVC 的好处是职责单一、互不影响,有利于分工协作,也有利于组件重用。
三层架构则是把项目分成三个层面:
- 数据访问层:对数据库的 CRUD 基本操作。
- 业务逻辑层:对业务逻辑进行封装,组合数据访问层中的基本功能,形成复杂的业务逻辑功能。例如注册业务功能,会先调用数据访问层的
selectByName()方法判断该用户名是否存在,如果不存在再调用数据访问层的insert()方法进行添加。 - 表现层:接收请求,封装数据,调用业务逻辑层,响应数据。
整个流程是:浏览器发送请求,表现层的 Servlet 接收请求并调用业务逻辑层的方法进行业务逻辑处理,业务逻辑层方法调用数据访问层方法进行数据操作,结果依次返回到 Servlet,然后 Servlet 将数据交由 JSP 展示。
三层各有固定的包名:表现层用 com.itheima.controller 或 com.itheima.web,业务逻辑层用 com.itheima.service,数据访问层用 com.itheima.dao 或 com.itheima.mapper。
落到本章的工程上,再加上两个"不属于任何一层"的角色(实体类和工具类),完整的结构是这样:
┌───────────────────────────────────────────────────────────────┐
│ 浏览器 │
│ login.jsp register.jsp brand.jsp addBrand.jsp updateBrand.jsp │
└──────────────────────────┬────────────────────────────────────┘
│ HTTP 请求(自动携带 Cookie: JSESSIONID)
┌──────────────────────────▼────────────────────────────────────┐
│ Filter 链(横切,不属于三层中的任何一层) │
│ EncodingFilter:统一 UTF-8 → LoginFilter:登录校验 │
└──────────────────────────┬────────────────────────────────────┘
│ 放行
┌──────────────────────────▼────────────────────────────────────┐
│ 表现层 / Web 层 com.itheima.web │
│ BrandServlet LoginServlet RegisterServlet CheckCodeServlet │
│ UserServlet │
│ 职责:收参数、校验格式、调 Service、把结果放进 request 域、 │
│ 转发到 JSP 或重定向到另一个 Servlet │
└──────────────────────────┬────────────────────────────────────┘
│ 普通 Java 方法调用(同一个 JVM、同一个线程)
┌──────────────────────────▼────────────────────────────────────┐
│ 业务逻辑层 com.itheima.service │
│ BrandService UserService │
│ 职责:业务规则(品牌名不能重)、组合多次 DAO 调用、事务边界 │
└──────────────────────────┬────────────────────────────────────┘
│
┌──────────────────────────▼────────────────────────────────────┐
│ 数据访问层 com.itheima.dao │
│ BrandDao UserDao │
│ 职责:一条方法一条 SQL,PreparedStatement 设参,结果集转实体 │
└──────────────────────────┬────────────────────────────────────┘
│ JDBC(Connection 来自 Druid 连接池)
┌──────────────────────────▼────────────────────────────────────┐
│ MySQL db1:tb_brand、tb_user │
└───────────────────────────────────────────────────────────────┘
另外两个"公共角色",被所有层使用,自己不属于任何一层:
com.itheima.pojo Brand、User —— 各层之间传递数据的载体
com.itheima.utils JdbcUtils —— 连接池与连接获取11.2.2 包结构与每一层的职责边界
brand-demo
├── src/main/java/com/itheima
│ ├── pojo Brand.java User.java 实体类(只有字段 + getter/setter + toString)
│ ├── dao BrandDao.java UserDao.java SQL 与结果集
│ ├── service BrandService.java UserService.java 业务规则与事务
│ ├── web BrandServlet.java LoginServlet.java
│ │ RegisterServlet.java CheckCodeServlet.java
│ │ UserServlet.java
│ ├── web/filter EncodingFilter.java LoginFilter.java
│ └── utils JdbcUtils.java
├── src/main/resources
│ └── druid.properties
└── src/main/webapp
├── WEB-INF/web.xml
├── css/ imgs/ js/
├── login.jsp register.jsp
├── brand.jsp addBrand.jsp updateBrand.jsp
└── index.jsp这张表定义了"什么代码允许写在哪个包里",越界就是设计缺陷:
| 层 | 允许出现 | 绝不允许出现 |
|---|---|---|
| pojo | 字段、getter/setter、toString | SQL、HttpServletRequest、业务判断 |
| dao | SQL、PreparedStatement、结果集封装 | HttpServletRequest、session、forward |
| service | 业务规则、事务、调用多个 DAO | HttpServletRequest、JSP 路径、HTML |
| web | request/response/session、转发、重定向 | SQL、Connection、ResultSet |
| filter | 编码设置、登录判断、日志 | 业务逻辑、数据库访问 |
判断自己有没有写错层,有个很灵的土办法:看 import。如果 BrandDao.java 里出现了 import javax.servlet.http.*,那它一定越界了;如果 BrandService.java 里出现了 import java.sql.*,那它一定把 SQL 写到业务层了。
11.2.3 建库建表
数据库 db1,两张表。tb_brand 的表结构照原文原样保留:
-- 删除 tb_brand 表
drop table if exists tb_brand;
-- 创建 tb_brand 表
create table tb_brand
(
-- id 主键
id int primary key auto_increment,
-- 品牌名称
brand_name varchar(20),
-- 企业名称
company_name varchar(20),
-- 排序字段
ordered int,
-- 描述信息
description varchar(100),
-- 状态:0:禁用 1:启用
status int
);
-- 添加数据
insert into tb_brand (brand_name, company_name, ordered, description, status)
values ('三只松鼠', '三只松鼠股份有限公司', 5, '好吃不上火', 0),
('华为', '华为技术有限公司', 100, '华为致力于把数字世界带入每个人、每个家庭、每个组织,构建万物互联的智能世界', 1),
('小米', '小米科技有限公司', 50, 'are you ok', 1);tb_user 用于登录与注册。原始资料只给了 User 实体(id、username、password 三个字段)和 insert into tb_user values(null,#{username},#{password}) 这条语句,没有给建表脚本,所以下面这份 DDL 是按实体字段和插入语句的列顺序还原的,字段名与顺序与原文严格一致:
drop table if exists tb_user;
create table tb_user
(
-- id 主键
id int primary key auto_increment,
-- 用户名
username varchar(20),
-- 密码
password varchar(32)
);
insert into tb_user (username, password) values ('zhangsan', '123');注意建表时的两个设计决策,它们决定了后面代码怎么写:
brand_name用下划线,Brand的属性用驼峰。这是数据库规范与 Java 命名规范的冲突。原文用 MyBatis 时靠resultMap里的<result column="brand_name" property="brandName"/>搭桥;本章用 JDBC,桥就变成手写的brand.setBrandName(rs.getString("brand_name"))。换实现不换问题,这个问题永远在。status存int而不是varchar。0/1 在数据库里省空间、好比较,代价是页面上要翻译成"禁用/启用",这件事必须由 JSP 的<c:if>或 EL 三元运算来做。
【补充】
ordered是 MySQL 的非保留关键字(不是order),可以直接当列名用,不加反引号也能跑。但它和order by撞脸,写 SQL 时特别容易手滑写成order by ordered desc,这行是对的,不必怀疑;会出事的是把列名写成order,那才必须加反引号。另外description也是常见列名,没有冲突。
11.2.4 一个请求的完整时序
这是全章最重要的一张图。笔试让你"描述一次请求的处理过程",面试让你"说说三层架构的调用链",答案都是它。 以"点击查询所有"为例,请求在六个站点之间穿行:
①浏览器 ②Filter 链 ③Web 层 ④Service ⑤DAO ⑥MySQL
| | | | | |
| GET /brand/selectAll?brandName=华 | | |
|-------------->| | | | |
| | EncodingFilter | | | |
| | setCharacterEncoding("UTF-8") | | |
| | LoginFilter | | | |
| | session 里有 user? | | | |
| | 有 → chain.doFilter | | | |
| |------------------->| | | |
| | | getPathInfo() | | |
| | | → "selectAll" | | |
| | | getParameter() | | |
| | |----------------->| | |
| | | | 业务规则校验 | |
| | | | trim、判空 | |
| | | |---------------->| |
| | | | | getConnection()|
| | | | |--------------->|
| | | | | PreparedStatement
| | | | | executeQuery() |
| | | | |--------------->|
| | | | |<-- ResultSet --|
| | | | | 逐行 → Brand |
| | | |<-- List<Brand>--| |
| | |<-- List<Brand>---| | |
| | | request.setAttribute("brands", …) | |
| | | forward("/brand.jsp") | |
| | | ┌── JSP 翻译成 Servlet 执行 ──┐ | |
| | | │ EL 从 request 域取 brands │ | |
| | | │ c:forEach 循环输出 <tr> │ | |
| | | └── 生成 HTML 字符串 ────────┘ | |
|<-- 200 text/html ------------------| | | |
| 浏览器渲染表格 | | | |把箭头去掉,用文字复述一遍,就是一道满分简答题:
- 浏览器按 URL 发出 GET 请求,自动带上
Cookie: JSESSIONID=…(第7章)。 - 请求先到 Filter 链。
EncodingFilter设置请求与响应的字符集;LoginFilter从 Session 取user,取不到就拦截,取到就chain.doFilter()放行(第9章)。 - 进入 Web 层
BrandServlet。它从getPathInfo()拿到动作名selectAll,用getParameter()取出brandName(第6章)。 - Web 层调用 Service 层
BrandService.selectAll(brandName)。Service 做业务规则处理(本例是去空格、判空),然后调用 DAO。 - DAO 层
BrandDao.selectAll(brandName)从连接池借一个Connection,用PreparedStatement执行select ... where brand_name like ?,把ResultSet逐行封装成Brand对象,装进List,关闭资源后返回(第10章)。 - 结果沿原路返回:DAO → Service → Web 层。Web 层把
List存进request域,forward到brand.jsp(第6、8章)。 - JSP 被 Tomcat 翻译成 Servlet 执行,用
${brands}和<c:forEach>渲染表格,输出 HTML(第8章)。 - 浏览器收到 200 与 HTML,渲染成页面。这一次请求结束,request 域随之销毁。
11.2.5 MVC 与三层架构到底是什么关系
这两个概念最容易混,因为都带"三层"的味道。原文的说法是:MVC 模式中的 C(控制器)和 V(视图)就是三层架构中的表现层,而 MVC 模式中的 M(模型)就是三层架构中的业务逻辑层和数据访问层。 可以把 MVC 理解成一个大概念,三层架构是对 MVC 模式的实现架构思想。
| 对比项 | MVC 模式 | 三层架构 |
|---|---|---|
| 关注点 | 一次请求内部角色怎么分 | 整个系统按职责怎么切 |
| 组成 | Model、View、Controller | 表现层、业务逻辑层、数据访问层 |
| 映射关系 | C + V = 表现层 | M = 业务逻辑层 + 数据访问层 |
| 回答的问题 | "谁来收请求、谁来算、谁来显示" | "代码放在哪个包、依赖朝哪个方向" |
| 粒度 | 一个 Servlet + 一个 JSP 就构成一组 MVC | 覆盖整个工程的所有模块 |
【对比】两者关系可以这样记:MVC 是"怎么分工",三层架构是"怎么分层",它们是同一件事的两种说法,粒度不同。 具体到代码:
BrandServlet是 C,brand.jsp是 V,Brand+BrandService+BrandDao合起来是 M。所以"Servlet 属于表现层"和"Servlet 是 MVC 的控制器"这两句话都对,不矛盾:只是一个站在 MVC 的角度说话,一个站在三层的角度说话。【补充】 那"六层"是从哪儿来的?严格说标准术语只有三层。工程里说的"六层/八层"是运行时的站点,不是架构分层:浏览器 → 过滤器链 → Web 层 → Service 层 → DAO 层 → 数据库。多出来的"浏览器"和"过滤器链"一个是客户端、一个是横切关注点,都不算三层中的任何一层。答辩时说"三层架构,请求依次经过过滤器、Servlet、Service、DAO、数据库",既准确又显得懂行。
11.3 从零搭建:工程、依赖与部署
11.3.1 pom.xml 完整坐标
新建 Maven 工程,packaging 必须是 war,这是 Web 项目和普通 jar 工程唯一的结构差别;打错这一行,Tomcat 里根本看不到你的应用。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>brand-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>war</packaging>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!-- mysql 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.34</version>
</dependency>
<!-- servlet:Tomcat 自带,绝不能打进 war -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<!-- jsp:同样由 Tomcat 提供 -->
<dependency>
<groupId>javax.servlet.jsp</groupId>
<artifactId>jsp-api</artifactId>
<version>2.2</version>
<scope>provided</scope>
</dependency>
<!-- jstl 与 standard:必须打进 war,Tomcat 不提供 -->
<dependency>
<groupId>jstl</groupId>
<artifactId>jstl</artifactId>
<version>1.2</version>
</dependency>
<dependency>
<groupId>taglibs</groupId>
<artifactId>standard</artifactId>
<version>1.1.2</version>
</dependency>
<!-- druid 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.12</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>8080</port>
<path>/brand-demo</path>
<uriEncoding>UTF-8</uriEncoding>
</configuration>
</plugin>
</plugins>
</build>
</project>和原文的 pom 相比,本章做了两处替换:去掉 mybatis 依赖、加上 druid 依赖,其余坐标(servlet-api 3.1.0、jsp-api 2.2、jstl 1.2、standard 1.1.2、mysql 5.1.34、编译级别 8、tomcat7 插件 2.2)一字不改。
【易错点】
scope选错,是新手最常见的"部署后莫名 500"来源。servlet-api与jsp-api是 Tomcat 自己的类,必须标provided,意思是"编译时给我用,打包时别放进去"。如果漏了provided,war 包里就带上了这两个 jar,而 Tomcat 启动时会用它的类加载器先加载一份,两套HttpServlet撞在一起,报java.lang.ClassCastException或javax.servlet.ServletException: Class … is not a Servlet。反过来,jstl与standard绝不能标provided:Tomcat 不带 JSTL,一旦漏打,页面就会把<c:forEach>当普通文本原样输出(11.8 节第 9 条)。【补充】 为什么用
tomcat7-maven-plugin而不是更"新"的版本?因为tomcat8/9-maven-plugin官方从来没发布过正式版,社区里流传的都是不可靠的第三方。跑 Maven Web 项目就两条路:① 用这个插件,mvn tomcat7:run直接起服务,端口和路径都在 pom 里配;② 用 IDEA 配一个本地 Tomcat,把 artifact 部署上去。插件方式省事,本地 Tomcat 方式更接近真实环境,建议两种都试一次,因为 war 没更新、上下文路径不对这两类问题,只有用过本地 Tomcat 才会真的记住。
11.3.2 web.xml 与目录结构
如果是 Servlet 3.0 以上的容器(Tomcat 7 起),web.xml 可以只留一个版本头,因为 @WebServlet、@WebFilter 注解能代替全部配置:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<!-- 会话超时:30 分钟 -->
<session-config>
<session-timeout>30</session-timeout>
</session-config>
</web-app>但本章有两件事必须写在 web.xml 里,注解做不到:
- 过滤器顺序。
@WebFilter的执行顺序由容器扫描 class 的先后决定,规范没有保证。而"先编码、后登录校验"是硬要求(登录校验里若读参数会拿到乱码),所以顺序必须显式声明。 - 欢迎页。
index.jsp想成为默认首页,靠<welcome-file-list>。
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>com.itheima.web.filter.EncodingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 写在后面 = 后执行 -->
<filter>
<filter-name>loginFilter</filter-name>
<filter-class>com.itheima.web.filter.LoginFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>loginFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<welcome-file-list>
<welcome-file>index.jsp</welcome-file>
</welcome-file-list>注意:一旦在 web.xml 里声明了某个 Filter,就不要再给它加 @WebFilter,否则同一个过滤器会被注册两次,chain.doFilter 走两遍,日志打两次,编码设置两次(编码重复设置无害,但登录校验里的 returnUrl 会被重复编码,跳转地址就错了)。
11.3.3 部署与访问路径
| 方式 | 命令 / 操作 | 访问地址 |
|---|---|---|
| Maven 插件 | mvn tomcat7:run | http://localhost:8080/brand-demo/ |
| 本地 Tomcat | 把 brand-demo.war 丢进 webapps,启动 bin/startup.bat | http://localhost:8080/brand-demo/ |
| IDEA 配置 | Run/Debug Configurations → Tomcat Server → Deployment | 由 Application context 决定 |
上下文路径(Context Path)是这一节唯一需要记住的概念:部署目录的名字就是 URL 里的第一段。webapps/brand-demo/ 对应 /brand-demo;把 war 改名成 shop.war,地址就变成 /shop。这也解释了两个非常常见的困惑:
- 为什么 JSP 里写
/brand-demo/selectAllServlet能跑,改名成shop之后就全 404?因为路径写死了。正确写法是${pageContext.request.contextPath}/…,跟着部署名走。 - 为什么
<a href="/selectAllServlet">一定 404?因为 HTML 里以/开头是站点根(http://localhost:8080/),不是应用根(http://localhost:8080/brand-demo/)。少了/brand-demo这一段,请求打到 Tomcat 的 ROOT 应用上去了。
【易错点】
form的action和a的href都用${ctx}/xxx,而request.getRequestDispatcher("/brand.jsp")里的/必须是应用根路径(容器会自动补上下文),两种写法的语义完全不同。混着记的结果是:JSP 里漏了${ctx}全站 404,Servlet 里手贱加了${ctx}变成/brand-demo/brand-demo/brand.jsp,也是 404。结论是:浏览器发出的地址要带上下文;服务器内部的转发不要带上下文。
11.4 自底向上写:实体、DAO、Service
这里先交代本章最重要的一件事: 原始资料里的品牌管理案例,DAO 层是用 MyBatis 实现的:BrandMapper 接口上贴 @Select("select * from tb_brand"),再用 resultMap 处理 brand_name → brandName 的列名映射。本讲义第10章讲的是 JDBC,全书从未介绍 MyBatis。为了让知识贯通、不引入没讲过的框架,本章把 DAO 层统一改写成第10章的 JDBC 写法。
为什么敢改?因为三层的接口约定没有变:Service 依然调 selectAll()、add(brand)、update(brand)、deleteById(id),方法名与参数一模一样。变的只是方法体里面那一层"怎么跟数据库说话"。这正是分层的价值所在:换掉一层,其他层可以一行不改。 到底要改哪几处,11.4.5 的【对比】块给答案。
11.4.1 实体类 Brand
实体类是各层之间传递数据的载体,字段名与数据库列名一一对应(驼峰 ↔ 下划线),除此之外不写任何逻辑:
package com.itheima.pojo;
/**
* 品牌
* 在实体类中,基本数据类型建议使用其对应的包装类型
*/
public class Brand {
// id 主键
private Integer id;
// 品牌名称
private String brandName;
// 企业名称
private String companyName;
// 排序字段
private Integer ordered;
// 描述信息
private String description;
// 状态:0:禁用 1:启用
private Integer status;
public Brand() {
}
public Brand(Integer id, String brandName, String companyName, Integer ordered,
String description, Integer status) {
this.id = id;
this.brandName = brandName;
this.companyName = companyName;
this.ordered = ordered;
this.description = description;
this.status = status;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
// …… 其余 getter / setter 与 toString 略,IDEA 中 Alt+Insert 一键生成
@Override
public String toString() {
return "Brand{" +
"id=" + id +
", brandName='" + brandName + '\'' +
", companyName='" + companyName + '\'' +
", ordered=" + ordered +
", description='" + description + '\'' +
", status=" + status +
'}';
}
}两个细节值得停下来想一秒:
为什么用 Integer 而不是 int? 因为 int 的默认值是 0,Integer 的默认值是 null。当表单没提交 ordered 时,int 会让这个字段"看起来是 0",你根本发现不了数据丢了;Integer 是 null,一进 Service 判空就能拦住。用包装类型,等于给自己留了一个"没填"的信号。
为什么要有无参构造? 因为 new Brand() 之后要一个个 set。JSP 的 EL 取值、参数封装、反射创建对象,都依赖无参构造。一旦你手写了带参构造,Java 就不再赠送默认无参构造,这是新手最常见的编译错误之一:new Brand() 报 "constructor Brand in class Brand cannot be applied to given types"。所以手写带参构造时,记得把无参构造也补上。
11.4.2 连接池工具类 JdbcUtils
原文 MyBatis 版有一个 SqlSessionFactoryUtils,作用是"整个应用只创建一次 SqlSessionFactory"。JDBC 版有完全对应的角色:整个应用只创建一次 DataSource。写法几乎一样,都是静态代码块 + 静态变量:
package com.itheima.utils;
import com.alibaba.druid.pool.DruidDataSourceFactory;
import javax.sql.DataSource;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;
public class JdbcUtils {
private static DataSource dataSource;
static {
// 静态代码块随着类的加载而自动执行,且只执行一次
try {
Properties prop = new Properties();
// 用类加载器从 classpath 读,绝不能写死硬盘路径
InputStream in = JdbcUtils.class.getClassLoader()
.getResourceAsStream("druid.properties");
prop.load(in);
dataSource = DruidDataSourceFactory.createDataSource(prop);
} catch (Exception e) {
// 初始化失败必须让程序立刻停下来,否则后面全是难查的 NPE
throw new ExceptionInInitializerError(e);
}
}
public static DataSource getDataSource() {
return dataSource;
}
/**
* 从连接池借一个连接。用完必须 close(),这里的 close 是"还回池子",不是真的断开
*/
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}配置文件放在 src/main/resources/druid.properties:
driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true
username=root
password=1234
initialSize=5
maxActive=10
maxWait=3000【易错点】原文 JDBC 练习里读配置文件是这样写的:
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"))。这在 IDEA 里跑@Test单元测试没问题,因为工作目录是项目根目录;但是一放进 Web 项目就必然FileNotFoundException:Tomcat 的工作目录是bin,war 包解压后也根本没有jdbc-demo/src这个路径。放进 Web 工程后必须改成类加载器读 classpath:getClassLoader().getResourceAsStream("druid.properties")。这个错误的表现是:单元测试全绿,一部署就 500,很多人会误以为是数据库连不上。
11.4.3 BrandDao:五个方法
DAO 层的规矩只有三条:一个方法一条 SQL;参数一律用 PreparedStatement 的 ?;不碰 request、session、forward。
先给一个私有小工具 toBrand(ResultSet),把"结果集的一行 → 一个 Brand 对象"这段重复代码收拢。这是 JDBC 版里最值钱的一个重构,五个方法都要用它:
package com.itheima.dao;
import com.itheima.pojo.Brand;
import com.itheima.utils.JdbcUtils;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class BrandDao {
/** 结果集的一行 → 一个 Brand 对象 */
private Brand toBrand(ResultSet rs) throws SQLException {
Brand brand = new Brand();
brand.setId(rs.getInt("id"));
brand.setBrandName(rs.getString("brand_name"));
brand.setCompanyName(rs.getString("company_name"));
brand.setOrdered(rs.getInt("ordered"));
brand.setDescription(rs.getString("description"));
brand.setStatus(rs.getInt("status"));
return brand;
}方法一:查询所有(兼模糊查询)
/**
* 查询所有;brandName 为 null 或空串时查全部,否则按品牌名模糊查询
*/
public List<Brand> selectAll(String brandName) {
StringBuilder sql = new StringBuilder("select * from tb_brand where 1 = 1 ");
List<Object> params = new ArrayList<>();
if (brandName != null && !brandName.trim().isEmpty()) {
sql.append("and brand_name like ? ");
params.add("%" + brandName.trim() + "%");
}
sql.append("order by ordered desc");
List<Brand> brands = new ArrayList<>();
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
pstmt.setObject(i + 1, params.get(i));
}
try (ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
brands.add(toBrand(rs));
}
}
} catch (SQLException e) {
throw new RuntimeException("查询品牌列表失败", e);
}
return brands;
}三个设计点,每一个都能单独出题:
where 1 = 1是拼 SQL 的标准做法。没有它,条件就得靠if判断"是不是第一个条件"来决定拼where还是and,代码立刻变丑且容易错。加上之后,所有条件统一用and开头。- 模糊查询的
%放在参数里,不放在 SQL 里。写成like ?然后params.add("%" + name + "%"),%就变成了参数值的一部分,不会被当成 SQL 语法。反过来写成"like '%" + name + "%'"就是拼串,用户输入' or '1'='1就能把整张表拖走。这两种写法在功能上等价,在安全性上是天壤之别,而代码只差两个引号的位置。 try-with-resources保证关闭。Connection、PreparedStatement、ResultSet都实现了AutoCloseable,try (...)括号里的资源会在代码块结束时自动close(),异常路径也不会漏。这是替代原文"手写五个close()"的现代写法,效果相同但不会忘。注意conn.close()对连接池来说是"归还",不是断开连接。这一句不写,连接池迟早被耗尽(11.8 节第 14 条)。
方法二:按 id 查(回显用)
/**
* 根据 id 查询
*/
public Brand selectById(int id) {
String sql = "select * from tb_brand where id = ?";
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
try (ResultSet rs = pstmt.executeQuery()) {
// 主键查询最多一行,用 if 而不是 while
return rs.next() ? toBrand(rs) : null;
}
} catch (SQLException e) {
throw new RuntimeException("按 id 查询品牌失败", e);
}
}注意返回 null 而不是抛异常:"查不到"是正常业务结果,不是错误。到底把它当异常还是当空值,交给 Service 决定。
方法三:新增
/**
* 添加,返回受影响的行数
*/
public int add(Brand brand) {
String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) "
+ "values(?,?,?,?,?)";
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, brand.getBrandName());
pstmt.setString(2, brand.getCompanyName());
pstmt.setInt(3, brand.getOrdered());
pstmt.setString(4, brand.getDescription());
pstmt.setInt(5, brand.getStatus());
return pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("添加品牌失败", e);
}
}不要写 insert into tb_brand values(null,?,?,?,?,?)。原文 MyBatis 版就是这种写法,因为 MyBatis 的 null 会被当成字面量处理,能跑通;但是 JDBC 的 PreparedStatement 里,values(null, …) 是给"第 1 个列"显式插入 SQL 的 NULL,一旦将来表结构加了一列,这条 SQL 立刻错位报 Column count doesn't match value count。把列名写全,是给自己留后路。
方法四:修改
/**
* 修改,返回受影响的行数
*/
public int update(Brand brand) {
String sql = "update tb_brand set brand_name = ?, company_name = ?, ordered = ?, "
+ "description = ?, status = ? where id = ?";
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, brand.getBrandName());
pstmt.setString(2, brand.getCompanyName());
pstmt.setInt(3, brand.getOrdered());
pstmt.setString(4, brand.getDescription());
pstmt.setInt(5, brand.getStatus());
pstmt.setInt(6, brand.getId());
return pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("修改品牌失败", e);
}
}where id = ? 是这条 SQL 的命门。 写漏了,就是把整张表的所有行改成同一份数据,而且 executeUpdate() 会欢快地返回"影响 3 行",不报任何错。11.8 节第 11 条讲的就是这个场景。
方法五:删除
/**
* 根据 id 删除,返回受影响的行数
*/
public int deleteById(int id) {
String sql = "delete from tb_brand where id = ?";
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
return pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("删除品牌失败", e);
}
}本章还额外需要一个方法:按品牌名精确查,供"新增时重名校验"和"注册时用户名查重"使用:
/**
* 根据品牌名精确查询,用于重名校验
*/
public Brand selectByBrandName(String brandName) {
String sql = "select * from tb_brand where brand_name = ?";
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, brandName);
try (ResultSet rs = pstmt.executeQuery()) {
return rs.next() ? toBrand(rs) : null;
}
} catch (SQLException e) {
throw new RuntimeException("按品牌名查询失败", e);
}
}
}【易错点】本章 JDBC 版最隐蔽的一处错误:
rs.getString("brandName")会抛java.sql.SQLException: Column 'brandName' not found。因为getString的参数是数据库的列名,不是 Java 的属性名,必须写brand_name。而在原文的 MyBatis 版里,同样的错误表现成"字段查出来全是null",因为 MyBatis 按属性名去匹配列名,匹配不上就静默给null。同一个设计问题(列名与属性名不一致),在两个框架里的报错方式完全不同:JDBC 立刻炸,MyBatis 悄悄错。所以原文才要写resultMap。这也是为什么"看懂报错"比"背下代码"重要。
11.4.4 BrandService:业务层为什么必须存在
先看代码,本章的 BrandService 只有薄薄一层:
package com.itheima.service;
import com.itheima.dao.BrandDao;
import com.itheima.pojo.Brand;
import java.util.List;
public class BrandService {
private BrandDao dao = new BrandDao();
public List<Brand> selectAll(String brandName) {
// 业务规则:不管调用方传什么,Service 保证交给 DAO 的是"干净"的参数
if (brandName != null) {
brandName = brandName.trim();
if (brandName.isEmpty()) {
brandName = null;
}
}
return dao.selectAll(brandName);
}
public Brand selectById(int id) {
if (id <= 0) {
throw new IllegalArgumentException("品牌 id 不合法:" + id);
}
return dao.selectById(id);
}
/**
* 新增。业务规则:品牌名不能为空、不能重名
*/
public void add(Brand brand) {
if (brand.getBrandName() == null || brand.getBrandName().trim().isEmpty()) {
throw new IllegalArgumentException("品牌名称不能为空");
}
brand.setBrandName(brand.getBrandName().trim());
// 组合两次 DAO 调用:先查重,再插入
if (dao.selectByBrandName(brand.getBrandName()) != null) {
throw new IllegalArgumentException("品牌名称已存在:" + brand.getBrandName());
}
dao.add(brand);
}
public void update(Brand brand) {
if (brand.getId() == null) {
throw new IllegalArgumentException("修改时必须携带 id");
}
dao.update(brand);
}
public void deleteById(int id) {
if (id <= 0) {
throw new IllegalArgumentException("品牌 id 不合法:" + id);
}
dao.deleteById(id);
}
}看到这里,很多人心里会冒出一个非常合理的问题:这一层几乎什么都没干,就是把参数转手给 DAO,它到底是不是多余的?
答案是:在"只查询"这个动作上,它确实只做了转发;但"只做转发"不等于"没有价值",它的价值是给未来的变化留了一个位置。 理由有四条,一条比一条硬:
第一,它承载业务规则,而业务规则天然不属于 DAO。 看 add():先判空、再查重、最后插入。这三步是"业务",DAO 只负责"一条 SQL",它没有能力表达"先查重再插入"这种跨两次调用的规则。注册业务是同一个形状,原文说得很清楚:"例如注册业务功能,我们会先调用数据访问层的 selectByName() 方法判断该用户名是否存在,如果不存在再调用数据访问层的 insert() 方法进行数据的添加操作"。这段"如果……再……"就是 Service 存在的第一个理由。
第二,它是事务的边界。 一次业务操作如果需要"要么全成、要么全不成",这个"全"必须由 Service 划定,因为只有 Service 知道这次操作包含哪几个 DAO 调用。DAO 层没有资格开事务,它连"这次操作要改几张表"都不知道。这一条是 11.4.5 的主题。
第三,它让 Web 层不依赖 JDBC。 BrandServlet 里只有 service.selectAll(...),没有 Connection、没有 SQLException。将来把 DAO 换成 MyBatis、换成 JPA、换成远程接口,Servlet 一行不用改。反过来,如果 Servlet 直接 new BrandDao(),那 Web 层就同时依赖了"HTTP"和"JDBC"两套完全无关的技术,任何一个变动都要动它。
第四,它给多个入口复用。 今天只有 Servlet 调 add;明天要加一个定时任务导入品牌、加一个单元测试、加一个命令行工具,它们都调 Service 就够了。如果把业务写在 Servlet 里,这些入口一个都用不上。Servlet 是绑在 HTTP 上的,没有请求就没有 Servlet。
【补充】那一层薄薄的 Service 什么时候会"变厚"?给你三个必然会遇到的场景,遇到一个它就不薄了:
① 业务规则变多:删除品牌前要检查"该品牌下有没有商品",有就拒绝。这条规则需要查另一张表,只能在 Service 写。
② 一次操作要动多张表:新增品牌的同时写一条操作日志到
tb_log。两次insert必须在同一个事务里,只能在 Service 开事务。③ 多个 DAO 协同:注册时"查重 + 插入",下单时"扣库存 + 建订单 + 减余额"。这些都是 Service 的活儿。
反过来说,如果一个 Service 方法从头到尾只有一行
return dao.xxx(),而且你确信这个业务永远不会长出上面三种需求,那它确实"暂时"是转发。但是"现在有没有用"不是工程上的判断标准,判断标准是"改动会落在哪一层"。 留一层空壳的成本是一个文件,拆掉它的成本是整个 Web 层重写。【考点】 "Service 层是不是多余"是面试高频题,标准答案三句话:Service 承载业务规则(跨多次 DAO 调用的判断),划定事务边界,隔离Web 层与数据访问技术。再补一句加分:分层追求的是将来改动只落在一层,当下少写代码只是附带结果。
11.4.5 事务应该开在哪一层
答案是:Service 层。而且只能是 Service 层。
先把 JDBC 的事务模型摆清楚。一个事务 = 一个 Connection 上的一组操作,加上 setAutoCommit(false) / commit() / rollback() 三件套:
Connection conn = JdbcUtils.getConnection();
try {
conn.setAutoCommit(false); // 关闭自动提交,事务开始
dao.addBrand(brand, conn); // 两次操作必须用同一个 Connection
dao.addLog("新增品牌", conn);
conn.commit(); // 一起成功
} catch (Exception e) {
conn.rollback(); // 一起失败
throw new RuntimeException("新增品牌失败", e);
} finally {
conn.setAutoCommit(true); // 恢复,再还给连接池
conn.close();
}这段代码暴露了一个必须解决的技术难题:两次 DAO 调用必须共用同一个 Connection。 而 11.4.3 里我们每个 DAO 方法自己 JdbcUtils.getConnection(),那是两个不同的连接,各自自动提交,根本不在一个事务里。commit() 和 rollback() 只对当前连接有效,管不到另一个连接。
解决办法有两条,考试答第一条就够:
- 把
Connection当参数从 Service 传给 DAO(也叫"事务下沉")。DAO 增加一组重载方法:add(Brand brand, Connection conn),内部不再自己取连接、也不关闭连接,关闭的责任归调用方。这是最直白、最容易讲清楚的做法。 - 用
ThreadLocal把连接绑到当前线程。过滤器或 Service 入口把连接放进ThreadLocal,JdbcUtils.getConnection()改成"先看当前线程有没有,有就复用,没有才从池里借",close()改成"不真关,只从 ThreadLocal 移除"。这就是各种框架(包括 MyBatis 的SqlSession)在背后干的事。原理一样,只是把"传参"换成了"隐式共享"。
为什么不能开在 DAO 层? 因为 DAO 只知道自己那一条 SQL。它不知道这次业务还包含别的操作,所以它无从判断"什么时候该提交";每执行完一条就提交,等于没有事务。为什么不能开在 Web 层? 因为 Web 层不应该碰 Connection,那是数据访问技术;一旦碰了,Servlet 就和 JDBC 焊死了,分层白做。只有 Service 同时知道"这次业务包含哪几步"和"不关心 HTTP",所以事务边界只能在 Service。
【补充】本章的
add()有必要开事务,尽管它只有一次写操作。因为它先selectByBrandName查重、再insert,这是典型的"检查后执行"(check-then-act)。两个用户同时提交同一个品牌名,可能都查到"不存在",然后都插入成功,数据库里出现两条重名记录。要杜绝这个竞态,有两条路:① 在tb_brand.brand_name上加唯一索引,让数据库兜底,插入第二条时抛Duplicate entry;② 把"查重 + 插入"放进一个事务并加锁。工程上首选第①条:数据库约束是最可靠的锁,Service 里的查重只是为了给用户一个友好提示,不能当作唯一防线。这句话请记牢:业务校验为了体验,数据库约束为了保证。
11.4.6 【对比】把 BrandDao 换成 MyBatis,只改三处
这是本章最值钱的一道题,因为它直接检验"你有没有真的理解分层"。原文的品牌管理案例用的就是 MyBatis,现在把两种实现并排放:
| 位置 | JDBC 版(本章) | MyBatis 版(原文) |
|---|---|---|
| DAO 形态 | class BrandDao,每个方法手写 SQL 与设参 | interface BrandMapper,方法上贴 @Select/@Insert 注解 |
| 获取"执行器" | JdbcUtils.getConnection() | sqlSession.getMapper(BrandMapper.class) |
| 列名映射 | 手写 rs.getString("brand_name") | resultMap 声明 brand_name → brandName |
| 提交事务 | conn.setAutoCommit(false) + commit() | sqlSession.commit() |
| 资源释放 | conn.close()(还池) | sqlSession.close() |
| 包名约定 | com.itheima.dao | com.itheima.mapper |
结论先给:只改三处,Servlet、JSP、Filter、表结构、实体类一行都不用动。
第一处:DAO 层本身。 删掉 BrandDao 类,换成接口 BrandMapper:
package com.itheima.mapper;
import com.itheima.pojo.Brand;
import org.apache.ibatis.annotations.*;
import java.util.List;
public interface BrandMapper {
@Select("select * from tb_brand")
@ResultMap("brandResultMap")
List<Brand> selectAll();
@Select("select * from tb_brand where id = #{id}")
@ResultMap("brandResultMap")
Brand selectById(int id);
@Insert("insert into tb_brand values(null,#{brandName},#{companyName},#{ordered},#{description},#{status})")
void add(Brand brand);
@Update("update tb_brand set brand_name = #{brandName}, company_name = #{companyName}, " +
"ordered = #{ordered}, description = #{description}, status = #{status} where id = #{id}")
void update(Brand brand);
@Delete("delete from tb_brand where id = #{id}")
void deleteById(int id);
}第二处:Service 层"怎么拿到 DAO"。 private BrandDao dao = new BrandDao(); 变成"从 SqlSession 拿 Mapper",并且增删改之后必须 commit()、用完必须 close():
public class BrandService {
// 原来是 private BrandDao dao = new BrandDao();
private SqlSessionFactory factory = SqlSessionFactoryUtils.getSqlSessionFactory();
public List<Brand> selectAll() {
SqlSession sqlSession = factory.openSession();
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
List<Brand> brands = mapper.selectAll();
sqlSession.close();
return brands;
}
public void add(Brand brand) {
SqlSession sqlSession = factory.openSession();
BrandMapper mapper = sqlSession.getMapper(BrandMapper.class);
mapper.add(brand);
sqlSession.commit(); // 忘了这一句,数据不会进库
sqlSession.close();
}
}第三处:配置与映射文件。 原来 druid.properties 里的四个连接参数,搬进 mybatis-config.xml 的 <dataSource>;再加一个 BrandMapper.xml 放 resultMap,并在 <mappers> 里扫描 com.itheima.mapper 包。
<!-- 片段:BrandMapper.xml -->
<mapper namespace="com.itheima.mapper.BrandMapper">
<resultMap id="brandResultMap" type="brand">
<result column="brand_name" property="brandName"></result>
<result column="company_name" property="companyName"></result>
</resultMap>
</mapper>为什么是这三处?因为它们全是"数据访问技术"的产物:SQL 怎么写、执行器怎么拿、列名怎么映射、事务怎么提交,这四件事通通属于 DAO 层及其配套设施。而 Servlet 只知道"调 service.selectAll() 拿一个 List<Brand>",JSP 只知道"从 brands 里取 brandName",它们眼里根本没有 JDBC 或 MyBatis。这就是 11.4.4 里说的"隔离 Web 层与数据访问技术",现在你看到它兑现了。
【易错点】换成 MyBatis 后有两个高频翻车点。① 增删改忘了
commit():openSession()默认不自动提交,代码不报错、控制台也没异常,就是数据库里没数据,比报错难查十倍。② 忘了配resultMap:列表页能显示,但是brandName和companyName两列全是空白,ordered、description、status却正常。因为后三个列名和属性名恰好一致,前两个不一致。原文专门为这个问题补了resultMap,还提到"查询到的字段名和实体类对象的属性名没有一一对应"。记住这个现象:部分列空白、部分列正常 = 列名与属性名不一致。
11.5 请求进来:BrandServlet
11.5.1 一个 Servlet 还是五个
原文的做法是一个功能一个 Servlet:SelectAllServlet、AddServlet、SelectByIdServlet、UpdateServlet,各自 @WebServlet("/xxxServlet")。这种写法最好理解,缺点是类越来越多,一个品牌管理就有四个,再加用户管理、订单管理,web 包会膨胀到几十个文件。
工程里更常见的做法是一个模块一个 Servlet,用"动作名"分发。本章用 URL 路径做动作名:/brand/selectAll、/brand/add、/brand/update、/brand/delete、/brand/selectById。
| 分发方式 | 映射 | 请求长什么样 | 优点 | 缺点 |
|---|---|---|---|---|
| 一功能一 Servlet(原文) | @WebServlet("/addServlet") | /addServlet | 最简单,职责绝对单一 | 类爆炸,公共代码重复 |
| 路径分发(本章) | @WebServlet("/brand/*") | /brand/add | URL 有语义、按模块聚合 | 要自己解析 getPathInfo() |
| 参数分发 | @WebServlet("/brand") | /brand?method=add | 不用解析路径 | 所有动作共用一个 URL,无法做 URL 级权限 |
参数分发的结构长这样,可以和路径分发对照着看:
@WebServlet("/brand")
public class BrandServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
String method = request.getParameter("method"); // selectAll / add / update / delete
switch (method) {
case "selectAll": /* … */ break;
// …
}
}
}两种都能用,本章选路径分发,原因是它更符合 REST 的直觉,而且过滤器里按 URL 前缀做权限控制时更自然。
11.5.2 BrandServlet 完整代码
package com.itheima.web;
import com.itheima.pojo.Brand;
import com.itheima.service.BrandService;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.io.IOException;
import java.util.List;
@WebServlet("/brand/*")
public class BrandServlet extends HttpServlet {
private BrandService service = new BrandService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 1. 取出动作名:请求 /brand/selectAll 时 pathInfo 是 "/selectAll"
String pathInfo = request.getPathInfo();
String action = (pathInfo == null || "/".equals(pathInfo))
? "selectAll" // 直接访问 /brand 或 /brand/ 时默认查全部
: pathInfo.substring(1); // 去掉开头的 "/"
// 2. 按动作名分发
switch (action) {
case "selectAll":
selectAll(request, response);
break;
case "selectById":
selectById(request, response);
break;
case "add":
add(request, response);
break;
case "update":
update(request, response);
break;
case "delete":
delete(request, response);
break;
default:
response.sendError(HttpServletResponse.SC_NOT_FOUND, "未知操作:" + action);
}
}
/** 查询所有 / 模糊查询 */
private void selectAll(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String brandName = request.getParameter("brandName");
List<Brand> brands = service.selectAll(brandName);
// 数据放 request 域,key 必须和 JSP 里 ${brands} 一致
request.setAttribute("brands", brands);
// 把关键词也回传,搜索框才能留住刚才输入的内容
request.setAttribute("brandName", brandName);
request.getRequestDispatcher("/brand.jsp").forward(request, response);
}
/** 按 id 查询,用于修改页回显 */
private void selectById(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
int id = Integer.parseInt(request.getParameter("id"));
Brand brand = service.selectById(id);
if (brand == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "品牌不存在,id=" + id);
return;
}
request.setAttribute("brand", brand);
request.getRequestDispatcher("/updateBrand.jsp").forward(request, response);
}
/** 新增 */
private void add(HttpServletRequest request, HttpServletResponse response)
throws IOException {
Brand brand = new Brand();
brand.setBrandName(request.getParameter("brandName"));
brand.setCompanyName(request.getParameter("companyName"));
brand.setOrdered(Integer.parseInt(request.getParameter("ordered")));
brand.setDescription(request.getParameter("description"));
brand.setStatus(Integer.parseInt(request.getParameter("status")));
service.add(brand);
// 重定向回列表:浏览器地址栏变成 /brand/selectAll,刷新不会重复提交
response.sendRedirect(request.getContextPath() + "/brand/selectAll");
}
/** 修改保存 */
private void update(HttpServletRequest request, HttpServletResponse response)
throws IOException {
Brand brand = new Brand();
brand.setId(Integer.parseInt(request.getParameter("id"))); // 隐藏域提交上来的
brand.setBrandName(request.getParameter("brandName"));
brand.setCompanyName(request.getParameter("companyName"));
brand.setOrdered(Integer.parseInt(request.getParameter("ordered")));
brand.setDescription(request.getParameter("description"));
brand.setStatus(Integer.parseInt(request.getParameter("status")));
service.update(brand);
response.sendRedirect(request.getContextPath() + "/brand/selectAll");
}
/** 删除 */
private void delete(HttpServletRequest request, HttpServletResponse response)
throws IOException {
int id = Integer.parseInt(request.getParameter("id"));
service.deleteById(id);
response.sendRedirect(request.getContextPath() + "/brand/selectAll");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}这段代码里有五个"接缝",每一个都对应前几章的一个知识点:
| 代码 | 来自第几章 | 说明 |
|---|---|---|
@WebServlet("/brand/*") | 第5章 | 通配路径,* 部分靠 getPathInfo() 取 |
request.getParameter(...) | 第6章 | 表单字段名必须与 name 属性完全一致 |
request.setAttribute("brands", …) | 第6章 | request 域,只在一次请求内有效 |
forward(...) / sendRedirect(...) | 第6章 | 转发与重定向的选择,下面专门讲 |
request.getContextPath() | 第6章 | 拿上下文路径,避免写死 /brand-demo |
这段代码为了把主线看清楚,省掉了 try-catch:Integer.parseInt 可能抛 NumberFormatException,service.add 可能抛业务异常。真实项目里要在这五个分支外面包一层 try-catch,把异常信息放进 request 域转发回表单页,或者 sendRedirect 带一个 msg 参数回列表页。省略它不影响"接缝"的理解,但是省略它绝对上不了线,这正是 11.5.5 那张表最后一列的意思。
11.5.3 转发还是重定向:新增之后为什么必须重定向
原文的写法是:新增成功后 request.getRequestDispatcher("/selectAllServlet").forward(request, response),转发到"查询所有"的 Servlet,让它查完再转发给 JSP。这段代码能跑,效果也对:提交完立刻看到新数据。
但它有一个副作用:转发之后,浏览器地址栏仍然是 POST /brand/add。 用户此时按 F5 刷新,浏览器会问"是否重新提交表单",点确定就又插入了一条一模一样的数据。用户不知情,数据就重了。
标准解法叫 PRG 模式(Post-Redirect-Get):表单用 POST 提交,处理完用重定向让浏览器再发一个 GET 去取列表。
原文(转发): POST /brand/add → 内部转发 → 200 列表页 地址栏仍是 /brand/add
PRG(本章): POST /brand/add → 302 重定向 → GET /brand/selectAll → 200 列表页区别就在于中间那一步 302:它让浏览器把"当前页面"换成了 GET /brand/selectAll,刷新就是重新 GET,不会重复提交。
| 对比项 | forward 转发 | sendRedirect 重定向 |
|---|---|---|
| 请求次数 | 1 次(服务器内部转) | 2 次(浏览器重新发) |
| 地址栏 | 不变 | 变成新地址 |
| request 域 | 数据还在 | 数据丢失(新请求) |
| 能否访问外部网站 | 不能 | 能 |
| 状态码 | 200 | 302 + Location |
| 适合场景 | 查询后展示数据 | 增删改之后 |
【易错点】重定向会丢 request 域,所以不能"在 add 里
setAttribute再重定向到 brand.jsp",页面会是空的。正确姿势是重定向到另一个 Servlet,由它重新查库、重新setAttribute。这也是为什么本章五个分支里有三个都以sendRedirect(... + "/brand/selectAll")结尾:它们的目的是"让列表自己去显示":数据由目标 Servlet 重新查出来。【对比】 那查询为什么用转发?因为查询本身就要把
List交给 JSP,转发能让 request 域里的数据直接过去,一次请求搞定。而增删改没有数据要传,它只是"改变状态",改完就该让浏览器去重新取一份最新列表。判断标准是:这次操作有没有数据要交给页面?有就转发,没有就重定向。
11.5.4 Servlet 里该不该写 SQL
不该。这道题在答辩和面试里出现频率极高,答案要分三层说:
第一层,从职责上讲。 Servlet 的职责是"处理 HTTP":收参数、校验格式、调 Service、决定跳哪儿。SQL 的职责是"操作数据"。把 SQL 写进 Servlet,等于让一个类同时依赖 HttpServletRequest 和 Connection,两个毫不相干的技术被焊在一起,将来换 Web 框架要改它,换数据库也要改它。
第二层,从成本上讲。 写进 Servlet 的 SQL 只能被这一个 Servlet 使用。selectAll 这个查询,登录后的首页要用、导出 Excel 要用、手机端接口也要用,写在 Servlet 里就只能复制三份,改一处漏两处。
第三层,从可测试性上讲。 DAO 层可以脱离 Tomcat 单独写 main 或 @Test 跑,因为它的入参是 int、String、Brand,不需要 request。SQL 一旦写进 Servlet,你就必须启动 Tomcat、打开浏览器、点按钮才能验证一句 SQL 对不对,调试成本差一个数量级。
【考点】"Servlet 里能不能直接写 JDBC?"标准答法:技术上能,设计上不能。 会导致三层架构被架空、代码无法复用、无法单元测试、Web 层与数据访问技术耦合。补一句加分的:判断越界的土办法是看 import:
web包里出现java.sql.*就错了。
11.5.5 请求参数校验放在哪一层
这是"接缝"里最容易吵起来的一个问题。正确答案是三层各管一段:
| 层 | 校验什么 | 例子 | 校验失败怎么办 |
|---|---|---|---|
| Web 层 | 格式:非空、是不是数字、长度、范围 | ordered 是不是空?id 是不是正整数?brandName 超过 20 个字? | 直接把错误信息放 request 域,转发回表单页,让用户重填 |
| Service 层 | 业务规则:唯一性、状态合法性、前置条件 | 品牌名是否已存在?这个品牌能不能删? | 抛 IllegalArgumentException 或自定义异常,由 Web 层捕获后提示 |
| DAO 层 | 不校验 | 无 | 无 |
| 数据库 | 兜底:约束 | not null、unique、varchar(20) | 抛 SQLException,Service 转成业务异常 |
为什么格式校验必须放 Web 层?因为只有 Web 层知道"用户看到的是什么"。参数是从哪个表单来的、哪个输入框该标红、错误提示放在哪个 div 里,这些信息 Service 一概不知。Service 抛一个"品牌名称不能为空",它不知道该把用户送回 addBrand.jsp 还是 updateBrand.jsp。
为什么业务规则必须放 Service?因为只有 Service 知道"这条规则属于业务"。"品牌名不能重"不是格式问题:"华为" 这个字符串格式完全合法;它也不是 SQL 问题,SQL 只管插不插得进去。它是业务规则,必须由 Service 持有。而且这条规则可能被多个入口触发(网页新增、批量导入、接口调用),放 Service 才能一处生效。
为什么数据库还要再来一遍?因为前两层都可能被绕过。 有人直接连数据库、有人写脚本、有人并发提交。业务校验为了体验,数据库约束为了保证。这句话在 11.4.5 里说过一次,这里是它的第二次应用。
【易错点】最典型的错误是"只在 Web 层判空,Service 完全信任调用方"。一旦将来加一个"批量导入"的入口,它直接调
service.add(),传进来一个null品牌名,Service 会欢快地把null插进数据库,直到数据库的not null约束报错,而那时错误信息已经变成SQLException,用户看到的是 500。每一层都要假设"调用方可能给我脏数据"。
11.6 页面出去:三个 JSP
11.6.1 brand.jsp:列表页
原文的做法是把静态的 brand.html 拷进 webapp,改名为 brand.jsp,然后在表格里用 JSTL 和 EL 从 request 域取 brands 集合展示。本章在这个基础上加了搜索框和删除确认:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>品牌管理</title>
</head>
<body>
<h3>品牌管理</h3>
<%-- 搜索区:用 GET,刷新页面只是重新查一次,不会重复提交 --%>
<form action="${ctx}/brand/selectAll" method="get">
品牌名称:<input name="brandName" value="${brandName}">
<input type="submit" value="查询">
<input type="button" value="新增" id="add">
</form>
<hr>
<table border="1" cellspacing="0" width="90%">
<tr>
<th>序号</th>
<th>品牌名称</th>
<th>企业名称</th>
<th>排序</th>
<th>品牌介绍</th>
<th>状态</th>
<th>操作</th>
</tr>
<c:forEach items="${brands}" var="brand" varStatus="status">
<tr align="center">
<td>${status.count}</td>
<td>${brand.brandName}</td>
<td>${brand.companyName}</td>
<td>${brand.ordered}</td>
<td>${brand.description}</td>
<td>
<c:if test="${brand.status == 1}">启用</c:if>
<c:if test="${brand.status != 1}">禁用</c:if>
</td>
<td>
<a href="${ctx}/brand/selectById?id=${brand.id}">修改</a>
<a href="${ctx}/brand/delete?id=${brand.id}"
onclick="return confirm('确定删除【${brand.brandName}】吗?')">删除</a>
</td>
</tr>
</c:forEach>
</table>
<c:if test="${empty brands}">
<p>没有查询到品牌数据。</p>
</c:if>
<script>
document.getElementById("add").onclick = function () {
location.href = "${ctx}/addBrand.jsp";
};
</script>
</body>
</html>三处值得单独说:
${status.count} 而不是 ${brand.id}。 原文在表格第一列用的是 status.count,并把 ${brand.id} 注释掉了。为什么?因为 id 是数据库自增主键,删过数据之后会断号(1、2、5、9),显示出来像"漏了数据",用户会以为系统出错。varStatus 的 count 是从 1 开始的当前序号,index 是从 0 开始的下标,两个都好用,看你从哪儿数。
两个 <c:if> 放在同一个 <td> 里。 原文是两个 <c:if> 各自包一个 <td>:
<c:if test="${brand.status == 1}">
<td>启用</td>
</c:if>
<c:if test="${brand.status != 1}">
<td>禁用</td>
</c:if>这段能跑,因为同一时刻只有一个 <c:if> 成立。但它的结构很脆:一旦哪天逻辑写错、两个条件同时成立,这一行就会多出一列,整个表格错位。把"变与不变"分开:<td> 是结构(永远存在一个),"启用/禁用"是内容(可能变),所以 <td> 放外面、<c:if> 放里面,这才是正确的嵌套顺序。这条经验适用于所有 JSP 循环里的条件渲染。
删除的确认框用 onclick="return confirm(...)"。 confirm 返回 true 或 false,return 把结果交给浏览器:false 就取消跳转。少写 return 的话,浏览器会先跳转再弹框,等于没确认。这也是个经典错误。
11.6.2 addBrand.jsp:新增页
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>添加品牌</title>
</head>
<body>
<h3>添加品牌</h3>
<form action="${ctx}/brand/add" method="post">
品牌名称:<input name="brandName" required><br>
企业名称:<input name="companyName" required><br>
排序:<input name="ordered" type="number" value="0"><br>
描述信息:<textarea rows="5" cols="20" name="description"></textarea><br>
状态:
<input type="radio" name="status" value="0" checked>禁用
<input type="radio" name="status" value="1">启用<br>
<input type="submit" value="提交">
</form>
</body>
</html>name 属性是这张页面上最重要的东西。它必须和 request.getParameter("…") 里的字符串一字不差,因为 getParameter 拿的就是 name。写成 brandname(小写 n)、brand_name(下划线)、brandName (多一个空格),Servlet 那边都是 null,Integer.parseInt(null) 直接 500。这是"表单没反应"类故障的第一嫌疑人。
required 和 type="number" 是 HTML5 的浏览器端校验,能挡住大部分空值和非法输入,但它不能替代后端校验。把 HTML 存成本地文件、手动改一下 action,或者直接用 Postman 发请求,这些校验全部失效。它的定位是"提升体验",不是"保证安全"。
11.6.3 updateBrand.jsp:回显页
回显是这个案例里最像"魔法"的部分,原理很朴素:点"修改"时先带着 id 请求后端,后端查出来存进 request 域,再转发到修改页,页面用 EL 把值填进 value 属性。
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<c:set var="ctx" value="${pageContext.request.contextPath}"/>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>修改品牌</title>
</head>
<body>
<h3>修改品牌</h3>
<form action="${ctx}/brand/update" method="post">
<%-- 隐藏域,提交 id。用户看不见,但表单会带上 --%>
<input type="hidden" name="id" value="${brand.id}">
品牌名称:<input name="brandName" value="${brand.brandName}"><br>
企业名称:<input name="companyName" value="${brand.companyName}"><br>
排序:<input name="ordered" value="${brand.ordered}"><br>
描述信息:<textarea rows="5" cols="20" name="description">${brand.description}</textarea><br>
状态:
<c:if test="${brand.status == 0}">
<input type="radio" name="status" value="0" checked>禁用
<input type="radio" name="status" value="1">启用<br>
</c:if>
<c:if test="${brand.status == 1}">
<input type="radio" name="status" value="0">禁用
<input type="radio" name="status" value="1" checked>启用<br>
</c:if>
<input type="submit" value="提交">
</form>
</body>
</html>四类控件的回显写法各不相同,这张表必须背下来,因为它是 JSP 综合题的标准考点:
| 控件 | 回显位置 | 写法 |
|---|---|---|
文本框 input type="text" | value 属性 | value="${brand.brandName}" |
文本域 textarea | 标签体(不是 value 属性) | <textarea>${brand.description}</textarea> |
单选钮 radio | checked 属性 | 按值分支,命中的那个加 checked |
下拉框 select | option 的 selected 属性 | selected="${brand.status == 0}" 或三元运算 |
复选框 checkbox | checked 属性 | 同单选钮 |
隐藏域 hidden | value 属性 | value="${brand.id}" |
下拉框的写法比单选钮更省事,因为它不用写两个 <c:if> 分支:
<select name="status">
<option value="0" ${brand.status == 0 ? "selected" : ""}>禁用</option>
<option value="1" ${brand.status == 1 ? "selected" : ""}>启用</option>
</select>原理是:EL 表达式直接把 "selected" 这个词输出到标签里,浏览器看到 selected 属性就选中。这是 EL "能拼字符串"的一个妙用,简洁但可读性略差;考试写哪种都算对,工程里单选钮写 <c:if>、下拉框写三元,是最常见的组合。
【考点】隐藏域
id为什么必须存在?因为表单只提交用户填的、和页面上有的东西。id既没显示给用户看(不该看),又必须传回后端(update需要where id = ?),唯一的办法就是藏一个type="hidden"的输入框。原文专门讨论了这个问题:"我们不希望页面将主键 id 展示给用户看,但是又希望在提交数据时能将主键 id 提交到后端,此时我们就想到了在学习 HTML 时学习的隐藏域。" 如果漏了它,Integer.parseInt(request.getParameter("id"))就变成Integer.parseInt(null),NumberFormatException,500。【易错点】
textarea的回显不能写value属性。写成<textarea value="${brand.description}"></textarea>页面上什么都没有,因为<textarea>的内容在标签体里,不在属性里。这个错误非常隐蔽,代码看着"很对称",就是没效果。另外<textarea>开始标签和结束标签之间不要留空格或换行,否则描述信息尾部会多出空白字符,存进数据库就成了"脏数据"。【补充】 EL 取不到值时不会报错,只会输出空白(返回
null,输出空串)。所以修改页"表单是空的"绝大多数情况与textarea写错无关,原因集中在三件事之一:①request.setAttribute("brand", brand)的 key 和${brand}不一致(比如 set 成"b");② 根本没走selectById,而是直接从列表页链接跳到了updateBrand.jsp(那样 request 域里什么都没有);③Brand的属性名拼错,比如${brand.brandNam},这个会抛PropertyNotFoundException导致 500,不是空白。三者的现象不同,正好可以反推原因。
11.7 横切与安全:两个过滤器和一个验证码
"横切"的意思是:这些逻辑不属于某一个功能,它们横着切过所有功能:每个请求都要编码,每个请求都要验登录。这种代码如果写进 Servlet,每个 Servlet 都要抄一遍;写进 Filter,写一次就全覆盖。这正是第9章 Filter 的存在意义。
11.7.1 EncodingFilter:解决 POST 中文乱码
package com.itheima.web.filter;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
@WebFilter("/*")
public class EncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 必须在任何 getParameter() 之前执行
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, response);
}
}三句话讲清它为什么必须存在、为什么必须是第一个:
- 乱码的本质是"编码和解码用了不同的字符集"。 浏览器按 UTF-8 把"三只松鼠"编成字节发过来,Tomcat 默认按 ISO-8859-1 解码,就变成"ä¸‰åªæ¾é¼ "。
setCharacterEncoding("UTF-8")告诉 Tomcat:"请用 UTF-8 解我的请求体。" - 它只对请求体(POST)有效。 GET 的参数在 URL 上,由 Tomcat 的
Connector用URIEncoding解码,setCharacterEncoding管不着。Tomcat 8 起URIEncoding默认就是 UTF-8,所以 GET 不乱码;Tomcat 7 默认是 ISO-8859-1,GET 中文会乱,要在server.xml的<Connector>上加URIEncoding="UTF-8",或者在 pom 的 tomcat7 插件里配<uriEncoding>UTF-8</uriEncoding>(11.3.1 已经配好了)。 - 必须在第一次
getParameter()之前设置。 一旦参数被解析过,Tomcat 就把它缓存起来了,再设编码也没用。所以这行代码只能放过滤器最前面,或者 Servlet 的第一行。绝不能放在getParameter之后。原文在每个 Servlet 里写request.setCharacterEncoding("utf-8"),位置正确,但要在四个 Servlet 里各写一遍;改成过滤器就是"写一次、全覆盖"。
11.7.2 LoginFilter:登录校验与 returnUrl
原文的思路是:从 Session 取 user,非 null 就放行,null 就存提示信息并转发到登录页。但是实测会发现一个问题:登录页的样式丢了。原因是登录页要加载 css/login.css,这个 CSS 请求也被过滤器拦了,被转发成了登录页的 HTML,浏览器当然渲染不出样式。原文的解决思路是"对登录注册相关的资源放行",并给出了白名单数组:
String[] urls = {"/login.jsp", "/imgs/", "/css/", "/loginServlet",
"/register.jsp", "/registerServlet", "/checkCodeServlet"};
String url = req.getRequestURL().toString();
for (String u : urls) {
if (url.contains(u)) {
chain.doFilter(request, response);
return;
}
}本章在这段基础上做三处升级:用 getRequestURI() 替代 getRequestURL()(后者带协议和端口,白名单若含 : 会误判)、把白名单提成常量、加上 returnUrl:
package com.itheima.web.filter;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.*;
import java.io.IOException;
import java.net.URLEncoder;
@WebFilter("/*")
public class LoginFilter implements Filter {
/** 白名单:登录、注册、验证码、查重接口、静态资源 */
private static final String[] WHITE_LIST = {
"/login.jsp", "/login", "/register.jsp", "/register",
"/checkCode", "/checkUsername",
"/css/", "/imgs/", "/js/", "/favicon.ico"
};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 1. 当前请求路径(不含协议、主机、端口),形如 /brand-demo/brand/selectAll
String uri = req.getRequestURI();
String ctx = req.getContextPath(); // "/brand-demo"
// 2. 白名单直接放行
for (String u : WHITE_LIST) {
if (uri.startsWith(ctx + u)) {
chain.doFilter(request, response);
return; // 注意:放行后必须 return
}
}
// 3. 已登录放行
Object user = req.getSession().getAttribute("user");
if (user != null) {
chain.doFilter(request, response);
return;
}
// 4. 未登录:Ajax 请求不能重定向到 HTML 登录页,要回 401
if ("XMLHttpRequest".equals(req.getHeader("X-Requested-With"))) {
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
resp.setContentType("text/plain;charset=utf-8");
resp.getWriter().write("NOT_LOGIN");
return;
}
// 5. 未登录的普通请求:记住"原本想去哪儿",跳登录页
String target = uri.substring(ctx.length()); // /brand/selectAll
String query = req.getQueryString(); // id=3
if (query != null && !query.isEmpty()) {
target = target + "?" + query;
}
resp.sendRedirect(ctx + "/login.jsp?returnUrl="
+ URLEncoder.encode(target, "UTF-8"));
}
}登录成功后,LoginServlet 把用户送回他原本想去的地方:
// 登录成功之后
session.setAttribute("user", user);
String returnUrl = request.getParameter("returnUrl");
// 只允许跳回本应用内的路径,防止"开放重定向"漏洞
if (returnUrl != null && returnUrl.startsWith("/") && !returnUrl.startsWith("//")) {
response.sendRedirect(request.getContextPath() + returnUrl);
} else {
response.sendRedirect(request.getContextPath() + "/brand/selectAll");
}returnUrl 这个设计看起来小,体验差别很大:用户直接点开 /brand/selectById?id=3 的链接,被弹去登录,登录完直接回到那个修改页,而不是回到首页再自己找一遍。
【易错点】三个和这个过滤器有关的常见错误,现象完全不同,必须分清:
① 白名单漏了
/css/:登录页能打开,但没有样式,是一片纯文本。这是原文实测遇到的第一个问题。原因:CSS 请求被拦截并转发成了 HTML。② 用
sendRedirect跳登录页,却忘了把/login.jsp放行:浏览器报ERR_TOO_MANY_REDIRECTS(重定向次数过多)。因为跳转后的请求又进过滤器,又没登录,又跳转……无限循环。③ 用
forward跳登录页,且dispatcherTypes包含FORWARD:@WebFilter(urlPatterns = "/*", dispatcherTypes = {REQUEST, FORWARD})时,转发到未放行的login.jsp会再次进入过滤器,再转发……栈溢出。默认的dispatcherTypes只有REQUEST,所以原文用forward不会死循环。这一点非常容易记错:forward默认不重新进过滤器,sendRedirect会。【补充】 为什么 Ajax 请求要单独返回 401 而不是重定向?因为
XMLHttpRequest会自动跟随重定向。如果过滤器把 Ajax 请求 302 到登录页,xhttp会默默把登录页的 HTML 拿回来当响应,前端拿到一段<html>…</html>,responseText == "true"永远是false。用户会看到"用户名可用",实际上后端已经把他踢去登录了。这类 bug 极难查,因为浏览器控制台一点错误都没有。正确做法是后端返 401,前端在status == 401时用location.href跳登录页。
11.7.3 验证码与注册查重:两个最小实现
验证码的接缝在于"两次请求要共享数据":生成验证码是一次请求,提交注册是另一次,中间必须有个地方存它。存哪儿?Cookie 不行(会暴露给用户,等于让他自己填),request 域不行(活不过一次请求),只能存 Session:
// CheckCodeServlet:生成并存入 Session
String checkCode = CheckCodeUtil.outputVerifyImage(100, 50, os, 4);
request.getSession().setAttribute("checkCodeGen", checkCode);
// RegisterServlet:取出并比对
HttpSession session = request.getSession();
String checkCodeGen = (String) session.getAttribute("checkCodeGen");
session.removeAttribute("checkCodeGen"); // 一次性,用完立即作废
if (checkCodeGen == null || !checkCodeGen.equalsIgnoreCase(checkCode)) {
request.setAttribute("register_msg", "验证码错误");
request.getRequestDispatcher("/register.jsp").forward(request, response);
return;
}equalsIgnoreCase 是因为验证码通常不区分大小写;removeAttribute 是为了防重放。不删的话,同一个验证码可以被反复使用,等于没有验证码。还有一个小细节:换验证码图片时要给 URL 加个时间戳(src = "/checkCode?t=" + new Date().getTime()),否则浏览器会走缓存,图片根本不变。
注册查重是 Ajax 的经典场景,本章把它和品牌查重做成同一个形状:后端只返回一个字符串,前端根据字符串决定显示还是隐藏提示:
@WebServlet("/checkUsername")
public class UserServlet extends HttpServlet {
private UserService service = new UserService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String username = request.getParameter("username");
// 这里是本章相对原文唯一的实质改动:
// 原文此处为了演示"不写死数据库也能跑",直接 boolean flag = true;
// 本章接上真实的 Service,查得到才说明被占用
boolean exist = service.selectByUsername(username) != null;
response.setContentType("text/html;charset=utf-8");
response.getWriter().write("" + exist); // 输出 true / false
}
}前端沿用原文的 XMLHttpRequest 写法,只在判断分支里补上"没登录"的处理:
document.getElementById("username").onblur = function () {
var username = this.value;
if (username.trim() === "") {
return;
}
var xhttp = new XMLHttpRequest();
xhttp.open("GET", "${ctx}/checkUsername?username=" + encodeURIComponent(username));
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200) {
if (this.responseText == "true") {
document.getElementById("username_err").style.display = "";
} else {
document.getElementById("username_err").style.display = "none";
}
} else if (this.readyState == 4 && this.status == 401) {
// 会话过期了,直接去登录页
location.href = "${ctx}/login.jsp";
}
};
xhttp.send();
};注意 encodeURIComponent(username):中文用户名不编码,后端 getParameter 会拿到乱码,查重永远返回 false。原文的示例用的是英文用户名,看不出这个问题。
【对比】前后端在这种"只查一个值"的接口上有两种做法,很容易混:
做法 后端返回 前端判断 适合场景 返回布尔标记(原文/本章) "true"/"false"responseText == "true"只关心"存不存在" 返回 JSON {"code":200,"data":true}先 JSON.parse再取data以后要扩展更多字段 返回状态码 200 存在 / 404 不存在 this.status == 200语义最干净,但不好带信息 本章选第一种,因为它最简单、最贴合原文,而且能让你把注意力放在"接缝"上:
setContentType("text/html;charset=utf-8")这一句不能漏,否则返回的中文提示会乱码;getWriter().write(...)之后不要再去forward,否则响应已经提交,报IllegalStateException。
11.8 联调与排错手册
这一节是全章最该反复看的部分。前面十节教你"怎么搭",这一节教你"搭错了怎么查"。 排错能力的分水岭是"看到一个现象,能不能在三十秒内把范围缩到一层"。
11.8.1 十六条故障速查表
| # | 现象 | 最可能的原因 | 定位手段 | 修复 |
|---|---|---|---|---|
| 1 | 访问 http://localhost:8080/brand-demo/ 直接 404,连首页都打不开 | war 没部署成功;项目名与 URL 不匹配(部署成了 brand-demo-1.0-SNAPSHOT);packaging 不是 war | 看 Tomcat 的 logs/catalina.out 有没有"Deployment of web application … has finished";看 webapps 下到底叫什么名字 | 改 pom.xml 的 <packaging>war</packaging> 与 <finalName>;或在 IDEA 的 Deployment 里把 Application context 设成 /brand-demo |
| 2 | 首页能开,点"查询所有"404,控制台报 HTTP Status 404 - /brand-demo/brand/selectAll | Servlet 映射路径与请求路径不一致;@WebServlet 的值少了 /;表单 action 少了 /brand-demo | 打开浏览器 F12 的 Network,看实际请求的 URL;对照 @WebServlet 的值 | 两边对齐。记住:@WebServlet("/brand/*") 对应请求 /brand/selectAll,不是 /selectAll |
| 3 | 点"提交"报 405 - HTTP method GET is not supported 或 POST is not supported | 表单 method="post",但 Servlet 只重写了 doGet(或反过来) | 看报错里的方法名;看表单的 method 属性 | 补 doPost 并在里面调 doGet(本章的 BrandServlet 已经这么写了) |
| 4 | 500,异常栈里是 java.lang.NumberFormatException: null 或 For input string: "" | 表单字段没提交上来(name 拼错/漏写);ordered 或 status 为空时直接 Integer.parseInt | 在 Servlet 里 System.out.println(request.getParameterMap()),看参数到底有没有到 | 对齐 name;给 <input type="number" required>;在 Web 层加判空再 parseInt |
| 5 | 500,异常栈里是 javax.el.PropertyNotFoundException: Property [brandNam] not found on type [Brand] | JSP 里 EL 的属性名拼错,或 Brand 没有对应的 getter | 读异常信息里的属性名,直接回 Brand.java 找 getter | 属性名与 getter 严格对应(brandName ↔ getBrandName()) |
| 6 | 新增"三只松鼠旗舰店",数据库里、页面上都是 ä¸‰åªæ¾é¼ 或 ??? | POST 中文乱码:没设 setCharacterEncoding("UTF-8"),或设置放在了 getParameter 之后 | 在 Servlet 第一行打印 request.getCharacterEncoding() | 加 EncodingFilter 并保证它在过滤器链最前面(web.xml 里放第一个) |
| 7 | 表单中文正常,但按品牌名搜索中文查不到;或 Ajax 返回的中文提示是乱码 | GET 请求的 URL 编码问题(Tomcat 7 默认 ISO-8859-1);响应没设 Content-Type 的 charset | 看请求 URL 里中文是否被 %E5%8D%8E 这样编码;看响应头 Content-Type | Tomcat 的 <Connector URIEncoding="UTF-8">;响应加 setContentType("text/html;charset=utf-8") |
| 8 | Druid 报 Error updating database. Cause: java.sql.SQLException,或日志里出现 WallFilter 拦截、"sql injection" 告警 | 用 Statement 拼字符串,用户输入里有 '、or、-- 等;模糊查询写成 "like '%" + name + "%'" | 把最终执行的 SQL 打印出来看长什么样;或在输入框里输一个 ' 试 | 全部改成 PreparedStatement + ?;% 拼在参数值里,不拼在 SQL 里 |
| 9 | 页面上直接显示了 <c:forEach items="${brands}" …> 这一串文字 | 缺 <%@ taglib %> 指令;jstl 与 standard 的 jar 没进 WEB-INF/lib(被误标 provided);web.xml 版本太低(2.3) | 看 WEB-INF/lib 里有没有 jstl-1.2.jar;看 JSP 第一行有没有 taglib | 加 taglib 指令;把 jstl 依赖的 scope 改成默认(compile);web.xml 用 3.0+ 的版本头 |
| 10 | 表格一行都没有、或整列空白、或修改页表单全空 | EL 取不到值:域里 key 名不一致;数据存进了 request 却在重定向后取(域已丢);JSP 没走 Servlet 直接访问 | 在 JSP 里临时写 ${brands}、${brand} 看是不是空;在 Servlet 里打印 request.getAttribute("brands") | key 名两边统一;重定向不传数据,改成转发;页面必须经 Servlet 转发进入 |
| 11 | 点"修改"提交后提示成功,但数据没变 | 隐藏域 id 没提交,update 的 where id = ? 没生效;或 Service.update 里 brand.getId() 是 null | 在 update 分支打印 request.getParameter("id");看 SQL 的 where 条件 | 补 <input type="hidden" name="id" value="${brand.id}">;Service 里对 id 判空 |
| 12 | 转发之后页面是空白,或者只显示了半截 HTML | forward 之后又调了 response.getWriter().write(...),或又 sendRedirect;forward 之后忘了 return | 看代码里 forward 后面还有没有输出语句 | 一个请求只能有一个出口:forward 之后立刻 return;不要在转发后再写响应 |
| 13 | 登录页样式全丢;或浏览器报 ERR_TOO_MANY_REDIRECTS;或栈溢出 | 过滤器白名单漏了 /css/;sendRedirect 到 login.jsp 却没放行;dispatcherTypes 含 FORWARD 且转发到未放行的页面 | 看浏览器 Network 里 CSS 请求返回的是什么(如果返回 HTML,就是被拦截了);数一下重定向链有几跳 | 白名单加静态资源;登录页必须放行;放行后必须 return,否则 chain.doFilter 会执行两次 |
| 14 | 用一会儿就报 wait millis 3001, active 10, maxActive 10,之后所有请求都卡住 | 连接池耗尽:DAO 里 Connection 没 close();异常路径漏关;连接借了不还 | 看 Druid 的监控页或日志里的 active 数;搜代码里 getConnection() 与 close() 是否成对 | 用 try-with-resources;确认 close() 是"还池"不是"断开",必须调 |
| 15 | 启动报 java.net.BindException: Address already in use: bind | 8080 端口被占用(上一个 Tomcat 没关干净,或别的程序占了) | netstat -ano | findstr :8080 找到 PID,taskkill /PID xxx /F | 关掉占用进程;或改端口(server.xml 的 <Connector port="8081">,或 pom 里 <port>) |
| 16 | 改了 Java 代码,重启后行为没变;但改 JSP 立刻生效 | war / classes 没重新编译或没同步到部署目录;IDEA 的 artifact 输出目录与 Tomcat 用的不是同一个 | 看 target/classes 里对应 .class 的修改时间;看 Tomcat webapps 下 WEB-INF/classes 的时间 | mvn clean package 重新打包;IDEA 里 Build → Rebuild Project,并确认 artifact 已更新 |
11.8.2 三个典型故障的完整复盘
表格给的是"结论",排错过程本身是"缩小范围"。下面三个案例把过程摊开。
案例一:全站 404,但是 Tomcat 明明启动了。
新手的第一反应是"代码写错了",于是开始改 Servlet,这是最糟的起点,因为全站 404 意味着请求根本没进你的代码,改代码毫无意义。
正确的顺序是三步,每步都把范围砍一半:
- 先确认"应用在不在":访问
http://localhost:8080/,能看到 Tomcat 的欢迎页吗?能,说明 Tomcat 活着,问题在应用层。 - 再确认"应用叫什么":看
webapps目录下真实的文件夹名。如果叫brand-demo-1.0-SNAPSHOT,那你访问/brand-demo/当然 404。这一步能解决八成的"全站 404"。 - 最后确认"应用起没起来":看
logs/catalina.out里有没有Deployment of web application archive [...] has finished in [...] ms。没有这一行,说明部署失败了。往上翻,一定能找到一条异常(常见的是web.xml格式错、ClassNotFoundException、Spring 之类的监听器初始化失败)。
排查的顺序永远是"从外往里":端口 → 应用 → 路径 → Servlet 映射 → 方法 → 参数 → SQL。 反过来"从里往外"猜,是最常见的低效。
案例二:页面把 <c:forEach> 原样打印出来了。
现象很怪:表格是空的,表格下面跟着一行字 </c:forEach>。原因可以排除"JSTL 用错了"这一项:JSTL 根本没被识别,容器把它当成了普通 HTML 文本。
三个原因,按概率从高到低查:
- JSP 顶部没有 taglib 指令。
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。注意uri是java.sun.com的老地址,写成jakarta.tags.core是 JSTL 3.0 的写法,配 JSTL 1.2 的 jar 会不认。 - jar 没进
WEB-INF/lib。去部署目录里看,如果只有jstl而没有standard,或者两个都没有,就是依赖没打进去。最常见的原因是把jstl也标了<scope>provided</scope>:provided的意思是"容器会给我",但是 Tomcat 不给 JSTL。 web.xml是 2.3 版本。2.3 的web.xml默认关闭 EL 和 JSTL 解析。看版本头是web-app_2_3.xsd就中招了,改成 3.0 以上。
判断技巧:如果连 ${brands} 也原样输出了 ${brands} 这几个字符,那一定是 EL 整体被禁用(第 3 条);如果 ${brands} 正常求值、只有 <c:forEach> 不生效,那是 JSTL 的问题(第 1、2 条)。 现象差一点点,原因完全不同。
案例三:跑了半小时,突然所有页面都卡住,重启就好。
这是资源泄漏的典型特征:一开始正常,越用越慢,最后彻底不动,重启立刻恢复。数据库连接池耗尽是最常见的一种。
Druid 的报错长这样:
com.alibaba.druid.pool.GetConnectionTimeoutException:
wait millis 3001, active 10, maxActive 10, creating 0读这三个数字就知道发生了什么:maxActive 10 是池子上限,active 10 是当前已借出未归还的数量,wait millis 3001 是下一个请求等了 3 秒(maxWait)还没等到。池子里一个空闲连接都没有了,因为借出去的十个都没还。
根因几乎总是同一个:Connection 借了没 close()。三种常见形态:
// 形态一:压根没写 close
Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.executeUpdate();
// 形态二:只在成功路径关,抛异常就漏了
Connection conn = JdbcUtils.getConnection();
pstmt.executeUpdate();
conn.close(); // 上面抛异常,这行永远不执行
// 形态三:关了 ResultSet,忘了关 Connection
rs.close();修复就是统一改成 try-with-resources:
try (Connection conn = JdbcUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
// …
} // 无论正常还是异常,conn 和 pstmt 都会被自动 close还有一句必须说清楚的话:连接池里的 close() 并不断开 TCP,它把连接还给池子。 很多人不敢调 close(),怕"连接断了下次用不了",这个误解会让泄漏永远修不好。池子的 close() 是"归还",对象还在,下一个请求接着用。
11.8.3 排错方法论:五步定位法
把上面三个案例抽象一下,就是一套通用的五步法:
- 看现象落在哪一层。浏览器白屏/404/405 → 大概率在 Web 层之前(部署、映射、方法);500 带异常栈 → 已经在代码里了;页面结构对但数据不对 → DAO 或 SQL;样式/脚本不对 → 静态资源被拦或路径错。
- 看第一个异常,不看最后一个。异常栈是从"最外层"往"最内层"打的,最后一个
Caused by才是根因。看到ServletException就往下翻,一直翻到最底。 - 在接缝处打点。层与层之间是最容易断的地方:Servlet 里打印
getParameterMap(),Service 里打印入参,DAO 里打印最终 SQL 与参数。打点要打在"我以为它有值"的地方,因为 bug 就藏在那儿。 - 一次只改一个变量。改三处再重启,成功了也不知道是哪处生效,失败了也不知道是哪处导致。这是新手和老手最大的差别。
- 改完回退验证。把刚才的修改撤掉,问题应该复现;如果不复现,说明你改的地方和问题无关,你只是"碰巧"绕过去了。
【补充】三个比"print"高效的定位工具,都内置在浏览器和 IDE 里,不需要装任何插件:
① F12 → Network:看请求的真实 URL、方法、状态码、请求头、响应体。90% 的 404/405/参数丢失,在这一栏里一眼就能看出来。特别是"表单提交没反应",先看有没有发出请求,没发出去是前端问题(
name写错、按钮不是submit),发出去了是后端问题。② F12 → Application → Cookies:看
JSESSIONID有没有、值变没变。登录后换了个JSESSIONID,说明 Session 丢了(常见于重定向到了别的域名/端口)。③ Tomcat 的
logs/localhost.<日期>.log:catalina.out只记容器级日志,应用抛的异常在localhost.log里,而且带完整栈。很多人只翻catalina.out,然后说"日志里什么都没有"。
11.9 验收清单与扩展任务
11.9.1 上线前的验收清单
把这张表打印出来,每做完一项打个勾。全部打勾,才叫"做完了"。
| ☐ | 验收项 | 怎么验 | 通过标准 |
|---|---|---|---|
| ☐ | 工程能打包 | mvn clean package | 生成 target/brand-demo.war,WEB-INF/lib 里有 jstl、standard、druid、mysql 驱动,没有 servlet-api、jsp-api |
| ☐ | 应用能启动 | 启动 Tomcat | 日志出现 Deployment … has finished,无异常 |
| ☐ | 首页可访问 | 访问 /brand-demo/ | 显示登录页(未登录时) |
| ☐ | 未登录被拦截 | 清 Cookie 后访问 /brand/selectAll | 跳到 login.jsp,地址栏带 returnUrl |
| ☐ | 登录成功 | 用 zhangsan/123 登录 | 进入列表页,显示用户名 |
| ☐ | 登录后回原页 | 直接访问 /brand/selectById?id=1,被拦后登录 | 登录完直接回到修改页,不是首页 |
| ☐ | 列表正确 | 打开列表页 | 三条初始数据都在,状态显示"启用/禁用",序号 1/2/3 |
| ☐ | 排序正确 | 看列表顺序 | 华为(100) → 小米(50) → 三只松鼠(5) |
| ☐ | 模糊查询 | 搜"华" | 只剩华为;搜索框保留"华" |
| ☐ | 查询防注入 | 搜 ' or '1'='1 | 查不到数据(或报业务提示),不返回全表,不 500 |
| ☐ | 新增 | 添加"香飘飘/香飘飘/1/绕地球一圈/启用" | 列表出现新记录,中文正常,排序位置正确 |
| ☐ | 新增防重提交 | 新增成功后按 F5 | 浏览器提示"重新提交表单"或直接刷新,不会多出一条 |
| ☐ | 修改回显 | 点某条的"修改" | 五个字段都填好,状态单选钮停在原值,页面看不到 id |
| ☐ | 修改保存 | 改排序为 999 提交 | 列表里该条排到最前,其他记录不变 |
| ☐ | 删除 | 点"删除"→ 取消 | 不发请求,数据还在 |
| ☐ | 删除确认 | 点"删除"→ 确定 | 该条消失,其余不变 |
| ☐ | 验证码 | 输错验证码注册 | 提示"验证码错误",已填用户名不清空 |
| ☐ | 验证码一次性 | 用同一个验证码提交两次 | 第二次失败 |
| ☐ | 注册查重 | 用户名输 zhangsan 后失焦 | 显示"用户名已存在",页面不刷新 |
| ☐ | 会话超时 | 关浏览器重开,直接访问列表 | 跳登录页 |
| ☐ | 连接不泄漏 | 连续点 50 次列表 | 不卡、不报 wait millis |
11.9.2 三个扩展方向
扩展一:分页。
这是最值得做的扩展,因为它一次改动贯穿三层,能让你彻底体会"分层的作用":
| 层 | 要改什么 |
|---|---|
| DAO | 加 selectByPage(int begin, int size),SQL 变 … limit ?, ?;再加 selectCount() 查总数 |
| Service | 加 PageBean<Brand> selectByPage(int currentPage, int pageSize),算出 begin、总页数、上一页/下一页 |
| Web | 从 getParameter("currentPage") 取页码(空则默认 1),调 Service,把 PageBean 存进 request 域 |
| JSP | 表格不变,底部加"共 N 条 / 第 x 页 / 上一页 下一页",并用 <c:if> 控制首末页置灰 |
关键只有一句 SQL:select * from tb_brand limit ?, ?,第一个 ? 是起始索引(从 0 开始,等于 (当前页 - 1) × 每页条数),第二个 ? 是每页条数。注意 MySQL 的 limit 是从 0 开始数的,第 1 页的起始索引是 0 不是 1,这个差一错误(off-by-one)是分页最常见的 bug。
扩展二:文件上传(品牌 logo)。
需要三样东西配合:表单 enctype="multipart/form-data"、Servlet 上 @MultipartConfig、用 request.getPart("logo") 取文件。核心动作是 part.write(文件名) 或 part.getInputStream() 自己写到磁盘,存进数据库的是文件路径而不是文件本身。三个常见错误:① 忘了 enctype,getParameter 全是 null;② 文件名重复会覆盖,要用 UUID 重命名;③ 上传目录必须放在应用外部或配置 Tomcat 的虚拟目录,否则重新部署 war 时文件全丢。
扩展三:前后端分离,用 axios 调 JSON 接口。
把 BrandServlet 改成返回 JSON 而不是转发 JSP:
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("[{\"id\":1,\"brandName\":\"华为\"}]");前端用第9章学过的 axios:
axios.get('${ctx}/brand/selectAll').then(resp => {
// resp.data 已经是解析好的对象,不用 JSON.parse
renderTable(resp.data);
});这个扩展会让你第一次直面跨域:如果页面不是从同一个 Tomcat 打开的(比如用 VSCode 的 Live Server 跑在 5500 端口),浏览器会拦下请求,报 CORS policy。解决办法是后端加响应头 Access-Control-Allow-Origin,或者用 Nginx 反向代理把两边合成一个源。问题不在 JavaWeb,浏览器按同源策略拦下它是为了保护用户。第4章的 HTTP 讲到这里与实战衔接完成。
本章小结
本章的主线只有一句:分层的目的只有一个:让"改一处"不牵动全身;而综合实战考的,是你能不能在每个接缝上说清楚"谁调谁、传什么、错了查哪儿"。
| 主题 | 要点 | 关键代码/类 |
|---|---|---|
| 需求验收 | "能跑"不等于"做好了",每条功能都要有可当场验证的标准 | 11.1 的 12 条验收表 |
| 三层架构 | 表现层收请求、业务层定规则、数据层发 SQL | web / service / dao |
| MVC 与三层 | C+V 是表现层,M 是业务层+数据层 | BrandServlet+brand.jsp / Brand+BrandService+BrandDao |
| 建表 | 列名下划线、属性名驼峰,这个冲突永远存在 | tb_brand、tb_user |
| 依赖 | servlet-api/jsp-api 用 provided,jstl/standard 必须打进 war | pom.xml |
| 实体 | 用包装类型留"没填"的信号;手写带参构造要补无参构造 | Brand |
| DAO | 一条方法一条 SQL,? 设参,try-with-resources 关资源 | BrandDao 五个方法 |
| Service | 业务规则、事务边界、隔离 Web 与数据访问技术 | BrandService.add() 先查重再插入 |
| 事务 | 开在 Service,因为只有它知道"这次业务包含哪几步" | setAutoCommit(false) + commit |
| 分发 | 一个模块一个 Servlet,按 getPathInfo() 分发 | @WebServlet("/brand/*") |
| 转发/重定向 | 有数据给页面就转发,改变状态就重定向(PRG) | forward / sendRedirect |
| 回显 | 文本框用 value,文本域用标签体,单选/下拉用 checked/selected | updateBrand.jsp |
| 横切 | 编码与登录校验写一次、全覆盖 | EncodingFilter、LoginFilter |
| 安全 | 参数校验三层各管一段;Ajax 未登录返 401 不重定向 | 白名单、returnUrl |
| 排错 | 从外往里:端口→应用→路径→映射→方法→参数→SQL | 11.8 十六条速查表 |
七条必须记住的结论:
- 分层的收益在"换"的时候兑现。把
BrandDao换成 MyBatis 的BrandMapper,只改 DAO、Service 拿 DAO 的方式、配置文件三处,Servlet 和 JSP 一行不动。 - Service 层是规则的容器和事务的边界,不做转发。它今天薄,是因为业务简单;业务一复杂,它必然变厚。
- 参数校验分三层:Web 层管格式,Service 层管业务规则,数据库管兜底。业务校验为了体验,数据库约束为了保证。
- 事务只能开在 Service,而且必须让多次 DAO 调用共用同一个
Connection,否则等于没开。 - 一个请求只有一个出口:
forward或sendRedirect,二选一,之后立刻return。 - 一半的"玄学 bug"是路径问题:浏览器发的地址要带上下文路径,服务器内部转发不要带。
- 本章的知识在第12章以"综合题"的形式被考:给出需求,让你写出涉及的类、包、调用顺序,并指出校验与事务放在哪一层。你只要能把这套流程默写出来,第12章的综合题就是送分题。
自测
基础题
- 三层架构的三层分别负责什么?MVC 中的 C、V、M 分别对应三层的哪一部分?
- 写出一个"查询所有品牌"的请求从浏览器到数据库、再回到页面的完整经过,至少包含六个环节。
- 为什么要有 Service 层?如果一个 Service 方法只有一行
return dao.selectAll();,它算不算多余?说明理由。 - 请求参数的校验应该放在哪几层?各层分别校验什么?为什么数据库还要再校验一遍?
- 事务应该开在哪一层?为什么不能开在 DAO 层,也不能开在 Servlet 层?
进阶题
- 看代码写结果:
BrandServlet映射为@WebServlet("/brand/*"),addBrand.jsp的表单action是${ctx}/brand/add,method="post"。用户提交成功后,浏览器地址栏显示什么?此时按 F5 会发生什么?如果改成forward到列表 Servlet,答案又是什么? - 找错:
LoginFilter用resp.sendRedirect(ctx + "/login.jsp")跳转,白名单里只有"/login"、"/register"、"/css/"。访问任意业务页面会出现什么现象?怎么修? - 找错:
BrandDao里写brand.setBrandName(rs.getString("brandName"));,运行时报什么异常?如果这段代码换成 MyBatis 的resultMap漏配,现象又是什么? - 设计:现在要求"删除品牌前,检查该品牌下是否还有商品(
tb_goods表),有则拒绝删除"。请说明要改哪几个文件、每个文件加什么方法,并说明为什么这个判断不能写在 DAO 里。 - 简答:把本章的
BrandDao换成原文的 MyBatisBrandMapper,需要改哪三处?哪些文件一行都不用动?为什么不用动?
参考答案
- 表现层接收请求、封装数据、调用业务层、响应数据;业务逻辑层封装业务规则、组合数据访问层的基本功能;数据访问层做数据库的 CRUD。C(控制器)和 V(视图)对应表现层,M(模型)对应业务逻辑层 + 数据访问层。
- 浏览器发 GET(带 JSESSIONID)→ Filter 链(编码、登录校验)→
BrandServlet取pathInfo与参数 →BrandService.selectAll()→BrandDao借连接、PreparedStatement查询、结果集转List<Brand>→ 结果逐层返回 → Servlet 存request域并forward到brand.jsp→ JSP 用 EL/JSTL 渲染 HTML → 浏览器渲染。- 不多余。它承载业务规则(如新增前查重)、划定事务边界、隔离 Web 层与数据访问技术,并让多个入口(Servlet、定时任务、测试)复用同一套业务逻辑。判断标准是"将来改动落在哪一层",与"现在有没有用"无关。
- Web 层校验格式(非空、数字、长度、范围),失败就转发回表单页提示;Service 层校验业务规则(唯一性、状态合法性、前置条件),失败抛异常;DAO 层不校验;数据库用
not null、unique、字段长度兜底,因为前两层都可能被绕过(直接连库、并发提交)。- 开在 Service 层。DAO 只知道一条 SQL,不知道这次业务包含哪几步,无法判断何时该提交;Servlet 不该碰
Connection,否则 Web 层与 JDBC 焊死。只有 Service 同时知道"这次业务包含哪些操作"且"不关心 HTTP"。另外多次 DAO 调用必须共用同一个Connection,否则各自自动提交,等于没有事务。- 地址栏显示
/brand-demo/brand/selectAll(sendRedirect的结果);按 F5 是重新发一次 GET,不会重复提交。如果改成forward,地址栏仍是/brand-demo/brand/add,F5 会触发"是否重新提交表单",确认后会再插入一条重复数据。- 现象:登录页的请求
/login.jsp不在白名单里(白名单只有/login),被过滤器再次拦截并重定向到/login.jsp,无限循环,浏览器报ERR_TOO_MANY_REDIRECTS。修法:白名单加上"/login.jsp",并且放行后立即return。- 抛
java.sql.SQLException: Column 'brandName' not found,因为getString的参数是数据库列名,必须写brand_name。换成 MyBatis 且漏配resultMap时,不报错,但是brandName和companyName两列全是null(页面空白),其余三列正常。- 改三个文件:①
GoodsDao新增int countByBrandId(int brandId);②BrandService.deleteById()里先调goodsDao.countByBrandId(id),大于 0 就抛业务异常,否则再brandDao.deleteById(id);③BrandServlet的delete分支捕获异常,把提示放进 request 域或sendRedirect带参数回列表页。不能写在 DAO 里,因为 DAO 只该有一条 SQL,"能不能删"是跨两张表的业务规则,属于 Service。- 改三处:① DAO 层:删掉
BrandDao类,换成BrandMapper接口,方法上贴@Select/@Insert/@Update/@Delete,包名改为com.itheima.mapper;② Service 层:new BrandDao()改为sqlSession.getMapper(BrandMapper.class),增删改后补commit()、用完close();③ 配置:把连接参数搬进mybatis-config.xml的<dataSource>,补BrandMapper.xml的resultMap(brand_name → brandName、company_name → companyName),并在<mappers>里扫描 mapper 包。BrandServlet、三个 JSP、两个 Filter、Brand实体、建表 SQL 全都不用动,因为它们只依赖"service.selectAll()返回List<Brand>"这个契约,不依赖 DAO 是用 JDBC 还是 MyBatis 实现的。