Ohhnews

分类导航

$ cd ..
DZone Java原文

使用自定义SOAP处理器在JAX-WS服务调用间保持Cookie

#jax-ws#soap#cookie#会话保持#java

再也没有回来的 Cookie

第一次调用认证了用户,第二次调用却失败了。

我当时正在集成一个客户提供的 SOAP 服务,该服务通过单个 WSDL 暴露,JAX-WS 客户端类由 IBM Rational Application Developer 根据 WSDL 及其 XSD 生成。第一次调用认证了用户,并返回了一个 Set-Cookie 头。之后的每一次调用都需要这个 Cookie。生成的客户端却没有发送任何 Cookie。每次调用都表现得像一次独立会话,工作流在第二步就中断了。没有异常,没有警告。只有一个已经忘了你是谁的服务端。

[LOADING...]

内置答案,以及它为何失败

JAX-WS 规范预见到了这一点:从 WSDL 生成的每个代理都实现 BindingProvider,规范定义了一个请求上下文属性,专门用于此目的:设置该标志后,运行时理应维护会话状态。在大多数教程里,文章到这里就结束了。我的方案在部署时因三个原因失败。

第一是作用域。该属性设置在单个代理实例上,而运行时的 Cookie 存储也随该代理存在。RAD 生成的代码鼓励按工作单元获取新的端口。每个新端口都会得到新的存储,因此方法返回时认证会话就被丢弃了。为整个工作流持有一个代理可以解决此问题,但会把会话生命周期绑定到对象生命周期。

第二是不透明。规范没有说明运行时把 Cookie 保存在哪里。JDK 捆绑实现委托给 java.net.CookieHandler.getDefault(),除非有人安装过,否则它为 null。IBM 的运行时——RAD 生成的客户端在 WebSphere 下通常运行于其上——则按自己的方式处理。相同的客户端类,在不同容器中行为不同,而且失败是静默的。

第三是那个显而易见的修复:直接内联安装一个默认的 CookieManager。

$ java
CookieHandler.setDefault(new CookieManager(null, CookiePolicy.ACCEPT_ALL));

这是一个 JVM 全局静态设置。在独立客户端中没问题,但在应用服务器内部,它会被每个已部署应用以及 JVM 中的每个出站 HTTP 客户端共享。它对于服务端客户端也是错误的模型:CookieManager 是为浏览器构建的,在浏览器中一个 Cookie 罐属于一个用户。服务器处理并发请求时,会把每个用户的会话 Cookie 汇集到同一个存储中;而且 CookieManager 会应用 RFC 的域和路径匹配,因此如果服务位于负载均衡器之后,Cookie 就会被悄悄丢弃。

可选方案

我希望 Cookie 处理的作用域限于调用、在代码中可见,并且独立于容器。其他方案都不够好。

为整个工作流持有一个代理是改动最小的方案,但它把会话绑定到对象的生命周期,而且跨组件或线程时压力就会显现。

安装默认 CookieManager 只需一行,在独立客户端中有效,但在应用服务器内部,它会变成跨应用和跨用户共享的可变状态,因此任何并发场景都不能采用。

基于纯 HTTP 手写客户端可以完全控制头部,且没有容器带来的意外,但会失去 WSDL 工具链:生成的类型、绑定、错误处理。为多个操作重建这套机制工作量太大。

自定义 SOAP 处理器是第四种选择:大约六十行你自己维护的代码,位于现有生成客户端的消息路径中。没有 JVM 全局状态,没有容器特定逻辑,也没有手改生成类。代价是:你按名称匹配 Cookie,而不是信任 RFC 规则;并且你要负责存储值的生命周期。对于这次集成来说可以接受。

处理器如何工作

处理器的契约是 SOAPHandler;方向来自上下文中的一个布尔值。下面的说明是示意性的,改编自我实际使用的类,并不作为经过测试的代码提供。该实现中有三个细节之所以存在,是因为早期版本出过问题。

在添加 Cookie 之前复制现有请求头,可以保持处理器的增量性:把全新的 map 放入 HTTP_REQUEST_HEADERS 会替换掉原有内容,而我的第一版正是这么做的,直到处理链下游的另一个处理器设置了自定义授权头,然后它消失了。以不区分大小写的方式匹配头名称是为了可移植性,因为大小写不敏感的 map 是实现选择,而不是规范保证。按第一个分号拆分,可以把 Path、Expires 和 HttpOnly 等响应属性排除在请求之外;把它们回显回去会生成服务器无法解析的 Cookie 值。遍历每个值而不是取索引零,可以覆盖设置了多个 Cookie 的响应,在这种情况下不保证会话 Cookie 排在第一个。

为什么用 ThreadLocal,以及它会在哪里出问题

该集成并发运行多个工作流,因此静态字段会把一个用户的会话泄漏到另一个用户的请求中。ThreadLocal 为每个线程提供自己的值,这适合在一个线程上从头到尾运行的工作流。代价是线程池会复用线程。如果工作流结束时没有清除该值,该线程上的下一个请求就会继承一个过期的会话 Cookie,最坏情况下会以另一个用户的身份发送某个用户的会话标识符。在工作流结束时清除它可以堵住这个漏洞:SESSION_COOKIE.remove();

注意,close() 故意为空:容器会在每次消息交换结束时调用它,而不是工作流结束时调用,因此在那里清除会把认证调用得到的 Cookie 丢弃。清理应放在登出步骤,或工作流外层的 finally 块中。而且,如果工作流在线程之间移交,ThreadLocal 就是错误的容器:此时 Cookie 应归属于已经表示该会话的那个对象。

注册处理器

JAX-WS 允许在外部 XML 文件中声明处理器:该文件与生成的服务类位于同一个包中,因为注解相对于该类解析:从该服务生成的每个代理现在都会经过该处理器。一个注意事项:根据更新后的 WSDL 重新生成会覆盖服务类并移除注解,因此请把它放在一个小的子类中,或为重新生成步骤编写脚本。

边界情况

Cookie 轮换 不需要特殊处理:捕获在每次入站响应时运行,因此工作流中途出现的新 Cookie 会在下一次交换时替换旧值。过期则不同:ThreadLocal 无法知道服务端会话何时超时,因此空闲的工作流会持续附带一个失效 Cookie。这个修复应放在工作流层面:捕获会话过期响应并重新认证。

无需容器的测试

测试可以构建一个桩消息上下文,注入 HTTP_RESPONSE_HEADERS,运行 handleMessage,并检查下一次出站调用上的 Cookie 头。覆盖那些曾让早期版本出问题的情形:一个响应中有两个 Set-Cookie 值,头名称出现意外大小写,一个必须能在复制后保留的既有请求 map,以及工作流末尾的 remove()。为了获得集成信心,可以让真实客户端对着一个桩端点运行:该端点在第一次调用时返回 Set-Cookie,并在第二次调用时断言 Cookie 头。

要点

JAX-WS 有会话机制。它的作用域是代理实例,在不同运行时中实现不同,并且由一个 JVM 全局单例支撑,而这个单例不适合处理并发用户的服务端客户端。自定义 SOAP 处理器用一个你拥有的小类取代它,作用域由你显式选择,并且无论应用部署在哪里行为都一致。对于维护短期内不会现代化的遗留 SOAP 集成的团队来说,这种取舍通常是正确的。

DZone 贡献者表达的观点仅代表他们自己。