Java EE 企业应用 06:一个 HTTP 请求怎样进入 Servlet
同一个 Servlet 为什么不能保存本次申请 ID
采购员访问申请查询入口,带来申请 ID、租户信息和可能重复的查询参数。看起来只需在 Servlet 中解析 ID、调查询服务、输出结果。如果把 requestId 临时写入 Servlet 实例字段,另一个请求可能在第一个请求尚未输出时覆盖它;若先把响应发给客户端,再检查租户归属,即使发现越权也收不回已经发送的字节。Web 入口的第一条契约不是某个注解,而是每个请求有自己的输入、输出与终止时点。容器可复用一个 Servlet 实例,不能把这个实例当作每次请求独占的工作区。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
本系列仍在隔离的 Open Liberty 26.0.0.5、Jakarta EE 11 Platform、Java 21、PostgreSQL 16.15 上讨论采购审批;本章只核对 HTTP/Servlet 边界,不拿一次回声响应证明采购查询、鉴权和数据库读写。工程已有 RequestEchoServlet.java 和 06-servlet.sh,但没有 Servlet 申请查询接口。单独的 REST 演示接口目前只有创建、状态变更和下单,连 GET 按 ID 查询都尚未添加;不可把计划表中的“实现申请查询”倒写成既有事实。
映射先选目标,再选择 HTTP 方法
Jakarta Servlet 6.1 §2.3 和 §12 规定 Servlet 生命周期与映射。容器装载类、创建实例、初始化后处理请求,在下线时销毁实例;对同一 Servlet 的并发请求可以由不同线程进入 service()。@WebServlet("/servlet/echo") 在当前 WAR 上对应 /procurement/servlet/echo:/procurement 来自部署的 context root,后半段来自注解。请求若没有匹配的映射,不会因为工程里有同名 Java 类就自动调用它。若还叠加 web.xml 映射,应检查最终生效的部署描述,不能仅从注解预测路径。
HttpServlet 根据 HTTP 方法分派到 doGet()、doPost() 等方法;当前回声类只覆写 doGet(),对 POST 不会自动执行“同样的查询”。Servlet 6.1 的 HTTP 协议支持章节规定未受支持的方法应由容器按相应语义响应;现有脚本把 POST 得到 405 当作具体待检判据,不是声称所有 Web 应用对任何 POST 都统一返回 405。HEAD/OPTIONS 与浏览器重试也各有不同语义。设计查询入口时选 GET 表示不改变采购状态,把提交、审批和下单留给显式写方法;GET 不是强制无副作用的魔法开关,服务实现仍须避免执行修改。缓存层可复用 GET,跨租户申请尤其要先授权并给出合适的缓存策略。
一个拟议的 GET /servlet/requests?id=... 需要在同一请求里确定正整数 ID、可信租户身份及最终响应状态,随后调用只读查询用例;现在既无此映射,也没有“可信租户”的认证实现。读者可以先用 /servlet/echo 验证请求参数和生命周期,待第 24–26 篇具备认证和对象权限后再启用真正的申请查询。若只用可伪造的 X-Lab-Tenant 演示头测试租户谓词,须保持 loopback 与实验开关,绝不能把不同头值返回不同结果当作真实身份隔离。先行 JPA 09:采购审批综合应用 的实体模型使用独立数据库及持久化接口,本系列查询仍采用 JDBC、显式 SQL 和自己的受管 DataSource;业务状态可对照,运行证据不能互换。
参数集合不是单个安全字符串
Servlet 6.1 §3.1 允许从请求取得 HTTP 参数,但参数既可能来自 URL 查询串,也可能来自符合条件的表单请求体。getParameter("value") 只给出一个值;对同名参数重复传入时,首先应该决定业务策略:拒绝歧义,还是明确定义多值的顺序与最大数。当前回声类使用 getParameterValues("value"),要求 1–5 项,每项 Java 字符串长度不超过 100,用竖线连接输出;?value=采购&value=审批 的预期响应是 采购|审批。这个长度检查按 UTF-16 String.length() 计数,不等于网络字节数、Unicode 字素数,更不是 SQL 安全证明;本例只演示有界输入和可重复参数,采购 ID 不应照搬这个策略。
编码有两条方向。URL 参数先由容器按请求字符解码策略解析,响应体要按声明的媒体类型和字符集编码。当前 Servlet 在 getParameterValues 之前调用 request.setCharacterEncoding("UTF-8"),获取 Writer 前再设置 setContentType("text/plain;charset=UTF-8");脚本用 curl -G --data-urlencode 编码中文查询值。Servlet 规范对请求体的编码设置与 URI 查询参数的具体解码途径并不完全等价,因此本机修复成功不等于所有服务器、代理的 URI 字节都按相同规则解析。对请求体直接调用 getReader() 或 getInputStream(),还涉及内容类型与参数解析的先后限制。增设 JSON 查询参数或表单时要分别覆盖多字节输入、非法编码、超长值与空值;不能只拿 ASCII ID 验收国际化。
这个顺序曾真实影响回声实验:编码设置缺失的先前 WAR 收到双中文参数后,脚本输出乱码、退出码 1;改为在参数解析前设置 UTF-8 并重新构建部署后,回声输出 采购|审批,脚本退出 0。归档 writing-plans/javaee-enterprise/verification/20261004T-a-batch-recheck/ 保存修复前后的源码哈希、WAR 摘要、构建输出和脚本记录。失败发生在中文解码而不是数据库写入;更不能把此次字节差异推广为所有代理都需要同样的设置。
更要提防参数粘连:如果把第一个 tenant 当作授权依据,却把最后一个同名 tenant 传给 DAO,不同层对重复参数取值的选择会变成权限漏洞。真实租户必须来自已验证的身份上下文;即使为了演示读取一个不可信头,也应在查询服务层使用明确的租户谓词,而不能信任浏览器可自行构造的单据 ID。对非法或重复的采购 ID,最清楚的合同是拒绝并返回固定错误格式,不让“读取第一个值”成为隐含业务规则。当前回声类对超过五个值或缺失 value 返回 400,却不拒绝两个相同值;它不是按 ID 查询的现成安全处理器。
响应提交之后不能重新决定状态
HttpServletResponse 在写出足够数据、显式 flush、重定向或容器完成请求后可能处于已提交状态(Servlet 6.1 §5)。在写出申请明细前应完成状态检查、授权及必要的数据库读取。调用 sendError(400, ...) 后应立即停止本次处理;现有 RequestEchoServlet 在错误分支显式 return,因此不会继续写回声。若先写部分 JSON,再发现数据库抛错,不能依赖一个迟到的 sendError(500) 使客户端忘掉前面的成功字节。更稳妥的办法是构造好有界结果和明确的状态码,再写响应;大流量响应则须承认流式输出与事后报错是另一种协议设计。
共享实例只保存不可变配置或线程安全依赖。请求的 value 数组、申请 ID、响应 Writer、认证主体与失败原因都应留在方法局部或请求作用域对象中,不能放入 Servlet 成员字段、静态变量或复用的 StringBuilder。即便将一个字段声明为 volatile,也只能提供特定可见性语义,不能把“读取 A 的 ID、写出 A 的结果”变成跨线程原子序列。Servlet 容器可以同时调用同一实例,符合生命周期规范不意味着容器替应用串行化业务代码。不同请求上的时间线要用独立请求 ID 关联;仅凭一张并发客户端成功截图无法证明实例字段从未混用。
申请查询结果如果含多项明细,还需要区分 Web 输出与数据库读取的生命周期。先在受管调用中完成按可信租户条件的查询,得到必要数据的不可变投影,再由 Servlet 将数据序列化到响应;不能在连接已关闭后把尚未读取的数据交给模板惰性遍历。累计工程使用 JDBC 显式 SQL;若换成 JPA 延迟加载会引入另一套上下文边界,可阅读 JPA 06:抓取边界,但不能把其查询次数算到本篇。对于未命中,还需区分 ID 格式错误与合法 ID 不存在:前者按协议给 400,后者可给 404;对另一租户的对象,不能在错误正文泄漏存在性,具体权限规则待后续身份章节实现。
Servlet 的异常路径要区分两次写入之间的失败。在 getWriter() 之前查询失败,仍可由明确的处理器决定 5xx;已经写出部分明细再失败,响应可能已提交。此时应在服务端保存异常和请求关联 ID,让客户端知道输出无效,而非把“错误 JSON”附加到截断的成功 JSON 后面。对短 JSON,可以先构造有界 DTO、完成授权与校验后再输出;对真正的大文件流,要设计可检测不完整输出的协议。本例只输出有限个短字符串,没有模拟输出中途数据库断开,因此不把这段设计推导写成运行结果。
入口、实验及证据边界
在 examples/javaee-enterprise/ 用专用 javaee_lab、绑定 127.0.0.1 的容器与独立凭证配置部署 WAR,构建入口是 ../hibernate-lab/mvnw -B -ntp -f "$PWD/pom.xml" clean verify。server.xml 通过 JAVAEE_WAR 选择部署包;先核对实际包摘要和上下文根,免得打到旧版本。现有脚本无需启用 JAVAEE_DEMO_MODE 或写数据库,只需服务运行并设置端口:
1 | |
脚本检查中文双值的响应体、X-Request-ID 响应头是否存在、缺少参数的 400 与 POST 的 405。它使用临时文件并在退出时清理,但未归档每次响应的完整原始头,也没有并发、参数超长、响应提交后异常或真正申请查询的断言。修复后的 20261004T-a-batch-recheck 归档 scenario-06-servlet-after-fix.stdout.txt 和退出码文件(0):双中文参数得到 采购|审批,无参数 HTTP 400,POST 为 HTTP 405;构建与部署 WAR 的 SHA-256 一致。该脚本覆盖的正常/失败子路径 PASS;六值、101 字符、错误编码、申请查询、并发隔离均为 NOT_RUN。头值唯一性和错误正文未被脚本检查,不能由零退出推出。
补足失败路径时,用同一隔离实例先发零个、六个 value 与 101 个字符的值,检查 400 且正文不含任何未授权数据;再发 POST 验证实际状态。构造并发请求时至少用两个彼此不同的随机 value 和各自的 X-Request-ID,等待全部响应结束,逐个核对请求与响应一一对应、无交叉串值;还要记录超时与非 200 的完整响应。06-servlet.sh 当前只覆盖前三类中的“零值”和错误方法,对超长值及并发须新增受控场景脚本或独立测试;受限所有权下本文只给出判据,不宣称已添加。若下一阶段实现申请查询,另外检查不存在申请为 404、非数字 ID 为 400、跨租户访问由真实身份上下文拒绝;该入口当前不存在,明确 NOT_RUN。实验清单见 06实验说明。
接口验收还须固定客户端连接的观察条件。相同回声 Servlet 在未携带参数、错误 HTTP 方法、连接超时、请求过大时,失败可能分别出现在方法内、HttpServlet 分派、容器限制或代理层。仅比较最后的状态码会把这些根因压平;保存请求方法、原始 URL 编码、请求头、响应提交时点与服务端异常,才能区分“业务拒绝”和“压根没进入本 Servlet”。代理和服务器各自限制 URL 长度,Servlet 规范没有统一数值;Java 的 100 字符检查不能充当入口唯一的大小保护。查询真正的采购数据时,服务层也应有分页和投影边界,免得读端点成为全表扫描与超大响应通道;本篇没有这样的查询 SQL 或容量测试,保持 NOT_RUN。
两道练习与答案
练习一: 两个请求 A(value=采购)与 B(value=审批)访问同一个把 value 暂存实例字段的 Servlet。A 写字段后暂停,B 覆盖字段并返回,A 再读取字段。A 可能返回什么?用 synchronized 包住输出是否是合理的首选修复?
答: A 可能错误地返回“审批”,因为实例字段在两请求间共享。把值改为 doGet() 的局部变量,使每次 getParameterValues() 与输出使用同一请求的引用;不应为防串值而默认序列化所有请求,串行化会把并发与慢请求问题转移到吞吐和尾延迟。验收时用 A、B 不同值并发,分别检查返回内容和关联 ID,而不是仅看总请求数。
练习二: 申请查询拟议入口收到 ?id=12&id=13,实现先用 getParameter("id") 做租户检查,随后用 getParameterValues("id")[1] 查数据库。结果 12 属于当前租户,13 属于另一租户。应返回什么;把错误写到输出流后再 sendError 可行吗?
答: 应把同名 ID 多值判为非法请求并返回 400,且在查询任何申请前完成参数规范化;身份应来自已认证上下文而非提交的 ID。不能先写出第 13 号申请的一部分再送错误状态,响应可能已经提交,泄漏已发生。修正后对缺值、重复值、超界 ID、跨租户 ID 分别写断言;这些是待实现入口的练习,不是现有 Servlet 已跑的测试。
适用范围与官方资料
回声 Servlet 帮助拆开映射、参数编码、线程共享与错误响应四个可检验点;它不提供采购申请查询、认证、安全日志、并发数据库一致性或出站通知。已有修复后的归档本地通过记录,未覆盖路径仍为 NOT_RUN;本站写稿没有代替用户重新运行实验。远端 GitLab master 源码链接须在工程提交/推送后逐个复核,当前工作树有未提交的新工程文件,不能宣称远端已提供相同字节。






