JavaWeb 复习讲义
原始讲义
第四部分 状态保持与视图:让服务器记住你

第7章 会话技术:Cookie 与 Session

难度:★★★★☆ | 前置知识:第6章 Request 与 Response | 本章解决:让服务器在多次请求之间认出"还是你",把状态保存在一次会话里

本章导读

7.1 会话与会话跟踪

7.1.1 什么是会话

"会话跟踪"这四个字要拆开看:先理解什么是会话,再去理解什么是会话跟踪。

会话:用户打开浏览器,访问 Web 服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含多次请求和响应。

这句话里有两个要点,值得展开:

一是"建立"的时机。从浏览器发出请求到服务端响应数据给前端之后,一次会话(在浏览器和服务器之间)就被建立了。注意会话并不在浏览器打开时就建立,要等到第一次发生请求—响应之后才算建立。

二是"持续"的条件。会话被建立后,如果浏览器或服务端都没有被关闭,则会话就会持续建立着,浏览器和服务器就可以继续使用该会话进行请求发送和响应,上述的整个过程就被称之为会话。

用实际场景来理解会更容易。比如访问京东:当打开浏览器进入京东首页后,浏览器和京东的服务器之间就建立了一次会话,后面的搜索商品、查看商品的详情、加入购物车等都是在这一次会话中完成的。

这里有一个容易想歪的地方:会话是"浏览器 ↔ 服务器"这一对之间的关系,与"用户"和"服务器"无关。所以同一台电脑上开三个浏览器访问同一个网站,服务器看来就是三个互不相干的会话。原文曾用一个示意图让读者数"总共建立了几个会话",答案是:每个浏览器都会与服务端建立一个会话,加起来总共是 3 个会话。这个结论很重要,它直接解释了后面 Session 的行为:换个浏览器登录,就换了一个 Session。

【补充】

会话的结束并没有一个"收到了结束信号"的时刻。服务器根本不会收到"用户关浏览器了"这样的通知,它判断会话是否还活着,靠的是有没有后续请求。所以真实系统里的"会话结束"通常是三种情况之一:浏览器关了(客户端标识丢了)、用户主动退出(服务端 invalidate())、长时间没操作(超时)。理解这一点,后面 7.3.6 讲 Session 销毁时就不会觉得"为什么要设超时"是多余的设计。

7.1.2 什么是会话跟踪

会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。

把定义拆成三步就清楚了:

那么问题来了:一个会话中的多次请求为什么要共享数据?有了这个数据共享功能后能实现哪些功能?原文给了四个典型场景,它们就是本章后面所有代码的动机:

注意这四个场景并不是同一种需求。"记住我"要求数据能在关掉浏览器之后还活着,所以只能放 Cookie 并持久化;"登录用户名展示"和"验证码"要求数据安全、不被用户看到和篡改,所以必须放 Session;购物车两种情况都能做,看业务要不要跨设备。本章 7.4 会把这层对应关系做成表。

7.1.3 为什么 HTTP 自己做不到:无状态

既然会话跟踪这么重要,为什么现在的浏览器和服务器默认不支持数据共享呢?原因不在浏览器,而在协议本身:

第4章讲 HTTP 报文时已经强调过"一次请求一次响应、连接即断",这里终于看到了它的代价。结论是:无状态让服务器变得简单、可扩展,但代价是它天生不认人。

【补充】

不保存状态是刻意的取舍,不属于设计失误。因为服务器不必为每个连接保存上下文,一个请求可以被任意一台机器处理,负载均衡才好做、宕机才好恢复。代价就是"认人"这件事得由应用层自己补,补的方式就是让客户端每次请求都主动出示一个凭证。Cookie 和 Session 都是这套思路的产物:一个把凭证连同数据一起放在客户端,一个只把凭证放在客户端、数据留在服务端。

【考点】 问:HTTP 是无状态的,那网站怎么知道我已经登录了?答:靠客户端每次请求自动携带的凭证:Cookie 里的 JSESSIONID(或 Token),服务端拿它去自己的内存/存储里换回会话数据。理由:协议层不保存状态,状态只能由应用层用"每次多带一点信息"的方式补回来。

7.1.4 两种实现方式

分析完原因,那么该如何实现会话跟踪技术呢?具体的实现方式有:

  1. 客户端会话跟踪技术:Cookie
  2. 服务端会话跟踪技术:Session

这两个技术都可以实现会话跟踪,它们之间最大的区别:Cookie 是存储在浏览器端,而 Session 是存储在服务器端。

具体的学习思路为:

本节小结只有两句话,但是这两句概括了整章的主线:

Cookie 和 Session 具体该如何使用,接下来就先从 Cookie 学起。

7.2 Cookie

学习 Cookie,主要解决下面四个问题:

7.2.1 Cookie 的概念与工作流程

概念:Cookie 是客户端会话技术,将数据保存到客户端,以后每次请求都携带 Cookie 数据进行访问。

数据存在浏览器,由服务器写入,浏览器每次请求自动携带。

Cookie 的工作流程可以按七步走一遍。假设服务端提供了两个 Servlet,分别是 ServletA 和 ServletB:

  1. 浏览器发送 HTTP 请求 1 给服务端,服务端 ServletA 接收请求并进行业务处理;
  2. 服务端 ServletA 在处理的过程中可以创建一个 Cookie 对象并将 name=zs 的数据存入 Cookie;
  3. 服务端 ServletA 在响应数据的时候,会把 Cookie 对象响应给浏览器;
  4. 浏览器接收到响应数据,会把 Cookie 对象中的数据存储在浏览器内存中,此时浏览器和服务端就建立了一次会话;
  5. 在同一次会话中浏览器再次发送 HTTP 请求 2 给服务端 ServletB,浏览器会携带 Cookie 对象中的所有数据;
  6. ServletB 接收到请求和数据后,就可以获取到存储在 Cookie 对象中的数据;
  7. 这样同一个会话中的多次请求之间就实现了数据共享。

这七步里有两次"搬运"最容易忽略:第一次是服务器 → 浏览器(靠响应头),第二次是浏览器 → 服务器(靠请求头)。Cookie 之所以"自动",是因为第二次搬运不需要写任何 JavaScript,浏览器自己就做了。7.2.4 会把这两次搬运对应的 HTTP 头点出来。

7.2.2 发送 Cookie

对于 Cookie 的使用,更关注的应该是后台代码如何操作 Cookie。对 Cookie 的操作主要分两大类,分别是发送 Cookie 和获取 Cookie。

发送 Cookie 分两步:

第一步,创建 Cookie 对象,并设置数据:

Cookie cookie = new Cookie("key", "value");

第二步,发送 Cookie 到客户端,使用 response 对象:

response.addCookie(cookie);

介绍完发送 Cookie 对应的步骤后,下面通过一个案例来完成 Cookie 的发送。需求:在 Servlet 中生成 Cookie 对象并存入数据,然后将数据发送给浏览器。实现步骤为:

  1. 创建 Maven 项目,项目名称为 cookie-demo,并在 pom.xml 添加依赖;
  2. 编写 Servlet 类,名称为 AServlet;
  3. 在 AServlet 中创建 Cookie 对象,存入数据,发送给前端;
  4. 启动测试,在浏览器查看 Cookie 对象中的值。

(1)创建 Maven 项目 cookie-demo,并在 pom.xml 添加依赖

<properties>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
</properties>

<dependencies>
    <!--servlet-->
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.1.0</version>
        <scope>provided</scope>
    </dependency>
    <!--jsp-->
    <dependency>
        <groupId>javax.servlet.jsp</groupId>
        <artifactId>jsp-api</artifactId>
        <version>2.2</version>
        <scope>provided</scope>
    </dependency>
    <!--jstl-->
    <dependency>
        <groupId>jstl</groupId>
        <artifactId>jstl</artifactId>
        <version>1.2</version>
    </dependency>
    <dependency>
        <groupId>taglibs</groupId>
        <artifactId>standard</artifactId>
        <version>1.1.2</version>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.tomcat.maven</groupId>
            <artifactId>tomcat7-maven-plugin</artifactId>
            <version>2.2</version>
        </plugin>
    </plugins>
</build>

(2)编写 Servlet 类,名称为 AServlet

@WebServlet("/aServlet")
public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

    }

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

(3)在 Servlet 中创建 Cookie 对象,存入数据,发送给前端

@WebServlet("/aServlet")
public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //发送Cookie
        //1. 创建Cookie对象
        Cookie cookie = new Cookie("username", "zs");
        //2. 发送Cookie,response
        response.addCookie(cookie);
    }

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

(4)启动测试,在浏览器查看 Cookie 对象中的值

访问 http://localhost:8080/cookie-demo/aServlet。在 Chrome 浏览器里查看 Cookie 的值有两种方式:

【易错点】

只写了 new Cookie(...) 却忘了 response.addCookie(cookie),是本章最常见的"代码没报错但浏览器里什么都没有"。new Cookie() 只是在你自己的 JVM 里造了一个对象,它不会自动跑到响应里;必须由 addCookie 显式挂到响应上,Tomcat 才会在响应头写 Set-Cookie。判断依据很简单:F12 的 Network 面板里那个请求的 Response Headers 中有没有 Set-Cookie。

【补充】 同一个响应里可以调用多次 response.addCookie(),Tomcat 会写出多条 Set-Cookie 头,浏览器最终会保存多条 Cookie,键名可以不同。如果两次写入的是同名 Cookie(且 path、domain 相同),后写入的会覆盖先写入的。这条规则是 7.2.8 "用 setMaxAge(0) 删除 Cookie"能成立的根据。

7.2.3 获取 Cookie

获取客户端携带的所有 Cookie,使用的是 request 对象。三步走:

第一步,获取 Cookie 数组:

Cookie[] cookies = request.getCookies();

第二步,遍历数组,获取每一个 Cookie 对象:用 for 循环。

第三步,使用 Cookie 对象的方法获取数据:

cookie.getName();
cookie.getValue();

下面再通过一个案例来完成 Cookie 的获取。需求:在 Servlet 中获取前一个案例存入在 Cookie 对象中的数据。实现步骤为:

  1. 编写一个新 Servlet 类,名称为 BServlet;
  2. 在 BServlet 中使用 request 对象获取 Cookie 数组,遍历数组,从数据中获取指定名称对应的值;
  3. 启动测试,在控制台打印出获取的值。

