Java Web 高级编程 - 第五章 使用会话维持状态
本章内容
在Web会话的理论中,会话是由服务器或Web应用程序管理的某些文件、内存片段、对象或者容器,它包含了分配给它的各种不同数据。
通常会话被赋予一个随机生成的字符串,称为会话ID。
第一次创建会话时(即收到请求时),创建的会话ID将会作为响应的一部分返回到用户浏览器中。接下来从该用户浏览器中发出的请求都将通过某种方式包含该会话ID。当应用程序收到含有会话ID的请求时,它可以通过该ID将现有会话与当前请求关联起来。

了解会话cookie
cookie是一种必要的通信机制,可以通过Set-Cookie响应头在服务器和浏览器之间传递任意的数据,并存储在用户计算机中,然后再通过请求头Cookie从浏览器返回到服务器中。
https://en.wikipedia.org/wiki/HTTP_cookie#Setting_a_cookie
Request 1
GET /support HTTP/1.1
Host: www.example.com
Response 1
HTTP/1.1 302 Moved Temporarily
Location: https://www.example.com/support/login
Set-Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn; Domain=.example.com; Path=/; HttpOnly
Request 2
GET /support/login HTTP/1.1
Host: www.example.com
Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn
Response 2
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 21765
Request 3
POST /support/login HTTP/1.1
Host: www.example.com
Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn
Response 3
HTTP/1.1 302 Moved Temporarily
Location: http://www.example.com/support/home
Set-Cookie: remusername=Nick; Expires=Wed, 02-Jun-2021 12:15:47 GMT;
Domain=.example.com; Path=/; HttpOnly
Request 4
GET /support/home HTTP/1.1
Host: www.example.com
Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn; remusername=Nick
Response 4
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 56823
使用cookie传输会话ID的问题是:用户可以在浏览器中禁止对cookie的支持,这样将完全禁止这种传输会话ID的方式。
URL中的会话ID
另一种传输会话ID的流行方式是通过URL。
Web或应用服务器知道如何超找URL中包含了会话ID的特定模式,如果找到了,就从URL中获得会话。
不同的技术对如何在URL中内嵌和定位会话ID使用不同的策略。
Java EE应用程序的会话ID被添加到URL的最后一个路径段的矩阵参数中。通过这种方式分离开会话ID和查询字符串的参数,使它们不会互相冲突。
http://www.example.com/support;JSESSIONID=NRxclGg2vG7kI4MdlLn?foo=bar&high=five
将会话ID内嵌在URL中,可以避免使用cookie。不过,你可能会好奇第一次如何将请求URL中的会话ID发送到浏览器。请求URL只在将会话ID从浏览器发送到服务器时有效。所以会话ID是如何产生的呢?答案是必须将会话ID内嵌在应用程序返回的所有URL中,包含页面的链接、表单操作以及302重定向。
Request 1
GET /support HTTP/1.1
Host: www.example.com
Response 1
HTTP/1.1 302 Moved Temporarily
Location: https://www.example.com/support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn
Request 2
GET /support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
Host: www.example.com
Response 2
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 21796
...
<form action="http://www.example.com/support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn"
method="post">
...
Request 3
POST /support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
Host: www.example.com
Response 3
HTTP/1.1 302 Moved Temporarily
Location: http://www.example.com/support/home;JSESSIONID=NRxclGg2vG7kI4MdlLn
Request 4
GET /support/home;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
Host: www.example.com
Response 4
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 56854
...
<a href="http://www.example.com/support/somewhere;JSESSIONID=NRxclGg2vG7kI4MdlLn">
...
HttpServletResponse接口定义了两个可以重写URL的方法encodeUrl;encodeRedirectURL,它们会在必要时把会话ID内嵌在URL中。
任何在链接、表单操作或其他标签中的URL都会被传入到encodeUrl方法中,然后该方法将返回一个正确的、经过编码处理的URL。
任何传入sendRedirect响应方法中的URL也可以传入encodeRedirectURL方法中,该方法将返回一个正确的、经过编码处理的URL。
会话的漏洞
1.复制并粘贴错误
影响应用程序的最简单方式之一就是:不知情的用户将浏览器中的URL复制粘贴到了邮件、论坛、聊天室或其他公共区域中。在URL中内嵌会话的方式正式引起此类问题的根源。还记得在客户端和服务器端反复发送的URL吗?这些URL、会话ID和所有的信息都显示在客户端浏览器的地址栏中。如果用户决定要跟朋友分享应用程序中的某个页面,并将地址栏中的URL复制粘贴出来,那么他的朋友将可以看到URL中包含的会话ID。如果他们在该会话终止之前访问该URL,那么他们也会被当成之前分享URL的用户。这明显会引起问题,因为该用户的朋友可能会不小心看到他的个人信息。
更危险的问题是如果恶意用户发现了该链接,可以使用劫持用户的会话。他将可以修改账户邮件地址、获取密码重置链接并最终修改密码-获得用户账户的所有权限。
产生问题的原因是无意操作-用户复制粘贴地址栏中的URL-解决此问题的唯一正确方法就是完全禁止在URL中内嵌会话ID。尽管这听起来像是一个严厉的错误,并且有可能对应用程序的可用性产生灾难性的影响,但事实上之前提到过,许多重要的网络公司都要求用户在访问他们的网站时使用cookie,因此cookie已经成为事实上的通用解决方案。cookie在Web用户中应用十分广泛,比起在URL中内嵌会话ID引起的问题,cookie固有的风险并不那么常见,危险性也较小。
2.会话固定
会话固定攻击类似于复制粘贴错误,不过其中的不知情用户变成了攻击者,而被攻击的用户则使用了含有会话的URL。攻击者可能会首先找到一些允许在URL中内嵌会话ID的网站。攻击者将通过这种方式获得一个会话ID,然后将含有会话ID的URL发送给目标用户。此时,当用户点击链接进入网站是,它的会话ID就变成了URL中含有的固定ID-攻击者已经持有该ID。如果用户接着在该回话期间登录网站,那么攻击者也可以登陆成功,因为这个会话ID是他分享的,因此他也可以访问用户的账户。
有两种方式可以解决这个问题:
- 如同复制粘贴错误一样,可以通过禁止在URL中内嵌ID的方式避免,同时也需要在应用程序中禁止接受通过URL传递的会话ID。
- 在登录后采用会话迁移。当用户登录时,修改会话ID或者将会话详细信息复制到新的会话中,并使之前的会话无效。
3.跨站脚本和会话劫持
之前提到了复制粘贴错误,当恶意的第三方利用这个问题是,就变成了会话固定攻击。还有另外一种形式的会话劫持,它将利用JavaScript读取会话cookie的内容。攻击者将利用网站的漏洞实行跨站脚本攻击,将JavaScript注入某个页面,使用JavaScript DOM属性document.cookie读取会话ID cookie中的内容。在攻击者从不知情用户处获得会话ID之后,他可以通过在自己的计算机中创建cookie模拟改会话,或者使用URL嵌入模拟受害者的身份。
防止此类攻击的最明显的方法就是不要再网站中使用跨站脚本。保护网站避免此类攻击还有李毅中方式,那就是在所有cookie中使用HttpOnly特性。通过设置该特性,cookie将至可被用在浏览器创建的HTTP(或HTTPS)请求中,无论请求是由链接创建,还是通过在地址栏手动输入URL、表单提交或AJAX请求。更重要的是,HttpOnly完全禁止了JavaScript,Flash或其他浏览器脚本以及插件获取cookie内容的能力。这将阻止跨站脚本会话劫持攻击的发生。会话ID cookie中总是应该包含HttpOnly特性。
4.不安全的cookie
最后需要考虑的一个漏洞是中间人攻击(MitM攻击),这是典型的数据截获攻击,攻击者通过观察客户端和服务器端交互的请求或响应,从中获取信息。这种类型的攻击促进了安全套接字层和传输层安全(SSL/TLS)的发展,他们是HTTPS协议的基础。使用HTTPS保护网络通信将有效地防止MitM攻击,并保护会话ID cookie不被盗用。
cookie的Secure标志专门用于解决这个问题。当服务器将会话ID通过响应返回到客户端时,它将设置Secure标志。该标志告诉浏览器只应该通过HTTPS传输cookie。从现在开始,cookie只以加密的方式传输,攻击者将无法拦截它。这种方式的缺点是网站必须一直使用HTTPS。否则,一旦将用户重定向至HTTP,浏览器将不再传输cookie并且会话也会丢失。
首先在web.xml中添加:
<jsp-config> <jsp-property-group> <url-pattern>*.jsp</url-pattern> <url-pattern>*.jspf</url-pattern> <page-encoding>UTF-8</page-encoding> <scripting-invalid>false</scripting-invalid> <include-prelude>/WEB-INF/jsp/base.jspf</include-prelude> <trim-directive-whitespaces>true</trim-directive-whitespaces> <default-content-type>text/html</default-content-type> </jsp-property-group> </jsp-config>
然后增加以下文件/WEB-INF/jsp/base.jspf
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
另外,在Web根目录中增加一个简单的index.jsp文件,用于将请求重定向值商店Servlet。
<c:redirect url="/shop" />
1.在部署描述符中配置会话
可以再部署描述符中配置HTTP会话,并且处于安全目的也应该配置。
在部署描述符中使用<session-config>标签配置会话。
https://www.owasp.org/index.php/HttpOnly
<session-config> <session-timeout>30</session-timeout> <cookie-config> <name>JSESSIONID</name> <domain>example.org</domain> <path>/shop</path> <comment><![CDATA[Keeps you logged in. See our privacy policy for more information.]]></comment> <http-only>true</http-only> <secure>false</secure> <max-age>1800</max-age> </cookie-config> <tracking-mode>COOKIE</tracking-mode> <tracking-mode>URL</tracking-mode> <tracking-mode>SSL</tracking-mode> </session-config>
URL-容器将只在URL中内嵌会话ID。不使用cookie或SSL会话ID。
COOKIE-容器将使用会话cookie追踪会话ID。
SSL-容器将使用SSL会话ID作为HTTP会话ID。要求使用的所有请求都必须是HTTPS请求。
可以为<tracking-mode>配置多个值,表示容器可以使用多种策略。例如,如果同时指定了COOKIE和URL,容器将优先使用cookie,但如果cookie不可用,那么容器将会使用URL。如果启用了SSL追踪模式,那么就不能使用COOKIE和URL模式。
只有在追踪模式中使用了COOKIE时,才可以使用<cookie-config>标签。
<name>可以自定义会话cookie的名字。默认值是JSESSIONID。
<domain>和<path>对应着cookie的Domain和Path特性。
<comment>将在会话ID cookie中添加Comment特性,在其中可以添加任意文本。这通常用于解释cookie的目的,并告知用户网站的隐私政策。
<http-only>和<secure>对应着cookie的HttpOnly和Secure特性。默认值都是false。应该一直将<http-only>设置为true。如果使用了HTTPS,也应该将<secure>设置为true。
<max-age>指定了cookie的Max-Age特性,用于控制cookie何时过期。
了解了可用的选项后,按照下面的XML配置设置项目:
<session-config> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> </cookie-config> <tracking-mode>COOKIE</tracking-mode> </session-config>
使用了该配置的应用程序,会话超市事件将被设置为30分钟,并且只使用cookie用于会话追踪,在会话cookie中也将包含HttpOnly特性用于解决安全问题。它将接受所有其他的默认值,并且不再cookie中指定comment特性。
2.存储和获取数据
2.1 在Servlet中使用会话
package com.wrox; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.Hashtable; import java.util.Map; @WebServlet( name = "storeServlet", urlPatterns = "/shop" ) public class StoreServlet extends HttpServlet { private final Map<Integer, String> products = new Hashtable<>(); public StoreServlet() { this.products.put(1, "Sandpaper"); this.products.put(2, "Nails"); this.products.put(3, "Glue"); this.products.put(4, "Paint"); this.products.put(5, "Tape"); } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if(action == null) action = "browse"; switch(action) { case "addToCart": this.addToCart(request, response); break; case "emptyCart": this.emptyCart(request, response); break; case "viewCart": this.viewCart(request, response); break; case "browse": default: this.browse(request, response); break; } } private void addToCart(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int productId; try { productId = Integer.parseInt(request.getParameter("productId")); } catch(Exception e) { response.sendRedirect("shop"); return; } HttpSession session = request.getSession(); if(session.getAttribute("cart") == null) session.setAttribute("cart", new Hashtable<Integer, Integer>()); @SuppressWarnings("unchecked") Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart"); if(!cart.containsKey(productId)) cart.put(productId, 0); cart.put(productId, cart.get(productId) + 1); response.sendRedirect("shop?action=viewCart"); } private void emptyCart(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.getSession().removeAttribute("cart"); response.sendRedirect("shop?action=viewCart"); } private void viewCart(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setAttribute("products", this.products); request.getRequestDispatcher("/WEB-INF/jsp/view/viewCart.jsp") .forward(request, response); } private void browse(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setAttribute("products", this.products); request.getRequestDispatcher("/WEB-INF/jsp/view/browse.jsp") .forward(request, response); } }
粗体代码调用了getSession获取会话,getAttribute返回会话中存储的对象。setAttribute将对象绑定到会话中。
2.2 在JSP中使用会话
<%@ page import="java.util.Map" %> <!DOCTYPE html> <html> <head> <title>View Cart</title> </head> <body> <h2>View Cart</h2> <a href="<c:url value="/shop" />">Product List</a><br /><br /> <a href="<c:url value="/shop?action=emptyCart" />">Empty Cart</a><br /><br /> <% @SuppressWarnings("unchecked") Map<Integer, String> products = (Map<Integer, String>)request.getAttribute("products"); @SuppressWarnings("unchecked") Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart"); if(cart == null || cart.size() == 0) out.println("Your cart is empty."); else { for(int id : cart.keySet()) { out.println(products.get(id) + " (qty: " + cart.get(id) + ")<br />"); } } %> </body> </html>
JSP使用隐式的session变量。
3.删除数据
private void emptyCart(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.getSession().removeAttribute("cart"); response.sendRedirect("shop?action=viewCart"); }
HttpSession最重要的方法之一就是invalidate方法。当用户注销时需要调用该方法。方法invalidate将销毁会话并解除所有绑定到会话的数据。即使客户浏览器使用相同的会话ID发起了另一个请求,已经无效的会话也不能再使用。相反,新的会话将被创建,并且响应中将包含新的会话ID。
4.在绘画中存储更复杂的数据
import java.io.Serializable; import java.net.InetAddress; public class PageVisit implements Serializable { private long enteredTimestamp; private Long leftTimestamp; private String request; private InetAddress ipAddress; }
package com.wrox; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.Vector; @WebServlet( name = "storeServlet", urlPatterns = "/do/*" ) public class ActivityServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { this.recordSessionActivity(request); this.viewSessionActivity(request, response); } private void recordSessionActivity(HttpServletRequest request) { HttpSession session = request.getSession(); if(session.getAttribute("activity") == null) session.setAttribute("activity", new Vector<PageVisit>()); @SuppressWarnings("unchecked") Vector<PageVisit> visits = (Vector<PageVisit>)session.getAttribute("activity"); if(!visits.isEmpty()) { PageVisit last = visits.lastElement(); last.setLeftTimestamp(System.currentTimeMillis()); } PageVisit now = new PageVisit(); now.setEnteredTimestamp(System.currentTimeMillis()); if(request.getQueryString() == null) now.setRequest(request.getRequestURL().toString()); else now.setRequest(request.getRequestURL()+"?"+request.getQueryString()); try { now.setIpAddress(InetAddress.getByName(request.getRemoteAddr())); } catch (UnknownHostException e) { e.printStackTrace(); } visits.add(now); } private void viewSessionActivity(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.getRequestDispatcher("/WEB-INF/jsp/view/viewSessionActivity.jsp") .forward(request, response); } }
<%@ page import="java.util.Vector, com.wrox.PageVisit, java.util.Date" %> <%@ page import="java.text.SimpleDateFormat" %> <%! private static String toString(long timeInterval) { if(timeInterval < 1_000) return "less than one second"; if(timeInterval < 60_000) return (timeInterval / 1_000) + " seconds"; return "about " + (timeInterval / 60_000) + " minutes"; } %> <% SimpleDateFormat f = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z"); %> <!DOCTYPE html> <html> <head> <title>Session Activity Tracker</title> </head> <body> <h2>Session Properties</h2> Session ID: <%= session.getId() %><br /> Session is new: <%= session.isNew() %><br /> Session created: <%= f.format(new Date(session.getCreationTime()))%><br /> <h2>Page Activity This Session</h2> <% @SuppressWarnings("unchecked") Vector<PageVisit> visits = (Vector<PageVisit>)session.getAttribute("activity"); for(PageVisit visit : visits) { out.print(visit.getRequest()); if(visit.getIpAddress() != null) out.print(" from IP " + visit.getIpAddress().getHostAddress()); out.print(" (" + f.format(new Date(visit.getEnteredTimestamp()))); if(visit.getLeftTimestamp() != null) { out.print(", stayed for " + toString( visit.getLeftTimestamp() - visit.getEnteredTimestamp() )); } out.println(")<br />"); } %> </body> </html>
package com.wrox; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.util.Hashtable; import java.util.Map; @WebServlet( name = "loginServlet", urlPatterns = "/login" ) public class LoginServlet extends HttpServlet { private static final Map<String, String> userDatabase = new Hashtable<>(); static { userDatabase.put("Nicholas", "password"); userDatabase.put("Sarah", "drowssap"); userDatabase.put("Mike", "wordpass"); userDatabase.put("John", "green"); } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(); if(request.getParameter("logout") != null) { session.invalidate(); response.sendRedirect("login"); return; } else if(session.getAttribute("username") != null) { response.sendRedirect("tickets"); return; } request.setAttribute("loginFailed", false); request.getRequestDispatcher("/WEB-INF/jsp/view/login.jsp") .forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(); if(session.getAttribute("username") != null) { response.sendRedirect("tickets"); return; } String username = request.getParameter("username"); String password = request.getParameter("password"); if(username == null || password == null || !LoginServlet.userDatabase.containsKey(username) || !password.equals(LoginServlet.userDatabase.get(username))) { request.setAttribute("loginFailed", true); request.getRequestDispatcher("/WEB-INF/jsp/view/login.jsp") .forward(request, response); } else { session.setAttribute("username", username); request.changeSessionId(); response.sendRedirect("tickets"); } } }
数据库只是一个简单的map,包含了用户名和密码,并未使用其他任何权限级别。用户要么可以访问系统,要么不能,并且密码也未以安全的方式存储。
方法doGet负责显示登录界面。该代码第一件工作就是检查用户是否已经登录,如果已经登录,就将他们重定向至票据页面。如果未登录,即将请求特性loginFailed设置为false,然后将请求转发至登录JSP。当JSP中的登录表单被提交时,请求将被发送到doPost方法。
doPost方法再次验证用户是否已经登录,并检查用户名和密码是否与数据库中存储的一致。如果登录失败,就将请求中的loginFailed设置为true,并将请求转发至登录JSP。如果用户名和密码都正确,那么在绘画中添加username特性,修改会话ID,然后将用户重定向至票据页面。
changeSessionId可以通过迁移会话的方式(修改会话ID)应付之前提到的会话固定攻击。
<!DOCTYPE html> <html> <head> <title>Customer Support</title> </head> <body> <h2>Login</h2> You must log in to access the customer support site.<br /><br /> <% if(((Boolean)request.getAttribute("loginFailed"))) { %> <b>The username or password you entered are not correct. Please try again.</b><br /><br /> <% } %> <form method="POST" action="<c:url value="/login" />"> Username<br /> <input type="text" name="username" /><br /><br /> Password<br /> <input type="password" name="password" /><br /><br /> <input type="submit" value="Log In" /> </form> </body> </html>
<a href="<c:url value="login?logout" />">Logout</a>
增加以上代码后,页面将会出现注销链接。单击链接,页面将被重新返回到登录页面,表示已经成功注销。
package com.wrox; import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionIdListener; import javax.servlet.http.HttpSessionListener; import java.text.SimpleDateFormat; import java.util.Date; @WebListener public class SessionListener implements HttpSessionListener, HttpSessionIdListener { private SimpleDateFormat formatter = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss"); //sessionCreated将在创建新的会话时调用 @Override public void sessionCreated(HttpSessionEvent e) { System.out.println(this.date() + ": Session " + e.getSession().getId() + " created."); SessionRegistry.addSession(e.getSession()); } //sersionDestroyed将在会话无效时调用。 //引起会话无效的原因可能是在代码中调用了会话的invalidate方法,也可能是由不活跃状态超时引起的隐式失效 @Override public void sessionDestroyed(HttpSessionEvent e) { System.out.println(this.date() + ": Session " + e.getSession().getId() + " destroyed."); SessionRegistry.removeSession(e.getSession()); } //当使用请求的changeSessionId方法改变会话ID时将会调用该方法。 @Override public void sessionIdChanged(HttpSessionEvent e, String oldSessionId) { System.out.println(this.date() + ": Session ID " + oldSessionId + " changed to " + e.getSession().getId()); SessionRegistry.updateSessionId(e.getSession(), oldSessionId); } private String date() { return this.formatter.format(new Date()); } }
现在编译、调试并访问应用程序。调试窗口中将立即出现一条日志消息显示已经创建了一个会话。登录到应用程序中,调式窗口将会出现另一条会话ID改变的日志消息。这是之前添加的代码,用于防止固定会话攻击。最后在退出应用程序时,调试窗口将会出现两条日志-一条表示会话已经销毁,另一条表示创建了新的会话(因为回到了登录页面)。
维持活跃会话列表
可以使用HttpSessionListener和HttpSessionIdListener在应用程序中维护一个活跃会话列表。
首先可以创建SessionRegistry类
package com.wrox; import javax.servlet.http.HttpSession; import java.util.ArrayList; import java.util.Hashtable; import java.util.List; import java.util.Map; public final class SessionRegistry { private static final Map<String, HttpSession> SESSIONS = new Hashtable<>(); public static void addSession(HttpSession session) { SESSIONS.put(session.getId(), session); } public static void updateSessionId(HttpSession session, String oldSessionId) { synchronized(SESSIONS) { SESSIONS.remove(oldSessionId); addSession(session); } } public static void removeSession(HttpSession session) { SESSIONS.remove(session.getId()); } public static List<HttpSession> getAllSessions() { return new ArrayList<>(SESSIONS.values()); } public static int getNumberOfSessions() { return SESSIONS.size(); } private SessionRegistry() { } }
该注册表保存了所有活跃会话的引用,但必须通过其他方式添加和删除会话。查看之前SessionListener中SessionRegistry相关代码。
在集群中使用会话ID
使用集群时立即会遇到的问题就是:会话以对象的方式存在于内存中,并且只存在于Web容器的单个实例中。在只能负载均衡的场景中,来自同一个客户端的两个连续请求将会访问不同的Web容器。第一个Web容器将会为它收到的第一个请求分配会话ID,然后第二个请求将会由另一个Web容器示例处理,第二个实例无法识别其中的会话ID,因此将重新创建并分配一个会话ID,此时会话就变得无用了。
解决该问题的方法是使用粘滞会话。粘滞会话的概念是:是负载均衡机制能够感知到会话,并且总是将来自于统一会话的请求发送到相同的服务器。
Tomcat环境中最常见的负载均衡方式是:使用Apache HTTPD或者Microsoft IIS Web服务器在Apache Tomcat势力之间处理服务期间的负载均衡请求。
使用粘滞会话的主要问题是,它可以支持扩展性,但不支持高可用性。如果创建特定会话的Tomcat实例终止服务,那么该会话将丢失,并且用户需要重新登录。更糟糕的时,用户可能会丢失未保存的工作。出于这个目的,会话可以再整个集群中复制,因此无论会话产生于哪个实例,它们对所有的Web容器实例都是可用的。在应用程序中启用会话复制是很简单的。只要在部署描述符中添加<distributable>标签即可。
该配置指示是应用程序支持分布式会话。并未在Web容器中配置会话复制机制。它也不意味着应用程序回自动遵守最佳实践。必须小心会话特性的设置以及如何更新那些会话特性。
@SuppressWarnings("unchecked")
Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart");
if(!cart.containsKey(productId))
cart.put(productId, 0);
cart.put(productId, cart.get(productId) + 1);
对于上面的代码,Web容器无法得知会话中的Map已经发生了变化。因此,对会话的修改并未得到复制,这意味着其他实例容器无法得知购物车中的新产品。
可以通过以下方式解决:
@SuppressWarnings("unchecked")
Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart");
if(!cart.containsKey(productId))
cart.put(productId, 0);
cart.put(productId, cart.get(productId) + 1);
session.setAttribute("cart", cart);
看起来很傻,因为只是使用同一对象替换了之前的cart会话特性。不过调用该方法将告诉容器会话发生了变化,需要将它复制到其他容器中。
任何时候对会话特性对象的修改,都需要重新调用setAttribute设置它,从而保证修改被复制到其他容器中。
任何添加到会话中的特性对象都可以实现HttpSessionActivationListener接口。
当会话被序列化并发送到其他服务器时,sessionWillPassivate方法将被调用,给绑定到会话的对象首先执行某些操作的机会。
当回话在另一个容器中反序列化是,sessionDidActivate方法将被调用,通知特性它已经被反序列化。
| Chapter 5 Updated on 9/4/14. |
128.73 KB | Click to Download |
摘录自:[美]Nicholas S.Williams著,王肖峰译 Java Web高级编程 [M]、清华大学出版社,2015、93-127、

浙公网安备 33010602011771号