JavaWeb 复习讲义
原始讲义
第三部分 请求与响应:一个 Servlet 的全部工作

第6章 Request 与 Response

难度:★★★☆☆ | 前置知识:第4章 HTTP 协议、第5章 Tomcat 与 Servlet | 本章解决:在一个 Servlet 里把请求报文中的数据取出来、把结果按响应报文的格式写回去,并分清"转发"与"重定向"这两种跳转

本章导读

报文部件长什么样(第4章)谁封装取 / 设的 API(本章)
请求行GET /request-demo/req1?username=zhangsan HTTP/1.1requestgetMethod()、getRequestURI()、getQueryString()
请求头user-agent: Mozilla/5.0 ...requestgetHeader("user-agent")
请求体username=zhangsan&password=123(POST 才有)requestgetReader()、getParameter() 系列
响应行HTTP/1.1 302responsesetStatus(302)
响应头Location: /request-demo/resp2responsesetHeader("Location", ...)
响应体<h1>你好</h1> 或图片字节responsegetWriter()、getOutputStream()

6.1 Request 与 Response 概述

request 是请求对象,response 是响应对象。 这两个对象在我们使用 Servlet 的时候就已经见过:它们就写在 doGet 和 doPost 的参数列表里。既然每个 Servlet 的方法签名里都有它们,那就必须先回答一个问题:这两个参数的作用到底是什么?

request 的作用是获取请求数据,整条链路是这样的:

response 的作用正好相反,是设置响应数据:

这两句话看起来像常识,但它把本章所有 API 的归属问题一次讲清了:凡是"读"的方法都在 request 上,凡是"写"的方法都在 response 上。 后面遇到任何一个陌生的方法名,先问它是读还是写,就能猜对它在哪个对象里。

【补充】

为什么这两个对象是"参数",而不是我们自己 new 出来的?因为只有 Tomcat 知道这次请求的原始字节长什么样。TCP 连接、HTTP 报文的分帧、请求行的切分、请求头的解析、请求体到底有多少字节,这些都是 Socket 层和协议层的工作,属于服务器(容器)的职责。容器把这些工作做完,把结果装进一个 request 对象,再调用你的 Servlet,这就是第5章讲的"容器负责创建、调用、销毁"。所以 request 与 response 的生命周期正好等于一次请求:请求进来时创建,响应结束时销毁。你在 Servlet 里把 request 存到一个静态变量里留着下次用,是拿不到东西的。

6.1.1 第一个案例:从 URL 参数到页面问候

对上面讲的内容,我们先通过一个案例来初步体验一下 request 和 response 的使用:

@WebServlet("/demo3")
public class ServletDemo3 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //使用request对象 获取请求数据
        String name = request.getParameter("name");//url?name=zhangsan

        //使用response对象 设置响应数据
        response.setHeader("content-type","text/html;charset=utf-8");
        response.getWriter().write("<h1>"+name+",欢迎您!</h1>");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("Post...");
    }
}

启动成功后就可以通过浏览器来访问,并且根据传入参数的不同就可以在页面上展示不同的内容。访问 http://localhost:8080/request-demo/demo3?name=zhangsan 时,页面上出现一行放大的标题文字"zhangsan,欢迎您!";把地址里的 name 改成 name=lisi,页面就变成"lisi,欢迎您!",而代码一行都没改。这正是第4章说的"URL 的查询字符串是客户端可以自由构造的输入",也是本章所有"取参数"API 存在的理由。

这段代码虽然只有六行,却已经包含了本章的整体结构:第 6 行从 request 里拿数据,第 9 行设置响应头告诉浏览器"我给你的是 HTML,编码是 UTF-8",第 10 行往响应体里写内容。取数据 → 处理 → 写数据,后面所有案例都是这个结构,只是每一段变复杂了而已。

把这节的内容收一下,主要是认识了 request 对象和 response 对象:

目前我们只知道这两个对象是用来干什么的,那么它们具体是如何实现的,就需要继续深入学习。接下来就先从 Request 对象开始,主要学习下面这些内容:

6.2 Request 继承体系

在学习这节内容之前,先思考一个问题。前面在介绍 Request 和 Response 对象的时候,比较细心的同学可能已经发现:

那么:

首先来看 Request 的继承体系:最上层是 ServletRequest,它下面一层是 HttpServletRequest,最下面落地到实现类 RequestFacade。ServletRequest 和 HttpServletRequest 都是 Java 提供的,所以我们可以打开 JavaEE 提供的 API 文档(参考:资料/JavaEE7-api.chm)去查它们的方法说明。

所以 ServletRequest 和 HttpServletRequest 是继承关系,并且两个都是接口,接口是无法创建对象的。这个时候就引发了下面这个问题:既然拿不到对象,那 doGet 里的 request 参数到底是什么?

这个时候,我们就需要用到 Request 继承体系中的 RequestFacade:

对于上述结论,要想验证,可以编写一个 Servlet,在方法中把 request 对象打印下,就能看到最终的对象是不是 RequestFacade,代码如下:

@WebServlet("/demo2")
public class ServletDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println(request);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    }
}

启动服务器,运行访问 http://localhost:8080/request-demo/demo2,控制台打印出来的是类似 org.apache.catalina.connector.RequestFacade@5e4e2e1 这样一行。前缀 org.apache.catalina 就是 Tomcat 的包名,RequestFacade 就是它给你准备的那个实现类。

【补充】

RequestFacade 这个名字里的 Facade 是"门面"。Tomcat 内部真正装数据的是 org.apache.catalina.connector.Request,但这个类里有很多容器自用的方法(比如把请求和某个连接绑定、设置内部属性)。如果直接把内部对象交给你的 Servlet,你一个强制类型转换就能改容器的内部状态,容器就失控了。所以 Tomcat 套了一层门面:门面只实现 HttpServletRequest 接口里那些方法,你只能按接口办事。 同理,response 参数的真实实现类是 ResponseFacade。知道这一点有两个好处:一是打印对象时不会对那串陌生的类名感到意外;二是能理解"面向接口编程"在框架里是怎么落地的。

到这里,这节的小结只有三句话:

【对比】

ServletRequest 与 HttpServletRequest 的分工:前者是协议无关的抽象,只规定了"取参数、取输入流、存取属性、转发"这些任何协议都有的能力;后者是HTTP 专用的子接口,在此之上增加了"取请求方式、取 URI、取请求头、取 Cookie、取 Session"等只有 HTTP 才有的能力。所以你在 service(ServletRequest req, ServletResponse res) 里拿不到 getHeader:必须先向下转型成 HttpServletRequest。这也是为什么我们几乎总是继承 HttpServlet 而不是实现 Servlet 接口:日常开发面向的全是 HTTP。

【考点】 问:"request 对象是谁创建的?"标准答案:由 Servlet 容器(Tomcat)创建,实现类是 RequestFacade,在调用 service/doGet/doPost 之前创建并作为参数传入,一次请求对应一个 request 对象,响应结束后销毁。理由:请求数据来自网络字节流,只有容器能解析,而 ServletRequest 是接口不能实例化。

6.3 Request 获取请求数据

HTTP 请求数据总共分为三部分内容,分别是请求行、请求头、请求体。对于这三部分内容的数据,分别该如何获取?下面逐个来看,先学请求行数据如何获取。

【补充】

请求行里还有第四样东西,就是"HTTP 协议及版本",它也有对应的 API:getProtocol() 返回形如 HTTP/1.1 的字符串;getScheme() 返回 http 或 https;getServerName() 返回主机名(如 localhost);getServerPort() 返回端口(如 8080)。把 getScheme() + "://" + getServerName() + ":" + getServerPort() + getRequestURI() 拼起来,就是一个完整的绝对 URL。需要"动态生成回调地址""往邮件里放一个站内链接"的时候,用的就是这几个方法。把地址写死在配置文件里并不合适:一换域名就全失效了。

6.3.1 获取请求行数据

请求行包含三块内容,分别是请求方式、请求资源路径、HTTP 协议及版本。例如下面这一行:

GET /request-demo/req1?username=zhangsan&password=123 HTTP/1.1

对于这三部分内容,request 对象都提供了对应的 API 方法来获取,具体如下:

作用方法签名
获取请求方式:GETString getMethod()
获取虚拟目录(项目访问路径):/request-demoString getContextPath()
获取 URL(统一资源定位符):http://localhost:8080/request-demo/req1StringBuffer getRequestURL()
获取 URI(统一资源标识符):/request-demo/req1String getRequestURI()
获取请求参数(GET 方式):username=zhangsan&password=123String getQueryString()

介绍完上述方法后,我们通过代码把这些方法都使用一下:

/**
 * request 获取请求数据
 */
@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        // String getMethod():获取请求方式: GET
        String method = req.getMethod();
        System.out.println(method);//GET
        // String getContextPath():获取虚拟目录(项目访问路径):/request-demo
        String contextPath = req.getContextPath();
        System.out.println(contextPath);
        // StringBuffer getRequestURL(): 获取URL(统一资源定位符):
        // http://localhost:8080/request-demo/req1
        StringBuffer url = req.getRequestURL();
        System.out.println(url.toString());
        // String getRequestURI():获取URI(统一资源标识符): /request-demo/req1
        String uri = req.getRequestURI();
        System.out.println(uri);
        // String getQueryString():获取请求参数(GET方式): username=zhangsan
        String queryString = req.getQueryString();
        System.out.println(queryString);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

启动服务器,访问 http://localhost:8080/request-demo/req1?username=zhangsan&passwrod=123,获取的结果如下:

GET
/request-demo
http://localhost:8080/request-demo/req1
/request-demo/req1
username=zhangsan&passwrod=123

五行输出正好对应请求行被拆开后的五个观察角度。第一行是请求方式;第二行只有项目名;第三行是从协议名一直到最后(带域名和端口);第四行是从项目名开始、到查询字符串之前(不带域名端口);第五行是问号后面那一整串原样文本。

【对比】

getContextPath() / getRequestURI() / getRequestURL() 这三个最容易混,边界可以这样记:contextPath 是"项目名前那一段",URI 是"项目名 + 资源路径",URL 是"协议 + 域名 + 端口 + URI"。 所以三者的关系是 URL = 协议://域名:端口 + URI、URI = contextPath + 资源路径。写重定向时用的是 contextPath(因为浏览器需要一个可以自己再发一次请求的路径),写转发时用的是资源路径(服务端自己找资源,不需要项目名),做权限判断、日志记录时通常用 URI(能区分到具体的资源,又不含容易变化的域名端口)。

