使用Session和Cookie
开发环境
本文升级了 IntelliJ IDEA、Kotlin 和 Tomcat 的版本
- IntelliJ IDEA 2023.3.2 (Ultimate Edition)
- JDK 11
- Gradle 7.5.1
- Kotlin 1.9.22
- Tomcat 10.1.18
Session
在 Web 应用程序中,我们经常要跟踪用户身份。当用户登录成功后,如果继续访问其他页面,Web 应用程序如何才能识别出该用户的身份?
HTTP 协议本身是无状态的,即 Web 应用程序无法区分收到的两个 HTTP 请求是否是同一个客户端发起的。为了跟踪用户状态,Web 应用程序可以向客户端分配一个唯一标识(ID),并以 Cookie 的形式发给客户端,客户端在后续发起 HTTP 请求时总是附带此 Cookie,如此 Web 应用程序就可以识别用户身份了。
上述这种基于唯一标识识别用户身份的机制称为 Session。每个用户第一次访问服务器后,服务器会自动分配一个 Session ID。如果用户在一段时候内没有访问服务器,那么 Session 会自动失效,下次访问时即使附带上次的 Session,服务器也会认为是第一次访问,从而分配新的 Session ID。
Servlet 对 Session 提供了内建支持。以登录为例,当一个用户登录成功后,我们就可以把这个用户名存入到一个 HttpSession
对象,以便后续访问其他页面的时候直接从 HttpSession
中取出用户名:
@WebServlet(name = "LoginServlet", urlPatterns = ["/login"])
class LoginServlet : HttpServlet() {
// 模拟数据库
private val users = mapOf("guodong" to "android", "xiaodou" to "servlet")
init {
println("${this::class.java.simpleName} init")
}
/**
* 返回登录界面
*/
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
resp.contentType = "text/html"
with(resp.writer) {
write("<h1>Log In</h1>")
write("<form action=\"/login\" method=\"post\">")
write("<p>Username: <input name=\"username\"></p>")
write("<p>Password: <input name=\"password\" type=\"password\"></p>")
write("<p><button type=\"submit\">Log In</button></p>")
write("</form>")
flush()
}
}
/**
* 处理登录请求
*/
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
val name = req.getParameter("username")
val password = req.getParameter("password")
val expectedPassword = users[name]
if (expectedPassword != null && expectedPassword == password) {
req.session.setAttribute("user", name)
resp.sendRedirect("/servlet")
} else {
resp.sendError(HttpServletResponse.SC_FORBIDDEN)
}
}
}
上述 LoginServlet
在判断用户登录成功后将用户名放入当前 HttpSession
中:
req.session.setAttribute("user", name)
在 IndexServlet
中,可以从 HttpSession
中取出用户名:
@WebServlet(name = "IndexServlet", urlPatterns = ["/"])
class IndexServlet : HttpServlet() {
override fun init() {
println("${this::class.java.simpleName} init")
}
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
val username = req.session.getAttribute("user")
resp.contentType = "text/html"
resp.characterEncoding = "UTF-8"
with(resp.writer) {
write("<h1>Welcome, ${username ?: "Guest"}</h1>")
if (username == null) {
write("<p><a href=\"/login\">Log In</a></p>")
} else {
write("<p><a href=\"/logout\">Log Out</a></p>")
}
}
}
}
如果用户已经登录,点击 Log Out 按钮访问 /logout
登出。登出就是从 HttpSession
中移除用户信息:
@WebServlet(name = "LogoutServlet", urlPatterns = ["/logout"])
class LogoutServlet : HttpServlet() {
override fun init() {
println("${this::class.java.simpleName} init")
}
/**
* 处理登出
*/
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
req.session.removeAttribute("user")
resp.sendRedirect("/")
}
}
Web 应用程序通过 HttpSession
接口访问当前 Session。Web 服务器会在内存中自动维护一个 ID 到 HttpSession
的映射表,而服务器依靠一个名为 JSESSIONID
的 Cookie 来识别 Session。在 Servlet 中第一次调用 req.getSession()
时,Servlet 容器会自动创建一个 Session ID,然后通过 JSESSIONID
的 Cookie 发送给客户端:
Cookie
Servlet 内建的 HttpSession
本质上就是通过名为 JSESSIONID
的 Cookie 来跟踪用户身份的。除了这个名称外,其他名称的 Cookie 我们可以任意使用。
我们以记录用户语言偏好来学习如何设置 Cookie:
@WebServlet(name = "LanguageServlet", urlPatterns = ["/language"])
class LanguageServlet : HttpServlet() {
companion object {
private val LANGUAGES = setOf("en", "zh")
}
override fun init() {
println("${this::class.java.simpleName} init")
}
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
val lang = req.getParameter("lang")
if (lang in LANGUAGES) {
val cookie = Cookie("lang", lang).apply {
path = "/"
maxAge = 60 * 60 * 24 * 100
}
resp.addCookie(cookie)
}
resp.sendRedirect("/index")
}
}
创建一个新 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:
我们可以在 IndexServlet
中读取 lang
的 Cookie 来获取用户的语言偏好,如下更新 IndexServlet
:
@WebServlet(name = "IndexServlet", urlPatterns = ["/index"])
class IndexServlet : HttpServlet() {
override fun init() {
println("${this::class.java.simpleName} init")
}
override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
val username = req.session.getAttribute("user")
resp.contentType = "text/html"
resp.characterEncoding = "UTF-8"
+ val lang = parseLanguageFromCookie(req)
+ if (lang == "zh") {
+ with(resp.writer) {
+ write("<h1>你好, ${username ?: "客人"}</h1>")
+ if (username == null) {
+ write("<p><a href=\"/login\">登录</a></p>")
+ } else {
+ write("<p><a href=\"/logout\">登出</a></p>")
+ }
+ write("<p><a href=\"/language?lang=en\">English</a>|<a href=\"/language?lang=zh\">中文</a></p>")
+ }
+ } else {
with(resp.writer) {
write("<h1>Welcome, ${username ?: "Guest"}</h1>")
if (username == null) {
write("<p><a href=\"/login\">Log In</a></p>")
} else {
write("<p><a href=\"/logout\">Log Out</a></p>")
}
+ write("<p><a href=\"/language?lang=en\">English</a>|<a href=\"/language?lang=zh\">中文</a></p>")
}
+ }
}
+ private fun parseLanguageFromCookie(req: HttpServletRequest): String {
+ val cookies = req.cookies ?: return "en"
+ return cookies.find { it.name == "lang" }?.value ?: return "en"
+ }
}