第9章 Filter、Listener 与 Ajax
难度:★★★★☆ | 前置知识:第7章 会话技术、第8章 JSP/EL/JSTL | 本章解决:把"每个资源都要写一遍"的公共逻辑交给 Filter 一次拦下,把"对象的生死"交给 Listener 感知,再用 Ajax + axios + JSON 让页面只换局部、让前后端只传数据
本章导读
- 第 7 章解决了"服务器记不住人"的问题:登录成功后把
User对象放入HttpSession,此后每个请求都能从 session 里把用户取出来。但是第 7 章留下了一个遗留问题:"判断有没有登录"这段代码,要在每一个 Servlet 里抄一遍。品牌列表要判断、添加品牌要判断、删除也要判断,这段代码要抄十遍,并且漏抄一处就是一个漏洞。本章的 Filter 用来解决这个遗留问题:在请求到达资源之前,统一拦截一次。 - 这类"散落在很多地方、每处都要写、却又和具体业务无关"的逻辑,有个正经名字叫横切关注点(cross-cutting concern):登录校验、统一编码、敏感词过滤、日志、耗时统计,都是它。Filter 是 JavaWeb 规范给横切关注点准备的第一个正式位置;学完你会发现,后来的 Spring 拦截器、AOP、网关过滤器,都是同一个思路的不同实现。
- Servlet、Filter、Listener 合称 JavaWeb 三大组件。第 5 章学了 Servlet,本章把另外两个补齐。Listener 负责"听":application、session、request 三个对象被创建、被销毁、被写入属性的时候,自动回调你的代码。它平时不显眼,但是 Spring 的
ContextLoaderListener、统计在线人数、启动时加载配置文件,全靠它。 - 后半章换一条线:交互方式。前面所有章节,浏览器和服务器之间传的是"页面":服务器把整个 HTML 拼好发回来,浏览器整页刷新。Ajax 改变了这条路径:浏览器只发一小段请求,服务器只回一小段数据,页面自己用 JS 把数据填进去。搜索联想、用户名查重、点"删除"不跳页,都是这个机制。
- Ajax 裸写很啰嗦(创建对象、
open、send、监听readyState),所以工程上用 axios 封装;数据也不再是username=zhangsan这种表单参数,而是 JSON。这三样是绑在一起的一整套:axios 发请求,JSON 装数据,页面局部更新。第 11 章的综合实战就是这套组合的完整版。 - 常见误解提前拆掉三个:① 以为 Filter 只拦"页面",它拦的是"请求",css、js、图片一样拦(9.4 的故障就出在这里);② 以为
chain.doFilter()表示"结束过滤器",它表示"放行",不放行页面就是一片空白;③ 以为 Ajax 是"新技术",它就是XMLHttpRequest加 JS,浏览器二十年前就有,axios 只是把它封装得更方便。
【考点】本章考试形态:一道 Filter 流程题(给两个过滤器,问控制台打印顺序)、一道"过滤器与拦截器区别"的简答、一道 Listener 分类填空、一道 Ajax 读代码题(问
readyState/status判断的作用)、一道 axios + JSON 的接口设计题。分值最稳的是 9.2 的流程、9.3 的顺序、9.7 的 JSON 转换。
9.1 Filter 概述与快速入门(原文 1.1、1.2)
9.1.1 什么是过滤器(原文 1.1)
Filter 表示过滤器,是 JavaWeb 三大组件(Servlet、Filter、Listener)之一。 Servlet 我们已经学过,Filter 和 Listener 从这一章开始学。
过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能。 浏览器本来可以访问服务器上的所有资源(servlet、jsp、html 等),而在访问到这些资源之前,可以让过滤器先拦截下来:在访问资源之前会先经过 Filter,Filter 决定这次请求是放行,还是就地拒绝。
那么,拦截到之后能做什么功能呢?过滤器一般完成一些通用的操作。 比如每个资源都要写一些代码完成某个功能,我们总不能在每个资源中都写这样的代码吧?而此时就可以把这些代码写在过滤器中,因为请求每一个资源都要经过过滤器。
回头看第 11 章要用的品牌数据管理案例,里面已经做了登录功能。但如果不登录,能不能访问到数据呢?在浏览器里直接访问首页,可以看到"查询所有"的超链接;点击这个超链接,居然可以看到品牌的数据。这显然和我们的要求不符。我们希望实现的效果是:用户如果登录过了,就跳转到品牌数据展示的页面;如果没有登录,就跳转到登录页面让用户进行登录。 要实现这个效果,需要在每一个资源中都写上这段逻辑,而像这种通用的操作,就可以放在过滤器中进行实现。这个就是权限控制,以后我们还会进行细粒度的权限控制。过滤器还可以做统一编码处理、敏感字符处理等等。
把上面这段话里的关键词抽出来,Filter 的定位就很清楚了:
| Filter 的典型用途 | 统一处理什么 | 不用 Filter 会怎样 |
|---|---|---|
| 登录 / 权限校验 | 每个请求都要"看有没有登录、有没有权限" | 每个 Servlet 开头抄一遍,漏一个就是越权漏洞 |
| 统一编码 | request.setCharacterEncoding("UTF-8")、response.setContentType(...) | 每个 Servlet 写两行,写漏一个页面就乱码 |
| 敏感字符处理 | 把请求参数里的脏词替换掉 | 每个取参数的地方都要过滤 |
| 日志与耗时统计 | 记录 URL、耗时、来源 IP | 只能散落在业务代码里,和业务混在一起 |
| 压缩 / 缓存 / 跨域头 | 统一往响应里加 header | 每个响应单独设置,容易漏 |
【补充】为什么这件事必须由"过滤器"来做,而不是写个工具类让大家调用?因为工具类靠"自觉":你写了
checkLogin(request)才算校验,忘了写就没人管;而 Filter 靠"机制":只要路径配成/*,请求物理上必须经过它,绕不过去。安全管理的一条基本原则是:能做成强制通道的,就不要做成可选项。 这也解释了为什么后面 Spring Security 的核心仍然是一串 Filter。【对比】 Filter 与 Spring MVC 的 Interceptor,是简答题的高频对比。两者都能"在请求前后插一段逻辑",但层次完全不同:
| 维度 | Filter(过滤器) | Interceptor(拦截器) |
|---|---|---|
| 归属 | Servlet 规范(javax.servlet.Filter),由 Tomcat 等容器提供 | Spring MVC 框架提供的组件,由 Spring 容器管理 |
| 拦截范围 | 进入容器的所有请求:servlet、jsp、html、css、js、图片 | 只拦截进入 DispatcherServlet 且能映射到 Handler 的请求,静态资源通常被放行 |
| 能否注入 Spring Bean | 不能直接注入(Filter 由容器 new 出来,不在 Spring 容器里),要靠 DelegatingFilterProxy 转一手 | 本身就是一个 Bean,@Autowired 随便用 |
| 依赖 | 不依赖任何框架,换掉 Spring 照样跑 | 只在 Spring MVC 项目里有效 |
| 执行位置 | 在 DispatcherServlet 之前 | 在 DispatcherServlet 之后、Controller 之前 |
| 典型用途 | 编码、跨域、登录兜底、请求体包装 | 权限注解、操作日志、参数校验 |
概括起来:Filter 是规范级、容器级的;Interceptor 是框架级、Spring 级的。请求先过 Filter,再进 DispatcherServlet,然后才轮到 Interceptor,最后到 Controller。
【考点】"Filter 和 Interceptor 有什么区别?"标准答法两句:Filter 基于 Servlet 规范、由容器调用,能拦所有请求但不方便用 Spring 的 Bean;Interceptor 基于 Spring MVC、由框架调用,只能拦 Controller 请求但能注入依赖。 理由:两者的创建者和调用栈不同。
9.1.2 开发步骤(原文 1.2.1)
进行 Filter 开发分成以下三步实现:
- 定义类,实现
Filter接口,并重写其所有方法。 - 配置 Filter 拦截资源的路径:在类上定义
@WebFilter注解。而注解的value属性值/*表示拦截所有的资源。 - 在
doFilter方法中输出一行文本,并且放行。
三步落在代码上就是这样一个结构:
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import java.io.IOException;
@WebFilter("/*")
public class FilterDemo implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("FilterDemo...");
// 放行
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}上述代码中的 chain.doFilter(request,response); 就是放行,也就是让其访问本该访问的资源。
三个方法各自什么时候被调用?这是必须背下来的:
| 方法 | 调用时机 | 调用次数 | 能做什么 |
|---|---|---|---|
init(FilterConfig) | 服务器启动、Web 应用被部署时 | 整个应用生命周期内 1 次 | 读初始化参数、拿 ServletContext、做一次性准备 |
doFilter(request, response, chain) | 每一次被拦截的请求 | 每个请求 1 次(并发时多线程同时进) | 放行前处理、决定放行或拦截、放行后处理 |
destroy() | 服务器正常关闭、应用被卸载时 | 1 次 | 释放资源(关流、停线程) |
【补充】上面这张表藏着一个必须知道的结论:Filter 是单例的。容器只 new 一个
FilterDemo对象,所有请求共用它,doFilter会在多个线程里同时执行。所以:绝对不要在 Filter 的成员变量里存"本次请求"的数据(比如private String username;然后在doFilter里赋值),否则两个用户同时请求,后一个会把前一个的值覆盖掉,典型的"串数据"bug。成员变量只放无状态、只读的东西,比如private BrandService brandService = new BrandService();。同理,Servlet 也是单例,这是第 5 章就强调过的并发红线。
FilterConfig 又是什么?它和 Servlet 的 ServletConfig 是一对,用来在 init 里拿配置:
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 1. 拿过滤器名(web.xml 里 <filter-name> 的值)
String filterName = filterConfig.getFilterName();
// 2. 拿初始化参数(web.xml 里 <init-param> 的值)
String encoding = filterConfig.getInitParameter("encoding");
// 3. 拿全局域对象 ServletContext
ServletContext servletContext = filterConfig.getServletContext();
}【易错点】三个新手高频错误,症状各不相同:① 只写了
doFilter,init和destroy没写 → 编译直接报"未实现抽象方法",因为 Servlet 3.1 的Filter是接口,三个方法都是抽象的;② 忘了写@WebFilter注解 → 代码一字不差,但访问页面时控制台什么都不打印,过滤器根本没被注册;③ 导错包 → 比如误导入java.util.logging.Filter,编译器会告诉你doFilter的参数列表对不上。认准javax.servlet.Filter。
9.1.3 代码演示(原文 1.2.2)
创建一个项目,项目下有一个 hello.jsp 页面。项目结构如下:
filter-demo
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── itheima
│ └── web
│ └── filter
│ └── FilterDemo.java
└── webapp
└── hello.jsppom.xml 配置文件内容如下:
<?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>filter-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>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<port>80</port>
</configuration>
</plugin>
</plugins>
</build>
</project>这里有三个细节值得停一下:
<packaging>war</packaging>:Web 项目必须打成 war 包,因为它要交给 Tomcat 部署,jar 包是没有webapp目录的。javax.servlet-api的<scope>provided</scope>:Servlet 的 API 由 Tomcat 自己提供,我们只在编译期需要它,绝对不能打进 war 包,否则应用里的Filter类和 Tomcat 自己的类加载器打架,启动就报ClassCastException。tomcat7-maven-plugin配了<port>80</port>:所以下面所有访问地址都是http://localhost/...,端口号被省略了。
hello.jsp 页面内容如下:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>hello JSP~</h1>
</body>
</html>现在在浏览器输入 http://localhost/filter-demo/hello.jsp 访问 hello.jsp 页面,这里是可以访问到 hello.jsp 页面内容的,页面上显示一行大字"hello JSP~"。
接下来编写过滤器。过滤器是 Web 三大组件之一,所以我们将 filter 创建在 com.itheima.web.filter 包下,起名为 FilterDemo。先写一个不放行的版本,看看会发生什么:
@WebFilter("/*")
public class FilterDemo implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("FilterDemo...");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}重新启动服务器,再次访问 hello.jsp 页面,这次发现页面没有任何效果(浏览器里一片空白),但是在 IDEA 的控制台可以看到 FilterDemo... 这行输出。
上述效果说明 FilterDemo 这个过滤器的 doFilter() 方法执行了,但是为什么在浏览器上看不到 hello.jsp 页面的内容呢?这是因为在 doFilter() 方法中添加放行的方法才能访问到 hello.jsp 页面。 那就在 doFilter() 方法中添加放行的代码:
//放行
chain.doFilter(request,response);再次重启服务器并访问 hello.jsp 页面,发现这次就可以在浏览器上看到页面效果。
FilterDemo 过滤器完整代码如下:
@WebFilter("/*")
public class FilterDemo implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("1.FilterDemo...");
//放行
chain.doFilter(request, response);
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}【易错点】忘写
chain.doFilter()导致页面空白,是本章第一个、也是最常见的一个错误,必须能一眼认出来:
- 现象:地址栏 URL 完全正常,既没有返回 404,也没有返回 500;浏览器页面一片空白;按 F12 看 Network,这条请求的状态码是 200,但是 Response 的字节数是 0;IDEA 控制台只有你自己写的那行
println。- 原因:请求被 Filter 拦下后,你既没有调用
chain.doFilter()放行,也没有自己往response里写任何东西。容器认为"这个请求处理完了,但没有产生内容",于是返回一个空响应体。- 正确做法:
doFilter里的每一条分支都必须二选一:要么放行(chain.doFilter),要么自己给出响应(response.getWriter().write(...)、forward、redirect)。什么都不做是最危险的写法,因为它不报错。- 判别技巧:状态码 200 但是 Response 为空,优先怀疑"请求被过滤器拦截了",而不是怀疑 JSP 写错了。
【补充】 为什么第一个演示要故意让你看到"空白页"?因为过滤器的默认行为是"拦",不是"过"。这一点和第 7 章的 session、第 8 章的 JSP 都不一样:那些组件你不写就不会生效,而 Filter 一旦注册就自动生效,你必须显式地放行。理解了这一点,9.4 的登录校验才顺理成章:那里的逻辑是"没登录就不放行",正是默认行为的自然用法。
到这里,过滤器已经能把请求拦下来、也能放行了。但"放行"到底是怎么个放法?请求被放行之后,还会不会再回到过滤器里?这就是下一节要拆的执行流程。
9.2 Filter 执行流程与拦截路径(原文 1.3、1.4)
9.2.1 执行流程(原文 1.3)
过滤器放行之后,请求去了哪里?资源执行完之后,又去了哪里?这是理解 Filter 的关键。我们通过以下两个问题来研究过滤器的执行流程:
问题一:放行后访问对应资源,资源访问完成后,还会回到 Filter 中吗?
会回到 Filter 中。如果一去不回,那"放行后对响应做处理"这类需求就无从实现了。
问题二:如果回到 Filter 中,是重头执行还是执行放行后的逻辑呢?
如果是重头执行的话,就意味着放行前逻辑会被执行两次,肯定不会这样设计;所以访问完资源后,会回到放行后逻辑,执行该部分代码。
通过上述的说明,我们就可以总结 Filter 的执行流程如下:
| 步骤 | 发生了什么 | 代码位置 |
|---|---|---|
| 1 | 请求到达服务器,进入过滤器 | doFilter 方法的开头 |
| 2 | 执行放行前逻辑 | chain.doFilter 之前的代码 |
| 3 | 执行放行代码,把请求交给下一个过滤器或资源 | chain.doFilter(request, response) |
| 4 | 访问到目标资源(servlet / jsp / html),生成响应 | 资源本身的代码 |
| 5 | 回到过滤器的放行后逻辑 | chain.doFilter 之后的代码 |
| 6 | 响应返回浏览器 | — |
下面通过代码验证。在 doFilter() 方法前后都加上输出语句:
@WebFilter("/*")
public class FilterDemo implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
//1. 放行前,对 request 数据进行处理
System.out.println("1.放行前...");
//放行
chain.doFilter(request, response);
//2. 放行后,对 response 数据进行处理
System.out.println("3.放行后...");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}同时在 hello.jsp 页面加上输出语句:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>hello JSP~</h1>
<%
System.out.println("2.hello jsp");
%>
</body>
</html>启动服务器访问 hello.jsp 页面,在控制台打印的内容是 1.放行前... → 2.hello jsp → 3.放行后...。打印顺序完全符合我们上面标记的标号,说明总结出来的流程没有问题。
这个流程带来一条非常实用的编码原则:以后我们可以将对请求进行处理的代码放在放行之前进行处理,而如果请求完资源后还要对响应的数据进行处理时,可以在放行后进行逻辑处理。
【补充】"洋葱模型":
chain.doFilter是往里钻,不是往外走。 很多人第一次看这段代码会以为chain.doFilter()之后的方法"不执行了"或者"马上执行";实际它是递归调用链的中间点。把chain.doFilter想象成"钻进去":钻到底(资源)之后开始"往外退",退的路上要原路经过每一层过滤器,只是顺序反过来。所以放行前的代码跑在请求方向,放行后的代码跑在响应方向。用一张表记住这两个方向各自能改什么:
| 阶段 | 数据流向 | 这时能做什么 | 这时不能做什么 |
|---|---|---|---|
| 放行前 | 请求下行(浏览器 → 资源) | 读/改请求参数与请求头、设置编码、判断是否放行、包装 request | 改响应内容(资源还没跑) |
| 资源执行 | — | Servlet/JSP 生成响应 | — |
| 放行后 | 响应上行(资源 → 浏览器) | 包装 response 追加内容、统计耗时、压缩、加页脚 | 改请求参数(资源已经读完了) |
注意最后一行:放行后再去 request.setParameter 或 setCharacterEncoding 是无效的,因为资源已经把参数读完、把响应写完了。这条规则是 9.3.3 讲"过滤器链顺序影响编码"的伏笔。
【考点】"
chain.doFilter()后面的代码什么时候执行?" 答案是:在目标资源(Servlet/JSP)执行完毕之后、响应真正提交给浏览器之前执行。 理由:doFilter是一次方法调用,chain.doFilter内部完成"调用下一个过滤器或资源"的全过程,返回时资源已经跑完了。
9.2.2 拦截路径配置(原文 1.4)
拦截路径表示 Filter 会对请求的哪些资源进行拦截,使用 @WebFilter 注解进行配置,如 @WebFilter("拦截路径")。拦截路径有如下四种配置方式:
| 配置方式 | 写法 | 含义 |
|---|---|---|
| 拦截具体的资源 | /index.jsp | 只有访问 index.jsp 时才会被拦截 |
| 目录拦截 | /user/* | 访问 /user 下的所有资源,都会被拦截 |
| 后缀名拦截 | *.jsp | 访问后缀名为 jsp 的资源,都会被拦截 |
| 拦截所有 | /* | 访问所有资源,都会被拦截 |
通过上面拦截路径的学习,你会发现拦截路径的配置方式和 Servlet 的请求资源路径配置方式一样,但是表示的含义不同。
不同在哪里?这一点原文只点了一句,考试却常考,展开说清楚:
| 对比项 | Servlet 的 url-pattern | Filter 的拦截路径 |
|---|---|---|
| 语义 | "我能处理哪些请求" | "我要拦哪些请求" |
| 匹配不上会怎样 | 404,请求没人处理 | 请求照常走,只是不经过这个过滤器 |
| 数量 | 一个 Servlet 类通常映射一组路径,是请求的终点 | 一个 Filter 可以拦很多路径,是路上的关卡,可以有很多道 |
有没有 / 这种写法 | 有,/ 表示默认 Servlet,处理静态资源 | 没有,Filter 只有 /*,没有单独的 / |
能不能写 /user/*.jsp | 不能,规范不允许混合匹配 | 同样不能,只能是精确、前缀 /*、扩展名 *.x 三选一 |
@WebFilter 还有几个常用属性,一起记住:
// 1. 最常用:拦截所有
@WebFilter("/*")
// 2. 等价的完整写法:value 属性
@WebFilter(value = "/*")
// 3. 拦截多个路径:用 urlPatterns 数组
@WebFilter(urlPatterns = {"/login.jsp", "/css/*", "/imgs/*"})
// 4. 指定拦截的请求类型:默认只有 REQUEST
@WebFilter(value = "/*", dispatcherTypes = {DispatcherType.REQUEST, DispatcherType.FORWARD})
// 5. 配初始化参数,配合 init 方法里的 filterConfig.getInitParameter("encoding") 使用
@WebFilter(value = "/*", initParams = {@WebInitParam(name = "encoding", value = "UTF-8")})【易错点】
@WebFilter与web.xml的/*写法差异,两种配置方式混用时最容易出错:
- 注解里路径是直接写在字符串里的:
@WebFilter("/*");web.xml里路径必须包在<url-pattern>标签里,而且要先声明<filter>再声明<filter-mapping>,两处的<filter-name>必须一字不差:<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>
- 顺序规则不同:
web.xml里按<filter-mapping>出现的先后顺序执行;注解配置按过滤器类名的字符串自然排序执行(9.3.3 详述)。想让"编码过滤器"永远排第一,注解方式下只能靠改类名或改用web.xml。- 别把两者同时写:同一个类上既有
@WebFilter又在web.xml里配一遍,容器会注册两个实例,doFilter执行两次(登录校验跑两遍,日志打两行),非常隐蔽。- 注解不生效的常见原因:
web.xml的<web-app>写了metadata-complete="true",或者version低于 3.0,注解会被整体忽略。【考点】
dispatcherTypes默认值是REQUEST:只拦"浏览器直接发来的请求",不拦服务器内部的forward和include。这条默认值有两个直接后果:① 9.4 的登录过滤器用forward跳登录页时,不会再次进入自己,所以不会无限递归;② 如果你想连转发也拦,必须显式写dispatcherTypes = DispatcherType.FORWARD,用web.xml则是加<dispatcher>FORWARD</dispatcher>。
9.3 过滤器链(原文 1.5)
9.3.1 概述(原文 1.5.1)
过滤器链是指在一个 Web 应用中可以配置多个过滤器,这多个过滤器称为过滤器链。 学习过滤器链,主要是学习过滤器链执行的流程。
假设有两个过滤器 Filter1、Filter2,请求进来后,过滤器链按照以下流程执行:
- 执行
Filter1的放行前逻辑代码 - 执行
Filter1的放行代码 - 执行
Filter2的放行前逻辑代码 - 执行
Filter2的放行代码 - 访问到资源
- 执行
Filter2的放行后逻辑代码 - 执行
Filter1的放行后逻辑代码
以上流程串起来就像一条链子,故称之为过滤器链。注意第 6、7 步的顺序:放行后的代码是反着回来的,先执行后进去的 Filter2,再执行先进去的 Filter1。这就是 9.2.1 里"洋葱模型"的具体体现:进去时从外到内,出来时从内到外。
9.3.2 代码演示(原文 1.5.2)
编写第一个过滤器 FilterDemo,配置成拦截所有资源:
@WebFilter("/*")
public class FilterDemo implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
//1. 放行前,对 request 数据进行处理
System.out.println("1.FilterDemo...");
//放行
chain.doFilter(request, response);
//2. 放行后,对 response 数据进行处理
System.out.println("5.FilterDemo...");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}编写第二个过滤器 FilterDemo2,同样配置成拦截所有资源:
@WebFilter("/*")
public class FilterDemo2 implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
//1. 放行前,对 request 数据进行处理
System.out.println("2.FilterDemo2...");
//放行
chain.doFilter(request, response);
//2. 放行后,对 response 数据进行处理
System.out.println("4.FilterDemo2...");
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void destroy() {
}
}修改 hello.jsp 页面中脚本的输出语句:
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h1>hello JSP~</h1>
<%
System.out.println("3.hello jsp");
%>
</body>
</html>启动服务器,在浏览器输入 http://localhost/filter-demo/hello.jsp 进行测试,控制台打印的顺序是:
1.FilterDemo...
2.FilterDemo2...
3.hello jsp
4.FilterDemo2...
5.FilterDemo...从结果可以看到,确实是按照我们之前说的执行流程进行执行的:FilterDemo 放行前 → FilterDemo2 放行前 → 资源 → FilterDemo2 放行后 → FilterDemo 放行后。
【补充】为了方便对照执行顺序,上面的输出标号按实际打印次序编成了 1~5。PDF 原文的两个过滤器里,
FilterDemo的放行后语句和hello.jsp的输出语句都写成了3.,同一份控制台里出现了两个3,属于排版错字,这里按真实执行顺序重编,代码逻辑与原意完全一致。
9.3.3 问题:谁先执行(原文 1.5.3)
上面代码中为什么是先执行 FilterDemo,后执行 FilterDemo2 呢?
我们现在使用的是注解配置 Filter,而这种配置方式的优先级是按照过滤器类名(字符串)的自然排序。比如有如下两个名称的过滤器:BFilterDemo 和 AFilterDemo,那一定是 AFilterDemo 过滤器先执行。
这个规则很反直觉,因为它和"代码里谁写在前面"毫无关系,只和类名首字母有关。想调整顺序,有三条路:
| 方式 | 怎么做 | 优先级规则 | 适用场景 |
|---|---|---|---|
| 改类名 | AFilter、BFilter,或加数字前缀 Filter01Login、Filter02Encoding | 类名字符串自然排序,小的先执行 | 注解配置、想快速验证 |
用 web.xml | 按 <filter-mapping> 的书写顺序排列 | 映射书写顺序,与类名无关 | 生产环境要求顺序固定 |
用 Spring Boot 的 FilterRegistrationBean | setOrder(int) | order 值小的先执行 | 现代 Spring Boot 项目 |
【易错点】过滤器链的顺序会直接影响编码是否生效,这是最隐蔽的一个顺序错误。假设你写了一个"统一编码过滤器":
@WebFilter("/*")
public class EncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=UTF-8");
chain.doFilter(request, response);
}
// init、destroy 省略
}【对比】如果它排在
LoginFilter后面,而LoginFilter里又调用了request.getParameter(...),那么编码设置就晚了:Tomcat 在第一次调用getParameter时就把请求体按当时的编码解析完并缓存了,之后再setCharacterEncoding("UTF-8")不会重新解析,中文照样乱码。正确做法是让编码过滤器永远排第一(注解下可用EncodingFilter这种以E开头的类名,正式项目用web.xml或FilterRegistrationBean.setOrder(1)固定)。另外提醒:
setCharacterEncoding只对请求体(POST 表单)有效,对 GET 请求的查询字符串无效:GET 参数由 Tomcat 的URIEncoding决定(Tomcat 8 起默认就是 UTF-8)。这个区别在第 6 章已经讲过,本章再次出现的地方通常是"POST 好了、GET 还乱"。【对比】 过滤器链 vs 责任链模式。过滤器链就是设计模式里的责任链:每个 Filter 只关心"我要做什么"和"要不要把请求交给下一个人",它不知道链上还有谁、也不知道终点是 Servlet 还是 JSP。好处是加一个功能就加一个 Filter,不用改任何已有代码(符合开闭原则);代价是排查问题时链路很长,一个请求到底过了几道关卡、每道改了什么,光看代码看不出来,只能靠日志。
【考点】 "两个过滤器都配
/*,谁先执行?" 答案是:注解配置按类名字符串排序,web.xml按filter-mapping的书写顺序。 理由:注解没有顺序语义,容器只能用类名排序保证可重复;web.xml是有序文档,顺序就是语义。
9.4 登录校验过滤器案例(原文 1.6)
9.4.1 需求(原文 1.6.1)
访问服务器资源时,需要先进行登录验证,如果没有登录,则自动跳转到登录页面。
这个需求听起来简单,但它正好是"横切关注点"的教科书级例子:登录校验和"查品牌""加品牌"这些业务没有一点关系,却要在每一个业务入口都执行一遍。
9.4.2 分析(原文 1.6.2)
我们要实现该功能,是在每一个资源里加入登录状态校验的代码吗?显然是不需要的,只需要写一个 Filter,在该过滤器中进行登录状态校验即可。
而在该 Filter 中逻辑如下:
| 步骤 | 判断 | 结果 |
|---|---|---|
| 1 | 从 session 中取 user | 取到非 null → 已登录 → 放行 |
| 2 | 从 session 中取 user | 取到 null → 未登录 → 存提示信息 + 跳登录页 |
| 3 | 例外 | 访问的是登录、注册、css、图片等资源 → 直接放行(这一条是 9.4.5 排查出问题之后补的) |
第 3 条现在看着理所当然,但它是"测试之后才发现的",我们按原文的顺序走一遍:先写前两条,撞墙,再补第三条。
9.4.3 创建 Filter(原文 1.6.3.1)
在 brand-demo 工程创建 com.itheima.web.filter 包,在该包下创建名为 LoginFilter 的过滤器:
@WebFilter("/*")
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
}
public void init(FilterConfig config) throws ServletException {
}
public void destroy() {
}
}9.4.4 编写逻辑代码(原文 1.6.3.2)
在 doFilter() 方法中编写登录状态校验的逻辑代码。
我们首先需要从 session 对象中获取用户信息,但是 ServletRequest 类型的 request 对象没有获取 session 对象的方法,所以此时需要将 request 对象强转成 HttpServletRequest 对象:
HttpServletRequest req = (HttpServletRequest) request;然后完成以下逻辑:
- 获取
Session对象; - 从
Session对象中获取名为user的数据; - 判断获取到的数据是否是
null:- 如果不是,说明已经登录,放行;
- 如果是,说明尚未登录,将提示信息存储到域对象中并跳转到登录页面。
代码如下:
@WebFilter("/*")
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest req = (HttpServletRequest) request;
//1. 判断session中是否有user
HttpSession session = req.getSession();
Object user = session.getAttribute("user");
//2. 判断user是否为null
if (user != null) {
// 登录过了
//放行
chain.doFilter(request, response);
} else {
// 没有登录,存储提示信息,跳转到登录页面
req.setAttribute("login_msg", "您尚未登录!");
req.getRequestDispatcher("/login.jsp").forward(req, response);
}
}
public void init(FilterConfig config) throws ServletException {
}
public void destroy() {
}
}【考点】"为什么必须把
ServletRequest强转成HttpServletRequest?" 答案是:doFilter的形参是ServletRequest接口,它只声明了 HTTP 无关的通用方法,getSession()、getRequestURI()、getMethod()都定义在子接口HttpServletRequest上。 理由:这是 Servlet 规范为了兼容非 HTTP 协议做的抽象,而 Tomcat 传给我们的实际对象本来就是RequestFacade(实现了HttpServletRequest),所以强转是安全的。反过来,如果有人把 Filter 用在非 HTTP 场景,这个强转就会抛ClassCastException,不过现实中不存在这种场景。【补充】 这里用的是
forward(转发),没有用redirect(重定向)。原因是要携带提示信息,方便只是附带的好处。转发是服务器内部的一次跳转:同一个请求对象、同一个 request 域,所以req.setAttribute("login_msg", ...)存进去的值,login.jsp里用${login_msg}或request.getAttribute能取到,浏览器地址栏也不变。重定向则是让浏览器重新发一次请求,request 域直接销毁,提示信息会丢,除非把它拼到 URL 上或临时存进 session。结论是:要带数据用转发,要换地址用重定向(第 6 章那张对比表在这里第二次用到)。
9.4.5 测试并抛出问题(原文 1.6.3.3)
在浏览器上输入 http://localhost:8080/brand-demo/,可以看到确实跳转到了登录页面:没有登录时确实被拦下来了,需求的前半部分实现了。
但是登录页面为什么展示成"没有样式"的样子呢?页面上只有几个输入框和白底黑字,登录按钮也没有蓝色背景,整个页面像是被剥掉了皮肤。
9.4.6 问题分析及解决(原文 1.6.3.4)
原因是:登录页面需要 css/login.css 这个文件进行样式的渲染,而登录页面在 <head> 里是用 <link> 标签引入这个 css 文件的。浏览器在渲染页面时,会为这个 <link> 另外发一次 HTTP 请求去拿 css/login.css。这次请求同样命中了 /* 的 LoginFilter。
此时 session 里当然没有 user(用户还没登录呢),于是过滤器把这次 css 请求也当成"未登录访问",给它 forward 到了 login.jsp。浏览器拿到的是一个 HTML 页面而不是 css 文件,样式自然加载失败。"在请求这个 css 资源时被过滤器拦截,就相当于没有加载到样式文件",这就是页面样式全部失效的原因。
解决这个问题,只需要对所有的登录相关的资源进行放行即可。还有一种情况就是当没有用户信息时需要进行注册,而注册时也希望被过滤器放行。综上,我们需要在判断 session 中是否包含用户信息之前,加上对登录及注册相关资源放行的逻辑处理:
//判断访问资源路径是否和登录注册相关
//1,在数组中存储登录和注册相关的资源路径
String[] urls = {"/login.jsp", "/imgs/", "/css/", "/loginServlet",
"/register.jsp", "/registerServlet", "/checkCodeServlet"};
//2,获取当前访问的资源路径
String url = req.getRequestURL().toString();
//3,遍历数组,获取到每一个需要放行的资源路径
for (String u : urls) {
//4,判断当前访问的资源路径字符串是否包含要放行的资源路径字符串
/*
比如当前访问的资源路径是 /brand-demo/login.jsp
而字符串 /brand-demo/login.jsp 包含了 字符串 /login.jsp ,所以这个字符串就需要放行
*/
if (url.contains(u)) {
//找到了,放行
chain.doFilter(request, response);
//break;
return;
}
}【易错点】这段放行代码有两个细节,写错了行为完全不同:
return不能换成break。break只是跳出for循环,循环之后还有"判断 session 有没有 user"的代码,会继续往下走,于是放行了一次、又被forward到登录页一次,页面直接报IllegalStateException: Cannot forward after response has been committed(响应已提交,不能再转发)。原文把break注释掉、改写成return,就是为了放行之后立刻结束整个doFilter方法。如果非要用break,那循环后面必须补一句return;。chain.doFilter之后必须return,不能"放行完继续判断"。放行意味着这个请求的后续处理已经交给下一个组件了,Filter 里不该再对它做任何决定。还有一个隐藏的错误:
url.contains(u)是字符串包含判断,不是精确匹配。/css/能匹配/css/login.css很好,但如果有恶意用户请求/brand-demo/xxx/login.jsp/../../admin,包含判断也可能被绕过。生产环境应该用req.getServletPath()做前缀匹配,或用 Spring Security 的匹配器。这个案例用contains是为了简单,考试按原文写即可,但要能说出它的不足。【补充】 为什么登录页被拦之后"没有报错,只是没样式"?因为过滤器对它做的是
forward到login.jsp,返回的是一个合法的 HTML 页面(HTTP 200)。浏览器把这段 HTML 当成 css 来解析,解析失败就静默忽略,控制台只会有一条"Resource interpreted as Stylesheet but transferred with MIME type text/html"的警告。这就是"过滤器拦得太宽"的典型症状:不报错、不 404,只是样式/图片/JS 静默失效。 排查时按 F12 看 Network,凡是 Content-Type 与预期不符的请求,都要怀疑被过滤器截胡了。【对比】 白名单放行 vs 只拦业务路径,两种思路都能解决这个问题,但要会选:
思路 写法 优点 缺点 白名单放行(本案例) 仍配 /*,在代码里判断路径,命中白名单就return放行一个过滤器管全部,静态资源和接口都在视野内 白名单会越来越长,漏一个就出问题 只拦业务路径 配 /brand/*、/selectAllServlet等具体路径不用写放行逻辑,静态资源天然不受影响 新增接口忘了加映射就失去保护 生产环境通常两种一起用:映射写宽一点(
/*),代码里做白名单,白名单集中配置在一处而不是散落在数组里。
9.4.7 过滤器完整代码(原文 1.6.3.5)
把放行逻辑和登录校验拼起来,LoginFilter 的完整代码如下:
@WebFilter("/*")
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest req = (HttpServletRequest) request;
//判断访问资源路径是否和登录注册相关
//1,在数组中存储登录和注册相关的资源路径
String[] urls = {"/login.jsp", "/imgs/", "/css/", "/loginServlet",
"/register.jsp", "/registerServlet", "/checkCodeServlet"};
//2,获取当前访问的资源路径
String url = req.getRequestURL().toString();
//3,遍历数组,获取到每一个需要放行的资源路径
for (String u : urls) {
//4,判断当前访问的资源路径字符串是否包含要放行的资源路径字符串
/*
比如当前访问的资源路径是 /brand-demo/login.jsp
而字符串 /brand-demo/login.jsp 包含了 字符串 /login.jsp ,所以这个字符串就需要放行
*/
if (url.contains(u)) {
//找到了,放行
chain.doFilter(request, response);
//break;
return;
}
}
//1. 判断session中是否有user
HttpSession session = req.getSession();
Object user = session.getAttribute("user");
//2. 判断user是否为null
if (user != null) {
// 登录过了
//放行
chain.doFilter(request, response);
} else {
// 没有登录,存储提示信息,跳转到登录页面
req.setAttribute("login_msg", "您尚未登录!");
req.getRequestDispatcher("/login.jsp").forward(req, response);
}
}
public void init(FilterConfig config) throws ServletException {
}
public void destroy() {
}
}再补两个配套文件,这个案例才算完整:登录页 login.jsp 负责把提示信息显示出来,LoginServlet 负责在登录成功后往 session 里放 user。
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>登录</title>
<link href="css/login.css" rel="stylesheet">
</head>
<body>
<h3>登录</h3>
<!-- 过滤器转发过来时带过来的提示信息 -->
<span style="color:red">${login_msg}</span>
<form action="loginServlet" method="post">
用户名:<input type="text" name="username"><br>
密码:<input type="password" name="password"><br>
<input type="submit" value="登录">
</form>
</body>
</html>@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
private UserService userService = new UserService();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//1. 接收用户名和密码
String username = request.getParameter("username");
String password = request.getParameter("password");
//2. 调用Service查询
User user = userService.login(username, password);
//3. 判断
if (user != null) {
// 登录成功,把用户存进 session,后面的 LoginFilter 就是靠它放行的
request.getSession().setAttribute("user", user);
response.sendRedirect(request.getContextPath() + "/brand.html");
} else {
// 登录失败,回到登录页
request.setAttribute("login_msg", "用户名或密码错误!");
request.getRequestDispatcher("/login.jsp").forward(request, response);
}
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}【易错点】登录页自己也被拦 → 无限递归。如果
LoginFilter在未登录时forward的目标/login.jsp不在放行数组里,而且过滤器的dispatcherTypes又包含了FORWARD,那么"转发到 login.jsp"这个动作会再次触发LoginFilter,又一次判断未登录、又一次转发……直到栈溢出(StackOverflowError)或浏览器报"重定向次数过多"。这个案例没出事,是因为@WebFilter默认只拦REQUEST(见 9.2.2 的考点)。所以:一旦你在注解里加了dispatcherTypes = DispatcherType.FORWARD,就必须同时把login.jsp放进白名单,两件事是一对,缺一个就炸。
9.5 Listener(原文 2)
9.5.1 概述(原文 2.1)
Listener 表示监听器,是 JavaWeb 三大组件(Servlet、Filter、Listener)之一。
监听器的作用,就是在 application、session、request 三个对象创建、销毁,或者往其中添加、修改、删除属性时,自动执行代码的功能组件。
request和session我们学习过,分别是HttpServletRequest和HttpSession类型的对象。- 而 application 是
ServletContext类型的对象。ServletContext代表整个 Web 应用,在服务器启动的时候,Tomcat 会自动创建该对象;在服务器关闭时会自动销毁该对象。
三者对照一下,作用域的大小关系一目了然:
| 域对象 | 类型 | 何时创建 | 何时销毁 | 作用范围 |
|---|---|---|---|---|
| application | ServletContext | 服务器启动、应用加载时 | 服务器关闭、应用卸载时 | 整个 Web 应用,所有用户共享 |
| session | HttpSession | 第一次调用 getSession() 时 | 超时(默认 30 分钟)或 invalidate() | 一次会话,一个用户独享 |
| request | HttpServletRequest | 每次请求到达时 | 响应结束时 | 一次请求 |
"监听"这个词很直观:你不用主动调用,只要事件发生,容器就会回头调你的方法。类比一下 JS 里的 onclick:你不点按钮,函数不执行;按钮被点了,函数自动执行。
9.5.2 分类(原文 2.2)
JavaWeb 提供了 8 个监听器,按监听的对象分成三组:
| 监听器接口 | 监听的对象 | 触发时机 | 常用度 |
|---|---|---|---|
ServletContextListener | application | ServletContext 创建 / 销毁 | ★★★★★ |
ServletContextAttributeListener | application | 域属性添加 / 替换 / 移除 | ★★☆☆☆ |
HttpSessionListener | session | HttpSession 创建 / 销毁 | ★★★★☆ |
HttpSessionAttributeListener | session | 域属性添加 / 替换 / 移除 | ★★☆☆☆ |
HttpSessionBindingListener | session 中的对象 | 对象被绑定 / 解绑到 session | ★☆☆☆☆ |
HttpSessionActivationListener | session 中的对象 | session 钝化 / 活化(序列化相关) | ★☆☆☆☆ |
ServletRequestListener | request | ServletRequest 创建 / 销毁 | ★★☆☆☆ |
ServletRequestAttributeListener | request | 域属性添加 / 替换 / 移除 | ★☆☆☆☆ |
这里面只有 ServletContextListener 这个监听器后期我们会经常接触到,ServletContextListener 是用来监听 ServletContext 对象的创建和销毁的。
ServletContextListener 接口中有以下两个方法:
void contextInitialized(ServletContextEvent sce):ServletContext对象被创建了会自动执行的方法;void contextDestroyed(ServletContextEvent sce):ServletContext对象被销毁时会自动执行的方法。
9.5.3 代码演示(原文 2.3)
我们只演示一下 ServletContextListener 监听器,分三步:
- 定义一个类,实现
ServletContextListener接口; - 重写所有的抽象方法;
- 使用
@WebListener进行配置。
代码如下:
@WebListener
public class ContextLoaderListener implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
//加载资源
System.out.println("ContextLoaderListener...");
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
//释放资源
}
}启动服务器,就可以在启动的日志信息中看到 contextInitialized() 方法输出的 ContextLoaderListener...,同时也说明了 ServletContext 对象在服务器启动的时候被创建了。关闭服务器时,contextDestroyed() 会被调用(可以在里面打一行日志验证)。
【补充】Listener 的三个典型用途,考试和工作都绕不开:
- Spring 的
ContextLoaderListener。这是最有名的一个。Spring 与 JavaWeb 整合时,在web.xml里配置org.springframework.web.context.ContextLoaderListener,它在contextInitialized里创建 Spring 的 IoC 容器(WebApplicationContext),并放入ServletContext;contextDestroyed里关闭容器、释放连接池。这就是"服务器一启动,Spring 就准备好了"的实现方式,没有它,你的@Autowired在 Servlet 里永远拿不到对象。文件名和本节示例类名相同,不是巧合,示例就是照着它起的。- 统计在线人数。用
HttpSessionListener的sessionCreated/sessionDestroyed加减计数。见下面的易错点。- 加载配置文件。在
contextInitialized里读取db.properties、application.properties,把解析结果存进ServletContext(sce.getServletContext().setAttribute("config", map)),全应用共享,避免每个 Servlet 都读一遍文件。这也是"只做一次的事,放在只执行一次的地方"这条原则的体现。【易错点】 用
HttpSessionListener统计在线人数,并发和时机两头都可能错:
- 并发丢更新。
sessionCreated会被多个请求线程同时调用,如果写count++(int成员变量),count++不是原子操作(读-改-写三步),两个线程同时读到 99,都写成 100,实际来了 2 个人却只加了 1。正确做法是用AtomicInteger:@WebListener public class OnlineCountListener implements HttpSessionListener { // 用原子类保证并发安全,不能用 int 直接 ++ private static final AtomicInteger ONLINE = new AtomicInteger(0); @Override public void sessionCreated(HttpSessionEvent se) { System.out.println("上线,当前在线:" + ONLINE.incrementAndGet()); } @Override public void sessionDestroyed(HttpSessionEvent se) { System.out.println("下线,当前在线:" + ONLINE.decrementAndGet()); } }
- 计数天然不准。
sessionDestroyed只在 session 超时或手动invalidate()时触发;服务器被kill -9、断电、进程崩溃时不会触发,计数只增不减;用户直接关浏览器也不会立刻减(要等 30 分钟超时)。所以这个数字只能算"活跃会话数",不是"当前在线人数"。- 集群下各自为政。多台 Tomcat 时,每台的 Listener 只统计自己收到的 session,总数要靠 Redis 共享或专门的会话管理,单机 Listener 做不到。
【考点】 "Listener 和 Filter 有什么区别?" 答案是:Filter 是"拦请求",主动参与请求处理,能改变请求和响应;Listener 是"听事件",被动等待容器回调,不参与请求链路。 理由:Filter 在请求链上,Listener 绑定在域对象的生命周期事件上。
9.6 Ajax(原文 3)
9.6.1 概述(原文 3.1)
AJAX(Asynchronous JavaScript And XML):异步的 JavaScript 和 XML。
我们先来说概念中的 JavaScript 和 XML:JavaScript 表明该技术和前端相关;XML 是指以此进行数据交换。而这两个我们之前都学习过(XML 的标签格式在第 4 章看过,JavaScript 在第 3 章学过)。
这个概念名带着一点"历史包袱":今天前后端之间传的早已经是 JSON(9.7 节详述),XML 基本不再使用,但是 AJAX 这个名字留下来了。理解它只要抓住一个词:异步。
一、作用:与服务器进行数据交换
通过 AJAX 可以给服务器发送请求,服务器将数据直接响应回给浏览器。
我们先来看之前做功能的流程:Servlet 调用完业务逻辑层后将数据存储到域对象中,然后跳转到指定的 jsp 页面,在页面上使用 EL 表达式和 JSTL 标签库进行数据的展示。这条链路的终点是"一个完整的 HTML 页面":服务器把页面拼好,浏览器整页替换。
而我们学习了 AJAX 后,就可以使用 AJAX 和服务器进行通信,以达到使用 HTML + AJAX 来替换 JSP 页面了:浏览器发送请求给 servlet,servlet 调用完业务逻辑层后将数据直接响应回给浏览器页面,页面使用 HTML 来进行数据展示。服务器不再返回页面,只返回数据。
两种方式的差别,一张表说清:
| 对比项 | 传统 JSP 方案 | HTML + AJAX 方案 |
|---|---|---|
| 服务器返回什么 | 拼好的完整 HTML 页面 | 纯数据(JSON 字符串) |
| 谁负责渲染 | 服务器(JSP 里 EL/JSTL) | 浏览器(JS 拼 DOM) |
| 浏览器是否整页刷新 | 是 | 否,只更新局部 |
| 前后端分工 | 前端要会 JSP,耦合在一起 | 前端只管页面,后端只管数据,可并行开发 |
| 服务器压力 | 每次都要渲染整个页面 | 只传数据,报文更小 |
二、作用:异步交互
可以在不重新加载整个页面的情况下,与服务器交换数据并更新部分网页,如:搜索联想、用户名是否可用校验,等等。
搜索联想的效果我们经常见到:在输入框输入一些关键字(例如"奥运")后,下面就会联想出相关的内容;联想出来的这部分数据肯定存储在服务器上,而我们并没有看出页面重新刷新,这就是更新局部页面的效果。
再比如注册页:在用户名输入框输入用户名,当输入框一失去焦点,如果用户名已经被占用,就会在下方展示提示信息;在这整个过程中也没有页面的刷新,只是在局部展示出了提示信息,这也是更新局部页面的效果。
三、同步和异步
知道了局部刷新后,接下来再聊聊同步和异步。
同步发送请求的过程:浏览器页面在发送请求给服务器后,在服务器处理请求的过程中,浏览器页面不能做其他的操作,只能等到服务器响应结束后,浏览器页面才能继续做其他的操作。表现就是:点一下按钮,整个页面"卡住"、转圈,期间点什么都没反应。
异步发送请求的过程:浏览器页面发送请求给服务器,在服务器处理请求的过程中,浏览器页面还可以做其他的操作。表现就是:请求在后台跑,页面照常滚动、照常输入,数据回来了再悄悄填进去。
【补充】同步为什么"卡"?因为浏览器对同一个页面(同一个 window)的主线程是单线程的:解析 HTML、执行 JS、响应点击,都排在这一条线程上。同步请求(比如直接提交表单、点
<a>链接)会阻塞这条线程直到响应回来,所以页面"冻住"。Ajax 的"异步"并没有开新线程执行你的 JS,它把网络等待交给了浏览器的网络线程,主线程继续跑;等数据回来,浏览器再把回调函数排进主线程的任务队列执行。这也解释了一个常见疑问:为什么在onreadystatechange里写alert会"卡住整个页面",因为回调本身还是在主线程上跑的。
9.6.2 快速入门(原文 3.2)
一、服务端实现
在项目中创建 com.itheima.web.servlet 包,并在该包下创建名为 AjaxServlet 的 servlet:
@WebServlet("/ajaxServlet")
public class AjaxServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//1. 响应数据
response.getWriter().write("hello ajax~");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}注意这里和以前最大的不同:没有 request.getRequestDispatcher(...).forward(...),也没有 sendRedirect。Servlet 直接把一段字符串写进响应体就结束了。这段内容是数据,不是页面。
二、客户端实现
在 webapp 下创建名为 01-ajax-demo1.html 的页面,在该页面书写 ajax 代码。分三步:
第一步,创建核心对象,不同的浏览器创建的对象是不同的:
var xhttp;
if (window.XMLHttpRequest) {
xhttp = new XMLHttpRequest();
} else {
// code for IE6, IE5
xhttp = new ActiveXObject("Microsoft.XMLHTTP");
}第二步,发送请求:
//建立连接
xhttp.open("GET", "http://localhost:8080/ajax-demo/ajaxServlet");
//发送请求
xhttp.send();第三步,获取响应:
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
// 通过 this.responseText 可以获取到服务端响应的数据
alert(this.responseText);
}
};完整代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
//1. 创建核心对象
var xhttp;
if (window.XMLHttpRequest) {
xhttp = new XMLHttpRequest();
} else {
// code for IE6, IE5
xhttp = new ActiveXObject("Microsoft.XMLHTTP");
}
//2. 发送请求
xhttp.open("GET", "http://localhost:8080/ajax-demo/ajaxServlet");
xhttp.send();
//3. 获取响应
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
alert(this.responseText);
}
};
</script>
</body>
</html>三、测试
在浏览器地址栏输入 http://localhost:8080/ajax-demo/01-ajax-demo1.html,在 01-ajax-demo1.html 加载的时候就会发送 ajax 请求,页面上会弹出一个提示框,内容是 hello ajax~。
我们可以通过开发者模式查看发送的 AJAX 请求:在浏览器上按 F12 快捷键打开开发者工具。默认看到的是所有的请求;如果我们只是想看异步请求,点击 All 旁边的 XHR,会发现只展示 Type 是 xhr 的请求。
【补充】
readyState与status为什么必须同时判断:这是 Ajax 读代码题的必考点,两个变量管的根本不是一件事:
| 变量 | 含义 | 取值 |
|---|---|---|
readyState | 请求/响应走到哪一步了(状态机) | 0 = 未初始化(open 之前);1 = 已建立连接(open 之后);2 = 已收到响应头;3 = 正在接收响应体;4 = 响应体接收完毕 |
status | 服务器给的 HTTP 状态码(业务结果) | 200 成功;404 找不到;500 服务器内部错误;302 重定向 |
- 只判
readyState == 4:不管成功失败都会进if。服务器返回 500 时,responseText里是一整页 Tomcat 的错误堆栈 HTML,你的代码会把它当数据alert出来,或者JSON.parse直接抛异常。- 只判
status == 200:这个条件在readyState变成 2 或 3 的时候就可能已经满足了(响应头一到,status 就是 200),此时响应体还没收完,responseText是半截数据。- 所以标准写法是
if (this.readyState == 4 && this.status == 200),两个条件缺一不可。这也解释了为什么onreadystatechange会被回调 3~4 次(每次readyState变化都触发),而处理数据的只有最后一次。另外,回调函数里的
this指向的是xhttp这个 XMLHttpRequest 对象(因为它是作为xhttp.onreadystatechange被调用的),所以this.responseText就是响应文本。如果换成箭头函数() => {},this就不再是 xhttp 了,这也是老代码坚持用function(){}的原因之一。
9.6.3 案例:注册时校验用户名是否已存在(原文 3.3)
需求:在完成用户注册时,当用户名输入框失去焦点时,校验用户名是否在数据库已存在。
一、分析
前端完成的逻辑:
- 给用户名输入框绑定光标失去焦点事件
onblur; - 发送 ajax 请求,携带
username参数; - 处理响应:是否显示提示信息。
后端完成的逻辑:
- 接收用户名;
- 调用 service 查询
User。此案例是为了演示前后端异步交互,所以此处我们不做业务逻辑处理; - 返回标记。
整体流程就是:失焦 → 发请求 → 查库 → 回一个 true/false → 页面根据标记显示或隐藏提示文字。全程没有页面跳转,也没有表单提交。
二、后端实现
在 com.itheima.web.servlet 包中定义名为 SelectUserServlet 的 servlet。代码如下:
@WebServlet("/selectUserServlet")
public class SelectUserServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//1. 接收用户名
String username = request.getParameter("username");
//2. 调用service查询User对象,此处不进行业务逻辑处理,直接给 flag 赋值为 true,表明用户名占用
boolean flag = true;
//3. 响应标记
response.getWriter().write("" + flag);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}【补充】那句
response.getWriter().write("" + flag)里的空字符串不是多余的,它是为了把boolean转成String。因为PrintWriter只有write(String)、write(char[])、write(int)这几个重载,没有write(boolean):直接写write(flag)编译不过;而如果传write(98)这种int,它会被当成字符的码点,写出的是字母b,不是数字 98。所以拼一个空串(或String.valueOf(flag))是必须的。同理,write(1)写出来的是控制字符,不是 "1"。
三、前端实现
将静态页面拷贝到项目 webapp 下,并在 register.html 页面的 body 结束标签前编写 script 标签,在该标签中实现如下逻辑。
第一步:给用户名输入框绑定光标失去焦点事件 onblur
//1. 给用户名输入框绑定 失去焦点事件
document.getElementById("username").onblur = function () {
}页面上要有这样一个输入框和一个默认隐藏的错误提示:
用户名:<input type="text" id="username" name="username">
<span id="username_err" style="display:none;color:red">用户名已存在</span>第二步:发送 ajax 请求,携带 username 参数
在第一步绑定的匿名函数中书写发送 ajax 请求的代码:
//2. 发送ajax请求
//2.1. 创建核心对象
var xhttp;
if (window.XMLHttpRequest) {
xhttp = new XMLHttpRequest();
} else {
// code for IE6, IE5
xhttp = new ActiveXObject("Microsoft.XMLHTTP");
}
//2.2. 发送请求
xhttp.open("GET", "http://localhost:8080/ajax-demo/selectUserServlet");
xhttp.send();
//2.3. 获取响应
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
//处理响应的结果
}
};由于我们发送的是 GET 请求,所以需要在 URL 后拼接从输入框获取的用户名数据。而我们在第一步绑定的匿名函数中通过以下代码可以获取用户名数据:
// 获取用户名的值
var username = this.value; //this : 给谁绑定的事件,this就代表谁而携带数据需要将 URL 修改为:
xhttp.open("GET", "http://localhost:8080/ajax-demo/selectUserServlet?username=" + username);第三步:处理响应,决定是否显示提示信息
当 this.readyState == 4 && this.status == 200 条件满足时,说明已经成功响应数据了。此时需要判断响应的数据是否是 "true" 字符串:如果是,说明用户名已经占用,给出错误提示;如果不是,说明用户名未被占用,清除错误提示。代码如下:
//判断
if (this.responseText == "true") {
//用户名存在,显示提示信息
document.getElementById("username_err").style.display = '';
} else {
//用户名不存在,清除提示信息
document.getElementById("username_err").style.display = 'none';
}前端完成代码如下:
//1. 给用户名输入框绑定 失去焦点事件
document.getElementById("username").onblur = function () {
//2. 发送ajax请求
// 获取用户名的值
var username = this.value;
//2.1. 创建核心对象
var xhttp;
if (window.XMLHttpRequest) {
xhttp = new XMLHttpRequest();
} else {
// code for IE6, IE5
xhttp = new ActiveXObject("Microsoft.XMLHTTP");
}
//2.2. 发送请求
xhttp.open("GET", "http://localhost:8080/ajax-demo/selectUserServlet?username=" + username);
xhttp.send();
//2.3. 获取响应
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
//alert(this.responseText);
//判断
if (this.responseText == "true") {
//用户名存在,显示提示信息
document.getElementById("username_err").style.display = '';
} else {
//用户名不存在,清除提示信息
document.getElementById("username_err").style.display = 'none';
}
}
};
}【易错点】同源策略与跨域,是 Ajax 报错里最常见、也最容易吓人的一个:
- 什么是同源:协议、域名、端口三者完全相同才算同源。
http://localhost:8080/ajax-demo/register.html请求http://localhost:8080/ajax-demo/selectUserServlet是同源(协议 http、域名 localhost、端口 8080 都一样),所以上面的代码能直接跑。- 什么情况会跨域:页面是
http://localhost:8080,接口写成http://localhost:9090(端口不同);页面用file://直接双击打开(协议不同);页面在127.0.0.1而接口在localhost(域名不同,虽然指向同一台机器)。这时浏览器控制台会给出红色报错:Access to XMLHttpRequest at '...' from origin '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.,注意 请求已经发出去了、服务器也处理了,只是浏览器不让 JS 读响应。- 怎么解决:① 最省事:部署到同一个域和端口(本课程所有案例都是这样,页面和 Servlet 都在一个 war 里);② 后端加 CORS 响应头
response.setHeader("Access-Control-Allow-Origin", "*"),复杂请求还要处理OPTIONS预检;③ 开发阶段用前端 dev-server 的 proxy 转发。生产环境推荐 ① 或网关统一处理。记住结论:跨域是浏览器施加的安全限制,服务器层面并没有这条限制。用 Postman 请求同一个接口是成功的,这证明"接口没问题,是浏览器拦的"。
【对比】
onblur校验 vs 表单提交校验:
对比项 表单提交后在 Servlet 里校验 onblur+ Ajax 校验用户何时知道结果 填完所有项、点提交、页面刷新之后 光标一离开输入框,立刻知道 已填内容会不会丢 会(整页刷新,除非回填) 不会(页面没动) 服务器压力 一次提交一次校验 每个字段失焦都发一次请求,请求数变多 能否省掉后端校验 不能 也不能,前端校验只为体验,绕过太容易(禁用 JS、直接调接口) 最后一行是安全常识:前端校验是体验,后端校验才是安全。所以真实项目里两边都要写,后端的非空、唯一性校验一个都不能少。
9.7 axios 与 JSON 数据交换(原文 4、5)
9.7.1 基本使用(原文 4.1)
Axios 对原生的 AJAX 进行封装,简化书写。 Axios 官网是 https://www.axios-http.cn。
对比一下 9.6 里那段原生代码就能体会"简化"有多值:创建对象要判浏览器、发送要 open + send、收数据要监听 onreadystatechange 再判两个状态码。axios 把这些全包掉了,只留一个"发请求 + 拿结果"。
axios 使用是比较简单的,分为以下两步:
第一步:引入 axios 的 js 文件
<script src="js/axios-0.18.0.js"></script>第二步:使用 axios 发送请求,并获取响应结果
发送 get 请求:
axios({
method: "get",
url: "http://localhost:8080/ajax-demo1/aJAXDemo1?username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})发送 post 请求:
axios({
method: "post",
url: "http://localhost:8080/ajax-demo1/aJAXDemo1",
data: "username=zhangsan"
}).then(function (resp) {
alert(resp.data);
});axios() 是用来发送异步请求的,小括号中使用 js 对象传递请求相关的参数:
method属性:用来设置请求方式的,取值为get或者post;url属性:用来书写请求的资源路径。如果是 get 请求,需要将请求参数拼接到路径的后面,格式为url?参数名=参数值&参数名2=参数值2;data属性:作为请求体被发送的数据。如果是 post 请求,数据需要作为data属性的值。
then() 需要传递一个匿名函数。我们将 then() 中传递的匿名函数称为回调函数,意思是该匿名函数在发送请求时不会被调用,而是在成功响应后调用的函数。而该回调函数中的 resp 参数是对响应的数据进行封装的对象,通过 resp.data 可以获取到响应的数据。
resp 这个对象里到底有什么?记这四个就够用:
| 属性 | 含义 | 用法 |
|---|---|---|
resp.data | 服务器返回的响应体,axios 会自动把 JSON 串解析成 JS 对象/数组 | 最常用,直接 resp.data.xxx |
resp.status | HTTP 状态码 | 一般不需要判,非 2xx 会走 catch |
resp.statusText | 状态码文字,如 OK | 调试用 |
resp.headers | 响应头 | 取 Content-Type、自定义头 |
【对比】
then/catch与async/await两种写法对照,同一件事的两种表达,考试可能让你改写:
// 写法一:then / catch(原文用的写法,回调风格)
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: { username: "zhangsan" }
}).then(function (resp) {
// 成功:resp.data 是响应体
alert(resp.data);
}).catch(function (err) {
// 失败:网络错误、超时、4xx、5xx 都会进这里
console.log(err);
});
// 写法二:async / await(等价写法,同步风格,更好读)
async function send() {
try {
const resp = await axios.post(
"http://localhost:8080/ajax-demo/axiosServlet",
{ username: "zhangsan" }
);
alert(resp.data);
} catch (err) {
console.log(err);
}
}
send();【补充】两者的对应关系是:
then的回调 ≈await之后继续执行的代码;catch≈try/catch的catch。区别在于await必须写在async函数里,否则语法错误;而且await会让"看起来同步"的代码在等待期间不阻塞主线程(它只是把后续代码挂起,主线程照样响应点击)。还有一个细节:catch里的err.response只有在服务器返回了响应(4xx/5xx)时才存在,如果是断网、跨域被拦、超时,err.response是undefined,只有err.request。判断"是接口报错还是网络不通",就看这两个字段。【补充】 axios 传对象和传字符串,发出的请求根本不是一类,这是"后端收不到参数"的头号原因:
data的写法axios 自动设置的 Content-Type请求体长相 后端怎么取 data: "username=zhangsan"application/x-www-form-urlencodedusername=zhangsanrequest.getParameter("username")能取到data: { username: "zhangsan" }application/json;charset=UTF-8{"username":"zhangsan"}getParameter取不到,必须request.getReader()读流再JSON.parseObject所以 9.7.2 的入门案例传字符串、用
getParameter;9.8.2 的添加品牌传对象、用getReader。这两处是配套的,都不是随便写的。改了一边没改另一边,现象就是getParameter返回null,后端拿到的对象全是null字段。
9.7.2 快速入门(原文 4.2)
一、后端实现
定义一个用于接收请求的 servlet,代码如下:
@WebServlet("/axiosServlet")
public class AxiosServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("get...");
//1. 接收请求参数
String username = request.getParameter("username");
System.out.println(username);
//2. 响应数据
response.getWriter().write("hello Axios~");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
System.out.println("post...");
this.doGet(request, response);
}
}二、前端实现
第一步,引入 js 文件:
<script src="js/axios-0.18.0.js"></script>第二步,发送 ajax 请求。
get 请求:
axios({
method: "get",
url: "http://localhost:8080/ajax-demo/axiosServlet?username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})post 请求:
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: "username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})整体页面代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script src="js/axios-0.18.0.js"></script>
<script>
//1. get
/* axios({
method:"get",
url:"http://localhost:8080/ajax-demo/axiosServlet?username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})*/
//2. post 在js中{} 表示一个js对象,而这个js对象中有三个属性
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: "username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})
</script>
</body>
</html>测试时,页面上会弹出 hello Axios~;同时 IDEA 控制台会打印 post... 和 zhangsan,说明参数确实被后端收到了。把注释里的 get 那段放开、把 post 注释掉,控制台就变成 get... 和 zhangsan。
9.7.3 请求方法别名(原文 4.3)
为了方便起见,axios 已经为所有支持的请求方法提供了别名。如下:
| 请求方式 | 别名写法 |
|---|---|
| get 请求 | axios.get(url[, config]) |
| delete 请求 | axios.delete(url[, config]) |
| head 请求 | axios.head(url[, config]) |
| options 请求 | axios.option(url[, config]) |
| post 请求 | axios.post(url[, data[, config]) |
| put 请求 | axios.put(url[, data[, config]) |
| patch 请求 | axios.patch(url[, data[, config]) |
而我们只关注 get 请求和 post 请求。
入门案例中的 get 请求代码可以改为如下:
axios.get("http://localhost:8080/ajax-demo/axiosServlet?username=zhangsan").then(function (resp) {
alert(resp.data);
});入门案例中的 post 请求代码可以改为如下:
axios.post("http://localhost:8080/ajax-demo/axiosServlet", "username=zhangsan").then(function (resp) {
alert(resp.data);
})注意两个别名的参数位置不一样:get 的第二个参数是 config(配置对象),参数要拼在 URL 上;post 的第二个参数是 data(请求体),第三个才是 config。这是最容易写错的地方:把 axios.get(url, {username:"zhangsan"}) 当成"传参",结果参数根本没发出去,后端收到 null。
【考点】"
axios.get(url, params)能传请求参数吗?" 答案是:不能,第二个位置的 config 不是参数,参数要拼在 URL 上,或者用{ params: {...} }让 axios 帮你拼。 理由:别名方法的签名是get(url[, config]),没有 data 位置。
9.7.4 JSON 概述与基础语法(原文 5.1、5.2)
一、概念
JSON(JavaScript Object Notation):JavaScript 对象表示法。
如下是 JavaScript 对象的定义格式:
{
name: "zhangsan",
age: 23,
city: "北京"
}接下来再看看 JSON 的格式:
{
"name": "zhangsan",
"age": 23,
"city": "北京"
}通过上面 js 对象格式和 json 格式进行对比,发现两个格式特别像。只不过 js 对象中的属性名可以使用引号(可以是单引号,也可以是双引号,甚至可以不加);而 json 格式中的键要求必须使用双引号括起来,这是 json 格式的规定。
作用:由于其语法格式简单,层次结构鲜明,现多用于作为数据载体,在网络中进行数据传输。服务端给浏览器响应的数据,如果是一个 Java 对象里封装的数据,就应该以 JSON 这种格式传回去。
大家还记得 ajax 的概念吗?是"异步的 JavaScript 和 XML"。这里的 XML 就是以前进行数据传递的方式,如下:
<student>
<name>张三</name>
<age>23</age>
<city>北京</city>
</student>再看 JSON 描述以上数据的写法:
{
"name": "张三",
"age": 23,
"city": "北京"
}上面两种格式进行对比后就会发现 JSON 格式数据的简单,以及所占的字节数少等优点。
二、基础语法:定义格式
JSON 本质就是一个字符串,但是该字符串内容是有一定的格式要求的。定义格式如下:
var 变量名 = '{"key":value,"key":value,...}';JSON 串的键要求必须使用双引号括起来,而值根据要表示的类型确定。value 的数据类型分为如下几种:
- 数字(整数或浮点数)
- 字符串(使用双引号括起来)
- 逻辑值(
true或者false) - 数组(在方括号中)
- 对象(在花括号中)
null
示例:
var jsonStr = '{"name":"zhangsan","age":23,"addr":["北京","上海","西安"]}'注意最外层的单引号:JSON 是"字符串",所以要用引号把它包起来;里面所有的键又必须是双引号,于是外面只能用单引号(或者用反引号),否则引号会打架。
三、代码演示
创建一个页面,在该页面的 <script> 标签中定义 json 字符串:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
//1. 定义JSON字符串
var jsonStr = '{"name":"zhangsan","age":23,"addr":["北京","上海","西安"]}'
alert(jsonStr);
</script>
</body>
</html>通过浏览器打开,弹出的就是这串字符本身:它现在只是一个字符串,不是对象。
现在我们需要获取到该 JSON 串中的 name 属性值,应该怎么处理呢?如果它是一个 js 对象,我们就可以通过 js对象.属性名 的方式来获取数据。JS 提供了一个对象 JSON,该对象有如下两个方法:
parse(str):将 JSON 串转换为 js 对象。使用方式是var jsObject = JSON.parse(jsonStr);stringify(obj):将 js 对象转换为 JSON 串。使用方式是var jsonStr = JSON.stringify(jsObject);
代码演示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
//1. 定义JSON字符串
var jsonStr = '{"name":"zhangsan","age":23,"addr":["北京","上海","西安"]}'
alert(jsonStr);
//2. 将 JSON 字符串转为 JS 对象
let jsObject = JSON.parse(jsonStr);
alert(jsObject)
alert(jsObject.name)
//3. 将 JS 对象转换为 JSON 字符串
let jsonStr2 = JSON.stringify(jsObject);
alert(jsonStr2)
</script>
</body>
</html>三次弹窗的区别很直观:第一次弹的是字符串;第二次弹对象,浏览器会显示成 [object Object];第三次弹 zhangsan,只有转成对象之后才取得到属性。最后 stringify 又把它变回字符串。
四、发送异步请求携带参数
后面我们使用 axios 发送请求时,如果要携带复杂的数据时都会以 JSON 格式进行传递,如下:
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: "username=zhangsan"
}).then(function (resp) {
alert(resp.data);
})请求参数不可能由我们自己拼接字符串吧?肯定不用。可以提前定义一个 js 对象,用来封装需要提交的参数,然后使用 JSON.stringify(js对象) 转换为 JSON 串,再将该 JSON 串作为 axios 的 data 属性值进行请求参数的提交。如下:
var jsObject = {name: "张三"};
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: JSON.stringify(jsObject)
}).then(function (resp) {
alert(resp.data);
})而 axios 是一个很强大的工具。我们只需要将需要提交的参数封装成 js 对象,并将该 js 对象作为 axios 的 data 属性值进行提交,它会自动将 js 对象转换为 JSON 串。如下:
var jsObject = {name: "张三"};
axios({
method: "post",
url: "http://localhost:8080/ajax-demo/axiosServlet",
data: jsObject //这里 axios 会将该js对象转换为 json 串的
}).then(function (resp) {
alert(resp.data);
})注意两点:
- js 提供的
JSON对象我们只需要了解一下即可,因为 axios 会自动对 js 对象和 JSON 串进行相互转换。 - 发送异步请求时,如果请求参数是 JSON 格式,那请求方式必须是 POST,因为 JSON 串需要放在请求体中。
【补充】JSON 为什么在前后端之间取代了表单参数。这是理解本章后半部分为什么这么写的关键:
- 表达能力。表单参数只有
key=value&key=value这一种扁平结构,值只能是字符串。"一个品牌对象里带一个数组""订单里带一组明细",表单参数表达不了,只能发明items[0].name这种约定,后端还得自己解析。JSON 天生支持嵌套对象和数组,{"brand":{"name":"华为"},"tags":["手机","5G"]}一层层写下去就行,和 Java 的Brand、List<Tag>一一对应。- 有类型。表单参数里
23、true、null全是字符串,后端要自己转;JSON 里的数字、布尔、null是有类型的,Fastjson 直接按字段类型赋值,不用你操心。- 一份数据两边通吃。JSON 的语法就是 JS 对象字面量,前端
JSON.parse一下(axios 自动做)就能当对象用;Java 这边JSON.toJSONString/JSON.parseObject一行互转。中间的 XML 虽然也能表达层次,但标签成对出现,字节数是 JSON 的两三倍,还要写解析器。- 代价。JSON 必须放在请求体里,所以带 JSON 的请求只能是 POST/PUT(GET 没有请求体,参数只能挂在 URL 上);浏览器原生表单不支持提交 JSON,必须用 JS 发;后端也不能用
getParameter取,得读流。这就是 9.8.2 那个request.getReader()存在的原因。【易错点】
Content-Type没设对,前端显示"成功"、后端收到null。后端响应 JSON 时,response.setContentType("text/json;charset=utf-8")(原文写法)和application/json;charset=utf-8都能用,application/json才是标准 MIME 类型;重点是必须设。不设的话浏览器按默认的text/html处理:axios 仍会尝试解析(因为它看内容猜),但如果前端用resp.data.xxx就会拿到字符串而不是对象,出现"resp.data有值但取属性全是undefined"。另一个高频错是只设了setCharacterEncoding没设setContentType,中文照样乱码,因为setCharacterEncoding对响应体的编码提示不够,setContentType("...;charset=utf-8")才是给浏览器的完整声明。
9.7.5 JSON 串和 Java 对象的相互转换(原文 5.3)
学习完 json 后,接下来聊聊 json 的作用。以后我们会以 json 格式的数据进行前后端交互:前端发送请求时,如果是复杂的数据就会以 json 提交给后端;而后端如果需要响应一些复杂的数据时,也需要以 json 格式将数据响应回给浏览器。
在后端我们就需要重点学习以下两部分操作:
- 请求数据:JSON 字符串转为 Java 对象;
- 响应数据:Java 对象转为 JSON 字符串。
这两个动作有正式的名字:Java 对象 → JSON 串叫序列化,JSON 串 → Java 对象叫反序列化。接下来介绍一套 API,可以实现上面两部分操作,这套 API 就是 Fastjson。
一、Fastjson 概述
Fastjson 是阿里巴巴提供的一个 Java 语言编写的高性能功能完善的 JSON 库,是目前 Java 语言中最快的 JSON 库,可以实现 Java 对象和 JSON 字符串的相互转换。
二、Fastjson 使用
Fastjson 使用也是比较简单的,分为以下三步完成。
第一步,导入坐标:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>第二步,Java 对象转 JSON:
String jsonStr = JSON.toJSONString(obj);将 Java 对象转换为 JSON 串,只需要使用 Fastjson 提供的 JSON 类中的 toJSONString() 静态方法即可。
第三步,JSON 字符串转 Java 对象:
User user = JSON.parseObject(jsonStr, User.class);将 json 转换为 Java 对象,只需要使用 Fastjson 提供的 JSON 类中的 parseObject() 静态方法即可。
parseObject 的第二个参数 User.class 是目标类型,Fastjson 靠它知道要 new 一个什么对象、每个字段该赋什么值。字段名必须和 JSON 的键对得上(区分大小写),对不上的字段就是 null,不会报错,这也是"后端收到对象但字段全空"的常见原因。
三、代码演示
引入坐标,创建一个类,专门用来测试 Java 对象和 JSON 串的相互转换,代码如下:
public class FastJsonDemo {
public static void main(String[] args) {
//1. 将Java对象转为JSON字符串
User user = new User();
user.setId(1);
user.setUsername("zhangsan");
user.setPassword("123");
String jsonString = JSON.toJSONString(user);
System.out.println(jsonString);//{"id":1,"password":"123","username":"zhangsan"}
//2. 将JSON字符串转为Java对象
User u = JSON.parseObject(
"{\"id\":1,\"password\":\"123\",\"username\":\"zhangsan\"}", User.class);
System.out.println(u);
}
}运行结果的三个细节,考试很可能就考这些:
- 输出的键顺序是
id、password、username,不是id、username、password。Fastjson 默认按字段名的字母序输出(id<password<username),和你在类里声明的顺序无关。JSON 的键是无序的,所以前端取数据必须按名字取(resp.data.username),绝不能按位置取。 - JSON 字符串里的双引号必须转义(
\"),因为它在 Java 里是一个字符串字面量。这就是为什么后端很少手写 JSON 串:拼错一个引号就解析失败。 User类必须有 getter/setter 和无参构造,Fastjson 靠 setter 赋值。只有private字段没有 setter 时,字段会是null。
9.8 前后端分离视角下的一次异步请求(原文 6)
有了 9.6 的原生 AJAX、9.7 的 axios 和 JSON,就可以把前面所有零件装成一台机器了。这一节做的是品牌列表的查询和添加,它的意义不在于代码有多长,而在于同一个页面里没有再出现一行 Servlet/JSP 的渲染代码:数据是 JS 拿的,表格是 JS 拼的,这个转变就是"前后端分离"在课程里的第一次落地。
9.8.1 需求(原文 6.1)
使用 Axios + JSON 完成品牌列表数据查询和添加功能。具体要求:
- 页面加载时自动请求后端,拿到品牌列表,把每一行数据填进表格;
- 点"新增品牌"提交表单时,把表单数据以 JSON 形式发给后端,添加成功后回到列表页,新数据出现在表格里。
数据库表 tb_brand 的字段(本案例只用到这几个):
| 字段名 | 类型 | 含义 |
|---|---|---|
id | int | 主键 |
brand_name | varchar | 品牌名称 |
company_name | varchar | 企业名称 |
ordered | int | 排序字段 |
description | varchar | 品牌介绍 |
status | int | 状态:0 禁用,1 启用 |
9.8.2 环境准备(原文 6.2.1)
一、pom.xml 依赖:除了前面章节的 Servlet 依赖,本案例还要 fastjson(JSON 互转)、mysql-connector-java(连数据库),以及持久层框架(本课程用 MyBatis)。
<dependencies>
<!-- Servlet 4.0 规范,Provided 因为 Tomcat 自带 -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
<!-- Fastjson:Java 对象与 JSON 串互转 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.62</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.5</version>
</dependency>
</dependencies>二、实体类 Brand:字段名要与数据库表列名对应,键名就是 Fastjson 转换时的 JSON 键名。
public class Brand {
private Integer id; // 主键
private String brandName; // 品牌名称
private String companyName; // 企业名称
private Integer ordered; // 排序字段
private String description; // 描述信息
private Integer status; // 状态:0 禁用,1 启用
// 无参构造、全参构造、getter/setter、toString 省略,Fastjson 依赖 getter/setter
}三、持久层 BrandMapper:两个方法,一个查全部,一个插入。(如果你还没学到持久层框架,把这两个方法换成前面 JDBC 的 selectForList 和 add 即可,Servlet 里的代码完全不用改,这正是"分层"的好处。)
public interface BrandMapper {
/** 查询所有品牌 */
@Select("select * from tb_brand")
List<Brand> selectAll();
/** 添加品牌,需要开启 useGeneratedKeys */
@Insert("insert into tb_brand(brand_name, company_name, ordered, description, status) " +
"values(#{brandName}, #{companyName}, #{ordered}, #{description}, #{status})")
@Options(useGeneratedKeys = true, keyProperty = "id")
void add(Brand brand);
}四、目录结构:src/main/webapp 下放 brand.html、addBrand.html 和 js/axios-0.18.0.js;BrandMapper 里通过 @Select/@Insert 注解写 SQL,不需要额外的 XML 映射文件。
9.8.3 查询所有——后端实现(原文 6.2.2)
在 src/main/java/com/itheima/web 包下定义 SelectAllServlet:
@WebServlet("/selectAll")
public class SelectAllServlet extends HttpServlet {
private BrandMapper brandMapper;
@Override
public void init() throws ServletException {
// 从 MyBatis 工具类拿到 Mapper(也可在 doGet 里获取)
brandMapper = MyBatisUtils.getMapper(BrandMapper.class);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//1. 调用方法查询品牌列表
List<Brand> brands = brandMapper.selectAll();
//2. 将集合转换为 JSON 数据
String jsonString = JSON.toJSONString(brands);
//3. 响应数据,一定要设置 Content-Type
response.setContentType("text/json;charset=utf-8");
response.getWriter().write(jsonString);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}这段代码只有三行核心逻辑,但这三行恰好是 9.7.5 讲的"响应数据:Java 对象转 JSON 字符串"在实际项目里的样子。List<Brand> 被 toJSONString 转成的结果是这样:
[
{"brandName":"三只松鼠","companyName":"三只松鼠股份有限公司","description":"好吃不上火","id":1,"ordered":5,"status":1},
{"brandName":"华为","companyName":"华为技术有限公司","description":"万物互联","id":2,"ordered":100,"status":1}
]注意 Fastjson 对集合的默认输出是 [...],对单个对象是 {...},前端拿到一个是数组、一个是对象,取法不同。还有一个细节:键的名字是 brandName(驼峰),不是 brand_name,因为 Fastjson 用的是 Java 字段名。前端拼表格时写 brand.brandName,写错成 brand.brand_name 就全是 undefined。
9.8.4 查询所有——前端实现(原文 6.2.3)
在 brand.html 中,用 window.onload 让页面加载完成时自动发请求:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>品牌列表</title>
</head>
<body>
<a href="addBrand.html"><input type="button" value="新增"></a><br>
<hr>
<table id="brandTable" border="1" cellspacing="0" width="100%">
</table>
<script src="js/axios-0.18.0.js"></script>
<script>
// 页面加载完成后再发送异步请求
window.onload = function () {
//1. 发送异步请求,查询数据
axios({
method: "get",
url: "http://localhost:8080/brand-demo/selectAll"
}).then(function (resp) {
// 拿到返回的 JSON 数据(axios 已自动转成 JS 数组)
let brands = resp.data;
//2. 拼写表格的 html 字符串,先写表头
let tableData = "<tr>\n" +
" <th>序号</th>\n" +
" <th>品牌名称</th>\n" +
" <th>企业名称</th>\n" +
" <th>排序</th>\n" +
" <th>品牌介绍</th>\n" +
" <th>状态</th>\n" +
" <th>操作</th>\n" +
" </tr>";
//3. 遍历数组,拼出每一行
for (let i = 0; i < brands.length; i++) {
let brand = brands[i];
tableData += "<tr align=\"center\">\n" +
" <td>" + (i + 1) + "</td>\n" +
" <td>" + brand.brandName + "</td>\n" +
" <td>" + brand.companyName + "</td>\n" +
" <td>" + brand.ordered + "</td>\n" +
" <td>" + brand.description + "</td>\n" +
" <td>" + brand.status + "</td>\n" +
" <td><a href=\"#\">修改</a> <a href=\"#\">删除</a></td>\n" +
" </tr>";
}
//4. 把拼好的字符串塞进页面
document.getElementById("brandTable").innerHTML = tableData;
})
}
</script>
</body>
</html>这里有四处值得停下来想:
window.onload不能省。脚本写在<body>里、table之后,看着没问题,但如果你把<script>挪到<head>里,执行时brandTable还不存在,getElementById返回null,innerHTML直接报错。window.onload保证 DOM 建完再执行,这是最稳的写法。innerHTML是"一次性覆盖"。拼一个完整的字符串再赋值,比在循环里反复appendChild性能好得多,这是前端拼表格的常规做法。i + 1才有序号。数组下标从 0 开始,直接写i表格第一行就显示 0。- 注意引号转义。
"<tr align=\"center\">"里的\"是给 JavaScript 看的,别写成""或者漏掉,否则字符串提前结束,控制台报Uncaught SyntaxError。
9.8.5 添加品牌——后端实现(原文 6.3.1)
添加比查询多了一步:从请求体里读 JSON 串,再反序列化成对象。定义 AddServlet:
@WebServlet("/add")
public class AddServlet extends HttpServlet {
private BrandMapper brandMapper;
@Override
public void init() throws ServletException {
brandMapper = MyBatisUtils.getMapper(BrandMapper.class);
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
//1. 接收请求参数,JSON 放在请求体里,必须用 getReader 读流
BufferedReader reader = request.getReader();
String params = reader.readLine();
//2. 将 JSON 字符串转换为 Brand 对象
Brand brand = JSON.parseObject(params, Brand.class);
//3. 调用方法添加品牌
brandMapper.add(brand);
MyBatisUtils.commitAndClose(); // 若为手动提交,这里必须 commit
//4. 响应数据,前端用它判断是否成功
response.getWriter().write("success");
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
this.doGet(request, response);
}
}getReader().readLine() 和 getParameter() 是两套互斥的读取方式:
| 方式 | 读取位置 | 适用于 | 特点 |
|---|---|---|---|
request.getParameter("name") | 查询串(GET)/ 表单体(POST) | key=value&key=value | 只能取字符串,Tomcat 帮你解析好 |
request.getReader() | 请求体(原始字节流) | JSON、XML 等任意格式 | 拿到的是整个请求体文本,要自己解析 |
【易错点】
getReader和getParameter混用会遇到两个问题:第一个容易出错的地方:请求体只能读一次。
getReader()拿到的流读完就到底了,再读是空字符串。如果你在 Servlet 里先getParameter再getReader,或者在 Filter 里为了打日志把请求体读了一遍,Servlet 里就读不到了(这正是 9.4 里"请求体被提前消费"类 bug 的根源,进阶做法是重写HttpServletRequestWrapper把请求体缓存起来)。第二个容易出错的地方:
readLine()只读一行。JSON 串中间没有换行符,所以一次readLine能拿全;但如果前端发的 JSON 是被格式化过、带换行的,readLine只能拿到第一行,parseObject直接抛com.alibaba.fastjson.JSONException: syntax error。稳妥写法是循环读到null为止:StringBuilder sb = new StringBuilder(); String line; BufferedReader reader = request.getReader(); while ((line = reader.readLine()) != null) { sb.append(line); } Brand brand = JSON.parseObject(sb.toString(), Brand.class);
9.8.6 添加品牌——前端实现(原文 6.3.2)
addBrand.html 的关键是把表单里的一个个值收集成一个 JS 对象,再交给 axios:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>添加品牌</title>
</head>
<body>
<h3>添加品牌</h3>
<form action="" method="post">
品牌名称:<input id="brandName" name="brandName"><br>
企业名称:<input id="companyName" name="companyName"><br>
排序:<input id="ordered" name="ordered"><br>
描述信息:<textarea rows="5" cols="20" id="description" name="description"></textarea><br>
状态:
<input type="radio" name="status" value="0">禁用
<input type="radio" name="status" value="1">启用<br>
<input type="button" id="btn" value="提交">
</form>
<script src="js/axios-0.18.0.js"></script>
<script>
//1. 给按钮绑定单击事件
document.getElementById("btn").onclick = function () {
//2. 获取表单数据,封装成 js 对象
let brandName = document.getElementById("brandName").value;
let companyName = document.getElementById("companyName").value;
let ordered = document.getElementById("ordered").value;
let description = document.getElementById("description").value;
//3. 单选框要遍历,找出被选中的那个
let status = 0;
let statusList = document.getElementsByName("status");
for (let i = 0; i < statusList.length; i++) {
if (statusList[i].checked) {
status = statusList[i].value;
}
}
//4. 封装成对象
let formData = {
brandName: brandName,
companyName: companyName,
ordered: ordered,
description: description,
status: status
};
//5. 发送异步请求,data 直接给对象,axios 自动转成 JSON 串
axios({
method: "post",
url: "http://localhost:8080/brand-demo/add",
data: formData
}).then(function (resp) {
//6. 判断响应数据,跳转回列表页
if (resp.data == "success") {
alert("添加成功");
location.href = "brand.html";
} else {
alert("添加失败");
}
})
}
</script>
</body>
</html>注意 data: formData 这里直接给对象,没有 JSON.stringify,9.7.4 讲过 axios 会自动转。转完之后请求头是 Content-Type: application/json;charset=UTF-8,请求体是 {"brandName":"...","companyName":"..."},正好对应后端 getReader + parseObject 的那套写法。这就是"前后端约定":前端传对象的形状,就是后端 Brand 类的形状,字段名一个都不能错。
【对比】JSP 渲染表格和前端渲染表格是两条完全不同的路,本案例走的是后者:
| 维度 | 服务端渲染(第 8 章 JSP + EL + JSTL) | 前端渲染(本章 axios + JSON) |
|---|---|---|
| 数据在哪拼成 HTML | 服务器上,JSP 用 <c:forEach> 拼 | 浏览器里,JS 用字符串 += 拼 |
| 浏览器收到什么 | 已经含数据的完整 HTML | 一个空表格 + 一堆 JSON 数据 |
| 换数据要做什么 | 重新请求整个页面,整页刷新 | 再发一次 AJAX,只换 <table> 里的内容 |
| 后端返回什么 | JSP 页面(视图) | JSON 字符串(纯数据) |
| 职责边界 | 后端既管数据又管页面 | 后端只出数据,前端只管展示 |
这条分界线就是"前后端分离"。后端不再关心页面长什么样,只提供 JSON 接口;前端可以是网页、App、小程序,同一套接口给谁用都行。代价是首屏要等 JS 执行完才有内容,而且前端拼 HTML 时如果不转义,用户输入里的
<script>会被当代码执行(XSS),生产环境要用textContent或转义函数。
9.8.7 把整条链路串起来看一遍(本章整合,原文无对应)
现在回头看一次"点击提交"到底发生了什么,把本章所有角色放进同一条时间线:
- 浏览器:
btn的 click 事件触发 → 收集表单值 → 拼成 JS 对象 → axios 发POST /add,请求体是 JSON 串。 - 过滤器(如果有
EncodingFilter和LoginFilter):按 9.3 的顺序依次执行,EncodingFilter在getReader之前设置编码;LoginFilter检查 session 里有没有登录用户,没登录就转发到login.jsp,请求根本到不了 Servlet。 - Servlet:
getReader读出 JSON 串 →JSON.parseObject反序列化成Brand→ 调brandMapper.add落库 →write("success")。 - 响应往回走:过滤器链逆序再走一遍(洋葱模型的另一半),可以在这里统一加响应头、记耗时。
- 浏览器:
then回调拿到resp.data == "success"→location.href = "brand.html"→ 新页面加载 →window.onload再发GET /selectAll→ 表格里出现新加的品牌。
这条链路里,Filter 管"能不能进",Listener 管"应用级的状态",AJAX/JSON 管"数据怎么来回"。三样东西各管一段,互不越界,这就是本章标题把它们放在一起的原因。
本章小结
本章四块内容,按"拦截 → 感知 → 异步 → 数据"四个关键词串起来:
一、Filter(重点,必考)
- 过滤器是 JavaWeb 三大组件之一(Servlet、Filter、Listener),作用是对请求和响应做统一处理,实现"横切关注点"的集中管理:统一编码、登录校验、敏感词过滤、日志、压缩。
- 开发三步:实现
javax.servlet.Filter接口 → 重写init、doFilter、destroy→ 用@WebFilter("拦截路径")或web.xml注册。 doFilter是核心,chain.doFilter(request, response)是"放行"。不放行,请求就到不了 Servlet,浏览器白屏;放行语句之前的代码在请求到达资源前执行,之后的代码在响应返回后执行(洋葱模型)。- 生命周期与 Servlet 类似但更简单:
init只执行 1 次(Tomcat 启动时),doFilter每个请求执行 1 次,destroy在 Tomcat 关闭时执行 1 次。Filter 是单例,不要用成员变量存每个请求的数据(线程安全问题)。 - 拦截路径四种写法:
/具体路径(精确)、/目录/*(目录)、*.扩展名(后缀)、/*(全部)。 - 多个过滤器构成过滤器链,执行顺序由类名的自然排序决定(
@WebFilter注解方式),也可以通过web.xml的<filter-mapping>顺序或FilterRegistrationBean指定。 - 登录校验案例的核心思路:白名单放行 + 其余校验 + 未登录转发到登录页。
二、Listener(了解 + 会用)
- 监听器用于监听域对象(
ServletContext、HttpSession、HttpServletRequest)的生命周期和属性变化,共 8 个接口,分三类:生命周期、属性变更、会话相关。 - 最常用的是
ServletContextListener(contextInitialized/contextDestroyed)和HttpSessionListener(sessionCreated/sessionDestroyed)。 - 典型用途:Spring 的
ContextLoaderListener启动时加载 Spring 容器、统计在线人数、应用启动时加载配置文件/初始化连接池。
三、Ajax(转折点)
- Ajax 是"异步的 JavaScript 和 XML",作用有二:与服务器进行数据交换(页面不刷新就能拿到数据)、异步交互(不阻塞用户操作)。
- 原生实现四步:创建
XMLHttpRequest→open→send→ 监听onreadystatechange,在readyState == 4 && status == 200时取responseText。 readyState看"请求走到哪一步",status看"服务器怎么说",两个条件缺一不可。
四、axios 与 JSON(工程化写法)
- axios 是 AJAX 的封装,两步:引入 js 文件、
axios({method, url, data}).then(resp => ...)。 resp.data是响应体,axios 会自动把 JSON 串解析成 JS 对象。- 请求方法别名:
axios.get(url[, config])、axios.post(url[, data[, config]]),注意 get 的第二个参数不是请求参数。 - JSON 是轻量的数据交换格式,键必须双引号,值支持数字、字符串、布尔、数组、对象、
null;JS 里用JSON.parse和JSON.stringify互转。 - JSON 取代表单参数的原因:能表达嵌套结构、有类型、前后端都能直接解析、体积小;代价是必须放在请求体里,所以只能 POST,且后端要用
getReader读。 - Java 侧用 Fastjson:
JSON.toJSONString(obj)序列化、JSON.parseObject(jsonStr, Xxx.class)反序列化,需要引入fastjson坐标。
必背清单(考试前扫一眼):
| 编号 | 知识点 | 参考答案 |
|---|---|---|
| 1 | Filter 三个方法 | init / doFilter / destroy |
| 2 | 放行语句 | chain.doFilter(request, response) |
| 3 | 忘写放行的后果 | 请求被拦在过滤器,资源不执行,页面空白 |
| 4 | 过滤器链顺序 | 注解方式按类名自然排序 |
| 5 | /* 的含义 | 拦截所有请求(含静态资源) |
| 6 | 统一编码为什么要在 getParameter 之前 | 参数一旦被解析,编码就固定了,再设无效 |
| 7 | Filter 与 Interceptor 的本质区别 | Filter 是 Servlet 规范级、由容器调用;Interceptor 是 Spring MVC 框架级、由 DispatcherServlet 调用 |
| 8 | 监听器监听什么 | 三大域对象的创建/销毁与属性变化 |
| 9 | Ajax 两个判定条件 | readyState == 4 且 status == 200 |
| 10 | JSON 为什么只能 POST | 要放请求体,GET 没有请求体 |
自测
基础题
1. 过滤器需要实现哪个接口?该接口中必须重写的方法是哪一个?它的作用是什么?
2. 一个请求匹配到了两个过滤器 FilterA 和 FilterB(都用 @WebFilter 注册),实际执行顺序是什么?为什么?
3. 下面代码有问题吗?如果有,运行现象是什么?
@WebFilter("/*")
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("进入了过滤器");
}
}4. 简述过滤器实现统一编码的原理。为什么 request.setCharacterEncoding("utf-8") 必须写在 request.getParameter(...) 之前?
5. @WebFilter("/*") 和 web.xml 里写 <url-pattern>/*</url-pattern> 有什么区别?在拦截路径的写法规则上,Filter 和 Servlet 有什么不同?
6. 说出三种 Listener 的典型用途。
7. Ajax 请求中 readyState 和 status 分别代表什么?为什么判断成功要两个条件同时满足?
8. 前端代码 axios.get(url, { username: "zhangsan" }) 能把 username 传给后端吗?说明理由。
进阶题
9. 用 Filter 实现"未登录用户访问任何业务页面都跳到登录页",写出完整思路和关键代码,并说明白名单为什么要包含 /login.jsp、/loginServlet 和静态资源。
10. 后端代码如下,前端用 axios({method:"post", url:"/add", data:{name:"张三"}}) 提交,brand.getName() 却返回 null。请指出至少两种可能的原因并给出修正。
String name = request.getParameter("name");参考答案
1. 实现
javax.servlet.Filter接口(Servlet 4.0 中是javax.servlet.Filter)。必须重写doFilter(ServletRequest, ServletResponse, FilterChain),它是过滤器的核心方法,每一个匹配的请求都会执行一次。方法内通过chain.doFilter(request, response)放行,请求继续走向下一个过滤器或目标资源;init和destroy有默认实现,可以不重写。2. 执行顺序是
FilterA→FilterB→ 目标资源 →FilterB的后置代码 →FilterA的后置代码。原因:@WebFilter注解方式下,多个过滤器的顺序由类名的自然排序(字符串比较)决定,FilterA<FilterB,所以 A 先执行。请求方向是"正序",响应方向是"逆序",整体呈洋葱模型。注意这是约定而非强保证,不同容器实现可能有差异,需要精确控制顺序时用web.xml的<filter-mapping>声明顺序。3. 有问题:没有写
chain.doFilter(request, response)。运行现象是控制台能打印"进入了过滤器",但浏览器页面空白,目标 Servlet 完全没有执行,响应体是 0 字节,HTTP 状态码仍是 200(因为容器认为请求已被处理完毕)。这是一道经典题:"doFilter 忘写,页面空白但不报错"。4. 原理:请求参数从字节到字符串需要经过一次"解码",
setCharacterEncoding指定的是这次解码用的字符集。Tomcat 在第一次调用getParameter(或getParameterMap、getParameterValues)时就把整个请求体解析完并缓存,此时编码已按当时的设置(默认ISO-8859-1)固定下来,之后再调用setCharacterEncoding不会重新解析,所以必须写在getParameter之前,这也是它必须放在过滤器里的原因:过滤器是整条链上最早能碰到请求的地方。补充:GET 请求的参数在 URL 上,由server.xml的URIEncoding或 Tomcat 8+ 默认的 UTF-8 决定,setCharacterEncoding对它无效。5. 两者效果等价(都拦截所有请求,包括静态资源),区别在声明方式和顺序控制:注解方式写在 Java 类上,顺序由类名排序决定;
web.xml方式集中配置,<filter-mapping>的先后就是执行顺序。拦截路径写法上,Filter 与 Servlet 有三点不同:① Filter 支持/*拦截所有,Servlet 的/*也能配但通常不用;② Filter 可以使用*.jsp、*.html这类后缀匹配(Servlet 规范里过滤器支持*.扩展名),而 Servlet 的映射规则更严格;③ Filter 还多了dispatcherTypes属性(REQUEST、FORWARD、INCLUDE、ERROR、ASYNC),默认只拦REQUEST,转发不拦,这点 Servlet 没有。这也是"登录校验过滤器拦不住 forward"的原因。6. 三个典型用途:① 加载配置文件 / 初始化资源:
ServletContextListener.contextInitialized在应用启动时执行一次,可读取application.properties、初始化数据库连接池;② 统计在线人数:HttpSessionListener.sessionCreated计数加一、sessionDestroyed减一,把结果放进ServletContext全局共享(用AtomicInteger或加锁保证线程安全);③ 框架集成:Spring 的ContextLoaderListener在 Web 应用启动时创建WebApplicationContext并放入ServletContext,这是 SSM/SSH 项目web.xml里必有的一行。7.
readyState表示 XMLHttpRequest 对象自身的状态(0 未初始化、1open已调用、2 已收到响应头、3 正在接收响应体、4 响应体接收完毕);status表示 HTTP 响应状态码(200 成功、404 找不到、500 服务器错误)。只判readyState == 4不够,因为 404、500 时readyState同样是 4,此时responseText里是错误页面的 HTML;只判status == 200也不够,因为状态码在readyState为 2 或 3 时就已经可读了,此时响应体还没收完,取到的数据是残缺的。所以必须readyState == 4 && status == 200。8. 不能。 请求方法别名的签名是
axios.get(url[, config]),第二个位置是配置对象(可以放headers、params、timeout等),不是请求数据;get请求没有请求体,参数只能拼在 URL 上。上面写法中{ username: "zhangsan" }会被当成 config,username不是合法配置项,被忽略,后端getParameter得到null。正确写法有两种:axios.get(url + "?username=zhangsan"),或者axios.get(url, { params: { username: "zhangsan" } })(后者由 axios 自动拼查询串,还会做 URL 编码,更推荐)。9. 思路分四步:
@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; //1. 判断访问的资源是否为登录相关资源或静态资源,如果是则直接放行 String[] urls = {"/login.jsp", "/loginServlet", "/register.jsp", "/registerServlet", "/css/", "/js/", "/imgs/"}; String requestURI = request.getRequestURI(); for (String url : urls) { if (requestURI.contains(url)) { chain.doFilter(request, response); return; // 放行后必须 return,否则下面代码还会执行 } } //2. 判断 session 中是否有用户 HttpSession session = request.getSession(); Object user = session.getAttribute("user"); if (user != null) { chain.doFilter(request, response); // 已登录,放行 } else { //3. 未登录,转发到登录页 request.setAttribute("msg", "您尚未登录,请先登录"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }白名单必须包含三类:① 登录页和登录接口:不包含就死循环,因为访问登录页本身也被拦,又被转发到登录页;② 注册页和注册接口:同理;③ 静态资源(css/js/图片):登录页要靠它们才能正常显示,不包含的话会出现"跳转到登录页但没样式"的现象(这正是 9.4.5 里容易出错的地方)。
10. 至少三种原因:
①
Content-Type不匹配:前端data传的是对象,axios 会把它序列化成 JSON 并设置Content-Type: application/json。JSON 在请求体里,而且是 JSON 格式,getParameter只解析application/x-www-form-urlencoded和multipart/form-data的请求体,所以取不到。修正:后端改用request.getReader()读流 +JSON.parseObject(params, Brand.class)。或者前端改成传字符串data: "name=张三",让它以表单格式提交。②
getParameter与getReader混用:如果过滤器或前置代码已经用getReader()读过请求体,流已到底,Servlet 里getParameter也拿不到数据。修正:不要在过滤器里读请求体,或者用HttpServletRequestWrapper缓存。③ 参数名对不上 / 大小写不一致:前端传
name、后端取username,或者 JSON 里是brandName、Brand类字段叫brand_name,Fastjson 反序列化后该字段为null。修正:保证前端对象的键名与 Java 字段名(或@JSONField指定的名字)完全一致。补充一个排查顺序:先在浏览器 F12 的 Network 面板看请求的
Content-Type和Payload,确认数据真的发出去了;再在后端System.out.println打印request.getContentType();最后才检查字段名。先看请求,再看后端,能省掉一半调试时间。