【考点】 问:"getRequestURL() 的返回值为什么是 StringBuffer 而不是 String?"答:因为它是 HTTP/1.0 时代留下的 API,当时 JDK 还没有 StringBuilder,为了能高效拼接协议、域名、端口、URI 这几段而返回可变的 StringBuffer;实际使用时 .toString() 一下即可。这类"返回值类型古怪"的 API 只需知道用法,不必深究。

6.3.2 获取请求头数据

对于请求头的数据,格式为 key: value,例如:

Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...
Accept: text/html,application/xhtml+xml

所以根据请求头名称获取对应值的方法为:

String getHeader(String name)

接下来,在代码中如果想要获取客户端浏览器的版本信息,则可以使用:

/**
 * request 获取请求数据
 */
@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //获取请求头: user-agent: 浏览器的版本信息
        String agent = req.getHeader("user-agent");
        System.out.println(agent);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

重新启动服务器后,访问 http://localhost:8080/request-demo/req1?username=zhangsan&passwrod=123,获取的结果如下:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36

这串东西就是浏览器的"自我介绍"。它的用途很实在:早期网站靠它判断你用的是 IE 还是 Chrome,从而决定给你返回哪一版页面;现在更多用来做统计和兼容处理。

【补充】

getHeader 是"一个名字换一个值",可 HTTP 允许同一个头出现多次(例如 Accept-Encoding、Cookie),所以 Servlet 还提供了 Enumeration<String> getHeaders(String name) 取全部同名头、Enumeration<String> getHeaderNames() 取所有头的名字。日常开发用得最多的是 user-agent(判断客户端)、referer(判断这个请求是从哪个页面点过来的,防盗链就用它)、cookie(第7章会看到,容器已经帮你封装成 getCookies())、content-type(判断请求体是表单、JSON 还是文件上传)。注意请求头里的名字不区分大小写,getHeader("User-Agent") 和 getHeader("user-agent") 拿到的是同一个值。

6.3.3 获取请求体数据

浏览器在发送 GET 请求的时候是没有请求体的,所以需要把请求方式变更为 POST,请求体中的数据格式如下:

username=zhangsan&password=123

对于请求体中的数据,Request 对象提供了如下两种方式来获取其中的数据,分别是:

ServletInputStream getInputStream()

该方法可以获取字节。

BufferedReader getReader()

接下来需要思考:要想获取到请求体的内容该如何实现?具体实现的步骤如下:

  1. 准备一个页面,在页面中添加 form 表单,用来发送 post 请求。
  2. 在 Servlet 的 doPost 方法中获取请求体数据。
  3. 在 doPost 方法中使用 request 的 getReader() 或者 getInputStream() 来获取。
  4. 访问测试。

第 1 步,在项目的 webapp 目录下添加一个 html 页面,名称为 req.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<!--
    action:form表单提交的请求地址
    method:请求方式,指定为post
-->
<form action="/request-demo/req1" method="post">
    <input type="text" name="username">
    <input type="password" name="password">
    <input type="submit">
</form>
</body>
</html>

第 2 步,在 Servlet 的 doPost 方法中获取数据:

/**
 * request 获取请求数据
 */
@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //在此处获取请求体中的数据
    }
}

第 3 步,调用 getReader() 或者 getInputStream() 方法。因为目前前端传递的是纯文本数据,所以我们采用 getReader() 方法来获取:

/**
 * request 获取请求数据
 */
@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //获取post 请求体:请求参数
        //1. 获取字符输入流
        BufferedReader br = req.getReader();
        //2. 读取数据
        String line = br.readLine();
        System.out.println(line);
    }
}

注意:BufferedReader 流是通过 request 对象来获取的,当请求完成后 request 对象就会被销毁,request 对象被销毁后 BufferedReader 流就会自动关闭,所以此处就不需要手动关闭流了。

第 4 步,启动服务器,通过浏览器访问 http://localhost:8080/request-demo/req.html,在输入框里填入用户名和密码,点击"提交"按钮后,就可以在控制台看到前端所发送的请求数据:

username=zhangsan&password=123

到这里,请求行的数据、请求头的数据、请求体的数据都已经能拿到了。小结一下:

HTTP 请求数据中包含了请求行、请求头和请求体,针对这三部分内容,Request 对象都提供了对应的 API 方法来获取对应的值:

报文部分API说明
请求行getMethod()获取请求方式
请求行getContextPath()获取项目访问路径
请求行getRequestURL()获取请求 URL
请求行getRequestURI()获取请求 URI
请求行getQueryString()获取 GET 请求方式的请求参数
请求头getHeader(String name)根据请求头名称获取其对应的值
请求体getReader()纯文本数据用字符流
请求体getInputStream()字节数据(如文件数据)用字节流

注意:浏览器发送的 POST 请求才有请求体。

【易错点】

getReader() 与 getInputStream() 也是互斥的:对同一个 request 先调了 getReader() 再调 getInputStream(),会抛 IllegalStateException: getInputStream() has already been called for this request。原因和后面 response 的字符流/字节流互斥是同一个道理:请求体在网络上只有一份字节,容器已经把它包装成了一个流,就不能再包装第二次。所以读取请求体之前要先想清楚:这次是文本还是文件?文本用 getReader(),文件用 getInputStream(),不要两个都试。

【易错点】 用 getReader().readLine() 读请求体,只能读到第一行。请求体如果是 username=zhangsan&password=123 这样一行到底的,没问题;但如果前端传的是多行文本(例如 <textarea> 里换行、或者 JSON 格式化过),readLine() 只返回第一行,后面的数据全丢。正确做法是循环读到 null 为止,或者直接改用下一节的 getParameter 系列,它已经把读流、切割、解码都做完了。

6.3.4 获取请求参数的通用方式

在学习下面内容之前,先提出两个问题:

1. 什么是请求参数? 为了能更好地回答这个问题,我们拿用户登录的例子来说明:

  1. 想要登录网址,需要进入登录页面。
  2. 在登录页面输入用户名和密码。
  3. 将用户名和密码提交到后台。
  4. 后台校验用户名和密码是否正确。
  5. 如果正确,则正常登录;如果不正确,则提示用户名或密码错误。

上述例子中,用户名和密码就是我们所说的请求参数。

2. 什么是请求数据? 请求数据则是包含请求行、请求头和请求体的所有数据。

3. 请求参数和请求数据的关系是什么?

对于请求参数的获取,常用的有以下两种:

String getQueryString()
BufferedReader getReader();

有了上述的知识储备,我们来实现一个案例需求:

  1. 发送一个 GET 请求并携带用户名,后台接收后打印到控制台。
  2. 发送一个 POST 请求并携带用户名,后台接收后打印到控制台。

GET 请求和 POST 请求接收参数的方式不一样,具体实现的代码如下:

@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

        String result = req.getQueryString();
        System.out.println(result);

    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        BufferedReader br = req.getReader();
        String result = br.readLine();
        System.out.println(result);
    }
}

对于上述的代码,会存在什么问题呢?问题就是重复:同一件事,doGet 里写一遍,doPost 里又得写一遍,而且两段代码用的还是不同的 API:换个请求方式就得换一套写法,业务代码永远统一不起来。

如何解决上述重复代码的问题呢?当然,也可以在 doGet 中调用 doPost,在 doPost 中完成参数的获取和打印,另外,doGet 和 doPost 方法都必须存在,不能删除任意一个。

GET 请求和 POST 请求获取请求参数的方式不一样,在获取请求参数这块该如何实现统一呢?要想实现,我们就需要思考:GET 请求方式和 POST 请求方式,区别主要在于获取请求参数的方式不一样,是否可以提供一种统一获取请求参数的方式,从而统一 doGet 和 doPost 方法内的代码?

解决方案一:自己判断请求方式。

@WebServlet("/req1")
public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //获取请求方式
        String method = req.getMethod();
        //获取请求参数
        String params = "";
        if("GET".equals(method)){
            params = req.getQueryString();
        }else if("POST".equals(method)){
            BufferedReader reader = req.getReader();
            params = reader.readLine();
        }
        //将请求参数进行打印控制台
        System.out.println(params);

    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        this.doGet(req,resp);
    }
}

使用 request 的 getMethod() 来获取请求方式,根据请求方式的不同分别获取请求参数值,这样就可以解决上述问题。但是以后每个 Servlet 都需要这样写代码,实现起来比较麻烦,这种方案我们不采用。

解决方案二:用 request 已经封装好的通用方法。

request 对象已经将上述获取请求参数的方法进行了封装,并且 request 提供的方法实现的功能更强大,以后只需要调用 request 提供的方法即可。那么在 request 的方法中都实现了哪些操作?

  1. 根据不同的请求方式获取请求参数:GET 读请求行里的查询串,POST 读请求体里的流。
  2. 把获取到的内容进行分割。查询串和表单体都是 key=value&key=value 的形式,先按 & 切成一对一对,再按 = 切成键和值。
  3. 把分割后的数据存入到一个 Map 集合中。

注意:因为参数的值可能是一个,也可能有多个,所以 Map 的值的类型为 String 数组。

基于上述理论,request 对象为我们提供了如下方法:

作用方法签名
获取所有参数 Map 集合Map<String,String[]> getParameterMap()
根据名称获取参数值(数组)String[] getParameterValues(String name)
根据名称获取参数值(单个值)String getParameter(String name)

接下来,我们通过案例把上述三个方法进行实例演示。

第 1 步,修改 req.html 页面,添加爱好选项,爱好可以同时选多个:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/request-demo/req2" method="get">
    <input type="text" name="username"><br>
    <input type="password" name="password"><br>
    <input type="checkbox" name="hobby" value="1"> 游泳
    <input type="checkbox" name="hobby" value="2"> 爬山 <br>
    <input type="submit">

</form>
</body>
</html>

注意两个复选框的 name 都是 hobby,但是 value 不同。这就是"一个名字对应多个值"的来源:勾选两个,提交出去的查询串里就会出现两次 hobby=。

第 2 步,在 Servlet 代码中获取页面传递 GET 请求的参数值。

2.1 获取 GET 方式的所有请求参数:

/**
 * request 通用方式获取请求参数
 */
@WebServlet("/req2")
public class RequestDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //GET请求逻辑
        System.out.println("get....");
        //1. 获取所有参数的Map集合
        Map<String, String[]> map = req.getParameterMap();
        for (String key : map.keySet()) {
            // username:zhangsan lisi
            System.out.print(key+":");

            //获取值
            String[] values = map.get(key);
            for (String value : values) {
                System.out.print(value + " ");
            }

            System.out.println();
        }
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