(1)编写一个新 Servlet 类,名称为 BServlet

@WebServlet("/bServlet")
public class BServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

    }

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

(2)在 BServlet 中使用 request 对象获取 Cookie 数组,遍历数组取指定名称的值

@WebServlet("/bServlet")
public class BServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //获取Cookie
        //1. 获取Cookie数组
        Cookie[] cookies = request.getCookies();
        //2. 遍历数组
        for (Cookie cookie : cookies) {
            //3. 获取数据
            String name = cookie.getName();
            if ("username".equals(name)) {
                String value = cookie.getValue();
                System.out.println(name + ":" + value);
                break;
            }
        }
    }

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

(3)启动测试,在控制台打印出获取的值

访问 http://localhost:8080/cookie-demo/bServlet,在 IDEA 控制台就能看到输出的结果 username:zs。

到这里,有一个必须自己动手验证的问题:如果在访问 AServlet 和 BServlet 的中间把浏览器关闭,重启浏览器后访问 BServlet,能否获取到 Cookie 中的数据? 这个问题的答案要等到 7.2.5 讲"存活时间"时才揭晓:它取决于 Cookie 是会话级的还是持久化的。

本节小结,Cookie 的基本使用包含两部分:

【易错点】

request.getCookies() 在没有 Cookie 时返回的是 null,不是空数组。所以直接 for (Cookie cookie : cookies) 会抛 NullPointerException。稳妥写法是先判空:

Cookie[] cookies = request.getCookies();
if (cookies != null) {
    for (Cookie cookie : cookies) {
        // ...
    }
}

为什么第一次访问一定没有 Cookie?因为浏览器此时手里一条都没有,自然不会往请求头里写 Cookie,Tomcat 也就封装不出数组。

7.2.4 Cookie 的原理分析

介绍完 Cookie 的基本使用之后,Cookie 的底层到底是如何实现一次会话两次请求之间的数据共享呢?

对于 Cookie 的实现原理,是基于 HTTP 协议的,其中涉及到 HTTP 协议中的两个请求头信息:

按请求—响应的顺序展开:

  1. 前面的案例中已经能够实现 AServlet 给前端发送 Cookie,BServlet 从 request 中获取 Cookie 的功能。对于 AServlet 响应数据的时候,Tomcat 服务器都是基于 HTTP 协议来响应数据;
  2. 当 Tomcat 发现后端要返回的是一个 Cookie 对象之后,Tomcat 就会在响应头中添加一行数据 Set-Cookie: username=zs;
  3. 浏览器获取到响应结果后,从响应头中就可以获取到 Set-Cookie 对应的值 username=zs,并将数据存储在浏览器的内存中;
  4. 浏览器再次发送请求给 BServlet 的时候,浏览器会自动在请求头中添加 Cookie: username=zs,发送给服务端 BServlet;
  5. request 对象会把请求头中 Cookie 对应的值封装成一个个 Cookie 对象,最终形成一个数组;
  6. BServlet 通过 request 对象获取到 Cookie[] 后,就可以从中获取自己需要的数据。

验证方式也很直接:访问 http://localhost:8080/cookie-demo/aServlet,用 Chrome 打开开发者工具(F12 或 Ctrl+Shift+I)查看响应头,能看到 Set-Cookie: username=zs;再访问 http://localhost:8080/cookie-demo/bServlet,查看请求头,能看到 Cookie: username=zs。

所谓"Cookie 自动携带",落到协议层面只有一句:服务器用 Set-Cookie 写入,浏览器用 Cookie 回传,之后每次请求都把同域下的 Cookie 全部带上。

【补充】

一条 Set-Cookie 头除了 名字=值,后面还可以跟若干属性,全部用分号分隔,例如 Set-Cookie: username=zs; Max-Age=604800; Path=/; HttpOnly。 其中 Max-Age 对应 Java 里的 setMaxAge,Path 对应 setPath,Domain 对应 setDomain,HttpOnly 表示禁止 JavaScript 通过 document.cookie 读取(防 XSS 盗取)。而浏览器回传时只回 Cookie: username=zs,属性不会回传。这一点常被误解为"服务器能读到我设的 Max-Age",但服务器读不到,过期判断完全由浏览器自己做。

【考点】 问:Cookie 是怎么实现会话跟踪的?答:服务端通过响应头 Set-Cookie 把键值对写入浏览器,浏览器在后续同域请求中通过请求头 Cookie 自动回传,服务端解析 Cookie 头就能拿到上次存的数据。理由:HTTP 无状态,只能靠每次请求额外携带信息来补。

7.2.5 Cookie 的存活时间:setMaxAge

这一节讲两个知识:第一个是 Cookie 的存活时间,第二个是 Cookie 如何存储中文。先看存活时间。

回到刚才那个问题,把过程再摆一遍:

  1. 浏览器发送请求给 AServlet,AServlet 会响应一个存有 username=zs 的 Cookie 对象给浏览器;
  2. 浏览器接收到响应数据,将 Cookie 存入到浏览器内存中;
  3. 当浏览器再次发送请求给 BServlet,BServlet 就可以使用 request 对象获取到 Cookie 数据;
  4. 在发送请求到 BServlet 之前,如果把浏览器关闭再打开进行访问,BServlet 能否获取到 Cookie 数据?

注意:浏览器关闭再打开不是指打开一个新的选项卡,而且必须是先关闭再打开,顺序不能变。(新开一个标签页共享的是同一份内存,所以不算"关闭重开"。)

针对上面这个问题,通过演示会发现,BServlet 中无法再获取到 Cookie 数据。这是为什么呢?

这个结论就印证了上面的演示效果。但如果使用这种默认情况下的 Cookie,有些需求就无法实现,比如"记住我":登录页面上有一个"记住我"的功能,第一次输入用户名和密码并勾选"记住我"然后进行登录,下次再登录的时候,用户名和密码就会被自动填充,不需要再重新输入登录。假如"记住我"需要记住用户名和密码一个星期,那么使用默认情况下的 Cookie 就会出现问题:因为默认情况下浏览器一关,Cookie 就会从浏览器内存中删除,对于"记住我"功能就无法实现。

所以现在遇到的难题是:如何将 Cookie 持久化存储? Cookie 已经提供了对应的 API 来完成这件事,这个 API 就是 setMaxAge:

setMaxAge(int seconds)

参数值有三种情况:

  1. 正数:将 Cookie 写入浏览器所在电脑的硬盘,持久化存储。到时间自动删除;
  2. 负数:默认值,Cookie 在当前浏览器内存中,当浏览器关闭,则 Cookie 被销毁;
  3. 零:删除对应 Cookie。

接下来就在 AServlet 中去设置 Cookie 的存活时间:

@WebServlet("/aServlet")
public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //发送Cookie
        //1. 创建Cookie对象
        Cookie cookie = new Cookie("username", "zs");
        //设置存活时间,1周 7天
        cookie.setMaxAge(60 * 60 * 24 * 7);   //易阅读,需程序计算
        //cookie.setMaxAge(604800);           //不易阅读(可以使用注解弥补),程序少进行一次计算
        //2. 发送Cookie,response
        response.addCookie(cookie);
    }

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

修改完代码后,启动测试,访问 http://localhost:8080/cookie-demo/aServlet:

到这里,本节开头那个"关闭浏览器后能不能拿到"的问题就有了完整答案:默认(setMaxAge 为负)拿不到,设了正数就能拿到。

【对比】

会话 Cookie 与持久 Cookie 的差别只在 setMaxAge 一个方法上,但行为完全不同:

类型setMaxAge 取值存放位置关闭浏览器后典型用途
会话 Cookie负数(默认)浏览器内存消失JSESSIONID、一次性标记
持久 Cookie正数(秒)浏览器所在硬盘仍在,到期自动删记住我、免登录、主题偏好

所以"关闭浏览器 Cookie 就没了"这句话只对会话 Cookie 成立,不区分类型地说它是错的。

【易错点】 setMaxAge 的单位是秒。毫秒与分钟都不对,想记一天要写 60 * 60 * 24,写成 24 就成了 24 秒,测试时"刚设完就没了"。另外 60 * 60 * 24 * 7 这种写法是 int 乘法,一周才 604800,远未溢出;但如果想设一年以上要注意 int 上限约 68 年,一般不会踩到。

【考点】 问:Cookie 的 setMaxAge(0) 和 setMaxAge(-1) 有什么区别?答:0 表示立即删除该 Cookie(浏览器收到就删掉),-1 表示会话级 Cookie,关浏览器才失效。理由:0 让过期时间落在过去,浏览器判定已过期即删除;负数表示"不写过期时间",由浏览器按会话管理。

7.2.6 Cookie 存储中文

先把现象演示出来:将之前 username=zs 的值改成 username=张三,把汉字"张三"存入到 Cookie 中,看是什么效果:

@WebServlet("/aServlet")
public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //发送Cookie
        String value = "张三";
        Cookie cookie = new Cookie("username", value);
        //设置存活时间,1周 7天
        cookie.setMaxAge(60 * 60 * 24 * 7);
        //2. 发送Cookie,response
        response.addCookie(cookie);
    }

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

启动访问测试,访问 http://localhost:8080/cookie-demo/aServlet,会发现浏览器会提示错误信息(Tomcat 在写响应头时直接抛异常,页面返回 500)。

通过上面的案例演示,得到一个结论:Cookie 不能直接存储中文。

Cookie 不能存储中文,但是如果有这方面的需求,这个时候该如何解决呢?这个时候可以使用之前学过的一个知识点叫 URL 编码,所以如果需要存储中文,就需要进行转码。具体的实现思路为:

  1. 在 AServlet 中对中文进行 URL 编码,采用 URLEncoder.encode(),将编码后的值存入 Cookie 中;
  2. 在 BServlet 中获取 Cookie 中的值,获取的值为 URL 编码后的值;
  3. 将获取的值再进行 URL 解码,采用 URLDecoder.decode(),就可以获取到对应的中文值。

(1)在 AServlet 中对中文进行 URL 编码

@WebServlet("/aServlet")
public class AServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //发送Cookie
        String value = "张三";
        //对中文进行URL编码
        value = URLEncoder.encode(value, "UTF-8");
        System.out.println("存储数据:" + value);
        //将编码后的值存入Cookie中
        Cookie cookie = new Cookie("username", value);
        //设置存活时间,1周 7天
        cookie.setMaxAge(60 * 60 * 24 * 7);
        //2. 发送Cookie,response
        response.addCookie(cookie);
    }

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

