技术演进中的开发沉思-151 java-servlet:会话管理

怎么理解会话状况呢:"Web 是无状态的,就像餐馆里的客人吃完就走,服务员记不住谁是谁 ------ 会话管理就是给客人发张会员卡,下次来能认出他,还能想起他的偏好。"

这句话成了我理解会话管理的钥匙。而真正让我吃透这个概念的,是当年写的那几个简单却经典的 Servlet:用来做访问计数的Counter、能查看所有会话的Killer、排查 Cookie 问题的Cookies...... 这些代码没有复杂的框架,却像 "后厨的基础厨具",帮我摸清了会话管理的每一个细节。

今天,咱们就结合这些当年的 "实战代码",聊聊会话管理:从 "会员档案" 的创建与维护,到 "会员卡"(Cookie)、"账单写号"(URL 重写)的底层逻辑,再到会话事件的监听,带大家感受 "用基础 API 解决实际问题" 的乐趣。

当年做电商项目时,我最头疼的就是 "无状态" 这个特性。HTTP 协议就像餐馆里的 "一次性消费":客人(浏览器)发一次请求(点一次菜),服务器(后厨)响应一次(上一次菜),之后就忘了这个客人是谁。直到我写了第一个会话相关的 Servlet------Counter,才真正明白:会话管理不是抽象的理论,而是 "用代码让服务器记住客人" 的具体操作。

那个Counter servlet 很简单,就是统计用户在一次会话中访问页面的次数。当我第一次刷新页面,看到计数从 1 变成 2,关了浏览器再打开(设置了 Cookie 有效期后),计数接着从 3 开始涨时,我突然懂了:原来 "会话" 就是服务器给客人建的 "电子档案本",Session ID就是档案本的编号,Cookie 就是用来装编号的 "会员卡"。

一、 管理会话数据

会话数据就是 "会员档案" 里的内容:客人的登录名、购物车商品、访问次数。管理会话数据,就是用HttpSession API"写档案、读档案、清档案"。当年我就是靠Counter servlet,把这些操作摸得明明白白。

1.1 操作会话数据:用Counter servlet 学 "存读写"

Counter servlet 是我写的第一个会话相关代码,核心逻辑就是 "记录访问次数",现在翻出当年的代码,还能看到注释里的错别字:

java 复制代码
package javaservlets.session;

import javax.servlet.*;

import javax.servlet.http.*;

public class Counter extends HttpServlet {

// 定义计数的键,当年怕和其他servlet冲突,特意加了类名前缀

static final String COUNTER_KEY = "Counter.count";

public void doGet(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

// 获取会话:true表示没有就新建(当年踩过坑,设成false导致空指针)

HttpSession session = req.getSession(true);

resp.setContentType("text/html");

java.io.PrintWriter out = resp.getWriter();

// 读计数:当年用getValue,后来Servlet 2.4才改成getAttribute

int count = 1;

Integer i = (Integer) session.getValue(COUNTER_KEY);

if (i != null) {

count = i.intValue() + 1;

}

// 写计数:对应putValue,后来改成setAttribute

session.putValue(COUNTER_KEY, new Integer(count));

// 输出结果:显示会话ID和访问次数

out.println("<html><body>");

out.println("你的会话ID是 <b>" + session.getId() + "</b><br>");

out.println("本次会话中,你访问了本页面 <b>" + count + "</b> 次");

out.println("<form method=GET action=\"" + req.getRequestURI() + "\">");

out.println("<input type=submit value=\"再点一次\">");

out.println("</form></body></html>");

out.flush();

}

}

这段代码让我明白三个关键问题:

  • "档案本" 怎么拿:req.getSession(true)是 "拿档案本",没有就新建;当年我误写成getSession(false),客人第一次访问时拿不到档案本,存计数时报空指针,调试了半小时才发现。
  • "档案" 怎么写:session.putValue(键, 值)是 "写档案",就像在档案本上写 "访问次数:3";后来 Servlet API 更新,为了和Map接口统一,改成了setAttribute,但核心逻辑没变。
  • "档案" 怎么读:session.getValue(键)是 "读档案",拿不到就返回 null;比如第一次访问时i是 null,计数从 1 开始。

当年我把这个 servlet 部署到服务器,刷新页面时看着计数涨,关了浏览器再打开(后来加了 Cookie 有效期),计数还能接着涨,那种 "服务器认得出我" 的感觉,比现在用 Spring Session 还兴奋 ------ 因为这是用最基础的 API,亲手实现了 "记住用户"。

1.2 会话的生存期:"档案本" 多久过期