获取的结果为:

get....
username:zhangsan
password:123
hobby:1 2

2.2 获取 GET 请求参数中的爱好,结果是数组值:

/**
 * request 通用方式获取请求参数
 */
@WebServlet("/req2")
public class RequestDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //GET请求逻辑
        //...
        System.out.println("------------");
        String[] hobbies = req.getParameterValues("hobby");
        for (String hobby : hobbies) {
            System.out.println(hobby);
        }
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

获取的结果为:

------------
1
2

2.3 获取 GET 请求参数中的用户名和密码,结果是单个值:

/**
 * request 通用方式获取请求参数
 */
@WebServlet("/req2")
public class RequestDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //GET请求逻辑
        //...
        String username = req.getParameter("username");
        String password = req.getParameter("password");
        System.out.println(username);
        System.out.println(password);
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    }
}

获取的结果为:

zhangsan
123

第 3 步,在 Servlet 代码中获取页面传递 POST 请求的参数值:

3.1 将 req.html 页面 form 表单的提交方式改成 post。 3.2 将 doGet 方法中的内容复制到 doPost 方法中即可。

结果完全一样。 这就是通用方式的价值:同一段业务代码,method 写成 get 还是 post 都不用改。

【补充】

三个方法怎么选:确定只有一个值用 getParameter,可能有多个值用 getParameterValues,要把所有参数一次性遍历出来用 getParameterMap。 注意 getParameterValues 在参数不存在时返回 null,直接 for 循环会抛 NullPointerException,所以取数组前最好判空;而 getParameter 在参数不存在时返回 null(不是空串),所以"用户没填"和"用户填了空"要分开判断。

【易错点】 getParameterMap() 返回的 Map 里,值一定是 String[],哪怕这个参数只出现一次。很多同学写 String username = map.get("username"); 编译就报错,因为类型是 String[]。另外,直接打印 map.get("hobby") 得到的是 [Ljava.lang.String;@1b6d3586 这样的地址串,要用 Arrays.toString() 才能看到内容。

【对比】 getParameter(读客户端)与 getAttribute(读服务端):这是全章最容易被混的一对,名字只差三个字母,来源却完全不同:

维度getParameter(String name)getAttribute(String name)
数据来自哪里客户端,浏览器提交的请求参数(请求行查询串或请求体)服务端,程序员自己用 setAttribute 存进域里的对象
谁写入的用户填表单、点链接、拼 URL你的 Java 代码
返回值类型String(永远是字符串)Object(可以是任意类型,取出来要强转)
是否可修改不能改,只能读;同名参数可能多个可以改、可以删(removeAttribute)
生命周期这一次请求的参数字节,读完就没了跟着所在域走,request 域活一次请求,session 域活一次会话
典型用途接表单、接 URL 参数转发时把查询结果带给下一个资源、把错误信息带回页面
【考点】

结论是:getParameter 拿的是浏览器送来的,getAttribute 拿的是服务器自己存的。所以"参数"永远不可能是对象,也不可能被 Servlet 修改;"属性"永远不由用户直接决定,只由代码写入。考试里常问"转发能不能传对象",答案就是能:靠 setAttribute 存、getAttribute 取;而重定向不能,因为它是两次请求,域对象都换了。

到这里,本节小结两句话:

public class RequestDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        //采用request提供的获取请求参数的通用方式来获取请求参数
        //编写其他的业务代码...
    }
    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        this.doGet(req,resp);
    }
}

这个"doPost 里直接调 doGet"的写法,本章后面所有案例都会沿用,请把它当成肌肉记忆。

6.3.5 IDEA 快速创建 Servlet

使用通用方式获取请求参数后,屏蔽了 GET 和 POST 的请求方式代码的不同,则代码可以定义如下格式(就是上面那一段)。由于格式固定,所以我们可以使用 IDEA 提供的模板来制作一个 Servlet 的模板,这样后期在创建 Servlet 的时候就会更高效。具体如何实现:

  1. 按照自己的需求,修改 Servlet 创建的模板内容。路径是 File → Settings → Editor → File and Code Templates → Files,找到 Servlet Class.java 或 Class,把内容改成上面那套固定结构。
  2. 使用 servlet 模板创建 Servlet 类:右键包名 → New → Servlet(或直接 New → Java Class 后套模板),IDEA 会自动生成带 doGet/doPost 的完整类。
【补充】

这里说的"模板"就是一段代码片段,IDEA 会把 ${NAME}、${PACKAGE_NAME} 这些占位符替换成你新建时填的类名与包名。模板里最值得保留的两处是:① doPost 里那行 this.doGet(req,resp);,避免每次手动补;② @WebServlet("/xxx") 注解,避免忘了配访问路径导致 404。如果你的模板里没有 @WebServlet,检查一下 IDEA 的 Servlet 模板版本:Servlet 3.0 之后才用注解代替 web.xml 配置。

6.3.6 请求参数中文乱码问题

前面几节取参数取的都是英文和数字,一切正常。但只要参数里出现中文,问题立刻出现。先看现象。

(1) 将 req.html 页面的请求方式修改为 get:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/request-demo/req2" method="get">
    <input type="text" name="username"><br>
    <input type="password" name="password"><br>
    <input type="checkbox" name="hobby" value="1"> 游泳
    <input type="checkbox" name="hobby" value="2"> 爬山 <br>
    <input type="submit">

</form>
</body>
</html>

(2) 在 Servlet 方法中获取参数,并打印:

/**
 * 中文乱码问题解决方案
 */
@WebServlet("/req4")
public class RequestDemo4 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 获取username
        String username = request.getParameter("username");
        System.out.println(username);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(3) 启动服务器,页面上输入中文参数,例如"张三"。

(4) 查看控制台打印内容,得到的是 å¼ ä¸ 这样一串看不懂的符号。

(5) 把 req.html 页面的请求方式改成 post,再次发送请求和中文参数。

(6) 查看控制台打印内容,依然为乱码。

通过上面的案例会发现:不管是 GET 还是 POST 请求,在发送的请求参数中如果有中文,在后台接收的时候都会出现中文乱码的问题。 具体该如何解决呢?两种请求方式的成因并不相同,要分开处理。

1. POST 请求的解决方案

分析出现中文乱码的原因:

解决方案:

修改后的代码为:

/**
 * 中文乱码问题解决方案
 */
@WebServlet("/req4")
public class RequestDemo4 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 解决乱码: POST getReader()
        //设置字符输入流的编码,设置的字符集要和页面保持一致
        request.setCharacterEncoding("UTF-8");
        //2. 获取username
        String username = request.getParameter("username");
        System.out.println(username);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

重新发送 POST 请求,就会在控制台看到正常展示的中文结果。至此 POST 请求中文乱码的问题就已经解决,但是这种方案不适用于 GET 请求。这个原因是什么呢,下面再分析。

【易错点】

setCharacterEncoding 必须写在第一次 getParameter 之前。原因很硬:getParameter 第一次被调用时,容器才真正去读请求体、按当时的编码把字节解码成字符串,并把结果缓存进参数 Map;之后再调用 getParameter 直接返回缓存值,不会再重新解码。所以如果你先取了一次参数,再调 setCharacterEncoding,前面那次已经按 ISO-8859-1 解坏了,后面怎么设都没用。这种顺序错误在 Filter 里尤其常见(第9章会写统一的编码过滤器),顺序必须是"先 setCharacterEncoding,再放行"。

2. GET 请求的解决方案

刚才提到一个问题:POST 请求的中文乱码解决方案为什么不适用 GET 请求?

所以 GET 请求不能用设置编码的方式来解决中文乱码问题。那问题又来了,如何解决 GET 请求的中文乱码呢?

1. 首先我们需要先分析下 GET 请求出现乱码的原因:

  1. 浏览器通过 HTTP 协议发送请求和数据给后台服务器(Tomcat)。
  2. 浏览器在发送 HTTP 的过程中会对中文数据进行 URL 编码。
  3. 在进行 URL 编码的时候会采用页面 <meta> 标签指定的 UTF-8 的方式进行编码,"张三"编码后的结果为 %E5%BC%A0%E4%B8%89。
  4. 后台服务器(Tomcat)接收到 %E5%BC%A0%E4%B8%89 后会默认按照 ISO-8859-1 进行 URL 解码。
  5. 由于前后编码与解码采用的格式不一样,就会导致后台获取到的数据为乱码。

思考:如果把 req.html 页面的 <meta> 标签的 charset 属性改成 ISO-8859-1,后台不做操作,能解决中文乱码问题么?

答案是否定的。 因为 ISO-8859-1 本身是不支持中文展示的,所以改了标签的 charset 属性后,会导致页面上的中文内容都无法正常展示。

分析完上面的问题后,我们会发现,其中有两个我们不熟悉的内容,就是 URL 编码和 URL 解码。什么是 URL 编码,什么又是 URL 解码呢?

URL 编码:这块知识我们只需要了解下即可,具体编码过程分两步,分别是:

  1. 将字符串按照编码方式转为二进制。
  2. 每个字节转为 2 个 16 进制数并在前边加上 %。

"张三"按照 UTF-8 的方式转换成二进制的结果为:

1110 0101 1011 1100 1010 0000 1110 0100 1011 1000 1000 1001

这个结果是如何计算的?可以使用 http://www.mytju.com/classcode/tools/encode_utf8.asp,输入"张三",就可以获取"张"和"三"分别对应的 10 进制,然后再使用计算器,选择程序员模式,计算出对应的二进制数据结果。在计算出的十六进制结果中,每两位前面加一个 %,就可以获取到 %E5%BC%A0%E4%B8%89。

但是对于上面的计算过程,如果没有工具、纯手工计算的话,相对来说还是比较复杂的,我们也不需要手动计算,在 Java 中已经为我们提供了编码和解码的 API 工具类,就是下面两个类:URLEncoder 和 URLDecoder。

编码:

java.net.URLEncoder.encode("需要被编码的内容","字符集(UTF-8)")

解码:

java.net.URLDecoder.decode("需要被解码的内容","字符集(UTF-8)")

下面对"张三"进行编码和解码:

public class URLDemo {

    public static void main(String[] args) throws UnsupportedEncodingException {

        String username = "张三";
        //1. URL编码
        String encode = URLEncoder.encode(username, "utf-8");
        System.out.println(encode); //打印:%E5%BC%A0%E4%B8%89

        //2. URL解码
        //String decode = URLDecoder.decode(encode, "utf-8");//打印:张三
        String decode = URLDecoder.decode(encode, "ISO-8859-1");//打印:`å¼ ä¸ `
        System.out.println(decode);
    }
}