(2)在 BServlet 中获取值,并对值进行解码

@WebServlet("/bServlet")
public class BServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //获取Cookie
        //1. 获取Cookie数组
        Cookie[] cookies = request.getCookies();
        //2. 遍历数组
        for (Cookie cookie : cookies) {
            //3. 获取数据
            String name = cookie.getName();
            if ("username".equals(name)) {
                String value = cookie.getValue();   //获取的是URL编码后的值:%E5%BC%A0%E4%B8%89
                //URL解码
                value = URLDecoder.decode(value, "UTF-8");
                System.out.println(name + ":" + value);   //value解码后为 张三
                break;
            }
        }
    }

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

至此,就可以将中文存入 Cookie 中进行使用。

本节小结:Cookie 的使用细节讲了存活时间和存储中文两块:存活时间需要掌握 setMaxAge() API 的使用;存储中文需要掌握 URL 编码和解码的使用。

【补充】

为什么中文一定要转码?因为 HTTP 头(包括 Set-Cookie)在规范里只允许 ASCII 可见字符,而 张三 的 UTF-8 字节是 E5 BC A0 E4 B8 89,直接写进响应头就产生了非法字符,Tomcat 会直接拒绝。URLEncoder.encode("张三", "UTF-8") 把它变成 %E5%BC%A0%E4%B8%89,全部是合法 ASCII,浏览器再原样回传,服务端 URLDecoder.decode 还原。注意 URLEncoder 会把空格编成 +,而 Cookie 值里的 + 是合法字符,解码时 URLDecoder 又会把 + 还原成空格,两者正好配对,所以不要自己手写替换规则。

【易错点】 只编码不解码,或者编码用 UTF-8、解码用 ISO-8859-1,是中文 Cookie 的两大常见错误。前者表现为控制台打印出一串 %E5%BC%A0...,后者表现为打印出乱码"å¼ ä¸‰"。编解码的字符集必须一致,统一写 "UTF-8"。

7.2.7 Cookie 的路径与域:setPath 与 setDomain

Cookie 存进去之后,浏览器并不是对任何请求都无脑回传,它要判断"这个 Cookie 该不该带给这个地址"。判断依据就是 Cookie 的 path 和 domain 两个属性。

Cookie cookie = new Cookie("username", "zs");
cookie.setMaxAge(60 * 60 * 24 * 7);
cookie.setPath("/");                 //整个应用下所有请求都携带
//cookie.setDomain(".jd.com");       //同域下的子域名都能读到(本地测试一般删掉这行)
response.addCookie(cookie);

默认 path 的效果可以用一个例子说明:如果 Cookie 是在 http://localhost:8080/cookie-demo/aServlet 这个请求里写下的,那么浏览器把它默认记为 /cookie-demo,之后访问 /cookie-demo/bServlet 会带,访问 /other-demo/x 就不会带。所以"我明明存了 Cookie,换个模块就读不到",八成是 path 的问题。

【补充】

setDomain 有三条硬规则:只能设成当前域名或其上级域(在 a.b.com 上能设 .b.com,设不了 .c.com),设成 .b.com 后 x.b.com、y.b.com 都能共享;不能设成顶级域(.com 会被浏览器拒绝);本机测试用 localhost 时不要去设 domain,容易一条都存不进去。

【易错点】 删除 Cookie 时 path 必须和当初写入时一致。在 /cookie-demo/aServlet 里写了 username(默认 path 为 /cookie-demo),却在 /other/x 里用 setMaxAge(0) 想删掉它,是删不掉的。浏览器认为这是两条不同的 Cookie。稳妥做法是写入时显式 setPath("/"),删除时也 setPath("/")。

7.2.8 Cookie 的销毁

Cookie 没有 delete() 之类的方法,销毁只有一条路:用同名 Cookie 覆盖它,并把 setMaxAge 设为 0。

@WebServlet("/deleteCookieServlet")
public class DeleteCookieServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //1. 创建同名Cookie
        Cookie cookie = new Cookie("username", "");
        //2. 路径必须与写入时一致
        cookie.setPath("/");
        //3. 存活时间设为0,浏览器收到即删除
        cookie.setMaxAge(0);
        //4. 必须发送出去,否则浏览器不知道
        response.addCookie(cookie);
    }

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

销毁要同时满足三个条件,缺一不可:同名(new Cookie 的第一个参数与原来一致)、同 path(必要时还有同 domain)、setMaxAge(0) 且真的 addCookie 了。三个条件就是浏览器判断"是不是同一条 Cookie"的键:名字 + 域 + 路径,三者相同才算同一条,才谈得上覆盖。

【易错点】

最常见的三种"删不掉":忘了 response.addCookie()(只是造了个对象);path 不一致;用 setValue(null) 代替 setMaxAge(0)(值为 null 会抛异常,而且并不会删除)。记住:Cookie 的删除本质是"覆盖成过期",不是"调用删除方法"。

7.2.9 Cookie 的细节与限制

到这里,Cookie 的用法已经齐了,但真正上线前还要知道它的几条硬限制,否则会写出"本地好好的,一上线就丢"的代码。

(1)数量限制:浏览器对同一个域名下能保存的 Cookie 条数是有上限的,各浏览器不同,一般在 20~50 条之间;超过之后,浏览器会按自己的策略淘汰最旧或最少使用的那些。所以不要拿 Cookie 当小数据库,一个业务塞十几条 Cookie 是危险设计。

(2)大小限制:单条 Cookie 的 name=value 加起来的字节数有上限,课程里常说的"Cookie 最大 3KB"是保守经验值;RFC 6265 要求浏览器至少支持 4096 字节(4KB)的单个 Cookie。超长会被截断或直接丢弃。

(3)只能存字符串:new Cookie(String name, String value) 两个参数都是字符串,存不了对象。想存对象只能自己序列化成字符串(如 JSON),取出来再反序列化。

(4)不安全:Cookie 存在用户自己的电脑上,用户可以在开发者工具里随意改,也可以伪造。所以任何涉及权限、金额、身份的数据都不能只靠 Cookie 里的值来信任。

(5)受同源策略约束:Cookie 严格按域名隔离,a.com 写的 Cookie,b.com 的页面读不到;跨域请求默认也不携带 Cookie(需要服务端 Access-Control-Allow-Credentials: true 且前端 withCredentials)。这就是"跨域登录态丢失"的根源。

【补充】

现代浏览器还给 Cookie 加了 SameSite 属性来防 CSRF:SameSite=Strict 表示只有从本站发起的请求才带 Cookie,SameSite=Lax(Chrome 默认)表示顶级导航的 GET 请求可以带,SameSite=None 则必须同时加 Secure(只能走 HTTPS)。Servlet 4.0 的 Cookie 类提供了 setHttpOnly(true),可以挡住 JavaScript 读取,但没有提供 setSameSite,只能用 response.setHeader("Set-Cookie", ...) 手工拼,这一点在考试里不会考,但做项目时会遇到。

【对比】 Cookie 与浏览器本地存储(localStorage / sessionStorage)经常被混为一谈:

维度CookielocalStoragesessionStorage
是否随请求自动发送是,同域请求自动带否,要 JS 手动取否,要 JS 手动取
大小约 4KB / 条约 5MB约 5MB
生命周期由 setMaxAge 决定永久,除非手动删关标签页即清
服务端可读可以(请求头)不可以不可以

关键差别在第一行:只有 Cookie 会被浏览器自动带给服务器,这也是它能做会话跟踪的根本原因。

【考点】 问:为什么不能用 Cookie 存密码?答:Cookie 存在用户端、明文可读可改,一旦被 XSS 或抓包获取就直接泄露。理由:Cookie 的传输在 HTTPS 下才加密,落地时是明文文件,且同域脚本可读(除非加 HttpOnly)。

7.3 Session

Cookie 已经能完成一次会话多次请求之间的数据共享,之前还提到过 Session 也可以实现,那么:

7.3.1 Session 的概念与工作流程

概念:Session 是服务端会话跟踪技术,将数据保存到服务端。

Session 的工作流程只有三句话:

  1. 在服务端的 AServlet 获取一个 Session 对象,把数据存入其中;
  2. 在服务端的 BServlet 获取到相同的 Session 对象,从中取出数据;
  3. 就可以实现一次会话中多次请求之间的数据共享了。

现在最大的问题是:如何保证 AServlet 和 BServlet 使用的是同一个 Session 对象? 这个问题在 7.3.3 的原理分析里回答。

7.3.2 获取 Session 与常用方法

在 Java EE 中提供了 HttpSession 接口,来实现一次会话的多次请求之间数据共享功能。具体的使用步骤为:

第一步,获取 Session 对象,使用的是 request 对象:

HttpSession session = request.getSession();

第二步,使用 Session 对象提供的功能:

介绍完 Session 相关的 API 后,接下来通过一个案例来完成对 Session 的使用。需求:在一个 Servlet 中往 Session 中存入数据,在另一个 Servlet 中获取 Session 中存入的数据。实现步骤为:

  1. 创建名为 SessionDemo1 的 Servlet 类;
  2. 创建名为 SessionDemo2 的 Servlet 类;
  3. 在 SessionDemo1 的方法中:获取 Session 对象、存储数据;
  4. 在 SessionDemo2 的方法中:获取 Session 对象、获取数据;
  5. 启动测试。

(1)创建名为 SessionDemo1 的 Servlet 类

@WebServlet("/demo1")
public class SessionDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

    }

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

(2)创建名为 SessionDemo2 的 Servlet 类

@WebServlet("/demo2")
public class SessionDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

    }

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

(3)SessionDemo1:获取 Session 对象、存储数据

@WebServlet("/demo1")
public class SessionDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //存储到Session中
        //1. 获取Session对象
        HttpSession session = request.getSession();
        //2. 存储数据
        session.setAttribute("username", "zs");
    }

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

(4)SessionDemo2:获取 Session 对象、获取数据

@WebServlet("/demo2")
public class SessionDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //获取数据,从session中
        //1. 获取Session对象
        HttpSession session = request.getSession();
        //2. 获取数据
        Object username = session.getAttribute("username");
        System.out.println(username);
    }

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