会员档案不能永久保存,不然服务器内存会被占满 ------ 就像餐馆不会保留几年没来的客人档案。会话的生存期就是 "档案本" 的有效期,超过时间没访问,服务器自动销毁。

当年我用Counter servlet 测试生存期:在web.xml里加了配置:

XML 复制代码
<session-config>

<session-timeout>30</session-timeout> <!-- 单位:分钟 -->

</session-config>

然后访问页面,计数到 5,半小时不操作,再刷新,计数又从 1 开始 ------ 这说明 "档案本" 过期被销毁了,服务器给了新的档案本。

java 复制代码
session.setMaxInactiveInterval(30 * 60); // 单位:秒,和xml配置效果一样

来我还试过用代码设置生存期:

复制代码
当年踩过一个误区:以为setMaxInactiveInterval(-1)是 “永久有效”,就像给档案本盖了 “永久保存” 的章。结果部署后没几天,服务器内存就满了 —— 老同事告诉我:“哪有永久的档案?就算是会员,几年不来也得清理,不然储物间会满。” 后来我才明白,生产环境里绝对不能设-1,必须给会话设过期时间。

6.1.3 浏览会话:用Killer servlet 看 "所有档案本"

当年排查 "购物车消失" 问题时,我写了个Killer servlet------ 它能列出服务器上所有的会话,还能手工销毁会话,就像 "档案管理员" 能看所有客人的档案本,还能手动撕了过期的。

Killer的核心代码是用HttpSessionContext获取所有会话 ID:

java 复制代码
// 当年用HttpSessionContext拿所有会话ID,现在已经弃用了

HttpSession session = req.getSession(true);

HttpSessionContext context = session.getSessionContext();

java.util.Enumeration enum = context.getIds(); // 拿到所有会话ID

while (enum.hasMoreElements()) {

String sessionID = (String) enum.nextElement();

HttpSession curSession = context.getSession(sessionID);

// 输出会话ID和最后访问时间

out.println("<tr><td>" + sessionID + "</td>");

out.println("<td>" + new java.util.Date(curSession.getLastAccessedTime()) + "</td></tr>");

}

第一次运行Killer时,我看到页面上列出了十几个会话 ID,还能勾选后点 "销毁会话",觉得特别酷 ------ 就像有了 "上帝视角"。结果老领导看到后,劈头盖脸一顿骂:"这太不安全了!要是被人恶意销毁正在支付的用户会话,客人付了钱却没订单,你负责?"

后来我才知道,Servlet API 2.1 后就把HttpSessionContext弃用了 ------ 就是因为它能泄露所有用户的会话信息,有安全风险。但这个 "不安全" 的 servlet,却帮我排查了不少问题:比如测试小哥说购物车空了,我用Killer看他的会话 ID,发现每次访问都变,再查 Cookie,才知道他浏览器禁用了 Cookie,导致服务器每次都给新档案本。

现在回想,Killer servlet 虽然过时了,但它教会我的 "排查思路" 却一直有用:会话有问题,先看会话 ID 是否不变,再看标识(Cookie/URL)是否传递 ------ 这个逻辑到现在都没改。

二、Cookies

Cookies 是服务器发给浏览器的 "小文本文件",里面存着 "会员号"(Session ID)------ 就像餐馆给客人发的实体会员卡,客人下次来自动出示,服务员不用再问 "你是谁"。当年我就是靠Cookies servlet,摸清了 Cookie 的 "脾气"。

用Cookies servlet 查 "会员卡"

Cookies servlet 很简单,就是读取请求中的所有 Cookie,输出名称、值、注释、有效期:

java 复制代码
package javaservlets.session;

import javax.servlet.*;

import javax.servlet.http.*;

public class Cookies extends HttpServlet {

public void doGet(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

resp.setContentType("text/html");

java.io.PrintWriter out = resp.getWriter();

Cookie[] cookies = req.getCookies(); // 读取所有Cookie

out.println("<html><body><center><h1>请求中的Cookie</h1>");

if (cookies == null || cookies.length == 0) {

out.println("没找到Cookie");

} else {

out.println("<table border><tr><th>名称</th><th>值</th><th>注释</th><th>有效期</th></tr>");

for (Cookie c : cookies) {

out.println("<tr>");

out.println("<td>" + c.getName() + "</td>");

out.println("<td>" + c.getValue() + "</td>");

out.println("<td>" + c.getComment() + "</td>");

out.println("<td>" + c.getMaxAge() + "</td>"); // 秒,-1表示会话级

out.println("</tr>");

}

out.println("</table>");

}

out.println("</center></body></html>");

out.flush();

}

}

