如需转载,请根据 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 许可,附上本文作者及链接。
本文作者: 执笔成念
作者昵称: zbcn
本文链接: https://1363653611.github.io/zbcn.github.io/2020/10/12/web_02session%E5%92%8Ccookie/
- 在Web应用程序中,我们经常要跟踪用户身份。当一个用户登录成功后,如果他继续访问其他页面,Web程序如何才能识别出该用户身份?
因为HTTP协议是一个无状态协议,即Web应用程序无法区分收到的两个HTTP请求是否是同一个浏览器发出的。为了跟踪用户状态,服务器可以向浏览器分配一个唯一ID,并以Cookie的形式发送到浏览器,浏览器在后续访问时总是附带此Cookie,这样,服务器就可以识别用户身份。
Session
我们把这种基于唯一ID识别用户身份的机制称为Session。每个用户第一次访问服务器后,会自动获得一个Session ID。如果用户在一段时间内没有访问服务器,那么Session会自动失效,下次即使带着上次分配的Session ID访问,服务器也认为这是一个新用户,会分配新的Session ID。
JavaEE的Servlet机制内建了对Session的支持。我们以登录为例,当一个用户登录成功后,我们就可以把这个用户的名字放入一个HttpSession
对象,以便后续访问其他页面的时候,能直接从HttpSession
取出用户名:
1 | "/login") ( |
2 | public class SignInServlet extends HttpServlet { |
3 | // // 模拟一个数据库: |
4 | private Map<String, String> users = Maps.newHashMap(); |
5 | { |
6 | //"bob", "bob123", "alice", "alice123", "tom", "tomcat" |
7 | users.put("bob","bob123"); |
8 | users.put("alice","alice123"); |
9 | users.put("tom","tomcat"); |
10 | } |
11 | |
12 | // GET请求时显示登录页: |
13 | protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { |
14 | resp.setContentType("text/html"); |
15 | PrintWriter pw = resp.getWriter(); |
16 | pw.write("<h1>Sign In</h1>"); |
17 | pw.write("<form action=\"/signin\" method=\"post\">"); |
18 | pw.write("<p>Username: <input name=\"username\"></p>"); |
19 | pw.write("<p>Password: <input name=\"password\" type=\"password\"></p>"); |
20 | pw.write("<p><button type=\"submit\">Sign In</button> <a href=\"/\">Cancel</a></p>"); |
21 | pw.write("</form>"); |
22 | pw.flush(); |
23 | } |
24 | |
25 | // POST请求时处理用户登录: |
26 | protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { |
27 | String name = req.getParameter("username"); |
28 | String password = req.getParameter("password"); |
29 | String expectedPassword = users.get(name.toLowerCase()); |
30 | if (expectedPassword != null && expectedPassword.equals(password)) { |
31 | // 登录成功: |
32 | req.getSession().setAttribute("user", name); |
33 | resp.sendRedirect("/"); |
34 | } else { |
35 | resp.sendError(HttpServletResponse.SC_FORBIDDEN); |
36 | } |
37 | } |
38 | } |
- 上述
SignInServlet
在判断用户登录成功后,立刻将用户名放入当前HttpSession
中:
1 | HttpSession session = req.getSession(); |
2 | session.setAttribute("user", name); |
在
IndexServlet
中,可以从HttpSession
取出用户名:1
"/index") (
2
public class IndexServlet extends HttpServlet {
3
4
// GET请求时显示登录页:
5
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
6
String user = (String)req.getSession().getAttribute("user");
7
resp.setContentType("text/html");
8
resp.setCharacterEncoding("UTF-8");
9
resp.setHeader("X-Powered-By", "JavaEE Servlet");
10
PrintWriter pw = resp.getWriter();
11
pw.write("<h1>Welcome, " + (user != null ? user : "Guest") + "</h1>");
12
if (user == null) {
13
// 未登录,显示登录链接:
14
pw.write("<p><a href=\"/signIn\">Sign In</a></p>");
15
} else {
16
// 已登录,显示登出链接:
17
pw.write("<p><a href=\"/signOut\">Sign Out</a></p>");
18
}
19
pw.flush();
20
}
21
}
如果用户已登录,可以通过访问
/signout
登出。登出逻辑就是从HttpSession
中移除用户相关信息:1
/**
2
* 登出
3
* <br/>
4
* @author zbcn8
5
* @since 2020/10/3 11:24
6
*/
7
"/signOut") (
8
public class SignOutServlet extends HttpServlet {
9
10
public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
11
// 从HttpSession移除用户名:
12
req.getSession().removeAttribute("user");
13
resp.sendRedirect("/index");
14
}
15
}
对于Web应用程序来说,我们总是通过
HttpSession
这个高级接口访问当前Session。如果要深入理解Session原理,可以认为Web服务器在内存中自动维护了一个ID到HttpSession
的映射表,我们可以用下图表示:
而服务器识别Session的关键就是依靠一个名为JSESSIONID
的Cookie。在Servlet中第一次调用req.getSession()
时,Servlet容器自动创建一个Session ID,然后通过一个名为JSESSIONID
的Cookie发送给浏览器:
这里要注意的几点是:
JSESSIONID
是由Servlet容器自动创建的,目的是维护一个浏览器会话,它和我们的登录逻辑没有关系;- 登录和登出的业务逻辑是我们自己根据
HttpSession
是否存在一个"user"
的Key判断的,登出后,Session ID并不会改变; - 即使没有登录功能,仍然可以使用
HttpSession
追踪用户,例如,放入一些用户配置信息等。
除了使用Cookie机制可以实现Session外,还可以通过隐藏表单、URL末尾附加ID来追踪Session。这些机制很少使用,最常用的Session机制仍然是Cookie。
使用Session时,由于服务器把所有用户的Session都存储在内存中,如果遇到内存不足的情况,就需要把部分不活动的Session序列化到磁盘上,这会大大降低服务器的运行效率,因此,放入Session的对象要小,通常我们放入一个简单的User
对象就足够了:
1 | public class User { |
2 | public long id; // 唯一标识 |
3 | public String email; |
4 | public String name; |
5 | } |
在使用多台服务器构成集群时,使用Session会遇到一些额外的问题。通常,多台服务器集群使用反向代理作为网站入口:
如果多台Web Server采用无状态集群,那么反向代理总是以轮询方式将请求依次转发给每台Web Server,这会造成一个用户在Web Server 1存储的Session信息,在Web Server 2和3上并不存在,即从Web Server 1登录后,如果后续请求被转发到Web Server 2或3,那么用户看到的仍然是未登录状态。
要解决这个问题,方案一是在所有Web Server之间进行Session复制,但这样会严重消耗网络带宽,并且,每个Web Server的内存均存储所有用户的Session,内存使用率很低。
另一个方案是采用粘滞会话(Sticky Session)机制,即反向代理在转发请求的时候,总是根据JSESSIONID的值判断,相同的JSESSIONID总是转发到固定的Web Server,但这需要反向代理的支持。
无论采用何种方案,使用Session机制,会使得Web Server的集群很难扩展,因此,Session适用于中小型Web应用程序。对于大型Web应用程序来说,通常需要避免使用Session机制。
Cookie
实际上,Servlet提供的HttpSession
本质上就是通过一个名为JSESSIONID
的Cookie来跟踪用户会话的。除了这个名称外,其他名称的Cookie我们可以任意使用。
如果我们想要设置一个Cookie,例如,记录用户选择的语言,可以编写一个LanguageServlet
:
1 | /** |
2 | * cookie 设置国际话 |
3 | * <br/> |
4 | * @author zbcn8 |
5 | * @since 2020/10/3 11:48 |
6 | */ |
7 | "/pref") (urlPatterns = |
8 | public class CookieServlet extends HttpServlet { |
9 | |
10 | private static final Set<String> LANGUAGES = Sets.newHashSet("en", "zh"); |
11 | |
12 | protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { |
13 | String lang = req.getParameter("lang"); |
14 | if (LANGUAGES.contains(lang)) { |
15 | // 创建一个新的Cookie: |
16 | Cookie cookie = new Cookie("lang", lang); |
17 | // 该Cookie生效的路径范围: |
18 | cookie.setPath("/index"); |
19 | // 该Cookie有效期: |
20 | cookie.setMaxAge(8640000); // 8640000秒=100天 |
21 | // 将该Cookie添加到响应: |
22 | resp.addCookie(cookie); |
23 | } |
24 | resp.sendRedirect("/index"); |
25 | } |
26 | } |
- 创建一个新Cookie时,除了指定名称和值以外,通常需要设置
setPath("/")
,浏览器根据此前缀决定是否发送Cookie。 - 如果一个Cookie调用了
setPath("/user/")
,那么浏览器只有在请求以/user/
开头的路径时才会附加此Cookie。 - 通过
setMaxAge()
设置Cookie的有效期,单位为秒, - 最后通过
resp.addCookie()
把它添加到响应。 - 如果访问的是https网页,还需要调用
setSecure(true)
,否则浏览器不会发送该Cookie。
因此,务必注意:浏览器在请求某个URL时,是否携带指定的Cookie,取决于Cookie是否满足以下所有要求:
- URL前缀是设置Cookie时的Path;
- Cookie在有效期内;
- Cookie设置了secure时必须以https访问。
我们可以在浏览器看到服务器发送的Cookie:
如果我们要读取Cookie,例如,在IndexServlet
中,读取名为lang
的Cookie以获取用户设置的语言,可以写一个方法如下:
1 | public String parseLanguageFromCookie(HttpServletRequest req) { |
2 | // 获取请求附带的所有Cookie: |
3 | Cookie[] cookies = req.getCookies(); |
4 | // 如果获取到Cookie: |
5 | if (cookies != null) { |
6 | // 循环每个Cookie: |
7 | for (Cookie cookie : cookies) { |
8 | // 如果Cookie名称为lang: |
9 | if (cookie.getName().equals("lang")) { |
10 | // 返回Cookie的值: |
11 | return cookie.getValue(); |
12 | } |
13 | } |
14 | } |
15 | // 返回默认值: |
16 | return "en"; |
17 | } |
18 | 可见,读取Cook |
总结
- Servlet容器提供了Session机制以跟踪用户;
- 默认的Session机制是以Cookie形式实现的,Cookie名称为
JSESSIONID
; - 通过读写Cookie可以在客户端设置用户偏好等。