(5)启动测试

先访问 http://localhost:8080/cookie-demo/demo1,将数据存入 Session;再访问 http://localhost:8080/cookie-demo/demo2,从 Session 中获取数据;查看控制台。

通过案例的效果,能看到 Session 是能够在一次会话中两次请求之间共享数据的。

本节小结,重点要掌握的是:

注意:Session 中可以存储的是一个 Object 类型的数据,因此可以存储任意数据类型。

【易错点】

getAttribute 返回的是 Object,取出来必须强转:

User user = (User) session.getAttribute("user");

忘了强转,编译器直接报错;强转类型写错(比如存的是 User 却转 String),运行时抛 ClassCastException。另外 getAttribute 返回 null 时再去调用对象方法就是 NPE,这是 7.3.6 和 7.5 里反复出现的错误。

【考点】 问:request.getSession() 和 request.getSession(false) 有什么区别?答:无参的 getSession() 等价于 getSession(true),Session 不存在时创建一个新的返回;getSession(false) 只在已存在时返回,不存在就返回 null。理由:参数叫 create,表示"允许创建",传 false 就是"只查不建"。

7.3.3 Session 的原理分析

"Session 是基于 Cookie 实现的"这句话不足以详细说明 Session 的底层实现,下面一步步分析 Session 的具体实现原理。

(1)前提条件

Session 要想实现一次会话多次请求之间的数据共享,就必须要保证多次请求获取 Session 的对象是同一个。那么它们是一个对象么?要验证这个结论也很简单,只需要在上面案例中的两个 Servlet 中分别打印下 Session 对象。

SessionDemo1:

@WebServlet("/demo1")
public class SessionDemo1 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //存储到Session中
        //1. 获取Session对象
        HttpSession session = request.getSession();
        System.out.println(session);
        //2. 存储数据
        session.setAttribute("username", "zs");
    }

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

SessionDemo2:

@WebServlet("/demo2")
public class SessionDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //获取数据,从session中
        //1. 获取Session对象
        HttpSession session = request.getSession();
        System.out.println(session);
        //2. 获取数据
        Object username = session.getAttribute("username");
        System.out.println(username);
    }

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

启动测试,分别访问:

http://localhost:8080/cookie-demo/demo1
http://localhost:8080/cookie-demo/demo2

通过打印可以得到如下结论:

那么问题又来了:如果新开一个浏览器,访问 demo1 或者 demo2,打印在控制台的 Session 还是同一个对象么?

注意:在一台电脑上演示的时候,如果是相同的浏览器必须要把浏览器全部关掉重新打开,才算新开的一个浏览器;当然也可以使用不同的浏览器进行测试,就不需要把之前的浏览器全部关闭。测试的结果:如果是不同浏览器或者重新打开浏览器后,打印的 Session 就不一样了。

所以 Session 实现的也是一次会话中的多次请求之间的数据共享。那么最主要的问题就来了,Session 是如何保证在一次会话中获取的 Session 对象是同一个呢?

  1. demo1 在第一次获取 Session 对象的时候,Session 对象会有一个唯一的标识,假如是 id:10;
  2. demo1 在 Session 中存入其他数据并处理完成所有业务后,需要通过 Tomcat 服务器响应结果给浏览器;
  3. Tomcat 服务器发现业务处理中使用了 Session 对象,就会把 Session 的唯一标识 id:10 当做一个 Cookie,添加 Set-Cookie: JSESSIONID=10 到响应头中,并响应给浏览器;
  4. 浏览器接收到响应结果后,会把响应头中的 Cookie 数据存储到浏览器的内存中;
  5. 浏览器在同一会话中访问 demo2 的时候,会把 Cookie 中的数据按照 Cookie: JSESSIONID=10 的格式添加到请求头中并发送给服务器 Tomcat;
  6. demo2 获取到请求后,从请求头中就读取 Cookie 中的 JSESSIONID 值为 10,然后就会到服务器内存中寻找 id:10 的 Session 对象,如果找到了,就直接返回该对象,如果没有则新创建一个 Session 对象;
  7. 关闭打开浏览器后,因为浏览器的 Cookie 已被销毁,所以就没有 JSESSIONID 的数据,服务端获取到的 Session 就是一个全新的 Session 对象。

至此,"Session 是基于 Cookie 来实现的"这句话就解释完了。通过实例可以验证这个过程:用 Chrome 访问 http://localhost:8080/cookie-demo/demo1,打开开发者模式(F12 或 Ctrl+Shift+I),查看响应头(Response Headers)数据,能看到 Set-Cookie: JSESSIONID=...;再次访问 http://localhost:8080/cookie-demo/demo2,查看请求头(Request Headers)数据,能看到 Cookie: JSESSIONID=...。

本节小结只需要记住一句:Session 是基于 Cookie 来实现的。

【补充】

严格说,是"Session 的标识基于 Cookie"。数据本身一直在 Tomcat 的内存里(Manager 管理的 ConcurrentHashMap 结构中),浏览器手里只有一个 JSESSIONID 字符串。这也解释了两件事:为什么换浏览器就换了 Session(新的浏览器没有那个 Cookie,服务器只能新建);为什么不关浏览器重启服务器后数据还在(JSESSIONID 还在浏览器里,服务器又能按 id 找回对象)。另外这个 Cookie 是会话级的(setMaxAge 为负),所以关浏览器它就没了,这正是"关浏览器 Session 失效"的机制。

【易错点】 判空顺序反了就会出错。写 if (session.getAttribute("user").equals(...)),在未登录时 getAttribute 返回 null,直接 NPE。正确写法是把常量或可空判断放前面:

Object user = session.getAttribute("user");
if (user == null) {
    // 未登录,重定向到登录页
}

【考点】 问:Session 一定需要 Cookie 吗?答:严格说不是"必须",但默认实现依赖 Cookie 传递 JSESSIONID;如果浏览器禁用了 Cookie,可以用 URL 重写(response.encodeURL)把 id 拼在地址后,见 7.3.4。理由:服务端只需拿到 id,拿到 id 的通道可以是 Cookie 也可以是 URL。

7.3.4 禁用 Cookie 之后:URL 重写与 encodeURL

既然 Session 靠 JSESSIONID 这个 Cookie 认人,那么一个很自然的问题就来了:如果用户把浏览器的 Cookie 禁用了,Session 还能用吗?

结论是还能用,但必须换一条传递 id 的通道。这条通道叫 URL 重写(URL rewriting):把 JSESSIONID 作为参数拼到 URL 后面,形如:

http://localhost:8080/cookie-demo/demo2;jsessionid=8A3F9C1E5B7D4A2F0C6E9B1D3A5F7C2E

服务端 Tomcat 在两个地方都会尝试取 id:先看 Cookie,取不到再看 URL 里的 ;jsessionid=。Servlet 规范提供了 API 帮你自动判断要不要拼:

// 生成一个普通链接/表单地址时使用
String url = response.encodeURL("/cookie-demo/demo2");
// 生成重定向地址时使用
String redirectUrl = response.encodeRedirectURL("/cookie-demo/loginServlet");
response.sendRedirect(redirectUrl);

这两个方法的行为很"聪明":如果浏览器支持 Cookie,它们原样返回你传进去的地址;只有检测到不支持 Cookie 时,才会把 ;jsessionid=... 拼上去。 所以代码里统一用 encodeURL 包一层是安全的,不会在正常环境下多出难看的后缀。

@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // ... 登录校验逻辑
        HttpSession session = request.getSession();
        session.setAttribute("user", user);

        // 用 encodeRedirectURL 包一层,兼容禁用 Cookie 的浏览器
        String contextPath = request.getContextPath();
        response.sendRedirect(response.encodeRedirectURL(contextPath + "/selectAllServlet"));
    }
}

要让 URL 重写真正生效,还有一个前提:页面上所有链接、表单 action 都必须经过 encodeURL 处理。如果页面里写死了 <a href="/brand-demo/selectAllServlet">,那第一次跳转就把 id 弄丢了,后面照样认不出人。所以 URL 重写实际上是一个"全站改造"的方案,成本不低。

【补充】

现代项目基本不再用 URL 重写,原因有三:一是 jsessionid 出现在地址栏里,会被用户复制分享、被搜索引擎收录、被 Referer 头带到第三方,等于把会话凭证公开;二是改造面太大,漏一个链接就断;三是现在禁用 Cookie 的用户极少。真实项目的做法是:默认走 Cookie,并在登录页给一句"请开启 Cookie"的提示。但考试会考:"禁用 Cookie 后 Session 还能用吗"的标准答案是"能,用 URL 重写 / response.encodeURL()"。

【对比】 两种传递 JSESSIONID 的通道:

维度Cookie(默认)URL 重写
id 存放位置请求头 Cookie: JSESSIONID=...地址栏 ;jsessionid=...
是否需要改页面不需要所有链接/表单都要包 encodeURL
安全性较好(可加 HttpOnly)差(会泄露到日志、Referer)
触发条件浏览器支持 Cookie浏览器禁用 Cookie
代码无感必须显式调用 encodeURL

7.3.5 Session 的钝化与活化

首先需要思考的问题是:服务器重启后,Session 中的数据是否还在?

要想回答这个问题,可以先看这样一个推理:

  1. 服务器端 AServlet 和 BServlet 共用的 Session 对象应该是存储在服务器的内存中;
  2. 服务器重新启动后,内存中的数据应该是已经被释放,对象也应该都销毁了。

所以 Session 数据应该也已经不存在了。但是如果 Session 不存在会引发什么问题呢?举个例子:

  1. 用户把需要购买的商品添加到购物车,因为要实现同一个会话多次请求数据共享,所以假设把数据存入 Session 对象中;
  2. 用户正要付钱的时候接到一个电话,付钱的动作就搁浅了;
  3. 正在用户打电话的时候,购物网站因为某些原因需要重启;
  4. 重启后 Session 数据被销毁,购物车中的商品信息也就会随之而消失;
  5. 用户想再次发起支付,就会出问题。

所以说对于 Session 的数据,应该做到就算服务器重启了,也应该能把数据保存下来才对。

那么 Tomcat 服务器在重启的时候,Session 数据到底会不会保存以及是如何保存的?可以通过实际案例来演示。

注意:这里所说的关闭和启动应该要确保是正常的关闭和启动。那如何才是正常关闭 Tomcat 服务器呢?需要使用命令行的方式来启动和停止 Tomcat 服务器:

启动:进入到项目 pom.xml 所在目录,执行 tomcat7:run
停止:在启动的命令行界面,输入 ctrl+c

有了上述两个正常启动和关闭的方式后,接下来的测试流程是:

  1. 先启动 Tomcat 服务器;
  2. 访问 http://localhost:8080/cookie-demo/demo1 将数据存入 Session 中;
  3. 正确停止 Tomcat 服务器;
  4. 再次重新启动 Tomcat 服务器;
  5. 访问 http://localhost:8080/cookie-demo/demo2 查看是否能获取到 Session 中的数据。

经过测试,会发现只要服务器是正常关闭和启动,Session 中的数据是可以被保存下来的。那么 Tomcat 服务器到底是如何做到的呢?具体的原因就是 Session 的钝化和活化:

对于上述的整个过程,只需要了解即可,因为所有的过程都是 Tomcat 自己完成的,不需要开发者参与。

本节小结,Session 的钝化和活化介绍完后,需要特别注意的是:

【补充】

钝化有一个隐含前提:Session 里存的对象必须能序列化。Tomcat 是把对象写进 SESSIONS.ser,如果一个 User 类没实现 java.io.Serializable,正常关闭时会抛 NotSerializableException,数据就保存不下来。所以凡是要放进 Session 的自定义实体类,习惯上都会加上 implements Serializable 和 serialVersionUID。

【易错点】 "服务器关了 Session 还在"这句话只对正常关闭成立。直接用 IDEA 的红色停止按钮强杀进程、kill -9、断电,都属于非正常关闭,Tomcat 来不及钝化,Session 全丢。这也是 7.3.6 里"服务器关闭"被列为销毁情形之一的原因:要分清哪种关闭。

7.3.6 Session 的销毁与超时配置

Session 的销毁有三种情形,前两种是原文讲的,第三种是补充的完整情形。

(1)超时销毁:默认情况下,无操作 30 分钟自动销毁

对于这个失效时间,是可以通过配置进行修改的。在项目的 web.xml 中配置:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
                             http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">

    <session-config>
        <session-timeout>100</session-timeout>
    </session-config>
</web-app>

<session-timeout> 的单位是分钟,上面配的是 100 分钟。如果没有配置,默认是 30 分钟,默认值是在 Tomcat 的 web.xml 配置文件中写死的。

(2)调用 Session 对象的 invalidate() 进行销毁

在 SessionDemo2 类中添加 Session 销毁的方法:

@WebServlet("/demo2")
public class SessionDemo2 extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //获取数据,从session中

        //1. 获取Session对象
        HttpSession session = request.getSession();
        System.out.println(session);

        // 销毁
        session.invalidate();
        //2. 获取数据
        Object username = session.getAttribute("username");
        System.out.println(username);
    }

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

启动访问测试,先访问 demo1 将数据存入到 Session,再次访问 demo2 从 Session 中获取数据。会发现 invalidate() 之后再 getAttribute 会抛 IllegalStateException(原文的示例正是想让你看到"销毁后这个对象就作废了")。

该销毁方法一般会在用户退出的时候使用,需要将 Session 销毁掉。所以"退出登录"的 Servlet 标准写法是:

@WebServlet("/logoutServlet")
public class LogoutServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //1. 拿到当前Session,不创建新的
        HttpSession session = request.getSession(false);
        //2. 存在才销毁
        if (session != null) {
            session.invalidate();
        }
        //3. 回到登录页
        response.sendRedirect(request.getContextPath() + "/login.jsp");
    }

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

(3)服务器关闭

正常关闭时走钝化(数据落盘,重启后活化回来),所以严格说不算"销毁";但非正常关闭(强杀进程、断电)时内存直接释放,Session 就真没了。所以在描述生命周期时,把"服务器关闭"列为销毁情形之一,指的是这种情况。

销毁方式触发者数据是否可恢复典型场景
超时(默认 30 分钟)Tomcat 定时清理否用户开着页面走了
invalidate()代码否点击"退出登录"
服务器非正常关闭进程被杀否断电、强杀
服务器正常关闭运维是(钝化/活化)正常重启发布
【补充】

除了 web.xml,超时时间还有两种设法:一是在代码里 session.setMaxInactiveInterval(30 * 60),单位是秒,且只对当前这一个 Session 生效;二是在 Tomcat 的 conf/web.xml 里改全局默认值。三者的优先级是"代码 > 项目 web.xml > Tomcat 全局"。另外 <session-timeout> 写成 0 或负数表示永不过期(危险,别这么干)。

【易错点】 超时的"30 分钟"指的是两次请求之间的最大空闲间隔,不是"从创建起总共 30 分钟"。用户一直点点点,Session 可以活一整天;一旦停手超过 30 分钟,下一次请求进来服务器已经把它清了。这也解释了一个经典现象:页面开着没动,回来一点就跳登录页了。

【考点】 问:session.invalidate() 之后还能继续用这个 session 对象吗?答:不能,任何读写都会抛 IllegalStateException。理由:invalidate 把对象标记为无效并从管理器中移除,之后的调用属于非法状态访问。

7.3.7 分布式部署下的 Session 共享

单机时代,Session 存在"这台 Tomcat 的内存里",一切正常。但真实项目通常是多台服务器 + Nginx 负载均衡,这时候一个尖锐的问题就冒出来了:

用户登录的请求被分发到服务器 A,Session 建在 A 上;下一次请求被分发到服务器 B,B 的内存里根本没有这个 JSESSIONID,于是 B 判定"未登录",直接跳回登录页。 这就是著名的"登录后到处掉线"。

这个问题有三种主流解法:

(1)Session 复制(Session Replication)

多台 Tomcat 组成集群,任何一台上的 Session 变化都同步广播给其他机器。Tomcat 自带这个能力(在 server.xml 的 <Host> 里加 <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/>)。

优点是应用代码零改动;缺点是机器一多,同步流量按平方级增长,3 台还行,30 台就撑不住了,而且每台都要存全量 Session,内存浪费严重。适合小集群。

(2)粘性会话(Sticky Session)

让负载均衡器按 JSESSIONID(或源 IP)做哈希,保证同一个会话的请求永远打到同一台机器上。Nginx 的 ip_hash 就是干这个的。

优点是简单、性能好、代码不改;缺点是某台机器挂了,它上面的所有会话全部丢失,而且负载可能不均(有的用户请求特别多)。适合能接受偶尔掉线的场景。

(3)集中式存储(推荐)

把 Session 从 Tomcat 内存里搬出来,统一放到一个外部存储(通常是 Redis),所有服务器都去那里读写。这样任何一台机器都能认出任何会话,某台机器挂了也不影响。

Spring 生态里的标准做法是引入 Spring Session,把 HttpSession 的实现替换成"以 Redis 为后端"的版本,业务代码里 request.getSession() 一行都不用改。核心思路可以手工实现:登录成功后生成一个随机 token,把用户数据以 token 为 key 存进 Redis 并设置过期时间,再把 token 写进 Cookie;之后每个请求用拦截器/Filter 拿 token 去 Redis 换用户数据。

方案代码改动可靠性扩展性适用规模
Session 复制无中(全量同步)差2~5 台
粘性会话无低(单点丢失)中小集群
Redis 集中存储小(或零,用框架)高好任意规模
【补充】

集中式存储还顺带解决了另外两个问题:一是重启发布不掉线,因为数据不在应用进程里;二是跨端共享,PC 端和 App 端可以共用同一份会话数据。代价是多了一次网络往返(读 Redis),所以热点会话常再加一层本地缓存。考试层面只需答出"三种思路:复制、粘性、集中存储,集中存储最优"即可。

【考点】 问:多台 Tomcat 做集群,Session 会有什么问题?怎么解决?答:默认 Session 存在各自内存里,请求换机器就认不出人;解决思路有 Session 复制、粘性会话、Redis 集中存储(最优)。理由:会话状态必须对所有节点可见,要么同步、要么绑定、要么外置。

7.4 Cookie 与 Session 的对比与应用场景

Cookie 和 Session 都是来完成一次会话内多次请求间数据共享的。把两个对象放在一块,就需要思考两个问题:Cookie 和 Session 的区别是什么?Cookie 和 Session 的应用场景分别是什么?

7.4.1 区别对比表

维度CookieSession
存储位置数据存储在客户端(浏览器)数据存储在服务端(服务器内存)
安全性不安全(用户可读可改)安全(用户只拿到一个 id)
数据大小最大约 3KB(单个 Cookie)无大小限制(受服务器内存约束)
存储时间可通过 setMaxAge() 长期存储默认 30 分钟(超时即销毁)
服务器性能不占服务器资源占用服务器资源
存储类型只能是字符串任意 Object
依赖关系独立使用基于 Cookie 传递 JSESSIONID
跨域受同源策略严格限制依托 Cookie,同样受限

这张表要能默写。记忆的口诀是"位置定安全,大小定性能":存在客户端所以不安全、所以小;存在服务端所以安全、所以大,但也因此吃服务器内存。

7.4.2 应用场景

结论:

这两句话是本章的"题眼"。Cookie 解决的是"这台浏览器上次来过,我给它留了个记号";Session 解决的是"这个人已经登录了,他的身份和权限是什么"。前者是识别设备,后者是识别用户。

介绍完 Cookie 和 Session 以后,具体用哪个还是需要根据具体的业务进行具体分析。

【对比】

"记住我"为什么必须用 Cookie,验证码为什么必须用 Session?因为二者的约束条件正好相反:

需求关键约束结论
记住我(关浏览器后仍要自动填充)必须能跨浏览器会话存活只能 Cookie + setMaxAge 正数
验证码(不能被用户看到)必须对用户不可见、不可改只能 Session
登录用户名展示必须可信Session
购物车(未登录也能用)未登录时也要能存Cookie

判断标准:要跨越"关闭浏览器"这道坎的,放 Cookie;要防着用户的,放 Session。

【易错点】 把"记住我"做成往 Session 里存用户名密码,是彻底做不出来的:关掉浏览器 Session 就找不回了。反过来,把验证码答案放进 Cookie,等于把答案写在考卷背面,用户改一下 Cookie 就能绕过校验。这两处混淆是期末编程题的高频扣分点。

7.4.3 Cookie 与 Session 里该存什么