当年我用这个 servlet 排查 "购物车消失" 问题:访问/Cookies,发现JSESSIONID这个 Cookie 的MaxAge是-1(会话级),关了浏览器就失效 ------ 这就是为什么购物车空了。后来我在Counter servlet 里加了代码,把JSESSIONID的有效期设为 7 天:

java 复制代码
// 找到JSESSIONID Cookie,设有效期7天

Cookie[] cookies = req.getCookies();

if (cookies != null) {

for (Cookie c : cookies) {

if (c.getName().equals("JSESSIONID")) {

c.setMaxAge(7 * 24 * 60 * 60); // 7天

c.setPath("/"); // 全站有效,当年漏了这个,导致/cart路径读不到

resp.addCookie(c);

break;

}

}

}

加了这段代码后,再用Cookies servlet 查看,JSESSIONID的MaxAge变成了604800(7 天秒数),关了浏览器再打开,购物车终于不丢了 ------ 那一刻,我才算真正懂了 Cookie 和会话的关系。

当年踩过的 "Cookie 坑"

用Cookies servlet 调试多了,我总结出几个 Cookie 的 "坑":

  • 路径坑:默认 Cookie 只在当前路径有效,比如在/login发的 Cookie,在/cart读不到 ------ 就像会员卡只能在一楼用,二楼不认。解决方法是c.setPath("/"),让 Cookie 全站有效。
  • 域名坑 :如果有子域名(www.example.comshop.example.com),默认 Cookie 不共享 ------ 就像总店的会员卡,分店不认。要设c.setDomain(".example.com")(前面的点不能少)。
  • 大小坑:每个 Cookie 不能超过 4KB,当年想存购物车所有商品,结果 Cookie 存不下,后来才知道 Cookie 只能存 "会员号" 这种小数据,大数据要存在服务器会话里。

还有个细节:当年做支付模块时,老领导让我给 Cookie 加setSecure(true),说 "敏感 Cookie 要走 HTTPS"------ 就像会员卡只在餐馆内部用,不允许带出,防止被别人截取。现在想起来,这些细节都是 "安全意识" 的启蒙。

三、URL Rewriting

有些客人禁用了 Cookie(不带会员卡),服务器怎么识别?答案是 URL 重写:把 "会员号"(Session ID)拼在 URL 后面,就像客人没带会员卡,服务员在账单上写会员号,下次客人拿着账单来,服务员就能认出他。当年我用CounterRewrite servlet,实现了这个逻辑。

CounterRewrite servlet 的 "账单写号"

CounterRewrite是Counter的修改版,核心是用resp.encodeURL()把 Session ID 拼到 URL 里:

java 复制代码
package javaservlets.session;

import javax.servlet.*;

import javax.servlet.http.*;

public class CounterRewrite extends HttpServlet {

static final String COUNTER_KEY = "CounterRewrite.count";

public void doGet(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

HttpSession session = req.getSession(true);

resp.setContentType("text/html");

java.io.PrintWriter out = resp.getWriter();

// 计数逻辑和Counter一样

int count = 1;

Integer i = (Integer) session.getValue(COUNTER_KEY);

if (i != null) count = i.intValue() + 1;

session.putValue(COUNTER_KEY, new Integer(count));

// 关键:用encodeURL重写表单action,自动拼Session ID

String url = req.getRequestURI();

String encodedUrl = resp.encodeURL(url); // 重写后的URL

out.println("<html><body>");

out.println("你的会话ID是 <b>" + session.getId() + "</b><br>");

out.println("访问次数:<b>" + count + "</b> 次");

// 表单action用重写后的URL

out.println("<form method=POST action=\"" + encodedUrl + "\">");

out.println("<input type=submit value=\"再点一次\">");

out.println("</form></body></html>");

out.flush();

}

// POST请求也用doGet处理,避免漏写重写

public void doPost(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

doGet(req, resp);

}

}

当年我测试时,故意禁用浏览器 Cookie,访问CounterRewrite,看到地址栏的 URL 变成了:/CounterRewrite;jsessionid=3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D------Session ID 被拼在了 URL 后面,刷新页面,计数能正常涨,而原来的Counter servlet 此时计数一直是 1。

当年的 "重写坑"

URL 重写虽然能兜底,但坑也不少:

  • 所有 URL 都要重写:当年我漏重写了 "帮助中心" 的链接,客人点了之后,URL 里没 Session ID,服务器给了新会话,购物车就空了 ------ 排查半天才发现是这个原因。后来我养成习惯:所有链接、表单 action,都用encodeURL()处理。
  • POST 请求也要重写:虽然 POST 参数在请求体里,但第一次访问时,客人还没有 Session ID,需要通过 URL 传递 ------ 所以CounterRewrite里特意加了doPost方法,调用doGet,避免漏重写。
  • URL 变长不美观:拼上 Session ID 后,URL 又长又乱,客人看着不舒服 ------ 所以 URL 重写是 "兜底方案",只在 Cookie 禁用时用,优先还是用 Cookie。

