跳至主要內容

使用Session和Cookie

guodongAndroid大约 4 分钟

开发环境

本文升级了 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 发送给客户端:

session set 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:

cookie set 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"
+   }
}