这一节是原文没有展开、但工程上极其重要的一条边界。

Cookie 里可以放的:会话标识(JSESSIONID)、"记住我"的用户名(最好只放用户名,别放密码)、主题偏好、语言、上次访问时间、不敏感的埋点 id。

Cookie 里绝对不能放的:密码(哪怕加密)、手机号身份证等隐私、余额、权限标识(如 isAdmin=true)、优惠券金额。原因是 Cookie 落在用户磁盘上、同域脚本可读、可被任意篡改。你把 isAdmin=false 存进去,用户改成 true 就是越权。

Session 里可以放的:登录用户对象(User)、验证码答案、购物车(登录后)、一次性令牌、权限列表。因为用户只拿得到一个随机 id,改不了内容。

Session 里不建议放的:超大集合(几万个元素会把内存吃爆)、不可序列化的对象(钝化会失败)、需要跨服务器长期共享的数据(该进 Redis 或数据库)。

【补充】

一个实用的判断方法:问自己"这个东西如果被用户改成一个随便的值,会不会造成损失?"会,就必须放服务端(Session / 数据库);不会(比如"列表每页显示 20 条"),放 Cookie 无所谓。再补一条:Session 也不是绝对安全,它防的是"用户篡改",防不了"会话劫持":JSESSIONID 被中间人偷走后照样能冒充,所以登录、支付这类操作必须走 HTTPS。

【考点】 问:Cookie 和 Session 应该怎么选?答:需要在浏览器关闭后仍然保留、且不敏感的数据放 Cookie;需要保密、且只在本次会话内有效的数据放 Session。理由:两者的存储位置决定了安全性与生命周期,选型本质是在这两者之间取平衡。

7.5 综合案例:用户登录注册

7.5.1 需求分析

需求说明:

  1. 完成用户登录功能,如果用户勾选"记住用户",则下次访问登录页面自动填充用户名密码;
  2. 完成注册功能,并实现验证码功能。

这个案例是本章所有知识点的落地面:登录状态保持用 Session,记住我用 Cookie,验证码用 Session。它同时还是一个标准的三层结构(Web → Service → Dao),第10章 JDBC 会把它换成对数据库的访问。

7.5.2 用户登录功能

1. 需求

2. 实现流程分析

  1. 前端通过表单发送请求和数据给 Web 层的 LoginServlet;
  2. 在 LoginServlet 中接收请求和数据[用户名和密码];
  3. LoginServlet 接收到请求和数据后,调用 Service 层完成根据用户名和密码查询用户对象;
  4. 在 Service 层需要编写 UserService 类,在类中实现 login 方法,方法中调用 Dao 层的 UserMapper;
  5. 在 UserMapper 接口中,声明一个根据用户名和密码查询用户信息的方法;
  6. Dao 层把数据查询出来以后,将返回数据封装到 User 对象,将对象交给 Service 层;
  7. Service 层将数据返回给 Web 层;
  8. Web 层获取到 User 对象后,判断 User 对象,如果为 null,则将错误信息响应给登录页面;如果不为 null,则跳转到列表页面,并把当前登录用户的信息存入 Session 携带到列表页面。

第 8 步是本章的核心:"把当前登录用户的信息存入 Session" 这一动作,就是登录状态保持的全部秘密。

3. 具体实现

(1)完成 Dao 层的代码编写

(1.1)将 UserMapper.java 放到 com.itheima.mapper 包下

public interface 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 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);
}

(1.2)将 User.java 放到 com.itheima.pojo 包下

public class User {
    private Integer id;
    private String username;
    private String password;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", password='" + password + '\'' +
                '}';
    }
}
【补充】

上面这个 User 如果要放进 Session,务必加上 implements java.io.Serializable,否则 7.3.5 讲的钝化会在服务器正常关闭时失败。为了兼容后续版本升级,通常再补一行 private static final long serialVersionUID = 1L;。

(1.3)将 UserMapper.xml 放入到 resources/com/itheima/mapper 目录下

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.UserMapper">

</mapper>

(2)完成 Service 层的代码编写

在 com.itheima.service 包下,创建 UserService 类:

public class UserService {
    //1.使用工具类获取SqlSessionFactory
    SqlSessionFactory factory = SqlSessionFactoryUtils.getSqlSessionFactory();

    /**
     * 登录方法
     * @param username
     * @param password
     * @return
     */
    public User login(String username, String password) {
        //2. 获取SqlSession
        SqlSession sqlSession = factory.openSession();
        //3. 获取UserMapper
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        //4. 调用方法
        User user = mapper.select(username, password);
        //释放资源
        sqlSession.close();

        return user;
    }
}

(3)完成页面和 Web 层的代码编写

(3.1)将静态页面拷贝到项目的 webapp 目录下。

(3.2)将 login.html 内容修改成 login.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!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" style="height: 350px">
    <form action="/brand-demo/loginServlet" method="post" id="form">
        <h1 id="loginMsg">LOGIN IN</h1>
        <div id="errorMsg">用户名或密码不正确</div>
        <p>Username:<input id="username" name="username" type="text"></p>
        <p>Password:<input id="password" name="password" type="password"></p>
        <p>Remember:<input id="remember" name="remember" type="checkbox"></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>

(3.3)创建 LoginServlet 类

@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
    private UserService service = 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 = service.login(username, password);

        //3. 判断
        if (user != null) {
            //登录成功,跳转到查询所有的BrandServlet

            //将登陆成功后的user对象,存储到session
            HttpSession session = request.getSession();
            session.setAttribute("user", user);

            String contextPath = request.getContextPath();
            response.sendRedirect(contextPath + "/selectAllServlet");
        } else {
            // 登录失败,
            // 存储错误信息到request
            request.setAttribute("login_msg", "用户名或密码错误");
            // 跳转到login.jsp

            request.getRequestDispatcher("/login.jsp").forward(request, response);
        }
    }

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

(3.4)在 brand.jsp 中标签下添加欢迎当前用户的提示信息

<h1>${user.username},欢迎您</h1>

(3.5)修改 login.jsp,将错误信息使用 EL 表达式来获取

修改前内容:<div id="errorMsg">用户名或密码不正确</div>
修改后内容:<div id="errorMsg">${login_msg}</div>

(4)启动,访问测试

先进入登录页面,输入错误的用户名或密码,会被转发回登录页并显示"用户名或密码错误";再输入正确的用户名和密码,会重定向到列表页,页面上显示当前登录用户的名字。

小结

【对比】

登录成功用 sendRedirect,登录失败用 forward,为什么不一样?

场景方式原因
登录成功 → 列表页重定向(两次请求)地址栏变干净,且刷新列表页不会重复提交表单
登录失败 → 登录页转发(一次请求)要带着 request 域里的 login_msg 一起过去

如果登录失败也用重定向,request.setAttribute("login_msg", ...) 就带不过去了,页面上的错误提示永远是空的。这正是第6章"转发能带 request 域、重定向不能"的直接应用。

【考点】 问:登录成功后为什么要把 user 放进 Session 而不是 request?答:request 域只活一次请求,重定向到列表页就是新请求,域里什么都没有;Session 活整次会话,后续所有请求都能取到。理由:跨请求共享数据必须用会话级作用域。

7.5.3 记住我——设置 Cookie

1. 需求

如果用户勾选"记住用户",则下次访问登录页面自动填充用户名密码。这样可以提升用户的体验。

对应上面这个需求,最大的问题是:如何自动填充用户名和密码?

2. 实现流程分析

因为"记住我"功能要实现的效果是,就算用户把浏览器关闭过几天再来访问也能自动填充,所以需要将登录信息存入一个可以长久保存、并且能够在浏览器关闭重新启动后依然有效的地方,就是前面讲的 Cookie。所以:

具体步骤:

  1. 前端需要在发送请求和数据的时候,多携带一个用户是否勾选 Remember 的数据;
  2. LoginServlet 获取到数据后,调用 Service 完成用户名和密码的判定;
  3. 登录成功,并且用户在前端勾选了"记住我",需要往 Cookie 中写入用户名和密码的数据,并设置 Cookie 存活时间;
  4. 设置成功后,将数据响应给前端。

3. 具体实现

(1)在 login.jsp 为复选框设置值

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!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" style="height: 350px">
    <form action="/brand-demo/loginServlet" method="post" id="form">
        <h1 id="loginMsg">LOGIN IN</h1>
        <div id="errorMsg">${login_msg}</div>
        <p>Username:<input id="username" name="username" type="text"></p>
        <p>Password:<input id="password" name="password" type="password"></p>
        <p>Remember:<input id="remember" name="remember" value="1" type="checkbox"></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>

(2)在 LoginServlet 获取复选框的值并在登录成功后设置 Cookie

@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
    private UserService service = new UserService();

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        //1. 获取用户名和密码
        String username = request.getParameter("username");
        String password = request.getParameter("password");

        //获取复选框数据
        String remember = request.getParameter("remember");

        //2. 调用service查询
        User user = service.login(username, password);

        //3. 判断
        if (user != null) {
            //登录成功,跳转到查询所有的BrandServlet

            //判断用户是否勾选记住我,字符串写前面是为了避免出现空指针异常
            if ("1".equals(remember)) {
                //勾选了,发送Cookie
                //1. 创建Cookie对象
                Cookie c_username = new Cookie("username", username);
                Cookie c_password = new Cookie("password", password);
                // 设置Cookie的存活时间
                c_username.setMaxAge(60 * 60 * 24 * 7);
                c_password.setMaxAge(60 * 60 * 24 * 7);
                //2. 发送
                response.addCookie(c_username);
                response.addCookie(c_password);
            }

            //将登陆成功后的user对象,存储到session
            HttpSession session = request.getSession();
            session.setAttribute("user", user);

            String contextPath = request.getContextPath();
            response.sendRedirect(contextPath + "/selectAllServlet");
        } else {
            // 登录失败,
            // 存储错误信息到request
            request.setAttribute("login_msg", "用户名或密码错误");
            // 跳转到login.jsp

            request.getRequestDispatcher("/login.jsp").forward(request, response);
        }
    }

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

(3)启动访问测试

只有当前用户名和密码输入正确,并且勾选了 Remember 的复选框,在响应头中才可以看得到 Cookie 的相关数据。

【易错点】