当年我总结了个 "最佳实践":先用 Cookie,用Cookies servlet 检测 Cookie 是否启用,禁用了再自动切换到 URL 重写 ------ 就像餐馆先给客人发会员卡,客人说 "我不带",再在账单上写会员号。

四、不使用浏览器的会话跟踪

现在的开发有 APP、小程序,但当年我做过一个桌面工具,要和 Servlet 通信 ------ 没有浏览器,不能用 Cookie,怎么跟踪会话?答案是 "手工传 Session ID",就像电话订餐的客人,报手机号当 "会员号",服务员通过手机号查档案。当年我用CounterJava(Servlet)和CounterApp(桌面程序),实现了这个逻辑。

CounterJava:给桌面程序 "发会员号"

CounterJava不返回 HTML,而是用二进制流返回计数,核心是保持会话逻辑不变:

复制代码
java 复制代码
package javaservlets.session;

import javax.servlet.*;

import javax.servlet.http.*;

import java.io.*;

public class CounterJava extends HttpServlet {

static final String COUNTER_KEY = "CounterJava.count";

public void service(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

HttpSession session = req.getSession(true);

int count = 1;

Integer i = (Integer) session.getValue(COUNTER_KEY);

if (i != null) count = i.intValue() + 1;

session.putValue(COUNTER_KEY, new Integer(count));

// 返回二进制数据,不是HTML

resp.setContentType("application/octet-stream");

DataOutputStream out = new DataOutputStream(resp.getOutputStream());

out.writeInt(count); // 写计数到流里

out.flush();

out.close();

}

}

CounterApp:桌面程序 "报会员号"

CounterApp是独立 Java 程序,核心是 "手工解析 Cookie、传 Cookie":

java 复制代码
package javaservlets.session;

import java.io.*;

import java.net.*;

public class CounterApp {

private String url;

private String cookie; // 手工存Cookie(Session ID)

public static void main(String[] args) throws Exception {

CounterApp app = new CounterApp("http://localhost:8080/CounterJava");

// 调用5次,看计数是否连续

for (int i = 1; i <= 5; i++) {

int count = app.getCount();

System.out.println("第" + i + "次调用,计数:" + count);

}

}

public CounterApp(String url) {

this.url = url;

}

public int getCount() throws Exception {

URL u = new URL(url);

URLConnection con = u.openConnection();

// 关键:手工设置Cookie请求头,传Session ID

if (cookie != null) {

con.setRequestProperty("Cookie", cookie);

}

// 发送请求

con.setDoOutput(true);

DataOutputStream out = new DataOutputStream(con.getOutputStream());

out.flush();

out.close();

// 读取响应:计数

DataInputStream in = new DataInputStream(con.getInputStream());

int count = in.readInt();

in.close();

// 第一次调用:手工解析set-cookie头,拿到Session ID

if (cookie == null) {

String setCookie = con.getHeaderField("Set-Cookie");

if (setCookie != null) {

// 截取Session ID(比如jrunsessionid=123; path=/ → 取前面部分)

cookie = setCookie.split(";")[0];

System.out.println("拿到Session ID:" + cookie);

}

}

return count;

}

}

当年运行CounterApp,输出是这样的:

java 复制代码
拿到Session ID:jrunsessionid=917315535100303809

第1次调用,计数:1

第2次调用,计数:2

第3次调用,计数:3

第4次调用,计数:4

第5次调用,计数:5

看到计数连续涨,我特别兴奋 ------ 这说明 "手工传 Session ID" 成功了!这个例子让我彻底明白:会话跟踪的核心不是 Cookie 或 URL,而是 "传递唯一标识(Session ID)"------ 不管是浏览器自动传(Cookie)、URL 拼(重写),还是程序手工传(请求头),本质都一样。

五、 会话事件

会话事件是 "档案本" 的 "动态通知":客人办卡(会话创建)时送优惠券,客人长期不来(会话销毁)时清理档案。当年我用Binder servlet 和SessionObject,实现了这种 "通知"。

SessionObject:绑定 / 解绑时 "发通知"

SessionObject实现了HttpSessionBindingListener接口,在 "绑定到会话" 和 "从会话解绑" 时触发方法:

java 复制代码
package javaservlets.session;