到这,我们就可以分析出 GET 请求中文参数出现乱码的原因了:

2. 清楚了出现乱码的原因,下面需要想办法进行解决。

在进行编码和解码的时候,不管使用的是哪个字符集,他们对应的 %E5%BC%A0%E4%B8%89 是一致的;那他们对应的二进制值也是一样的,为:

1110 0101 1011 1100 1010 0000 1110 0100 1011 1000 1000 1001

所以我们可以考虑把 å¼ ä¸ 转换成字节,再把字节转换成"张三",在转换的过程中让它们的编码一致,就可以解决中文乱码问题。

具体的实现步骤为:

  1. 按照 ISO-8859-1 编码获取乱码 å¼ ä¸ 对应的字节数组。
  2. 按照 UTF-8 编码获取字节数组对应的字符串。

实现代码如下:

public class URLDemo {

    public static void main(String[] args) throws UnsupportedEncodingException {

        String username = "张三";
        //1. URL编码
        String encode = URLEncoder.encode(username, "utf-8");
        System.out.println(encode);
        //2. URL解码
        String decode = URLDecoder.decode(encode, "ISO-8859-1");

        System.out.println(decode); //此处打印的是对应的乱码数据

        //3. 转换为字节数据,编码
        byte[] bytes = decode.getBytes("ISO-8859-1");
        for (byte b : bytes) {
            System.out.print(b + " ");
        }
        //此处打印的是:-27 -68 -96 -28 -72 -119
        //4. 将字节数组转为字符串,解码
        String s = new String(bytes, "utf-8");
        System.out.println(s); //此处打印的是张三
    }
}

说明:在第 18 行中打印的数据是 -27 -68 -96 -28 -72 -119,和"张三"转换成的二进制数据 1110 0101 1011 1100 1010 0000 1110 0100 1011 1000 1000 1001 为什么不一样呢?

打印出来的是十进制数据,只需要换算一下就能得到它们的对应关系:1110 0101 是十六进制的 E5,也就是十进制的 229,而 Java 的 byte 是有符号的 8 位整数,229 超出了 127 的上限,按补码表示为 −27。所以 -27 就是 E5,-68 就是 BC,-96 就是 A0,三个一组拼回 E5 BC A0,正是"张"的 UTF-8 编码。

至此对于 GET 请求中文乱码的解决方案,我们就已经分析完了,最后在代码中去实现下:

/**
 * 中文乱码问题解决方案
 */
@WebServlet("/req4")
public class RequestDemo4 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 解决乱码:POST,getReader()
        //request.setCharacterEncoding("UTF-8");//设置字符输入流的编码

        //2. 获取username
        String username = request.getParameter("username");
        System.out.println("解决乱码前:"+username);

        //3. GET,获取参数的方式:getQueryString
        // 乱码原因:tomcat进行URL解码,默认的字符集ISO-8859-1
        /* //3.1 先对乱码数据进行编码:转为字节数组
        byte[] bytes = username.getBytes(StandardCharsets.ISO_8859_1);
        //3.2 字节数组解码
        username = new String(bytes, StandardCharsets.UTF_8);*/

        username = new String(username.getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);

        System.out.println("解决乱码后:"+username);

    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

控制台的输出会是这样两行:先打印"解决乱码前:å¼ ä¸ ",再打印"解决乱码后:张三"。

【补充】

注意:把 request.setCharacterEncoding("UTF-8") 代码注释掉后,会发现 GET 请求参数的乱码解决方案同时也把 POST 请求参数乱码的问题解决了。只不过对于 POST 请求参数一般都会比较多,采用这种方式解决乱码起来比较麻烦,所以对于 POST 请求还是建议使用设置编码的方式进行。

另外需要说明一点的是:Tomcat 8.0 之后,已将 GET 请求乱码问题解决,设置默认的解码方式为 UTF-8。

【补充】 乱码的本质只有一个:编码和解码两端用的字符集不一致。 一个字符串在网络上走一遭,至少要过三道"字符 ↔ 字节"的关口:浏览器把页面里的中文按 <meta charset> 编码成字节(第一道);这些字节进了 URL 就要做百分号编码(仍然是字节);Tomcat 拿到百分号串后要按自己的字符集把字节还原成字符(第二道);如果你的代码还要再转一次(第三道),任何一道用的字符集不一样,就会出现 å¼ ä¸ 这种"一个汉字被拆成两三个西欧字符"的乱码。所以要定位乱码,不要问"哪里错了",而要问"哪两端的字符集不一样",把两端都改成 UTF-8 就必然正确。

【补充】 Tomcat 8 到底改了什么?GET 请求的查询串不是通过流读的,它是由连接器(Connector)在解析请求行时直接解码的,而这个解码用的字符集来自 conf/server.xml 里 <Connector> 的 URIEncoding 属性。Tomcat 7 及以前,URIEncoding 默认是 ISO-8859-1;Tomcat 8 起默认改成了 UTF-8,于是"浏览器 UTF-8 编码、Tomcat UTF-8 解码"两端对齐,GET 乱码自然消失。所以现在用 Tomcat 8/9/10 做实验,即使不写任何解决代码,GET 传中文也不会乱码。但这不代表"乱码问题不存在":一旦换成 Tomcat 7、或者有人手动把 URIEncoding 改回去、或者用 useBodyEncodingForURI="true" 让 URI 跟着请求体的编码走,乱码立刻复现。这也是后面第9章要用编码过滤器统一处理的原因:不依赖服务器版本和配置,代码里自己保证两端一致。

【对比】 POST 与 GET 的乱码,解决手段为什么完全不同:

维度POST 乱码GET 乱码(Tomcat 8 之前)
参数在哪里请求体里请求行的查询串里
容器怎么解码按请求体的字符编码解码,默认 ISO-8859-1按连接器的 URIEncoding 解码,Tomcat 7 默认 ISO-8859-1
标准解法request.setCharacterEncoding("UTF-8")改 URIEncoding(改配置),或代码里 new String(x.getBytes("ISO-8859-1"),"UTF-8")
解法生效位置必须在第一次取参数之前取到乱码字符串之后,随时可以补救
现状仍需要注意(Tomcat 各版本默认都是 ISO-8859-1)Tomcat 8+ 默认 UTF-8,通常不再出现

本节小结:

  1. 中文乱码解决方案
    • POST 请求和 GET 请求的参数中如果有中文,后台接收数据就会出现中文乱码问题。
    • GET 请求在 Tomcat 8.0 以后的版本就不会出现了。
    • POST 请求解决方案是设置输入流的编码:request.setCharacterEncoding("UTF-8");,注意设置的字符集要和页面保持一致。
    • 通用方式(GET/POST):需要先解码,再编码,就是 new String(username.getBytes("ISO-8859-1"),"UTF-8");。
  2. URL 编码实现方式
    • 编码:URLEncoder.encode(str,"UTF-8");
    • 解码:URLDecoder.decode(s,"ISO-8859-1");

6.4 请求转发与 request 域

前面所有的内容都是"一个 Servlet 处理一个请求"。但真实的业务往往需要多个资源配合:一个 Servlet 负责查数据,另一个资源负责把数据展示成页面。怎么让请求从 A 走到 B?这就是请求转发。

1. 请求转发(forward):一种在服务器内部的资源跳转方式。

  1. 浏览器发送请求给服务器,服务器中对应的资源 A 接收到请求。
  2. 资源 A 处理完请求后将请求发给资源 B。
  3. 资源 B 处理完后将结果响应给浏览器。
  4. 请求从资源 A 到资源 B 的过程就叫请求转发。

2. 请求转发的实现方式:

req.getRequestDispatcher("资源B路径").forward(req,resp);

具体如何来使用,我们先来看下需求:访问 /req5,让它在内部把请求转给 /req6,由 /req6 完成响应。

针对上述需求,具体的实现步骤为:

  1. 创建一个 RequestDemo5 类,接收 /req5 的请求,在 doGet 方法中打印 demo5。
  2. 创建一个 RequestDemo6 类,接收 /req6 的请求,在 doGet 方法中打印 demo6。
  3. 在 RequestDemo5 的方法中使用 request.getRequestDispatcher("/req6").forward(request,response) 进行请求转发。
  4. 启动测试。

(1)创建 RequestDemo5 类:

/**
 * 请求转发
 */
@WebServlet("/req5")
public class RequestDemo5 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("demo5...");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(2)创建 RequestDemo6 类:

/**
 * 请求转发
 */
@WebServlet("/req6")
public class RequestDemo6 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("demo6...");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(3)在 RequestDemo5 的 doGet 方法中进行请求转发:

/**
 * 请求转发
 */
@WebServlet("/req5")
public class RequestDemo5 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("demo5...");
        //请求转发
        request.getRequestDispatcher("/req6").forward(request,response);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(4)启动测试。 访问 http://localhost:8080/request-demo/req5,就可以在控制台看到如下内容:

demo5...
demo6...

说明请求已经转发到了 /req6。注意浏览器地址栏里始终是 /req5,你看不到 /req6 出现在地址里,这是转发最重要的特征。

3. 请求转发资源间共享数据:使用 Request 对象。

此处主要解决的问题是:把请求从 /req5 转发到 /req6 的时候,如何传递数据给 /req6。需要使用 request 对象提供的三个方法:

作用方法签名
存储数据到 request 域(范围、数据是存储在 request 对象)中void setAttribute(String name,Object o);
根据 key 获取值Object getAttribute(String name);
根据 key 删除该键值对void removeAttribute(String name);

接着上个需求来:

  1. 在 RequestDemo5 的 doGet 方法中转发请求之前,将数据存入 request 域对象中。
  2. 在 RequestDemo6 的 doGet 方法中从 request 域对象中获取数据,并将数据打印到控制台。
  3. 启动访问测试。

(1)修改 RequestDemo5 中的方法:

@WebServlet("/req5")
public class RequestDemo5 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("demo5...");
        //存储数据
        request.setAttribute("msg","hello");
        //请求转发
        request.getRequestDispatcher("/req6").forward(request,response);

    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(2)修改 RequestDemo6 中的方法:

/**
 * 请求转发
 */
@WebServlet("/req6")
public class RequestDemo6 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("demo6...");
        //获取数据
        Object msg = request.getAttribute("msg");
        System.out.println(msg);

    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(3)启动测试。 访问 http://localhost:8080/request-demo/req5,就可以在控制台看到如下内容:

demo5...
demo6...
hello