"1".equals(remember) 这种"常量在前"的写法用于防 NPE,不能只当成风格偏好。remember 来自 request.getParameter("remember"),用户没勾选时复选框不会提交,这里拿到的是 null。写成 remember.equals("1") 就必抛 NullPointerException,表现为"不勾选记住我反而报 500"。记住口诀:字符串比较,常量在前。

【补充】 本例把密码明文写进 Cookie,只是为了演示 API,实际项目绝对不允许。工程上的做法是写一个"记住我令牌"(随机长字符串),服务端保存 令牌 → 用户id 的映射并设置过期时间,Cookie 里只放令牌。这样即使 Cookie 泄露,也只泄露一个可随时吊销的凭证,而不是用户的真实密码。

7.5.4 记住我——获取 Cookie

1. 需求

登录成功并勾选了 Remember 后,后端返回给前端的 Cookie 数据就已经存储好了,接下来就需要在页面获取 Cookie 中的数据,并把数据设置到登录页面的用户名和密码框中。

如何在页面直接获取 Cookie 中的值呢?

2. 实现流程分析

在页面可以使用 EL 表达式:${cookie.key.value},其中 key 指的是存储在 Cookie 中的键名称。

  1. 在 login.jsp 用户名的表单输入框使用 value 值给表单元素添加默认值,value 可以使用 ${cookie.username.value};
  2. 在 login.jsp 密码的表单输入框使用 value 值给表单元素添加默认值,value 可以使用 ${cookie.password.value}。

3. 具体实现

(1)修改 login.jsp 页面

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!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" style="height: 350px">
    <form action="/brand-demo/loginServlet" method="post" id="form">
        <h1 id="loginMsg">LOGIN IN</h1>
        <div id="errorMsg">${login_msg}</div>
        <p>Username:<input id="username" name="username"
                           value="${cookie.username.value}" type="text"></p>

        <p>Password:<input id="password" name="password"
                           value="${cookie.password.value}" type="password"></p>
        <p>Remember:<input id="remember" name="remember" value="1" type="checkbox"></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>

4. 访问测试

重新访问登录页面,就可以看到用户名和密码已经被填充。

【补充】

${cookie} 是 EL 的 11 个隐式对象之一,它代表"所有 Cookie 组成的 Map",键是 Cookie 名,值是 Cookie 对象本身,所以要多取一层 .value 才能拿到字符串。同理还有 ${cookie.username.maxAge} 可以取到存活时间。注意 ${cookie.xxx.value} 在 Cookie 不存在时返回空字符串,不会报错,这正好让"没勾记住我"时输入框是空的。这里也提前用到了第8章 EL 的知识,第8章会系统讲。

【易错点】 把 ${cookie.username} 直接写进 value,页面上会显示 javax.servlet.http.Cookie@1a2b3c 这样的对象地址。原因是少了一层 .value。EL 表达式取 Map 得对象、再取属性,两层都不能省。

7.5.5 用户注册功能

1. 需求

2. 实现流程分析

  1. 前端通过表单发送请求和数据给 Web 层的 RegisterServlet;
  2. 在 RegisterServlet 中接收请求和数据[用户名和密码];
  3. RegisterServlet 接收到请求和数据后,调用 Service 层完成用户信息的保存;
  4. 在 Service 层需要编写 UserService 类,在类中实现 register 方法,需要判断用户是否已经存在,如果不存在,则完成用户数据的保存;
  5. 在 UserMapper 接口中,声明两个方法,一个是根据用户名查询用户信息方法,另一个是保存用户信息方法;
  6. 在 UserService 类中保存成功则返回 true,失败则返回 false,将数据返回给 Web 层;
  7. Web 层获取到结果后,如果返回的是 true,则提示"注册成功",并转发到登录页面;如果返回 false,则提示"用户名已存在",并转发到注册页面。

3. 具体实现

(1)Dao 层代码参考资料中的内容完成。

(2)编写 Service 层代码

public class UserService {
    //1.使用工具类获取SqlSessionFactory
    SqlSessionFactory factory = SqlSessionFactoryUtils.getSqlSessionFactory();

    /**
     * 注册方法
     * @return
     */
    public boolean register(User user) {
        //2. 获取SqlSession
        SqlSession sqlSession = factory.openSession();
        //3. 获取UserMapper
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        //4. 判断用户名是否存在
        User u = mapper.selectByUsername(user.getUsername());

        if (u == null) {
            // 用户名不存在,注册
            mapper.add(user);
            sqlSession.commit();
        }
        sqlSession.close();

        return u == null;
    }
}

(3)完成页面和 Web 层的代码编写

(3.1)将 register.html 内容修改成 register.jsp

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<!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="/brand-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>
            <tr>
                <td>验证码</td>
                <td class="inputs">
                    <input name="checkCode" type="text" id="checkCode">
                    <img src="imgs/a.jpg">
                    <a href="#" id="changeImg">看不清?</a>
                </td>
            </tr>
        </table>
        <div class="buttons">
            <input value="注 册" type="submit" id="reg_btn">
        </div>
        <br class="clear">
    </form>
</div>
</body>
</html>

(3.2)编写 RegisterServlet

@WebServlet("/registerServlet")
public class RegisterServlet extends HttpServlet {
    private UserService service = new UserService();

    @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. 调用service 注册
        boolean flag = service.register(user);
        //3. 判断注册成功与否
        if (flag) {
            //注册功能,跳转登陆页面
            request.setAttribute("register_msg", "注册成功,请登录");

            request.getRequestDispatcher("/login.jsp").forward(request, response);
        } else {
            //注册失败,跳转到注册页面

            request.setAttribute("register_msg", "用户名已存在");

            request.getRequestDispatcher("/register.jsp").forward(request, response);
        }

    }

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

(3.3)需要在页面上展示后台返回的错误信息,需要修改 register.jsp

修改前:<span id="username_err" class="err_msg" style="display:none">用户名不太受欢迎</span>
修改后:<span id="username_err" class="err_msg">${register_msg}</span>

(3.4)如果注册成功,需要把成功信息展示在登录页面,所以也需要修改 login.jsp

修改前:<div id="errorMsg">${login_msg}</div>
修改后:<div id="errorMsg">${login_msg} ${register_msg}</div>

(3.5)修改 login.jsp,将注册跳转地址修改为 register.jsp

修改前:<a href="register.html">没有账号?</a>
修改后:<a href="register.jsp">没有账号?</a>

(3.6)启动测试

如果注册的用户信息已经存在,会转发回注册页并显示"用户名已存在";如果注册的用户信息不存在,注册成功,会转发到登录页并显示"注册成功,请登录"。

7.5.6 验证码——展示

1. 需求分析

展示验证码:展示验证码图片,并可以点击切换。

验证码的生成是通过工具类来实现的,具体的工具类参考 CheckCodeUtil.java。在该工具类中编写 main 方法进行测试:

public static void main(String[] args) throws IOException {
    //生成验证码的图片位置
    OutputStream fos = new FileOutputStream("d://a.jpg");
    //checkCode为最终验证码的数据
    String checkCode = CheckCodeUtil.outputVerifyImage(100, 50, fos, 4);
    System.out.println(checkCode);
}

生成完验证码以后,就可以知晓:

2. 实现流程分析

  1. 前端发送请求给 CheckCodeServlet;
  2. CheckCodeServlet 接收到请求后,生成验证码图片,将图片用 Response 对象的输出流写回到前端。

思考:如何将图片写回到前端浏览器呢?

  1. Java 中已经有工具类生成验证码图片,测试类中只是把图片生成到磁盘上;
  2. 生成磁盘的过程中使用的是 OutputStream 流,如何把这个图片生成在页面呢?
  3. 前面在讲 Response 对象的时候,它有一个方法可以获取其字节输出流,getOutputStream();
  4. 把写往磁盘的流对象更换成 Response 的字节流,即可完成图片响应给前端。

3. 具体实现

(1)修改 register.jsp 页面,将验证码的图片从后台获取

<tr>
    <td>验证码</td>
    <td class="inputs">
        <input name="checkCode" type="text" id="checkCode">
        <img id="checkCodeImg" src="/brand-demo/checkCodeServlet">
        <a href="#" id="changeImg">看不清?</a>
    </td>
</tr>

<script>
    document.getElementById("changeImg").onclick = function () {
        //路径后面添加时间戳的目的是避免浏览器进行缓存静态资源
        document.getElementById("checkCodeImg").src = "/brand-demo/checkCodeServlet?" + new Date().getMilliseconds();
    }
</script>

(2)编写 CheckCodeServlet 类,用来接收请求生成验证码

@WebServlet("/checkCodeServlet")
public class CheckCodeServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        // 生成验证码
        ServletOutputStream os = response.getOutputStream();
        String checkCode = CheckCodeUtil.outputVerifyImage(100, 50, os, 4);
    }

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

为什么点击"看不清"要在 URL 后面拼一个时间戳?因为浏览器会缓存图片请求,如果两次 src 完全一样,浏览器就直接用缓存,不会再发请求,验证码看起来"点了没反应"。拼上 new Date().getMilliseconds() 让每次 URL 都不同,就绕过了缓存。这也是"GET 请求会被缓存"的一个典型副作用。

【易错点】 CheckCodeServlet 里只写了 response.getOutputStream(),却忘了设置 Content-Type。虽然 Tomcat 多数情况下能靠内容猜出是图片,但规范做法是先写 response.setContentType("image/jpeg"),否则某些浏览器会把它当文本下载而不是显示。另外,getOutputStream() 和 getWriter() 在同一个响应里只能用一个,同时调用会抛 IllegalStateException。

7.5.7 验证码——校验

1. 需求

判断程序生成的验证码和用户输入的验证码是否一样,如果不一样,则阻止注册。验证码图片访问和提交注册表单是两次请求,所以要将程序生成的验证码存入 Session 中。

思考:为什么要把验证码数据存入到 Session 中呢?

2. 实现流程分析

  1. 在 CheckCodeServlet 中生成验证码的时候,将验证码数据存入 Session 对象;
  2. 前端将验证码和注册数据提交到后台,交给 RegisterServlet 类;
  3. RegisterServlet 类接收到请求和数据后,其中就有验证码,和 Session 中的验证码进行对比;
  4. 如果一致,则完成注册;如果不一致,则提示错误信息。

3. 具体实现

(1)修改 CheckCodeServlet 类,将验证码存入 Session 对象

