JavaWeb 学习笔记 6:会话跟踪
HTTP 协议本身是无状态的,所以不能跟踪会话状态。所以会有额外的技术用于跟踪会话:
- Cookie,客户端技术
- Session,服务端技术
1.Cookie
1.1.写入 Cookie
可以在服务端通过HttpServletResponse.addCookie
向浏览器写入 Cookie:
@WebServlet("/a")
public class ControllerA extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 向浏览器添加 cookie
Cookie cookie = new Cookie("username", "icexmoon");
Cookie cookie1 = new Cookie("msg", "hello");
resp.addCookie(cookie);
resp.addCookie(cookie1);
}
}
请求 http://localhost:8080/session-demo/a 能看到响应报文头:
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Set-Cookie: username=icexmoon
Set-Cookie: msg=hello
Content-Length: 0
Date: Mon, 11 Sep 2023 09:39:41 GMT
使用开发者工具可以看到浏览器端 Cookie 已添加:
应当注意到,服务端添加的 Cookie 默认的存活时间(Expire / Max age)默认是会话,即会话结束(关闭浏览器)后 Cookie 就会被销毁。此时 Cookie 仅保存在内存中,并不会被持久化保存(保存到硬盘)。
使用Cookie.setMaxAge
可以设置 Cookie 的生存时间(单位:秒):
// 向浏览器添加 cookie
Cookie cookie = new Cookie("username", "icexmoon");
Cookie cookie1 = new Cookie("msg", "hello");
// 设置有效时间为 1 天
cookie.setMaxAge(1 * 24 * 60 * 60);
resp.addCookie(cookie);
resp.addCookie(cookie1);
响应报文:
Set-Cookie: username=icexmoon; Expires=Tue, 12-Sep-2023 09:54:25 GMT
Set-Cookie: msg=hello
响应报文中的 Cookie 有效期是直接以截至时间的方式返回的:
Expires=Tue, 12-Sep-2023 09:54:25 GMT
这是格林尼治时间(GMT),换算成中国时间(东八区)要+8小时。
用开发者工具查看就能看到有效期已经改变:
有效期可以设置为以下几种:
- 正数,在X秒后过期
- 0,立即过期(删除)
- 负数,会话有效期,在会话结束(浏览器退出)后过期
1.2.读取 Cookie
使用HttpServletRequest.getCookies
可以读取浏览器传递的 Cookie:
@WebServlet("/b")
public class ControllerB extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Cookie[] cookies = req.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("username")) {
String username = cookie.getValue();
System.out.println("username: " + username);
break;
}
}
}
}
请求 http://localhost:8080/session-demo/b 就能看到服务端输出的 Cookie 内容。
查看请求报文:
GET /session-demo/b HTTP/1.1
Cookie: JSESSIONID=20C2014C72F0D7ED4D34B821B9A0BC89; username=icexmoon; msg=hello; sentinel_dashboard_cookie=69C1AF3B99482E641CDD23041937F691; JSESSIONID=6EEEF7C596140410E7A21F9DAECF4525
...
当前域名下的所有 Cookie 都以Cookie: xxx=xxx; xxx=xxx
这样的请求头传递。
1.3.中文 Cookie
HTTP 协议规定,报文头内容只能是 ASCII 字符集的字符,所以如果尝试写入中文的 Cookie 信息(UTF-8 字符集)就会报错:
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Cookie cookie = new Cookie("username", "魔芋红茶");
response.addCookie(cookie);
}
错误信息:
java.lang.IllegalArgumentException: Control character in cookie value or attribute.
所以要将 UTF-8 字符串转换为全部由 ASCII 字符组成的字符串才能作为 Cookie 内容传递。有多种编码可以实现这一点,最常用的有 URL 编码和 Base64 编码。
这里用 URL 编码举例说明:
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = "魔芋红茶";
username = URLEncoder.encode(username, StandardCharsets.UTF_8.name());
Cookie cookie = new Cookie("username", username);
response.addCookie(cookie);
}
响应报文中的信息:
Set-Cookie: username=%E9%AD%94%E8%8A%8B%E7%BA%A2%E8%8C%B6
自然的,在服务端接收到的 Cookie 也是 URL 编码过的,所以需要解码:
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = ServletUtil.getCookie(request, "username");
if (username!=null){
username = URLDecoder.decode(username, StandardCharsets.UTF_8.name());
}
System.out.println(username);
}
2.Session
Session 同样可以用于跟踪会话,并保存会话的状态信息,与 Cookie 不同的是,Session 是服务端技术,保存在服务端。
2.1.写入 Session
@WebServlet("/e")
public class ControllerE extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpSession session = request.getSession();
session.setAttribute("msg", "Hello World!");
}
}
2.2.读取 Session
@WebServlet("/f")
public class ControllerF extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
HttpSession session = request.getSession();
String msg = (String) session.getAttribute("msg");
System.out.println("Msg in session: " + msg);
}
}
2.3.实现原理
Session 是基于 Cookie 实现的,浏览器端持有的是作为 Cookie 存储的 SessionID,服务端为每个 SessionID 保存对应的 Session 对象,并且可以用浏览器端用 Cookie 方式传递的 SessionID 获取到对应的 Session 对象。
整个过程可以表示为:
根据 Session 的实现原理,Session 的有效期也包含两部分:
- 浏览器端 SessionID 的有效期
- 服务器端的 Session 对象的有效期
两者任意一个失效 Session 就不可用了。
浏览器端的 SessionID 的有效期是会话,即关闭浏览器后就失效:
服务器端的 Session 对象由 Web 服务器软件的设置决定,对于 Tomcat,默认的设置为 30 分钟后被清理。需要说明的是,每次有当前会话的请求产生,对应的 Session 对象的过期时间就会刷新,即 +30 分钟。也就是说只要一直有请求,Session 就不会过期,但是如果有超过 30 分钟没有请求,那 Session 对象就会过期被删除。
之所以为 Session 对象设置有效期,是因为 Session 需要占用服务端内存资源。因此尽量不要为 Session 设置过长的有效期。
Tomcat 的默认设置在 /conf/web.xml 中:
<session-config>
<session-timeout>30</session-timeout>
</session-config>
可以通过修改 Web 应用的 web.xml 覆盖 Tomcat 的默认设置:
<web-app>
<display-name>Archetype Created Web Application</display-name>
<session-config>
<session-timeout>1</session-timeout>
</session-config>
</web-app>
这样就可以将 Session 对象的过期时间修改为 1 分钟,1 分钟后再请求就会发现对应的 Session 对象已经获取不到了。
也可以用 HttpSession.invalidate
方法主动让某个 Session 对象过期。
2.4.Session 的持久化
Session 对象是保存在内存中的,这意味着服务器重启后之前的 Session 对象将不存在。对此,Tomcat 可以在正常退出时将内存中的 Session 序列化后保存在硬盘上,再次启动后从硬盘加载 Session 对象到内存。
非常正常退出,比如关闭线程或者服务器电源关闭等无法持久化保存 Session。
下面用一个简单测试进行验证。
使用命令行mvn tomcat7:run
启动 Web 项目。
请求 xxx/e
后再请求xxx/f
,可以看到 session 已经生成,并且可以读取。
在命令行中按Ctrl+C
结束 Tomcat。
注意把 Session 有效期改回 30 秒,并去除相关主动销毁 Session 的代码。
此时会在 Tomcat 下的 localhost/session-demo/org
目录下出现一个序列化文件SESSIONS.ser
:
重新启动 Tomcat 后,如果需要使用 Session,Tomcat 会将之前的 Session 对象从序列化文件加载,并删除该序列化文件,因此可以访问之前的 Session。
2.5.Session 和 Cookie 的区别
- 存储位置:Cookie 是将数据存储在客户端,Session 将数据存储在服务端
- 安全性:Cookie不安全,Session安全
- 数据大小:Cookie最大3KB,Session无大小限制
- 存储时间:Cookie可以通过setMaxAge()长期存储,Session默认30分钟
- 服务器性能:Cookie不占服务器资源,Session占用服务器资源
3.案例:登录注册
登录和验证的实现都比较简单,这里只说明一下验证码的实现。
这里使用一个工具类 CheckCodeUtil 实现验证码的生成:
public class CheckCodeUtil {
/**
* 输出随机验证码图片流,并返回验证码值(一般传入输出流,响应response页面端,Web项目用的较多)
*
* @param w 宽
* @param h 高
* @param os 输出流
* @param verifySize 验证码位数
* @return 生成的验证码(字符串)
* @throws IOException
*/
public static String outputVerifyImage(int w, int h, OutputStream os, int verifySize) throws IOException {
String verifyCode = generateVerifyCode(verifySize);
outputImage(w, h, os, verifyCode);
return verifyCode;
}
// ...
}
利用这个工具类生成验证码,并将生成的验证码图片写入响应报文的输出流:
@WebServlet("/user/check_code")
public class CheckCodeController extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
OutputStream os = response.getOutputStream();
String checkCode = CheckCodeUtil.outputVerifyImage(100, 50, os, 4);
request.getSession().setAttribute("checkCode", checkCode);
}
// ...
}
用于验证的字符串形式的验证码要保存到 Session,以便在收到注册请求时进行验证。
注册页面用于显示验证码的图片设置src
:
<tr>
<td>验证码</td>
<td class="inputs">
<input name="checkCode" type="text" id="checkCode">
<img id="checkCodeImg" src="/login-demo/user/check_code">
<a href="#" id="changeImg" onclick="refreshCheckCode()">看不清?</a>
</td>
</tr>
现在页面加载时就能显示验证码。为了能点击 看不清 链接时能刷新,需要实现一个替换图片 src
的 js 方法:
// 刷新验证码
function refreshCheckCode(){
$("img#checkCodeImg").attr("src","/login-demo/user/check_code");
}
要注意的是,此时只有在开发者工具选择禁用缓存的情况下才能正常刷新验证码,缓存生效时是不会有效果的,因为验证码图片会被缓存起来,浏览器会直接使用缓存,不会再次请求。
这就需要让生成验证码图片的 Servlet 返回的响应报文中禁用缓存:
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 浏览器不能缓存验证码 Cache-Control: no-cache
response.setHeader("Cache-Control", "no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate");
// ...
}
现在就没有类似的问题了。
在客户端发起注册请求时检查验证码:
// 检查验证码是否正确
String checkCode = (String) request.getSession().getAttribute("checkCode");
String inputCheckCode = request.getParameter("checkCode");
if (checkCode == null || inputCheckCode == null){
throw new RuntimeException("请先输入验证码");
}
if (!checkCode.equalsIgnoreCase(inputCheckCode)){
System.out.println(checkCode);
System.out.println(inputCheckCode);
throw new RuntimeException("验证码不正确");
}
The End,谢谢阅读。
本文的完整示例可以从这里获取。
4.参考资料
- HTTP 缓存 - HTTP | MDN (mozilla.org)
- 黑马程序员JavaWeb基础教程