此时就可以实现在转发的多个资源之间共享数据。为什么能共享?因为转发过程中没有产生新的请求,A 和 B 拿到的是同一个 request 对象,A 往里面存的东西,B 自然读得到。

4. 请求转发的特点:

【补充】

request 域的生命周期就是一次请求:请求进入容器时创建,转发过程中一直存活,响应结束后随 request 一起销毁。它和另外三个域一起构成"四大作用域",范围由小到大如下:

作用域对应的对象范围生命周期典型用途
page 域pageContext(JSP 内置对象)当前 JSP 页面页面执行期间,出了这个页面就没了页面内部的临时变量(第8章)
request 域HttpServletRequest当前请求,含转发链上的所有资源一次请求:请求进来创建,响应结束销毁转发时把查询结果、错误提示带给下一个资源
session 域HttpSession当前会话,跨多次请求一次会话:从第一次 getSession() 到超时或 invalidate()保存登录用户(第7章)
application 域ServletContext整个 Web 应用,所有用户共享应用启动到关闭全局配置、在线人数统计(第9章)
【对比】

四者的关系是包含关系:一个 session 里有很多次 request,一个 application 里有很多个 session;范围越大活得越久,但越容易互相干扰、越占内存。选域的原则是"够用就好":只给下一个资源看的数据放 request,要给用户跨请求看的放 session,要给所有人看的才放 application。

【对比】 转发与重定向最容易被混淆的一点:能不能访问 WEB-INF 下的资源。 放在 WEB-INF 里的文件,浏览器永远无法直接请求(容器会直接返回 404),这是它安全的原因;但转发是服务器内部的行为,可以转到 /WEB-INF/xxx.jsp,所以真实项目里常把不想被用户直接访问的页面放进 WEB-INF,只允许 Servlet 转发进去(第8章会大量用到)。重定向是让浏览器再发一次请求,浏览器发不出对 WEB-INF 的请求,所以重定向到 WEB-INF 一定失败。

【易错点】 转发之后必须 return,或者说转发之后不要再往 response 里写东西。因为 forward 只是把请求交给另一个资源,它并不会立刻中断当前方法的执行:如果你写了

request.getRequestDispatcher("/req6").forward(request,response);
response.getWriter().write("我还想再写一句");

那么 req6 已经把响应写满并提交(committed)了,你这一句要么被丢弃,要么抛 IllegalStateException,页面出现半截内容。正确写法是转发语句后面直接 return;(或者干脆把转发放在方法的最后一个语句)。

【考点】 问:"转发和重定向,哪个能共享 request 域的数据?为什么?"答:转发能,重定向不能。 理由是请求次数:转发是服务器内部的一次请求,A 和 B 共用同一个 request 对象;重定向是浏览器收到 302 后又发了一次新请求,容器为它创建了全新的 request 对象,A 存的数据在新对象里根本不存在。

6.5 Response 对象

前面讲解完 Request 对象,下面回到刚开始的那张图:

Response 的继承体系和 Request 的继承体系也非常相似:最上层是 ServletResponse,下一层是 HttpServletResponse,最下面的实现类是 ResponseFacade(由 Tomcat 提供,同样是门面模式)。介绍完 Response 的相关体系结构后,接下来对于 Response 我们需要学习如下内容:

【补充】

request 和 response 是成对出现的:容器每收到一个请求,就为它创建一个 request 对象和一个 response 对象,一起递进 service、doGet/doPost,方法返回后一起销毁。所以你在 doGet 里往 response 里写的东西,只会回到"发起这次请求的那个浏览器",绝不会串到别人那里,这是它们和后面第 7 章 session(一个用户多个请求共用)最根本的区别。也正因为同生同灭,response 的生命周期就是一次请求,不需要你手动关流,容器会替你 flush。

6.5.1 Response 设置响应数据功能介绍

HTTP 响应数据总共分为三部分内容,分别是响应行、响应头、响应体。对于这三部分内容的数据,response 对象都提供了哪些方法来进行设置?

1. 响应行

对于响应行,比较常用的是设置响应状态码:

void setStatus(int sc);

2. 响应头

设置响应头键值对:

void setHeader(String name,String value);

3. 响应体

对于响应体,是通过字符、字节输出流的方式往浏览器写。

获取字符输出流:

PrintWriter getWriter();

获取字节输出流:

ServletOutputStream getOutputStream();

介绍完这些方法后,后面我们会通过案例把这些方法都用一用,首先先来完成下重定向的功能开发。

【补充】

setStatus、setHeader、getWriter 这三个方法,正好把第4章那张"响应报文结构图"填满了:响应行的状态码由 setStatus 定,响应头由 setHeader 一个个加,响应体由输出流写。三者还有一个共同点:都必须在响应"提交"(commit)之前调用。所谓提交,就是容器把响应头和已经写出的响应体实际发回给浏览器,通常发生在缓冲区(默认 8KB)写满或请求处理结束时。一旦提交,再 setStatus/setHeader 就无效(不会报错,但也不会生效),再写响应体则可能抛异常。这就是上一节"转发之后不要再写"的根本原因。

【补充】 有一个方法同时干了"响应头 + 编码"两件事,就是 setContentType(String type)。response.setContentType("text/html;charset=utf-8") 等价于 setHeader("content-type","text/html;charset=utf-8"),但它额外告诉容器"响应体要用 UTF-8 编码",于是 getWriter() 拿到的流会按 UTF-8 工作。所以凡是写中文,都用 setContentType。setHeader("content-type", ...) 只改了头,不会改流的编码。

6.5.2 Response 请求重定向

1. Response 重定向(redirect):一种资源跳转方式。

  1. 浏览器发送请求给服务器,服务器中对应的资源 A 接收到请求。
  2. 资源 A 现在无法处理该请求,就会给浏览器响应一个 302 的状态码 + location 的一个访问资源 B 的路径。
  3. 浏览器接收到响应状态码为 302 就会重新发送请求到 location 对应的访问地址去访问资源 B。
  4. 资源 B 接收到请求后进行处理并最终给浏览器响应结果,这整个过程就叫重定向。

2. 重定向的实现方式:

resp.setStatus(302);
resp.setHeader("location","资源B的访问路径");

具体如何来使用,我们先来看下需求:访问 /resp1,让它告诉浏览器"去访问 /resp2"。

针对上述需求,具体的实现步骤为:

  1. 创建一个 ResponseDemo1 类,接收 /resp1 的请求,在 doGet 方法中打印 resp1....。
  2. 创建一个 ResponseDemo2 类,接收 /resp2 的请求,在 doGet 方法中打印 resp2....。
  3. 在 ResponseDemo1 的方法中使用 response.setStatus(302); 和 response.setHeader("Location","/request-demo/resp2") 来给前端响应结果数据。
  4. 启动测试。

(1)创建 ResponseDemo1 类:

@WebServlet("/resp1")
public class ResponseDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("resp1....");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(2)创建 ResponseDemo2 类:

@WebServlet("/resp2")
public class ResponseDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("resp2....");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(3)在 ResponseDemo1 的 doGet 方法中给前端响应数据:

@WebServlet("/resp1")
public class ResponseDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("resp1....");
        //重定向
        //1.设置响应状态码 302
        response.setStatus(302);
        //2. 设置响应头 Location
        response.setHeader("Location","/request-demo/resp2");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

(4)启动测试。 访问 http://localhost:8080/request-demo/resp1,就可以在控制台看到如下内容:

resp1....
resp2....

说明 /resp1 和 /resp2 都被访问到了,到这重定向就已经完成了。同时浏览器的地址栏会从 /resp1 变成 /resp2,和转发正好相反。

虽然功能已经实现,但是从设置重定向的两行代码来看,会发现除了重定向的地址不一样,其他的内容都是一模一样,所以 response 对象给我们提供了简化的编写方式:

response.sendRedirect("/request-demo/resp2")

所以第 3 步中的代码就可以简化为:

@WebServlet("/resp1")
public class ResponseDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("resp1....");
        //重定向
        response.sendRedirect("/request-demo/resp2");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

3. 重定向的特点:

【补充】

sendRedirect 内部做的就是"setStatus(302) + setHeader("Location", url)"这两步,所以两行代码和一行代码完全等价。但要注意两个细节:① 302 是临时重定向,浏览器不会缓存这个跳转关系;301 是永久重定向,浏览器会记住,下次直接跳到新地址。② 调完 sendRedirect 之后容器会在响应结束时自动提交响应,所以你在这行之后写的任何 getWriter().write(...) 都没有意义,方法应该立刻 return。

6.5.3 转发与重定向的对比

介绍完请求重定向和请求转发以后,接下来需要把这两个放在一块对比下。这张表是本章最重要的一张表,考试考简答、面试问区别,都在这上面:

对比项请求转发 forward请求重定向 redirect
谁发起的跳转服务器内部,由容器把同一个请求交给下一个资源浏览器,由浏览器根据 302 + Location 重新发请求
浏览器地址栏不变,仍显示第一次访问的地址改变,显示跳转后的新地址
请求次数1 次(一次请求、一次响应)2 次(两次请求、两次响应)
能否共享 request 域能,A 存 B 取,同一个 request 对象不能,第二次是全新的 request 对象
跳转目标范围只能是当前应用内部的资源,不能跳到别的服务器任意 URL,可以跳到本站其他应用甚至外部网站
能否访问 WEB-INF 下的资源能(服务器内部行为,不受浏览器限制)不能(浏览器直接请求会被容器拒绝)
能否跳转到外部网站不能能,例如 sendRedirect("https://www.baidu.com")
效率高,一次网络往返低,两次网络往返
浏览器是否感知感知不到,地址不变感知得到,地址变了、有历史记录
典型场景查询数据后转到 JSP 展示、把请求交给另一个 Servlet 继续处理、访问 WEB-INF 下的页面登录成功后跳到首页、注册成功后跳到登录页、支付完成后跳到结果页(防止刷新重复提交表单)
代码写法request.getRequestDispatcher("/资源B").forward(request,response);response.sendRedirect(request.getContextPath()+"/资源B");

以后到底用哪个,还是需要根据具体的业务来决定。

【对比】

用一句口诀记这张表:"转发是一次请求,服务器自己转;重定向是两次请求,浏览器替你去。" 由这一句可以推出全部差异:因为是一次请求,所以地址不变、域能共享、只能在本应用内转(服务器只认识自己的资源);因为是两次请求,所以地址变了、域不能共享、能去任何地方(浏览器认 URL),也正因为多了一次往返所以效率低。

