JavaWeb 复习讲义
原始讲义
第五部分 进阶与实战:从能跑到跑得好

第9章 Filter、Listener 与 Ajax

难度:★★★★☆ | 前置知识:第7章 会话技术、第8章 JSP/EL/JSTL | 本章解决:把"每个资源都要写一遍"的公共逻辑交给 Filter 一次拦下,把"对象的生死"交给 Listener 感知,再用 Ajax + axios + JSON 让页面只换局部、让前后端只传数据

本章导读

【考点】

本章考试形态:一道 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 开发分成以下三步实现:

  1. 定义类,实现 Filter 接口,并重写其所有方法。
  2. 配置 Filter 拦截资源的路径:在类上定义 @WebFilter 注解。而注解的 value 属性值 /* 表示拦截所有的资源。
  3. 在 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.jsp

pom.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>

这里有三个细节值得停一下:

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-patternFilter 的拦截路径
语义"我能处理哪些请求""我要拦哪些请求"
匹配不上会怎样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,请求进来后,过滤器链按照以下流程执行:

  1. 执行 Filter1 的放行前逻辑代码
  2. 执行 Filter1 的放行代码
  3. 执行 Filter2 的放行前逻辑代码
  4. 执行 Filter2 的放行代码
  5. 访问到资源
  6. 执行 Filter2 的放行后逻辑代码
  7. 执行 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 的 FilterRegistrationBeansetOrder(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;

然后完成以下逻辑:

代码如下:

@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 三个对象创建、销毁,或者往其中添加、修改、删除属性时,自动执行代码的功能组件。

三者对照一下,作用域的大小关系一目了然:

域对象类型何时创建何时销毁作用范围
applicationServletContext服务器启动、应用加载时服务器关闭、应用卸载时整个 Web 应用,所有用户共享
sessionHttpSession第一次调用 getSession() 时超时(默认 30 分钟)或 invalidate()一次会话,一个用户独享
requestHttpServletRequest每次请求到达时响应结束时一次请求

"监听"这个词很直观:你不用主动调用,只要事件发生,容器就会回头调你的方法。类比一下 JS 里的 onclick:你不点按钮,函数不执行;按钮被点了,函数自动执行。

9.5.2 分类(原文 2.2)

JavaWeb 提供了 8 个监听器,按监听的对象分成三组:

监听器接口监听的对象触发时机常用度
ServletContextListenerapplicationServletContext 创建 / 销毁★★★★★
ServletContextAttributeListenerapplication域属性添加 / 替换 / 移除★★☆☆☆
HttpSessionListenersessionHttpSession 创建 / 销毁★★★★☆
HttpSessionAttributeListenersession域属性添加 / 替换 / 移除★★☆☆☆
HttpSessionBindingListenersession 中的对象对象被绑定 / 解绑到 session★☆☆☆☆
HttpSessionActivationListenersession 中的对象session 钝化 / 活化(序列化相关)★☆☆☆☆
ServletRequestListenerrequestServletRequest 创建 / 销毁★★☆☆☆
ServletRequestAttributeListenerrequest域属性添加 / 替换 / 移除★☆☆☆☆

这里面只有 ServletContextListener 这个监听器后期我们会经常接触到,ServletContextListener 是用来监听 ServletContext 对象的创建和销毁的。

ServletContextListener 接口中有以下两个方法:

9.5.3 代码演示(原文 2.3)

我们只演示一下 ServletContextListener 监听器,分三步:

  1. 定义一个类,实现 ServletContextListener 接口;
  2. 重写所有的抽象方法;
  3. 使用 @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 的三个典型用途,考试和工作都绕不开:

  1. Spring 的 ContextLoaderListener。这是最有名的一个。Spring 与 JavaWeb 整合时,在 web.xml 里配置 org.springframework.web.context.ContextLoaderListener,它在 contextInitialized 里创建 Spring 的 IoC 容器(WebApplicationContext),并放入 ServletContext;contextDestroyed 里关闭容器、释放连接池。这就是"服务器一启动,Spring 就准备好了"的实现方式,没有它,你的 @Autowired 在 Servlet 里永远拿不到对象。文件名和本节示例类名相同,不是巧合,示例就是照着它起的。
  2. 统计在线人数。用 HttpSessionListener 的 sessionCreated / sessionDestroyed 加减计数。见下面的易错点。
  3. 加载配置文件。在 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)

需求:在完成用户注册时,当用户名输入框失去焦点时,校验用户名是否在数据库已存在。

一、分析

前端完成的逻辑:

  1. 给用户名输入框绑定光标失去焦点事件 onblur;
  2. 发送 ajax 请求,携带 username 参数;
  3. 处理响应:是否显示提示信息。

后端完成的逻辑:

  1. 接收用户名;
  2. 调用 service 查询 User。此案例是为了演示前后端异步交互,所以此处我们不做业务逻辑处理;
  3. 返回标记。

整体流程就是:失焦 → 发请求 → 查库 → 回一个 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 对象传递请求相关的参数:

then() 需要传递一个匿名函数。我们将 then() 中传递的匿名函数称为回调函数,意思是该匿名函数在发送请求时不会被调用,而是在成功响应后调用的函数。而该回调函数中的 resp 参数是对响应的数据进行封装的对象,通过 resp.data 可以获取到响应的数据。

resp 这个对象里到底有什么?记这四个就够用:

属性含义用法
resp.data服务器返回的响应体,axios 会自动把 JSON 串解析成 JS 对象/数组最常用,直接 resp.data.xxx
resp.statusHTTP 状态码一般不需要判,非 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 的数据类型分为如下几种:

示例:

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,该对象有如下两个方法:

代码演示:

<!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);
})

注意两点:

【补充】

JSON 为什么在前后端之间取代了表单参数。这是理解本章后半部分为什么这么写的关键:

  1. 表达能力。表单参数只有 key=value&key=value 这一种扁平结构,值只能是字符串。"一个品牌对象里带一个数组""订单里带一组明细",表单参数表达不了,只能发明 items[0].name 这种约定,后端还得自己解析。JSON 天生支持嵌套对象和数组,{"brand":{"name":"华为"},"tags":["手机","5G"]} 一层层写下去就行,和 Java 的 Brand、List<Tag> 一一对应。
  2. 有类型。表单参数里 23、true、null 全是字符串,后端要自己转;JSON 里的数字、布尔、null 是有类型的,Fastjson 直接按字段类型赋值,不用你操心。
  3. 一份数据两边通吃。JSON 的语法就是 JS 对象字面量,前端 JSON.parse 一下(axios 自动做)就能当对象用;Java 这边 JSON.toJSONString / JSON.parseObject 一行互转。中间的 XML 虽然也能表达层次,但标签成对出现,字节数是 JSON 的两三倍,还要写解析器。
  4. 代价。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 格式将数据响应回给浏览器。

在后端我们就需要重点学习以下两部分操作:

这两个动作有正式的名字: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);
    }
}

运行结果的三个细节,考试很可能就考这些:

9.8 前后端分离视角下的一次异步请求(原文 6)

有了 9.6 的原生 AJAX、9.7 的 axios 和 JSON,就可以把前面所有零件装成一台机器了。这一节做的是品牌列表的查询和添加,它的意义不在于代码有多长,而在于同一个页面里没有再出现一行 Servlet/JSP 的渲染代码:数据是 JS 拿的,表格是 JS 拼的,这个转变就是"前后端分离"在课程里的第一次落地。

9.8.1 需求(原文 6.1)

使用 Axios + JSON 完成品牌列表数据查询和添加功能。具体要求:

数据库表 tb_brand 的字段(本案例只用到这几个):

字段名类型含义
idint主键
brand_namevarchar品牌名称
company_namevarchar企业名称
orderedint排序字段
descriptionvarchar品牌介绍
statusint状态: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>

这里有四处值得停下来想:

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 把整条链路串起来看一遍(本章整合,原文无对应)

现在回头看一次"点击提交"到底发生了什么,把本章所有角色放进同一条时间线:

  1. 浏览器:btn 的 click 事件触发 → 收集表单值 → 拼成 JS 对象 → axios 发 POST /add,请求体是 JSON 串。
  2. 过滤器(如果有 EncodingFilter 和 LoginFilter):按 9.3 的顺序依次执行,EncodingFilter 在 getReader 之前设置编码;LoginFilter 检查 session 里有没有登录用户,没登录就转发到 login.jsp,请求根本到不了 Servlet。
  3. Servlet:getReader 读出 JSON 串 → JSON.parseObject 反序列化成 Brand → 调 brandMapper.add 落库 → write("success")。
  4. 响应往回走:过滤器链逆序再走一遍(洋葱模型的另一半),可以在这里统一加响应头、记耗时。
  5. 浏览器:then 回调拿到 resp.data == "success" → location.href = "brand.html" → 新页面加载 → window.onload 再发 GET /selectAll → 表格里出现新加的品牌。

这条链路里,Filter 管"能不能进",Listener 管"应用级的状态",AJAX/JSON 管"数据怎么来回"。三样东西各管一段,互不越界,这就是本章标题把它们放在一起的原因。

本章小结

本章四块内容,按"拦截 → 感知 → 异步 → 数据"四个关键词串起来:

一、Filter(重点,必考)

二、Listener(了解 + 会用)

三、Ajax(转折点)

四、axios 与 JSON(工程化写法)

必背清单(考试前扫一眼):

编号知识点参考答案
1Filter 三个方法init / doFilter / destroy
2放行语句chain.doFilter(request, response)
3忘写放行的后果请求被拦在过滤器,资源不执行,页面空白
4过滤器链顺序注解方式按类名自然排序
5/* 的含义拦截所有请求(含静态资源)
6统一编码为什么要在 getParameter 之前参数一旦被解析,编码就固定了,再设无效
7Filter 与 Interceptor 的本质区别Filter 是 Servlet 规范级、由容器调用;Interceptor 是 Spring MVC 框架级、由 DispatcherServlet 调用
8监听器监听什么三大域对象的创建/销毁与属性变化
9Ajax 两个判定条件readyState == 4 且 status == 200
10JSON 为什么只能 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 未初始化、1 open 已调用、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();最后才检查字段名。先看请求,再看后端,能省掉一半调试时间。