第7章 会话技术:Cookie 与 Session
难度:★★★★☆ | 前置知识:第6章 Request 与 Response | 本章解决:让服务器在多次请求之间认出"还是你",把状态保存在一次会话里
本章导读
- 第6章把一个请求从进来到出去的全过程走了一遍:
request装请求数据、response装响应数据,两者的作用域都是一次请求。请求一结束,request域里的东西全部作废。于是就有了一个绕不过去的坎:用户在登录页填了账号密码,登录成功了,可下一个请求(比如查询品牌列表)进来时,服务器已经把"刚才是谁登录的"忘得干干净净。 - 本章要补的就是这个缺口。HTTP 协议本身是无状态的,靠协议自己记不住任何事,所以必须额外加一层机制,这就是会话跟踪技术。它有两个实现:Cookie 把数据存在浏览器,Session 把数据存在服务器。两者的区别在存放位置:Cookie 在客户端,Session 在服务端。
- 这条线是贯穿后面三章的:本章把用户对象存进 Session,第8章 JSP 里就能用
${user.username}把它取出来显示在页面上,第9章 Filter 里就能判断"Session 里有没有 user",没有就拦回登录页。登录状态保持这个功能,本章负责"存",第8章负责"取",第9章负责"守门"。三章合起来才是一个完整的登录。 - 本章的知识曲线是:先搞清"会话"是什么、为什么 HTTP 记不住(7.1),再学 Cookie 的用法与底层(7.2),然后学 Session 的用法与底层(7.3),接着把两者摆在一起对比(7.4),最后用一个登录注册案例把验证码、记住我、登录状态保持全部串起来(7.5)。
- 三个最常见的学习误解,先在这里点破:其一,以为 Cookie 是"浏览器缓存",它实际是浏览器替某个网站存的一小段键值对,每次请求都会自动带上去;其二,以为
request.getSession()是"从浏览器拿 Session",浏览器只拿得到一个字符串JSESSIONID,对象本身一直在服务器内存里;其三,以为关掉浏览器 Session 就没了,服务端的 Session 还在,只是浏览器把JSESSIONID弄丢了,服务器再也对不上号。 - 学完本章你应该能做到:手写发送与获取 Cookie 的 Servlet;说清
setMaxAge三个取值区间各自的含义;说清 Session 为什么必须依赖 Cookie;在关闭 Cookie 的环境里用response.encodeURL()兜底;独立完成带验证码和记住我的登录注册模块;并且能回答"Cookie 和 Session 有什么区别、各自该存什么"。
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 自己做不到:无状态
既然会话跟踪这么重要,为什么现在的浏览器和服务器默认不支持数据共享呢?原因不在浏览器,而在协议本身:
- 浏览器和服务器之间使用的是 HTTP 请求来进行数据传输;
- HTTP 协议是无状态的,每次浏览器向服务器请求时,服务器都会将该请求视为新的请求;
- HTTP 协议设计成无状态的目的是让每次请求之间相互独立,互不影响;
- 请求与请求之间独立后,就无法实现多次请求之间的数据共享。
第4章讲 HTTP 报文时已经强调过"一次请求一次响应、连接即断",这里终于看到了它的代价。结论是:无状态让服务器变得简单、可扩展,但代价是它天生不认人。
【补充】不保存状态是刻意的取舍,不属于设计失误。因为服务器不必为每个连接保存上下文,一个请求可以被任意一台机器处理,负载均衡才好做、宕机才好恢复。代价就是"认人"这件事得由应用层自己补,补的方式就是让客户端每次请求都主动出示一个凭证。Cookie 和 Session 都是这套思路的产物:一个把凭证连同数据一起放在客户端,一个只把凭证放在客户端、数据留在服务端。
【考点】 问:HTTP 是无状态的,那网站怎么知道我已经登录了?答:靠客户端每次请求自动携带的凭证:Cookie 里的
JSESSIONID(或 Token),服务端拿它去自己的内存/存储里换回会话数据。理由:协议层不保存状态,状态只能由应用层用"每次多带一点信息"的方式补回来。
7.1.4 两种实现方式
分析完原因,那么该如何实现会话跟踪技术呢?具体的实现方式有:
- 客户端会话跟踪技术:Cookie
- 服务端会话跟踪技术:Session
这两个技术都可以实现会话跟踪,它们之间最大的区别:Cookie 是存储在浏览器端,而 Session 是存储在服务器端。
具体的学习思路为:
- Cookie 的基本使用、原理、使用细节;
- Session 的基本使用、原理、使用细节;
- Cookie 和 Session 的综合案例。
本节小结只有两句话,但是这两句概括了整章的主线:
- HTTP 协议是无状态的,靠 HTTP 协议是无法实现会话跟踪;
- 想要实现会话跟踪,就需要用到 Cookie 和 Session。
Cookie 和 Session 具体该如何使用,接下来就先从 Cookie 学起。
7.2 Cookie
学习 Cookie,主要解决下面四个问题:
- 什么是 Cookie?
- Cookie 如何来使用?
- Cookie 是如何实现的?
- Cookie 的使用注意事项有哪些?
7.2.1 Cookie 的概念与工作流程
概念:Cookie 是客户端会话技术,将数据保存到客户端,以后每次请求都携带 Cookie 数据进行访问。
数据存在浏览器,由服务器写入,浏览器每次请求自动携带。
Cookie 的工作流程可以按七步走一遍。假设服务端提供了两个 Servlet,分别是 ServletA 和 ServletB:
- 浏览器发送 HTTP 请求 1 给服务端,服务端 ServletA 接收请求并进行业务处理;
- 服务端 ServletA 在处理的过程中可以创建一个 Cookie 对象并将
name=zs的数据存入 Cookie; - 服务端 ServletA 在响应数据的时候,会把 Cookie 对象响应给浏览器;
- 浏览器接收到响应数据,会把 Cookie 对象中的数据存储在浏览器内存中,此时浏览器和服务端就建立了一次会话;
- 在同一次会话中浏览器再次发送 HTTP 请求 2 给服务端 ServletB,浏览器会携带 Cookie 对象中的所有数据;
- ServletB 接收到请求和数据后,就可以获取到存储在 Cookie 对象中的数据;
- 这样同一个会话中的多次请求之间就实现了数据共享。
这七步里有两次"搬运"最容易忽略:第一次是服务器 → 浏览器(靠响应头),第二次是浏览器 → 服务器(靠请求头)。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 对象并存入数据,然后将数据发送给浏览器。实现步骤为:
- 创建 Maven 项目,项目名称为
cookie-demo,并在pom.xml添加依赖; - 编写 Servlet 类,名称为
AServlet; - 在
AServlet中创建 Cookie 对象,存入数据,发送给前端; - 启动测试,在浏览器查看 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 的值有两种方式:
- 方式一:打开地址栏左侧的站点信息面板,进入"Cookie"一项,能看到当前域名下已经存了一条
username=zs; - 方式二:选中"打开开发者工具",或者使用快捷键 F12 / Ctrl+Shift+I,切到 Application(应用)面板,左侧选 Storage → Cookies,右侧表格里会出现一条
username/zs的记录。
【易错点】只写了
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 对象中的数据。实现步骤为:
- 编写一个新 Servlet 类,名称为
BServlet; - 在
BServlet中使用request对象获取 Cookie 数组,遍历数组,从数据中获取指定名称对应的值; - 启动测试,在控制台打印出获取的值。
(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 的基本使用包含两部分:
- 发送 Cookie:创建 Cookie 对象并设置值
Cookie cookie = new Cookie("key","value");发送 Cookie 到客户端使用的是 Response 对象response.addCookie(cookie); - 获取 Cookie:使用 Request 对象获取 Cookie 数组
Cookie[] cookies = request.getCookies();遍历数组;获取数组中每个 Cookie 对象的值cookie.getName()和cookie.getValue()。
【易错点】
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 协议中的两个请求头信息:
- 响应头:
Set-Cookie - 请求头:
Cookie
按请求—响应的顺序展开:
- 前面的案例中已经能够实现 AServlet 给前端发送 Cookie,BServlet 从
request中获取 Cookie 的功能。对于 AServlet 响应数据的时候,Tomcat 服务器都是基于 HTTP 协议来响应数据; - 当 Tomcat 发现后端要返回的是一个 Cookie 对象之后,Tomcat 就会在响应头中添加一行数据
Set-Cookie: username=zs; - 浏览器获取到响应结果后,从响应头中就可以获取到
Set-Cookie对应的值username=zs,并将数据存储在浏览器的内存中; - 浏览器再次发送请求给 BServlet 的时候,浏览器会自动在请求头中添加
Cookie: username=zs,发送给服务端 BServlet; request对象会把请求头中 Cookie 对应的值封装成一个个 Cookie 对象,最终形成一个数组;- 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 如何存储中文。先看存活时间。
回到刚才那个问题,把过程再摆一遍:
- 浏览器发送请求给 AServlet,AServlet 会响应一个存有
username=zs的 Cookie 对象给浏览器; - 浏览器接收到响应数据,将 Cookie 存入到浏览器内存中;
- 当浏览器再次发送请求给 BServlet,BServlet 就可以使用
request对象获取到 Cookie 数据; - 在发送请求到 BServlet 之前,如果把浏览器关闭再打开进行访问,BServlet 能否获取到 Cookie 数据?
注意:浏览器关闭再打开不是指打开一个新的选项卡,而且必须是先关闭再打开,顺序不能变。(新开一个标签页共享的是同一份内存,所以不算"关闭重开"。)
针对上面这个问题,通过演示会发现,BServlet 中无法再获取到 Cookie 数据。这是为什么呢?
- 默认情况下,Cookie 存储在浏览器内存中,当浏览器关闭,内存释放,则 Cookie 被销毁。
这个结论就印证了上面的演示效果。但如果使用这种默认情况下的 Cookie,有些需求就无法实现,比如"记住我":登录页面上有一个"记住我"的功能,第一次输入用户名和密码并勾选"记住我"然后进行登录,下次再登录的时候,用户名和密码就会被自动填充,不需要再重新输入登录。假如"记住我"需要记住用户名和密码一个星期,那么使用默认情况下的 Cookie 就会出现问题:因为默认情况下浏览器一关,Cookie 就会从浏览器内存中删除,对于"记住我"功能就无法实现。
所以现在遇到的难题是:如何将 Cookie 持久化存储? Cookie 已经提供了对应的 API 来完成这件事,这个 API 就是 setMaxAge:
setMaxAge(int seconds)参数值有三种情况:
- 正数:将 Cookie 写入浏览器所在电脑的硬盘,持久化存储。到时间自动删除;
- 负数:默认值,Cookie 在当前浏览器内存中,当浏览器关闭,则 Cookie 被销毁;
- 零:删除对应 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:
- 访问一次 AServlet 后,把浏览器关闭重启,再去访问
http://localhost:8080/cookie-demo/bServlet,能在控制台打印出username:zs,说明 Cookie 没有随着浏览器关闭而被销毁; - 通过浏览器查看 Cookie 的内容,会发现 Cookie 的相关信息里多出了一个明确的过期时间(Expires / Max-Age),而不是会话级。
到这里,本节开头那个"关闭浏览器后能不能拿到"的问题就有了完整答案:默认(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 编码,所以如果需要存储中文,就需要进行转码。具体的实现思路为:
- 在 AServlet 中对中文进行 URL 编码,采用
URLEncoder.encode(),将编码后的值存入 Cookie 中; - 在 BServlet 中获取 Cookie 中的值,获取的值为 URL 编码后的值;
- 将获取的值再进行 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 两个属性。
setPath(String path):指定浏览器在访问哪个路径时才携带该 Cookie。默认值是当前写入该 Cookie 的那个请求的 URI 去掉最后一段。setDomain(String domain):指定该 Cookie 归属哪个域名,一般用于一级域名相同、二级域名不同的场景(如item.jd.com与search.jd.com)。
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)经常被混为一谈:
维度 Cookie localStorage sessionStorage 是否随请求自动发送 是,同域请求自动带 否,要 JS 手动取 否,要 JS 手动取 大小 约 4KB / 条 约 5MB 约 5MB 生命周期 由 setMaxAge决定永久,除非手动删 关标签页即清 服务端可读 可以(请求头) 不可以 不可以 关键差别在第一行:只有 Cookie 会被浏览器自动带给服务器,这也是它能做会话跟踪的根本原因。
【考点】 问:为什么不能用 Cookie 存密码?答:Cookie 存在用户端、明文可读可改,一旦被 XSS 或抓包获取就直接泄露。理由:Cookie 的传输在 HTTPS 下才加密,落地时是明文文件,且同域脚本可读(除非加 HttpOnly)。
7.3 Session
Cookie 已经能完成一次会话多次请求之间的数据共享,之前还提到过 Session 也可以实现,那么:
- 什么是 Session?
- Session 如何来使用?
- Session 是如何实现的?
- Session 的使用注意事项有哪些?
7.3.1 Session 的概念与工作流程
概念:Session 是服务端会话跟踪技术,将数据保存到服务端。
- Session 是存储在服务端而 Cookie 是存储在客户端;
- 存储在客户端的数据容易被窃取和截获,存在很多不安全的因素;
- 存储在服务端的数据相比于客户端来说就更安全。
Session 的工作流程只有三句话:
- 在服务端的 AServlet 获取一个 Session 对象,把数据存入其中;
- 在服务端的 BServlet 获取到相同的 Session 对象,从中取出数据;
- 就可以实现一次会话中多次请求之间的数据共享了。
现在最大的问题是:如何保证 AServlet 和 BServlet 使用的是同一个 Session 对象? 这个问题在 7.3.3 的原理分析里回答。
7.3.2 获取 Session 与常用方法
在 Java EE 中提供了 HttpSession 接口,来实现一次会话的多次请求之间数据共享功能。具体的使用步骤为:
第一步,获取 Session 对象,使用的是 request 对象:
HttpSession session = request.getSession();第二步,使用 Session 对象提供的功能:
- 存储数据到 session 域中:
void setAttribute(String name, Object o) - 根据 key,获取值:
Object getAttribute(String name) - 根据 key,删除该键值对:
void removeAttribute(String name)
介绍完 Session 相关的 API 后,接下来通过一个案例来完成对 Session 的使用。需求:在一个 Servlet 中往 Session 中存入数据,在另一个 Servlet 中获取 Session 中存入的数据。实现步骤为:
- 创建名为
SessionDemo1的 Servlet 类; - 创建名为
SessionDemo2的 Servlet 类; - 在
SessionDemo1的方法中:获取 Session 对象、存储数据; - 在
SessionDemo2的方法中:获取 Session 对象、获取数据; - 启动测试。
(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 的获取:
HttpSession session = request.getSession(); - Session 常用方法的使用:
void setAttribute(String name, Object o)、Object getAttribute(String name)
注意: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通过打印可以得到如下结论:
- 两个 Servlet 类中获取的 Session 对象是同一个;
- 把 demo1 和 demo2 请求刷新多次,控制台最终打印的结果都是同一个。
那么问题又来了:如果新开一个浏览器,访问 demo1 或者 demo2,打印在控制台的 Session 还是同一个对象么?
注意:在一台电脑上演示的时候,如果是相同的浏览器必须要把浏览器全部关掉重新打开,才算新开的一个浏览器;当然也可以使用不同的浏览器进行测试,就不需要把之前的浏览器全部关闭。测试的结果:如果是不同浏览器或者重新打开浏览器后,打印的 Session 就不一样了。
所以 Session 实现的也是一次会话中的多次请求之间的数据共享。那么最主要的问题就来了,Session 是如何保证在一次会话中获取的 Session 对象是同一个呢?
- demo1 在第一次获取 Session 对象的时候,Session 对象会有一个唯一的标识,假如是
id:10; - demo1 在 Session 中存入其他数据并处理完成所有业务后,需要通过 Tomcat 服务器响应结果给浏览器;
- Tomcat 服务器发现业务处理中使用了 Session 对象,就会把 Session 的唯一标识
id:10当做一个 Cookie,添加Set-Cookie: JSESSIONID=10到响应头中,并响应给浏览器; - 浏览器接收到响应结果后,会把响应头中的 Cookie 数据存储到浏览器的内存中;
- 浏览器在同一会话中访问 demo2 的时候,会把 Cookie 中的数据按照
Cookie: JSESSIONID=10的格式添加到请求头中并发送给服务器 Tomcat; - demo2 获取到请求后,从请求头中就读取 Cookie 中的
JSESSIONID值为 10,然后就会到服务器内存中寻找id:10的 Session 对象,如果找到了,就直接返回该对象,如果没有则新创建一个 Session 对象; - 关闭打开浏览器后,因为浏览器的 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 中的数据是否还在?
要想回答这个问题,可以先看这样一个推理:
- 服务器端 AServlet 和 BServlet 共用的 Session 对象应该是存储在服务器的内存中;
- 服务器重新启动后,内存中的数据应该是已经被释放,对象也应该都销毁了。
所以 Session 数据应该也已经不存在了。但是如果 Session 不存在会引发什么问题呢?举个例子:
- 用户把需要购买的商品添加到购物车,因为要实现同一个会话多次请求数据共享,所以假设把数据存入 Session 对象中;
- 用户正要付钱的时候接到一个电话,付钱的动作就搁浅了;
- 正在用户打电话的时候,购物网站因为某些原因需要重启;
- 重启后 Session 数据被销毁,购物车中的商品信息也就会随之而消失;
- 用户想再次发起支付,就会出问题。
所以说对于 Session 的数据,应该做到就算服务器重启了,也应该能把数据保存下来才对。
那么 Tomcat 服务器在重启的时候,Session 数据到底会不会保存以及是如何保存的?可以通过实际案例来演示。
注意:这里所说的关闭和启动应该要确保是正常的关闭和启动。那如何才是正常关闭 Tomcat 服务器呢?需要使用命令行的方式来启动和停止 Tomcat 服务器:
启动:进入到项目 pom.xml 所在目录,执行 tomcat7:run
停止:在启动的命令行界面,输入 ctrl+c有了上述两个正常启动和关闭的方式后,接下来的测试流程是:
- 先启动 Tomcat 服务器;
- 访问
http://localhost:8080/cookie-demo/demo1将数据存入 Session 中; - 正确停止 Tomcat 服务器;
- 再次重新启动 Tomcat 服务器;
- 访问
http://localhost:8080/cookie-demo/demo2查看是否能获取到 Session 中的数据。
经过测试,会发现只要服务器是正常关闭和启动,Session 中的数据是可以被保存下来的。那么 Tomcat 服务器到底是如何做到的呢?具体的原因就是 Session 的钝化和活化:
- 钝化:在服务器正常关闭后,Tomcat 会自动将 Session 数据写入硬盘的文件中。钝化的数据路径为:
项目目录\target\tomcat\work\Tomcat\localhost\项目名称\SESSIONS.ser; - 活化:再次启动服务器后,从文件中加载数据到 Session 中。数据加载到 Session 中后,路径中的
SESSIONS.ser文件会被删除掉。
对于上述的整个过程,只需要了解即可,因为所有的过程都是 Tomcat 自己完成的,不需要开发者参与。
本节小结,Session 的钝化和活化介绍完后,需要特别注意的是:
- Session 数据存储在服务端,服务器重启后,Session 数据会被保存;
- 浏览器被关闭启动后,重新建立的连接就已经是一个全新的会话,获取的 Session 数据也是一个新的对象;
- Session 的数据要想共享,浏览器不能关闭,所以 Session 数据不能长期保存数据;
- Cookie 是存储在客户端,是可以长期保存。
【补充】钝化有一个隐含前提: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 区别对比表
| 维度 | Cookie | Session |
|---|---|---|
| 存储位置 | 数据存储在客户端(浏览器) | 数据存储在服务端(服务器内存) |
| 安全性 | 不安全(用户可读可改) | 安全(用户只拿到一个 id) |
| 数据大小 | 最大约 3KB(单个 Cookie) | 无大小限制(受服务器内存约束) |
| 存储时间 | 可通过 setMaxAge() 长期存储 | 默认 30 分钟(超时即销毁) |
| 服务器性能 | 不占服务器资源 | 占用服务器资源 |
| 存储类型 | 只能是字符串 | 任意 Object |
| 依赖关系 | 独立使用 | 基于 Cookie 传递 JSESSIONID |
| 跨域 | 受同源策略严格限制 | 依托 Cookie,同样受限 |
这张表要能默写。记忆的口诀是"位置定安全,大小定性能":存在客户端所以不安全、所以小;存在服务端所以安全、所以大,但也因此吃服务器内存。
7.4.2 应用场景
- 购物车:使用 Cookie 来存储;
- 以登录用户的名称展示:使用 Session 来存储;
- 记住我功能:使用 Cookie 来存储;
- 验证码:使用 Session 来存储。
结论:
- Cookie 是用来保证用户在未登录情况下的身份识别;
- Session 是用来保存用户登录后的数据。
这两句话是本章的"题眼"。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 需求分析
需求说明:
- 完成用户登录功能,如果用户勾选"记住用户",则下次访问登录页面自动填充用户名密码;
- 完成注册功能,并实现验证码功能。
这个案例是本章所有知识点的落地面:登录状态保持用 Session,记住我用 Cookie,验证码用 Session。它同时还是一个标准的三层结构(Web → Service → Dao),第10章 JDBC 会把它换成对数据库的访问。
7.5.2 用户登录功能
1. 需求
- 用户登录成功后,跳转到列表页面,并在页面上展示当前登录的用户名称;
- 用户登录失败后,跳转回登录页面,并在页面上展示对应的错误信息。
2. 实现流程分析
- 前端通过表单发送请求和数据给 Web 层的
LoginServlet; - 在
LoginServlet中接收请求和数据[用户名和密码]; LoginServlet接收到请求和数据后,调用 Service 层完成根据用户名和密码查询用户对象;- 在 Service 层需要编写
UserService类,在类中实现login方法,方法中调用 Dao 层的UserMapper; - 在
UserMapper接口中,声明一个根据用户名和密码查询用户信息的方法; - Dao 层把数据查询出来以后,将返回数据封装到
User对象,将对象交给 Service 层; - Service 层将数据返回给 Web 层;
- 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">
<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)启动,访问测试
先进入登录页面,输入错误的用户名或密码,会被转发回登录页并显示"用户名或密码错误";再输入正确的用户名和密码,会重定向到列表页,页面上显示当前登录用户的名字。
小结
- 在
LoginServlet中,将登录成功的用户数据存入 Session 中,方便在列表页面中获取当前登录用户信息进行展示; - 在
LoginServlet中,将登录失败的错误信息存入到request中,如果存入到 Session 中就会出现这次会话的所有请求都有登录失败的错误信息,这个是不需要的,所以不用存入到 Session 中。
【对比】登录成功用
sendRedirect,登录失败用forward,为什么不一样?
场景 方式 原因 登录成功 → 列表页 重定向(两次请求) 地址栏变干净,且刷新列表页不会重复提交表单 登录失败 → 登录页 转发(一次请求) 要带着 request域里的login_msg一起过去如果登录失败也用重定向,
request.setAttribute("login_msg", ...)就带不过去了,页面上的错误提示永远是空的。这正是第6章"转发能带 request 域、重定向不能"的直接应用。【考点】 问:登录成功后为什么要把
user放进 Session 而不是 request?答:request 域只活一次请求,重定向到列表页就是新请求,域里什么都没有;Session 活整次会话,后续所有请求都能取到。理由:跨请求共享数据必须用会话级作用域。
7.5.3 记住我——设置 Cookie
1. 需求
如果用户勾选"记住用户",则下次访问登录页面自动填充用户名密码。这样可以提升用户的体验。
对应上面这个需求,最大的问题是:如何自动填充用户名和密码?
2. 实现流程分析
因为"记住我"功能要实现的效果是,就算用户把浏览器关闭过几天再来访问也能自动填充,所以需要将登录信息存入一个可以长久保存、并且能够在浏览器关闭重新启动后依然有效的地方,就是前面讲的 Cookie。所以:
- 将用户名和密码写入 Cookie 中,并且持久化存储 Cookie,下次访问浏览器会自动携带 Cookie;
- 在页面获取 Cookie 数据后,设置到用户名和密码框中;
- 何时写入 Cookie?用户必须登录成功后才需要写;用户必须在登录页面勾选了"记住我"的复选框。
具体步骤:
- 前端需要在发送请求和数据的时候,多携带一个用户是否勾选 Remember 的数据;
LoginServlet获取到数据后,调用 Service 完成用户名和密码的判定;- 登录成功,并且用户在前端勾选了"记住我",需要往 Cookie 中写入用户名和密码的数据,并设置 Cookie 存活时间;
- 设置成功后,将数据响应给前端。
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">
<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 中的键名称。
- 在 login.jsp 用户名的表单输入框使用
value值给表单元素添加默认值,value可以使用${cookie.username.value}; - 在 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">
<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. 实现流程分析
- 前端通过表单发送请求和数据给 Web 层的
RegisterServlet; - 在
RegisterServlet中接收请求和数据[用户名和密码]; RegisterServlet接收到请求和数据后,调用 Service 层完成用户信息的保存;- 在 Service 层需要编写
UserService类,在类中实现register方法,需要判断用户是否已经存在,如果不存在,则完成用户数据的保存; - 在
UserMapper接口中,声明两个方法,一个是根据用户名查询用户信息方法,另一个是保存用户信息方法; - 在
UserService类中保存成功则返回true,失败则返回false,将数据返回给 Web 层; - 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);
}生成完验证码以后,就可以知晓:
- 验证码就是使用 Java 代码生成的一张图片;
- 验证码的作用:防止机器自动注册,攻击服务器。
2. 实现流程分析
- 前端发送请求给
CheckCodeServlet; CheckCodeServlet接收到请求后,生成验证码图片,将图片用 Response 对象的输出流写回到前端。
思考:如何将图片写回到前端浏览器呢?
- Java 中已经有工具类生成验证码图片,测试类中只是把图片生成到磁盘上;
- 生成磁盘的过程中使用的是
OutputStream流,如何把这个图片生成在页面呢? - 前面在讲 Response 对象的时候,它有一个方法可以获取其字节输出流,
getOutputStream(); - 把写往磁盘的流对象更换成 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 中呢?
- 生成验证码和校验验证码是两次请求,此处就需要在一个会话的两次请求之间共享数据;
- 验证码属于安全数据类的,所以我们选中 Session 来存储验证码数据。
2. 实现流程分析
- 在
CheckCodeServlet中生成验证码的时候,将验证码数据存入 Session 对象; - 前端将验证码和注册数据提交到后台,交给
RegisterServlet类; RegisterServlet类接收到请求和数据后,其中就有验证码,和 Session 中的验证码进行对比;- 如果一致,则完成注册;如果不一致,则提示错误信息。
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 路径域 | 决定哪些请求会携带该 Cookie | setPath / setDomain |
| Cookie 销毁 | 同名 + 同 path + setMaxAge(0) + 真的发送 | setMaxAge(0) + addCookie |
| Session 概念 | 服务端会话技术,数据存服务器,靠 JSESSIONID 认人 | request.getSession() |
| Session 方法 | 存、取、删,值可以是任意 Object | setAttribute / getAttribute / removeAttribute |
| Session 原理 | 基于 Cookie 传递 JSESSIONID | 响应头 Set-Cookie、请求头 Cookie |
| 禁用 Cookie | 用 URL 重写把 id 拼到地址上 | response.encodeURL() |
| 钝化与活化 | 正常关闭时 Session 落盘,重启后加载回来 | SESSIONS.ser |
| Session 销毁 | 超时(默认 30 分钟)、invalidate()、服务器非正常关闭 | invalidate()、<session-timeout> |
| 分布式共享 | 复制、粘性、集中存储三种思路 | Redis + 统一会话 |
七条必须记住的结论:
- Cookie 存客户端、Session 存服务端,这是两者一切差异的源头。
- Session 的底层是 Cookie,浏览器手里只有
JSESSIONID,数据一直在服务器内存里。 request域活一次请求,Session 域活整次会话,跨请求共享只能靠后者。- 会话 Cookie 关浏览器就没,持久 Cookie 靠
setMaxAge正数活下来,"记住我"必须用后者。 - Cookie 不能存中文,要
URLEncoder/URLDecoder配对使用。 - Cookie 与 Session 该存什么由"会不会被用户改"决定:敏感的、可信的放 Session,需要跨浏览器存活的放 Cookie。
- 本章的知识在下两章立刻被用到:第8章 JSP 用
${user.username}从 Session 取登录用户、用${cookie.username.value}取记住我的值;第9章 Filter 用session.getAttribute("user") == null判断未登录并拦截。本章负责"存进去",第8章负责"显示出来",第9章负责"不许随便进"。
自测
基础题
- 用自己的话说明"会话"与"会话跟踪"的区别,并解释 HTTP 为什么自己做不了会话跟踪。
- 写出 Cookie 的发送与获取各两行核心代码,并说明它们分别用的是
request还是response。 setMaxAge的参数为正数、负数、零时,分别是什么效果?- Session 的
getSession()与getSession(false)有什么区别?分别在什么场景下用? - 列出 Session 被销毁的几种情况,并说明默认超时时间是多少、在哪里改。
进阶题
- 看代码写结果:下面的 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());
}
}
}- 找错:下面的"退出登录"代码有什么问题?
@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");
}
}- 简答:为什么验证码必须存 Session?如果存到 request 域或 Cookie 里分别会出什么问题?
- 简答:网站做了集群(3 台 Tomcat),用户登录后总是过一会儿就掉线,请分析原因并给出三种解决思路。
- 设计题:一个购物网站要求"未登录也能加购物车,登录后购物车内容合并到账号下",请说明 Cookie 和 Session 各自承担什么职责。
参考答案
- 会话是浏览器与服务器之间一次持续的交互过程,可包含多次请求响应;会话跟踪是服务器识别多次请求是否来自同一浏览器的过程,目的是在同一次会话的多次请求间共享数据。HTTP 是无状态的,每次请求都被当作全新请求,请求之间相互独立,所以协议本身无法共享数据。
- 发送:
Cookie cookie = new Cookie("key","value"); response.addCookie(cookie);(用 response)。获取:Cookie[] cookies = request.getCookies();再遍历cookie.getName()/cookie.getValue()(用 request)。- 正数:写入硬盘持久化,到期自动删除;负数(默认):存在浏览器内存,关闭浏览器销毁;零:删除该 Cookie。
getSession()等价于getSession(true),不存在就新建;getSession(false)不存在返回null。登录、存数据时用前者;判断"是否已登录"、退出登录时用后者,避免无谓地创建 Session。- 三种:超时(默认 30 分钟无操作)、调用
invalidate()、服务器非正常关闭(正常关闭走钝化,数据会保留)。超时时间在项目web.xml的<session-config><session-timeout>中改,单位分钟;也可用session.setMaxInactiveInterval(秒)。- 输出
username=zs(可能还多一行JSESSIONID=...,因为访问过 Servlet 且用了 Session 才会有;本例未用 Session,故只有username=zs)。关键点:setMaxAge为正数使 Cookie 持久化,且两次请求同域同应用,浏览器会自动携带。- 三处问题:①
invalidate()之后再调用session.getAttribute会抛IllegalStateException,取数据的代码必须删掉或移到销毁之前;② 应用getSession(false)并在为null时跳过销毁,避免给未登录用户白建一个 Session;③ 重定向地址最好用request.getContextPath()拼,避免相对路径在多层 URL 下解析错误。- 验证码的生成与校验是两次独立请求,
request域在第一次请求结束即销毁,第二次取不到(拿到null,还会引发 NPE);存 Cookie 则把答案暴露给客户端,用户可篡改 Cookie 使任意输入都通过校验,失去防机器注册的意义。Session 跨请求且在服务端,两个条件都满足。- 原因:Session 默认存在各自 Tomcat 的内存里,负载均衡把后续请求分到了另一台机器,那台机器没有对应的
JSESSIONID,于是判定未登录。三种思路:Session 复制(集群间同步)、粘性会话(按 id 或 IP 哈希固定到一台)、集中式存储(Session 放 Redis,所有节点共享,最优)。- 未登录时用 Cookie 存购物车(或购物车 id),因为此时没有登录身份可依托,且要跨浏览器会话保留;登录后用 Session(或服务端存储)保存购物车,因为要可信、要跨设备。合并时机是登录成功那一刻:读取 Cookie 中的购物车数据,与账号下的购物车合并后写入服务端,再清掉 Cookie 中的临时数据。