【考点】 问:"登录成功后跳转首页,该用转发还是重定向?"答:用重定向。 两个理由:一是登录成功后应当把地址栏换成首页的地址,否则用户一刷新就会重复提交登录表单(转发时地址还停在登录的 URL 上);二是登录成功后通常要丢弃请求里携带的密码等参数,重定向产生的新请求天然不带这些数据。反过来,"查完商品列表要展示页面"就用转发,因为列表数据要跟着 request 域一起过去。

6.5.4 路径问题

问题 1:转发的时候路径上没有加 /request-demo 而重定向加了,那么到底什么时候需要加,什么时候不需要加呢?

判断的依据很简单,只需要记住下面的规则即可:

对于转发来说,因为是在服务端进行的,所以不需要加虚拟目录;对于重定向来说,路径最终是由浏览器来发送请求,就需要添加虚拟目录。

掌握了这个规则,接下来就通过一些练习来强化下知识的学习。下面四种写法,哪些需要加虚拟目录?

1. <a href='路径'>
2. <form action='路径'>
3. req.getRequestDispatcher("路径")
4. resp.sendRedirect("路径")

答案:

1. 超链接,从浏览器发送,需要加
2. 表单,从浏览器发送,需要加
3. 转发,是从服务器内部跳转,不需要加
4. 重定向,是由浏览器进行跳转,需要加。

问题 2:在重定向的代码中,/request-demo 是固定编码的,如果后期通过 Tomcat 插件配置了项目的访问路径,那么所有需要重定向的地方都需要重新修改,该如何优化?

答案也比较简单,我们可以在代码中动态去获取项目访问的虚拟目录。具体如何获取?可以借助前面所学习的 request 对象中的 getContextPath() 方法,修改后的代码如下:

@WebServlet("/resp1")
public class ResponseDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("resp1....");

        //简化方式完成重定向
        //动态获取虚拟目录
        String contextPath = request.getContextPath();
        response.sendRedirect(contextPath+"/resp2");
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

重新启动访问测试,功能依然能够实现。此时就可以动态获取项目访问的虚拟路径,从而降低代码的耦合度。

【易错点】

重定向路径漏写虚拟目录,是最常见的 404 来源之一。现象是:点登录按钮后浏览器跳到了 http://localhost:8080/resp2(注意项目名 /request-demo 不见了),页面 404。原因就是 sendRedirect("/resp2") 写成了以 / 开头的"服务器根路径",而不是"应用根路径"。要么写全 /request-demo/resp2,要么用 request.getContextPath()+"/resp2"。同理,重定向也可以写相对路径(如 sendRedirect("resp2")),但相对路径是相对于当前请求的 URI 计算的,一旦当前 URI 层级变化就会算错,所以实际开发里统一用"contextPath + 绝对路径"这种写法最稳。

6.5.5 Response 响应字符数据

要想将字符数据写回到浏览器,我们需要两个步骤:

接下来,我们通过几个案例把响应字符数据给实际应用一下。

1. 返回一个简单的字符串 aaa:

/**
 * 响应字符数据:设置字符数据的响应体
 */
@WebServlet("/resp3")
public class ResponseDemo3 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html;charset=utf-8");
        //1. 获取字符输出流
        PrintWriter writer = response.getWriter();
        writer.write("aaa");
    }
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

访问 /resp3,浏览器页面上就只有一个 aaa。

2. 返回一串 html 字符串,并且能被浏览器解析:

PrintWriter writer = response.getWriter();
//content-type,告诉浏览器返回的数据类型是HTML类型数据,这样浏览器才会解析HTML标签
response.setHeader("content-type","text/html");
writer.write("<h1>aaa</h1>");

这时页面上的 aaa 变成了一级标题那么大。关键在 content-type:如果它是 text/plain,浏览器就把 <h1>aaa</h1> 原样显示成一串字符;只有告诉浏览器这是 text/html,标签才会被解析成标题。

注意:一次请求响应结束后,response 对象就会被销毁掉,所以不要手动关闭流。

3. 返回一个中文的字符串"你好",需要注意设置响应数据的编码为 utf-8:

//设置响应的数据格式及数据的编码
response.setContentType("text/html;charset=utf-8");
writer.write("你好");
【易错点】

先取流、后设编码,中文会乱码。因为 getWriter() 一旦被调用,字符流的编码就已经按当时的 content-type 定下来了(没设过就是 ISO-8859-1)。之后再 setContentType("text/html;charset=utf-8") 只改了响应头,流不会重建,浏览器按 UTF-8 去解一段实际是 ISO-8859-1 编出来的字节,于是"你好"变成"ä½ å¥½"。顺序永远是:先 setContentType,再 getWriter。 这和上一节 setCharacterEncoding 必须在 getParameter 之前是同一类"时机"问题。

【易错点】 getWriter() 和 getOutputStream() 不能同时使用。两个都调,第二个会抛:

java.lang.IllegalStateException: getWriter() has already been called for this response

反过来先调 getOutputStream() 再调 getWriter(),抛的是 getOutputStream() has already been called for this response。原因:响应体在网络上只能是一串字节,字符流只是"字节流 + 编码器"的包装,容器只允许你选一种包装方式。判断标准很简单:内容是文本(HTML、JSON、纯字符串)用 getWriter(),内容是二进制(图片、文件、验证码)用 getOutputStream()。 如果非得混用(例如先写文本再输出图片),要么把图片单独做成一个请求,要么全部改用字节流自己控制编码。

6.5.6 Response 响应字节数据

要想将字节数据写回到浏览器,我们需要两个步骤:

接下来,我们通过案例把响应字节数据给实际应用一下。

1. 返回一个图片文件到浏览器:

/**
 * 响应字节数据:设置字节数据的响应体
 */
@WebServlet("/resp4")
public class ResponseDemo4 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 读取文件
        FileInputStream fis = new FileInputStream("d://a.jpg");
        //2. 获取response字节输出流
        ServletOutputStream os = response.getOutputStream();
        //3. 完成流的copy
        byte[] buff = new byte[1024];
        int len = 0;
        while ((len = fis.read(buff))!= -1){
            os.write(buff,0,len);
        }
        fis.close();
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

访问 /resp4,浏览器会直接显示 d://a.jpg 这张图片:因为响应体里装的是图片的原始字节,浏览器按内容把它当图片渲染了。

上述代码中,对于流的 copy 的代码还是比较复杂的,所以我们可以使用别人提供好的方法来简化代码的开发。具体的步骤是:

(1)pom.xml 添加依赖:

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.6</version>
</dependency>

(2)调用工具类方法:

//fis:输入流
//os:输出流
IOUtils.copy(fis,os);

优化后的代码:

/**
 * 响应字节数据:设置字节数据的响应体
 */
@WebServlet("/resp4")
public class ResponseDemo4 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 读取文件
        FileInputStream fis = new FileInputStream("d://a.jpg");
        //2. 获取response字节输出流
        ServletOutputStream os = response.getOutputStream();
        //3. 完成流的copy
        IOUtils.copy(fis,os);
        fis.close();
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

IOUtils.copy 内部做的是同样的事:一个缓冲区、一个 while 循环、读到 -1 结束。它只是把这段模板代码收进了工具类,行为上和你手写的一模一样。

【补充】

上面的例子是"把图片显示出来",而真实需求常常是"把图片下载下来",也就是弹出保存对话框。两者只差一个响应头:

@WebServlet("/download")
public class DownloadServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 拿到要下载的文件名(这里假设从参数里取)
        String fileName = request.getParameter("fileName");//例如 a.jpg

        //2. 告诉浏览器:这是附件,请下载;文件名用 URL 编码,防止中文乱码
        response.setHeader("Content-Disposition",
            "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));

        //3. 读取文件并写到响应体
        FileInputStream fis = new FileInputStream("d://" + fileName);
        ServletOutputStream os = response.getOutputStream();
        IOUtils.copy(fis, os);
        fis.close();
    }
}
【补充】

两个要点:① Content-Disposition: attachment 是"下载"和"显示"的唯一区别,inline 是直接显示,attachment 是下载。② 响应头只能放 ASCII 字符,中文文件名必须 URLEncoder.encode 一下,否则浏览器拿到的文件名是乱码或直接报错;收到后现代浏览器会自动解码。另外注意下载场景一般不设 content-type 也能工作,如果设,用 application/octet-stream(万能的二进制流)。

【补充】 字节流最典型的另一个用途是生成验证码图片。验证码必须"现画现出",不能存成磁盘文件,所以要先把图画在内存里的 BufferedImage 上,再通过 ImageIO.write 直接写进 response.getOutputStream():

@WebServlet("/checkCode")
public class CheckCodeServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 在内存中创建一张图片(宽 100,高 37,RGB)
        int width = 100;
        int height = 37;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        //2. 拿到画笔,先填背景,再画边框
        Graphics g = image.getGraphics();
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, width, height);
        g.setColor(Color.GRAY);
        g.drawRect(0, 0, width - 1, height - 1);

        //3. 画 4 个随机字符
        String codes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        Random random = new Random();
        g.setFont(new Font("宋体", Font.BOLD, 24));
        for (int i = 0; i < 4; i++) {
            int index = random.nextInt(codes.length());
            g.setColor(new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
            //每个字符稍微错开一点位置,看起来更像验证码
            g.drawString(codes.charAt(index) + "", 20 + i * 20, 26);
        }

        //4. 画几条干扰线
        for (int i = 0; i < 5; i++) {
            g.setColor(new Color(random.nextInt(255), random.nextInt(255), random.nextInt(255)));
            g.drawLine(random.nextInt(width), random.nextInt(height),
                       random.nextInt(width), random.nextInt(height));
        }

        //5. 告诉浏览器:这是图片,并且不要缓存(否则点刷新验证码不变)
        response.setContentType("image/jpeg");
        response.setHeader("Pragma", "no-cache");
        response.setHeader("Cache-Control", "no-cache");
        response.setDateHeader("Expires", 0);

        //6. 把图片以 png 格式写进响应字节流
        ImageIO.write(image, "png", response.getOutputStream());
    }
}

这段代码把本章 response 的三块能力全用上了:setContentType 设响应头告诉浏览器"我是图片",setHeader 控制缓存,getOutputStream 写响应体。注意第 6 步必须是字节流:ImageIO.write 的参数是 OutputStream,如果你之前调过 getWriter(),这里就会抛 IllegalStateException。页面上的用法是 <img src="/request-demo/checkCode" onclick="this.src='/request-demo/checkCode?'+new Date().getTime()">,点一下换一张,靠的就是每次请求都重新生成。真实的登录案例里,还会把生成的 4 个字符存进 session(第7章),提交时比对。