import javax.servlet.http.*;

import java.util.Date;

public class SessionObject implements HttpSessionBindingListener {

// 绑定到会话时触发(客人办卡,存档案)

public void valueBound(HttpSessionBindingEvent event) {

System.out.println(new Date() + ":对象绑定到会话 " + event.getSession().getId());

// 当年在这里初始化资源,比如打开数据库连接

}

// 从会话解绑时触发(会话销毁,清档案)

public void valueUnbound(HttpSessionBindingEvent event) {

System.out.println(new Date() + ":对象从会话 " + event.getSession().getId() + " 解绑");

// 当年在这里释放资源,比如关闭数据库连接

}

}

Binder servlet:给会话 "绑对象"

Binder servlet 很简单,就是把SessionObject绑定到会话:

java 复制代码
package javaservlets.session;

import javax.servlet.*;

import javax.servlet.http.*;

public class Binder extends HttpServlet {

public void doGet(HttpServletRequest req, HttpServletResponse resp)

throws ServletException, java.io.IOException {

HttpSession session = req.getSession(true);

// 把SessionObject绑定到会话,触发valueBound

session.putValue("Binder.object", new SessionObject());

resp.setContentType("text/html");

PrintWriter out = resp.getWriter();

out.println("<html><body>对象已绑定到会话 " + session.getId() + "</body></html>");

out.flush();

}

}

当年运行Binder,服务器控制台会输出:

java 复制代码
2005-10-26 20:30:00:对象绑定到会话 3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D

等会话过期(30 分钟后),又会输出:

java 复制代码
2005-10-26 21:00:00:对象从会话 3F9A7C2D1E4B5F6A7C8D9E0F1A2B3C4D 解绑

这个例子让我明白:会话事件不是 "花架子",而是 "资源管理的好工具"------ 比如在valueBound里打开数据库连接,valueUnbound里关闭,避免会话销毁后连接泄漏。当年做电商的订单模块,我就是用这个逻辑管理数据库连接,没再出现过 "连接池满" 的问题。

最后小结:

当年写这些 Servlet 时,还在用 JDK 1.4、Servlet 2.3,没有 Spring Boot,没有 Redis,全靠基础 API 一点点拼。但正是这些简单的代码,让我吃透了会话管理的核心:

  • "记住" 的本质:给用户分配唯一的 Session ID,通过 Cookie、URL 等方式传递,服务器用 ID 找对应的 "档案本"(会话数据)。
  • "工具" 的选择:Cookie 是首选(自动传、不影响 URL),URL 重写是兜底(兼容禁用 Cookie 的场景),手工传 ID 是特殊场景(APP、桌面程序)。
  • "细节" 的重要:Cookie 的路径、域名、有效期,会话的过期时间,资源的绑定 / 解绑 ------ 这些细节决定了会话管理的稳定性和安全性。

现在的开发用 Spring Session、Redis 存会话,用 JWT 替代 Session ID,但底层逻辑和当年的Counter、Cookies servlet 没区别。就像现在的餐馆用电子会员卡(小程序),但 "记住客人" 的核心需求没变,只是 "会员卡" 的形式变了。

这些当年的代码,就像 "后厨的基础厨具"------ 虽然简单,但教会我的不只是 API,还有 "解决问题的思路":排查会话问题,就从 "Session ID 是否不变→标识是否传递→数据是否存对" 一步步查。这种思路,比任何框架都重要。

相关推荐
SheepHappy6 小时前
MyBatis-Plus 源码阅读(一)CRUD 代码自动生成原理深度剖析
java
狂奔小菜鸡6 小时前
Day7 | Java的流程控制详解
java·后端·编程语言
霸道流氓气质6 小时前
Java中使用Collator实现对象List按照中文姓名属性进行A-Z的排序实现
java·开发语言·list
ttghgfhhjxkl6 小时前
《macOS 配置 GO 语言后,如何切换不同 GO 版本?》
开发语言·macos·golang
我命由我123456 小时前
Android PDF 操作 - AndroidPdfViewer 弹出框显示 PDF
android·java·java-ee·pdf·android studio·android-studio·android runtime
2501_938791226 小时前
服务器恶意进程排查:从 top 命令定位到病毒文件删除的实战步骤
java·linux·服务器
不见长安在6 小时前
HashMap的源码学习
java·hashmap
星光一影6 小时前
基于Jdk17+SpringBoot3AI智慧教育平台,告别低效学习,AI精准导学 + 新架构稳跑
java·学习·mysql
SimonKing6 小时前
Spring Boot全局异常处理的背后的故事
java·后端·程序员