@WebServlet("/checkCodeServlet")
public class CheckCodeServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        // 生成验证码
        ServletOutputStream os = response.getOutputStream();
        String checkCode = CheckCodeUtil.outputVerifyImage(100, 50, os, 4);

        // 存入Session
        HttpSession session = request.getSession();
        session.setAttribute("checkCodeGen", checkCode);

    }

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

(2)在 RegisterServlet 中,获取页面的和 Session 对象中的验证码,进行对比

package com.itheima.web;

import com.itheima.pojo.User;
import com.itheima.service.UserService;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.*;
import java.io.IOException;

@WebServlet("/registerServlet")
public class RegisterServlet extends HttpServlet {
    private UserService service = new UserService();

    @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);

        // 获取用户输入的验证码
        String checkCode = request.getParameter("checkCode");

        // 程序生成的验证码,从Session获取
        HttpSession session = request.getSession();
        String checkCodeGen = (String) session.getAttribute("checkCodeGen");

        // 比对
        if (!checkCodeGen.equalsIgnoreCase(checkCode)) {

            request.setAttribute("register_msg", "验证码错误");

            request.getRequestDispatcher("/register.jsp").forward(request, response);

            // 不允许注册
            return;
        }
        //2. 调用service 注册
        boolean flag = service.register(user);
        //3. 判断注册成功与否
        if (flag) {
            //注册功能,跳转登陆页面

            request.setAttribute("register_msg", "注册成功,请登录");

            request.getRequestDispatcher("/login.jsp").forward(request, response);
        } else {
            //注册失败,跳转到注册页面

            request.setAttribute("register_msg", "用户名已存在");

            request.getRequestDispatcher("/register.jsp").forward(request, response);
        }

    }

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

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

【考点】

问:验证码为什么要存 Session,不能存 request 域或 Cookie?答:①生成验证码和提交表单是两次独立请求,request 域在第一次请求结束时就销毁了,第二次根本取不到;②验证码是安全数据,放 Cookie 等于把答案交给用户,可以随意改成自己输入的值来绕过校验;③Session 既跨请求又在服务端,两个条件都满足。理由:作用域决定可见范围,存储位置决定可信度。

【易错点】 用户直接打开注册页、没加载验证码就提交表单时,session.getAttribute("checkCodeGen") 返回 null,checkCodeGen.equalsIgnoreCase(checkCode) 会抛 NullPointerException。正确写法是先判空:

if (checkCodeGen == null || !checkCodeGen.equalsIgnoreCase(checkCode)) {
    // 验证码错误
}

原文代码把 checkCodeGen 放在前面调用,只有在"必定先加载图片"的前提下才安全,实际项目要补这个判空。

【补充】 验证码校验完应当立即失效,否则同一个验证码可以被重复使用(重放攻击)。标准做法是在比对之后加一行 session.removeAttribute("checkCodeGen");。另外,比较时用 equalsIgnoreCase 是因为验证码通常不区分大小写,这也是工具类的常规设计。

本章小结

本章的主线只有一条:HTTP 无状态,所以要用 Cookie 和 Session 把"你是谁"记住。

知识点要点关键 API
会话浏览器与服务器之间一次持续的交互过程,可含多次请求响应无
会话跟踪服务器识别多次请求是否来自同一浏览器,以便共享数据无
Cookie 概念客户端会话技术,数据存浏览器,每次请求自动携带new Cookie(name, value)
Cookie 发送通过响应对象挂到响应上,Tomcat 写出 Set-Cookie 头response.addCookie(cookie)
Cookie 获取从请求中拿到数组,遍历比对名字request.getCookies()、getName()、getValue()
Cookie 存活时间正数持久化到硬盘,负数会话级,零表示删除setMaxAge(int seconds)
Cookie 中文不能直接存,必须 URL 编解码且字符集一致URLEncoder.encode / URLDecoder.decode
Cookie 路径域决定哪些请求会携带该 CookiesetPath / setDomain
Cookie 销毁同名 + 同 path + setMaxAge(0) + 真的发送setMaxAge(0) + addCookie
Session 概念服务端会话技术,数据存服务器,靠 JSESSIONID 认人request.getSession()
Session 方法存、取、删,值可以是任意 ObjectsetAttribute / getAttribute / removeAttribute
Session 原理基于 Cookie 传递 JSESSIONID响应头 Set-Cookie、请求头 Cookie
禁用 Cookie用 URL 重写把 id 拼到地址上response.encodeURL()
钝化与活化正常关闭时 Session 落盘,重启后加载回来SESSIONS.ser
Session 销毁超时(默认 30 分钟)、invalidate()、服务器非正常关闭invalidate()、<session-timeout>
分布式共享复制、粘性、集中存储三种思路Redis + 统一会话

七条必须记住的结论:

  1. Cookie 存客户端、Session 存服务端,这是两者一切差异的源头。
  2. Session 的底层是 Cookie,浏览器手里只有 JSESSIONID,数据一直在服务器内存里。
  3. request 域活一次请求,Session 域活整次会话,跨请求共享只能靠后者。
  4. 会话 Cookie 关浏览器就没,持久 Cookie 靠 setMaxAge 正数活下来,"记住我"必须用后者。
  5. Cookie 不能存中文,要 URLEncoder / URLDecoder 配对使用。
  6. Cookie 与 Session 该存什么由"会不会被用户改"决定:敏感的、可信的放 Session,需要跨浏览器存活的放 Cookie。
  7. 本章的知识在下两章立刻被用到:第8章 JSP 用 ${user.username} 从 Session 取登录用户、用 ${cookie.username.value} 取记住我的值;第9章 Filter 用 session.getAttribute("user") == null 判断未登录并拦截。本章负责"存进去",第8章负责"显示出来",第9章负责"不许随便进"。

自测

基础题

  1. 用自己的话说明"会话"与"会话跟踪"的区别,并解释 HTTP 为什么自己做不了会话跟踪。
  2. 写出 Cookie 的发送与获取各两行核心代码,并说明它们分别用的是 request 还是 response。
  3. setMaxAge 的参数为正数、负数、零时,分别是什么效果?
  4. Session 的 getSession() 与 getSession(false) 有什么区别?分别在什么场景下用?
  5. 列出 Session 被销毁的几种情况,并说明默认超时时间是多少、在哪里改。

进阶题

  1. 看代码写结果:下面的 Servlet 部署在 /demo 下,依次访问 /demo/a 和 /demo/b,控制台输出什么?
@WebServlet("/a")
public class AServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        Cookie c = new Cookie("username", "zs");
        c.setMaxAge(60 * 60);
        response.addCookie(c);
    }
}

@WebServlet("/b")
public class BServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            System.out.println(cookie.getName() + "=" + cookie.getValue());
        }
    }
}
  1. 找错:下面的"退出登录"代码有什么问题?
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        HttpSession session = request.getSession();
        session.invalidate();
        Object user = session.getAttribute("user");
        System.out.println(user);
        response.sendRedirect("login.jsp");
    }
}
  1. 简答:为什么验证码必须存 Session?如果存到 request 域或 Cookie 里分别会出什么问题?
  2. 简答:网站做了集群(3 台 Tomcat),用户登录后总是过一会儿就掉线,请分析原因并给出三种解决思路。
  3. 设计题:一个购物网站要求"未登录也能加购物车,登录后购物车内容合并到账号下",请说明 Cookie 和 Session 各自承担什么职责。

参考答案

  1. 会话是浏览器与服务器之间一次持续的交互过程,可包含多次请求响应;会话跟踪是服务器识别多次请求是否来自同一浏览器的过程,目的是在同一次会话的多次请求间共享数据。HTTP 是无状态的,每次请求都被当作全新请求,请求之间相互独立,所以协议本身无法共享数据。
  2. 发送:Cookie cookie = new Cookie("key","value"); response.addCookie(cookie);(用 response)。获取:Cookie[] cookies = request.getCookies(); 再遍历 cookie.getName() / cookie.getValue()(用 request)。
  3. 正数:写入硬盘持久化,到期自动删除;负数(默认):存在浏览器内存,关闭浏览器销毁;零:删除该 Cookie。
  4. getSession() 等价于 getSession(true),不存在就新建;getSession(false) 不存在返回 null。登录、存数据时用前者;判断"是否已登录"、退出登录时用后者,避免无谓地创建 Session。
  5. 三种:超时(默认 30 分钟无操作)、调用 invalidate()、服务器非正常关闭(正常关闭走钝化,数据会保留)。超时时间在项目 web.xml 的 <session-config><session-timeout> 中改,单位分钟;也可用 session.setMaxInactiveInterval(秒)。
  6. 输出 username=zs(可能还多一行 JSESSIONID=...,因为访问过 Servlet 且用了 Session 才会有;本例未用 Session,故只有 username=zs)。关键点:setMaxAge 为正数使 Cookie 持久化,且两次请求同域同应用,浏览器会自动携带。
  7. 三处问题:① invalidate() 之后再调用 session.getAttribute 会抛 IllegalStateException,取数据的代码必须删掉或移到销毁之前;② 应用 getSession(false) 并在为 null 时跳过销毁,避免给未登录用户白建一个 Session;③ 重定向地址最好用 request.getContextPath() 拼,避免相对路径在多层 URL 下解析错误。
  8. 验证码的生成与校验是两次独立请求,request 域在第一次请求结束即销毁,第二次取不到(拿到 null,还会引发 NPE);存 Cookie 则把答案暴露给客户端,用户可篡改 Cookie 使任意输入都通过校验,失去防机器注册的意义。Session 跨请求且在服务端,两个条件都满足。
  9. 原因:Session 默认存在各自 Tomcat 的内存里,负载均衡把后续请求分到了另一台机器,那台机器没有对应的 JSESSIONID,于是判定未登录。三种思路:Session 复制(集群间同步)、粘性会话(按 id 或 IP 哈希固定到一台)、集中式存储(Session 放 Redis,所有节点共享,最优)。
  10. 未登录时用 Cookie 存购物车(或购物车 id),因为此时没有登录身份可依托,且要跨浏览器会话保留;登录后用 Session(或服务端存储)保存购物车,因为要可信、要跨设备。合并时机是登录成功那一刻:读取 Cookie 中的购物车数据,与账号下的购物车合并后写入服务端,再清掉 Cookie 中的临时数据。