6.6 综合案例:用户登录与注册

前面几节把 request 和 response 的 API 一条条拆开讲了,但真实开发里这些 API 从来不是单独出现的:一个 Servlet 干的活,永远是"从 request 里把参数拿出来 → 交给业务(这里是 MyBatis 查库)→ 把结果通过 response 写回去"。这一节用两个最经典的功能(登录、注册)把这条链路完整走一遍,顺便把第 5 章 MyBatis 的知识接进来。

【补充】

两个案例的代码结构完全一样,都是五步:① request.getParameter 收参数;② 取 SqlSessionFactory、拿 SqlSession、getMapper;③ 调 mapper 方法访问数据库;④ sqlSession.close() 释放资源;⑤ response.getWriter().write(...) 写结果。中间夹着的业务判断(密码对不对、用户名是否已存在)才是每个功能各自的差异。把结构记住,考试时写任何"用 Servlet + MyBatis 完成某功能"的题,都能照着填。后面第 9 章会把这套结构进一步抽到 BaseServlet 和过滤器里,那时连重复的 setCharacterEncoding 都不用写了。

6.6.1 需求分析

先看登录功能。用户在登录页面输入用户名和密码,提交给 LoginServlet,LoginServlet 拿到这两个参数去数据库的 tb_user 表里查,查到就提示"登陆成功",查不到就提示"登陆失败"。

把它拆成可执行的步骤:

  1. 用户在登录页面输入用户名和密码,提交请求给 LoginServlet。
  2. 在 LoginServlet 中接收请求和数据(用户名和密码)。
  3. 在 LoginServlet 中通过 MyBatis 调用 UserMapper,根据用户名和密码查询数据库表。
  4. 将查询的结果封装到 User 对象中进行返回。
  5. 在 LoginServlet 中判断返回的 User 对象是否为 null。
  6. 如果不为 null,说明用户名和密码正确,返回"登陆成功"给前端。
  7. 如果为 null,说明用户名或密码错误,返回"登陆失败"给前端。

注册功能的思路几乎一样,只是多了一步"先查重、再插入":

  1. 用户在注册页面输入用户名和密码,提交请求给 RegisterServlet。
  2. 在 RegisterServlet 中接收请求和数据(用户名和密码)。
  3. 在 RegisterServlet 中通过 MyBatis 调用 UserMapper 来根据用户名查询数据库表。
  4. 将查询的结果封装到 User 对象中进行返回。
  5. 在 RegisterServlet 中判断返回的 User 对象是否为 null。
  6. 如果为 null,说明根据用户名可用,则调用 UserMapper 来实现添加用户。
  7. 如果不为 null,则说明用户名不可用,返回"用户名已存在"数据给前端。
【考点】

注意两个案例里 request 和 response 的分工非常清楚:request.getParameter(...) 只出现在方法的最前面(收数据),response.getWriter().write(...) 只出现在方法的最末尾(写结果),中间全是业务逻辑。这种"收 → 处理 → 写"的三段式结构,是后面第 8 章 Servlet 生命周期、第 9 章 MVC 分层的基础,考试写代码题按这个结构写基本不会跑偏。

6.6.2 环境准备

1. 静态页面。 把资料里提供的 login.html、register.html 以及配套的 css、js 目录拷贝到项目的 webapp 目录下。因为 webapp 下的资源可以直接被浏览器访问,所以这两个页面能直接打开。

2. 数据库与实体类。 创建 db1 数据库,建 tb_user 表(三个字段:id、username、password),并在 com.itheima.pojo 包下创建 User 实体类,属性与表字段一一对应,提供 getter/setter 和 toString()。

【易错点】

mybatis-config.xml 和 UserMapper.xml 必须放在 resources 目录下(UserMapper.xml 还要按 com/itheima/mapper 的路径放),不能放 webapp 下。原因是 Maven 构建时只会把 src/main/resources 里的文件原样复制进 target/classes,而 Resources.getResourceAsStream("mybatis-config.xml") 是从类路径(classpath)根目录找文件。放错了位置,getResourceAsStream 返回 null,紧接着 new SqlSessionFactoryBuilder().build(null) 就会抛空指针,报错信息还跟"配置写错"一点都不像,很容易查半天。

3. 在 pom.xml 中引入 MyBatis 与 MySQL 驱动:

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.5</version>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.34</version>
</dependency>

4. 创建 mybatis-config.xml 核心配置文件、UserMapper.xml 映射文件、UserMapper 接口。

4.1 将资料中的 mybatis-config.xml 拷贝到 resources 目录下:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <!--起别名-->
    <typeAliases>
        <package name="com.itheima.pojo"/>
    </typeAliases>

    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <!--
                     useSSL:关闭SSL安全连接 性能更高
                     useServerPrepStmts:开启预编译功能
                     &amp; 等同于 & ,xml配置文件中不能直接写 &符号
                -->
                <property name="url" value="jdbc:mysql:///db1?useSSL=false&amp;useServerPrepStmts=true"/>
                <property name="username" value="root"/>
                <property name="password" value="1234"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <!--扫描mapper-->
        <package name="com.itheima.mapper"/>
    </mappers>
</configuration>

4.2 在 com.itheima.mapper 包下创建 UserMapper 接口:

public interface UserMapper {

}

4.3 将资料中的 UserMapper.xml 拷贝到 resources 目录下。

注意:在 resources 下创建 UserMapper.xml 的目录时,要使用 / 分割。因为在 IDE 的 New Directory 输入框里写 com.itheima.mapper,有些版本会把它当成一个名字里带点的文件夹,而不是三级目录。这样 MyBatis 扫描时找不到它,报 Invalid bound statement。写成 com/itheima/mapper 就会正确地建出三层目录。

至此我们所需要的环境就都已经准备好了,具体该如何实现?

6.6.3 用户登录

1. 在 UserMapper 接口中提供一个根据用户名和密码查询用户对象的方法:

/**
 * 根据用户名和密码查询用户对象
 * @param username
 * @param password
 * @return
 */
@Select("select * from tb_user where username = #{username} and password = #{password}")
User select(@Param("username") String username,@Param("password") String password);

说明:@Param 注解的作用是用于传递参数,使方法的参数可以与 SQL 中的字段名相对应。

2. 修改 login.html:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>login</title>
    <link href="css/login.css" rel="stylesheet">
</head>

<body>
<div id="loginDiv">
    <form action="/request-demo/loginServlet" method="post" id="form">
        <h1 id="loginMsg">LOGIN IN</h1>
        <p>Username:<input id="username" name="username" type="text"></p>

        <p>Password:<input id="password" name="password" type="password"></p>

        <div id="subDiv">
            <input type="submit" class="button" value="login up">
            <input type="reset" class="button" value="reset">&nbsp;&nbsp;&nbsp;
            <a href="register.html">没有账号?点击注册</a>
        </div>
    </form>
</div>

</body>
</html>

这里有两个地方和 Servlet 强相关,必须对上:

3. 编写 LoginServlet:

@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 接收用户名和密码
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        //2. 调用MyBatis完成查询
        //2.1 获取SqlSessionFactory对象
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        //2.2 获取SqlSession对象
        SqlSession sqlSession = sqlSessionFactory.openSession();
        //2.3 获取Mapper
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        //2.4 调用方法
        User user = userMapper.select(username, password);
        //2.5 释放资源
        sqlSession.close();

        //获取字符输出流,并设置content type
        response.setContentType("text/html;charset=utf-8");
        PrintWriter writer = response.getWriter();
        //3. 判断user是否为null
        if(user != null){
            // 登陆成功
            writer.write("登陆成功");
        }else {
            // 登陆失败
            writer.write("登陆失败");
        }
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

4. 启动服务器测试。

至此用户的登录功能就已经完成了。

【易错点】

表单是 method="post",但请求进来走的是 doGet,这不算错:我们在 doPost 里写了一行 this.doGet(request, response);。这行"doPost 调 doGet"是 IDEA Servlet 模板的默认写法,作用是让两个方法共用一套逻辑。如果你把这行删掉,POST 提交就会得到 405(方法不允许)。反过来,如果表单是 get 而你把逻辑只写在 doPost 里,同样会 405。判断依据是浏览器实际用的方法,不是你以为的方法。

【易错点】 上面的查询之所以能拿到中文用户名,是因为 username 和 password 都是英文,没有触发编码问题。但如果你把"昵称"这种中文参数加进来,POST 提交就会乱码:必须在第一行 getParameter 之前加上 request.setCharacterEncoding("utf-8")。这个顺序问题在 6.3.6 已经强调过,综合案例里再犯一次是最亏的。

6.6.4 用户注册

1. 编写 UserMapper,提供根据用户名查询用户数据的方法和添加用户的方法:

/**
 * 根据用户名查询用户对象
 * @param username
 * @return
 */
@Select("select * from tb_user where username = #{username}")
User selectByUsername(String username);

/**
 * 添加用户
 * @param user
 */
@Insert("insert into tb_user values(null,#{username},#{password})")
void add(User user);

注意 insert 语句里第一个字段 id 写的是 null:因为建表时 id 是主键自增,插入时交给数据库自己生成即可。

2. 修改 register.html:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>欢迎注册</title>
    <link href="css/register.css" rel="stylesheet">
</head>
<body>

<div class="form-div">
    <div class="reg-content">
        <h1>欢迎注册</h1>
        <span>已有帐号?</span> <a href="login.html">登录</a>
    </div>
    <form id="reg-form" action="/request-demo/registerServlet" method="post">

        <table>

            <tr>
                <td>用户名</td>
                <td class="inputs">
                    <input name="username" type="text" id="username">
                    <br>
                    <span id="username_err" class="err_msg" style="display: none">用户名不太受欢迎</span>
                </td>

            </tr>

            <tr>
                <td>密码</td>
                <td class="inputs">
                    <input name="password" type="password" id="password">
                    <br>
                    <span id="password_err" class="err_msg" style="display: none">密码格式有误</span>
                </td>
            </tr>

        </table>

        <div class="buttons">
            <input value="注 册" type="submit" id="reg_btn">
        </div>
        <br class="clear">
    </form>

</div>
</body>
</html>

3. 创建 RegisterServlet 类:

@WebServlet("/registerServlet")
public class RegisterServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //1. 接收用户数据
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        //封装用户对象
        User user = new User();
        user.setUsername(username);
        user.setPassword(password);

        //2. 调用mapper 根据用户名查询用户对象
        //2.1 获取SqlSessionFactory对象
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        //2.2 获取SqlSession对象
        SqlSession sqlSession = sqlSessionFactory.openSession();
        //2.3 获取Mapper
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);

        //2.4 调用方法
        User u = userMapper.selectByUsername(username);

        //3. 判断用户对象是否为null
        if( u == null){
            // 用户名不存在,添加用户
            userMapper.add(user);

            // 提交事务
            sqlSession.commit();
            // 释放资源
            sqlSession.close();
        }else {
            // 用户名存在,给出提示信息
            response.setContentType("text/html;charset=utf-8");
            response.getWriter().write("用户名已存在");
        }

    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        this.doGet(request, response);
    }
}

4. 启动服务器进行测试。

【易错点】

注册成功后数据库里没数据,九成是忘了 sqlSession.commit()。MyBatis 的 openSession() 默认是手动提交事务,增删改不 commit 就等于没做,而且不会报错。对比一下登录案例:登录只做 select,查完直接 close() 就行,不需要 commit;注册做了 insert,就必须 commit。这个差异是最典型的"看起来都对,就是没效果"。

【对比】 两个 Servlet 里 response 的用法有一个细微区别:LoginServlet 是无论成功失败都要写响应,所以把 setContentType 和 getWriter 提到了 if 外面,只取一次流;RegisterServlet 是只有失败时才写响应,成功时什么也不返回(页面停在原地),所以 setContentType 和 getWriter 写在了 else 里面。这带来一个知识点:getWriter() 可以调用多次,返回的是同一个 PrintWriter 对象,所以放外面放里面都不会出错;但只要调过一次 getWriter(),这个响应就再也不能用 getOutputStream() 了。

6.6.5 SqlSessionFactory 工具类抽取

上面两个功能已经实现,但是在写 Servlet 的时候,因为需要使用 MyBatis 来完成数据库的操作,所以对于 MyBatis 的基础操作就出现了些重复代码,如下:

String resource = "mybatis-config.xml";
InputStream inputStream = Resources.getResourceAsStream(resource);
SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

有了这些重复代码就会造成一些问题:

那如何来优化呢?

有了这两个方向后,代码具体该如何编写?

public class SqlSessionFactoryUtils {

    private static SqlSessionFactory sqlSessionFactory;

    static {
        //静态代码块会随着类的加载而自动执行,且只执行一次
        try {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static SqlSessionFactory getSqlSessionFactory(){
        return sqlSessionFactory;
    }
}

工具类抽取以后,以后在对 MyBatis 的 SqlSession 进行操作的时候,就可以直接使用:

SqlSessionFactory sqlSessionFactory = SqlSessionFactoryUtils.getSqlSessionFactory();

这样就可以很好地解决上面所说的代码重复和重复创建工厂导致性能低的问题了。

【补充】

为什么用 static 代码块而不是写在一个普通方法里、每次调用时判断"工厂为 null 就创建"?原因是时机和线程安全。static 块在类第一次被加载时由 JVM 执行,且 JVM 保证类初始化只发生一次、并且是线程安全的,多个用户同时登录时,不会出现两个线程同时去 new SqlSessionFactoryBuilder().build(...) 的情况。而 SqlSessionFactory 本身是线程安全的、可以在整个应用生命周期内复用(这也是它能做成静态单例的原因);相反 SqlSession 不是线程安全的,所以必须每个请求 openSession() 一个新的、用完 close()。结论:工厂全局一个,会话一次一个。

【考点】 把登录案例的 LoginServlet 用工具类改造后,2.1 那三行就变成一行:

SqlSessionFactory sqlSessionFactory = SqlSessionFactoryUtils.getSqlSessionFactory();

这是最常考的一道"重构题":问"上面代码有什么问题、如何优化",答"SqlSessionFactory 被重复创建,资源消耗大;抽取工具类,用静态代码块保证只创建一次,对外暴露 getSqlSessionFactory() 静态方法"。如果继续追问"SqlSession 要不要也做成静态的",答案是不行,因为它不线程安全。

本章小结

本章的内容可以浓缩成一张表:request 负责"读",response 负责"写":

报文部分要拿/要设的东西request 侧的 APIresponse 侧的 API
请求行 / 响应行方法、URL、状态码getMethod()、getRequestURI()、getRequestURL()、getContextPath()、getQueryString()setStatus(int sc)
请求头 / 响应头键值对getHeader(String name)、getHeaderNames()setHeader(String name,String value)、setContentType(...)
请求体 / 响应体参数、字节、字符getParameter()、getParameterValues()、getParameterMap()、getReader()、getInputStream()getWriter()、getOutputStream()
跳转资源之间怎么走getRequestDispatcher(path).forward(req,resp)sendRedirect(path)(= 302 + Location)
数据携带一次请求内共享setAttribute / getAttribute / removeAttribute无(域不属于 response)

再提炼 8 条要点:

  1. 继承体系:ServletRequest → HttpServletRequest → RequestFacade;ServletResponse → HttpServletResponse → ResponseFacade。接口由 Java EE 定义,实现类由 Tomcat 提供。
  2. 请求三部分:请求行用 getMethod/getRequestURI/getQueryString 等取;请求头用 getHeader 取;请求体用流或 getParameter 系列取。
  3. getParameter 是通用方式,GET 和 POST 都能用,内部帮你把 queryString 或请求体解析成了 Map<String,String[]>;一个名字对应多个值时用 getParameterValues,一次拿全部用 getParameterMap。
  4. getParameter 与 getAttribute 的本质区别:前者取的是客户端传来的数据,只能读不能写,类型永远是 String;后者取的是服务端自己存进 request 域的数据,可读可写可删,类型是 Object。
  5. 中文乱码的根因是两端字符集不一致:POST 用 request.setCharacterEncoding("utf-8")(必须在 getParameter 之前),GET 在 Tomcat 8 之后默认已是 UTF-8,老版本才需要改 URIEncoding 或 new String(bytes,"utf-8") 手动转。
  6. 转发:request.getRequestDispatcher("/内部路径").forward(request,response),一次请求、地址栏不变、可共享 request 域、能访问 WEB-INF。
  7. 重定向:response.sendRedirect(...),两次请求、地址栏改变、不能共享 request 域、可跳任意地址。路径规则:浏览器用加虚拟目录,服务端用不加。
  8. 响应体只能用一种流:写文本用 getWriter()(先 setContentType 定编码),写图片/文件用 getOutputStream();两者互斥,混用抛 IllegalStateException。

下一章要用到本章的什么? 第 7 章讲会话技术(Cookie 与 Session),它的起点正是本章的 request 域:request 域的生命周期只有一次请求,用户登录后换个页面数据就没了,所以才需要作用域更大的 Session。而 Session 的"凭据"是靠 response.addCookie(...) 写进响应头、再靠 request.getCookies() 从请求头读回来的。这套流程依然逃不出本章的"读 request、写 response"这两件事。

自测

基础题

  1. 说出 request 和 response 的继承体系(三层),并说明哪一层是接口、哪一层是 Tomcat 提供的实现类。
  2. getParameter、getParameterValues、getParameterMap 三者分别返回什么类型?分别在什么场景下使用?
  3. 解决 POST 中文乱码要调用哪个方法?这行代码必须写在什么位置?为什么?
  4. 判断下面四条路径哪些需要加虚拟目录 /request-demo:<a href>、<form action>、getRequestDispatcher、sendRedirect。
  5. 写出重定向的两行原始代码,以及它的一行简化写法。

进阶题

  1. 看代码写结果:
@WebServlet("/a")
public class AServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        request.setAttribute("msg", "hello");
        response.sendRedirect("/request-demo/b");
    }
}

@WebServlet("/b")
public class BServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.getWriter().write(String.valueOf(request.getAttribute("msg")));
    }
}

访问 /a 后,浏览器地址栏是什么?/b 的页面上输出什么?

  1. 找错:下面的 Servlet 想输出中文"你好",却输出了一堆乱码,指出问题所在。
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    PrintWriter writer = response.getWriter();
    response.setContentType("text/html;charset=utf-8");
    writer.write("你好");
}
  1. 找错:下面的代码运行后抛出异常,为什么?
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    response.getWriter().write("下载开始");
    ServletOutputStream os = response.getOutputStream();
    os.write(new byte[]{1, 2, 3});
}
  1. 简答:为什么 SqlSessionFactory 可以做成静态单例,而 SqlSession 不行?
  2. 简答:登录成功后要跳转到首页,应该用转发还是重定向?至少说出两条理由。

参考答案

  1. ServletRequest → HttpServletRequest → RequestFacade;ServletResponse → HttpServletResponse → ResponseFacade。前两层是接口(后一层继承前一层),最后一层是 Tomcat 提供的实现类(门面模式)。
  2. getParameter 返回 String,单个值的参数;getParameterValues 返回 String[],一个名字对应多个值(如复选框);getParameterMap 返回 Map<String,String[]>,一次性拿全部参数。
  3. request.setCharacterEncoding("utf-8")。必须写在第一次调用 getParameter(或 getReader)之前,因为它只影响"还没被读取"的请求体;一旦参数已被解析,再设编码不生效。
  4. 加、加、不加、加。(浏览器发起的前两个要加,服务端的转发不加,重定向由浏览器发起要加。)
  5. response.setStatus(302); response.setHeader("Location","/request-demo/b");;简化写法 response.sendRedirect("/request-demo/b");。
  6. 地址栏变成 /request-demo/b;页面上输出 null。因为重定向是两次请求,/a 里存进 request 域的数据随着第一次请求结束就销毁了,/b 拿到的是全新的 request。
  7. 顺序错了。getWriter() 已按默认编码(ISO-8859-1)创建了字符流,之后 setContentType 只改了响应头,流没有重建,浏览器按 UTF-8 解析 ISO-8859-1 的字节就乱码。应先 setContentType 再 getWriter。
  8. getWriter() 与 getOutputStream() 不能同时使用,第二个调用抛 IllegalStateException: getWriter() has already been called for this response。文本和二进制只能选一种输出方式。
  9. SqlSessionFactory 是线程安全的,创建成本高(要解析配置、建连接池),所以全局一个、用静态代码块保证只初始化一次;SqlSession 不是线程安全的,且代表一次数据库会话,必须每个请求 openSession() 一个、用完 close()。
  10. 用重定向。① 地址栏会变成首页地址,用户刷新不会重复提交登录表单;② 两次请求,登录时携带的密码等参数不会带到首页;③ 首页通常不在 WEB-INF 下、也不需要共享 request 域,重定向足够。