接手過跨語言整合的人,多半有過這個經驗:
對方丟一個 .asmx?WSDL 或 .svc?wsdl 的網址給你,說「這是我們的 SOAP 服務,你呼叫一下」。你用 wsdl2java 產出一堆 stub,幾行程式呼叫進去——能跑就交差。
但很快地,奇怪的事情會冒出來:
- 同樣的參數,從 SoapUI 打過去成功,從 Java 打過去
500 - 對方說「我們有收到請求,但欄位是空的」
- 你抓不到任何錯誤訊息,因為 stub 把 SOAP Fault 包成 Java exception,內容只剩一行 message
這時候你需要做的事很單純:把實際送出去的 XML 印出來。看它跟對方期待的長得一不一樣。
這篇會從 SOAP 信封的結構開始,示範用 JAX-WS 攔截 inbound/outbound XML,最後用 curl 與 HttpURLConnection 直接打 HTTP POST,繞過所有 stub。
SOAP 信封長什麼樣
SOAP 的本質很單純——一個帶固定外殼的 XML 而已。外殼叫 Envelope(信封),裡面包 Header 和 Body。
一個最小的 SOAP 1.1 請求長這樣:
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:web="http://tempuri.org/">
<soap:Header>
<!-- 認證、WS-Addressing 等 metadata,可省略 -->
</soap:Header>
<soap:Body>
<web:GetUserInfo>
<web:userId>123</web:userId>
</web:GetUserInfo>
</soap:Body>
</soap:Envelope>
幾個容易踩到的細節:
Envelope 的 namespace 決定了 SOAP 版本。http://schemas.xmlsoap.org/soap/envelope/ 是 1.1,http://www.w3.org/2003/05/soap-envelope 是 1.2。.NET 的 ASMX 預設只接 1.1,WCF 兩個都行。
而 web: 那個 namespace 不是 SOAP 本身的,是「服務自己的 namespace」。.NET 沒指定的話,預設值就是 http://tempuri.org/——這是 Visual Studio 沒改過的 placeholder。照理應該換掉,但你會驚訝它在多少 production 服務裡還留著。
最後是 Header。它是選填的,沒東西就整個拿掉,不要放個空 <soap:Header/> 在那邊。
回傳長類似這樣:
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetUserInfoResponse xmlns="http://tempuri.org/">
<GetUserInfoResult>
<Name>Albert</Name>
<Email>albert@example.com</Email>
</GetUserInfoResult>
</GetUserInfoResponse>
</soap:Body>
</soap:Envelope>
如果伺服端拋例外,Body 裡會換成 <soap:Fault>:
<soap:Body>
<soap:Fault>
<faultcode>soap:Server</faultcode>
<faultstring>Object reference not set to an instance of an object.</faultstring>
<detail/>
</soap:Fault>
</soap:Body>
JAX-WS 看到 Fault 就把它轉成 SOAPFaultException。Java 端拿到的只剩 faultstring——detail 裡可能還有重要的錯誤碼,但預設不會印出來。這就是為什麼你得自己把 raw XML 拿到手。
用 wsdl2java 產 client stub
從 WSDL 產 Java client 的工具有兩派:JDK 內建的 wsimport(JDK 11 以後拿掉了,得從 jakarta.xml.ws-api 另外裝)跟 Apache CXF 的 wsdl2java。新專案我會選 CXF——維護比較活,產出來的 stub 也比較容易客製。
假設對方的 WSDL 在 https://example.com/UserService.asmx?WSDL,產 stub 的指令:
wsdl2java -d ./src/main/java -p com.example.client.user \
https://example.com/UserService.asmx?WSDL
-p 是強制塞 package name,不然會用 WSDL 裡的 namespace 反推,常常產出像 org.tempuri 這種你不想看的東西。
接著就能呼叫:
UserService service = new UserService(); // service locator
UserServiceSoap port = service.getUserServiceSoap(); // SOAP port
GetUserInfoResponse resp = port.getUserInfo(123); // 真正的呼叫
System.out.println(resp.getName());
到這一步看起來很乾淨——但底層送出去的 XML,你完全沒看到。
把送出與回傳的 XML 印出來
JAX-WS 有兩種方法可以攔截到 raw XML。
方法一:System Property(一行搞定)
最快的方式是在啟動時加 JVM 參數:
-Dcom.sun.xml.ws.transport.http.client.HttpTransportPipe.dump=true
-Dcom.sun.xml.ws.transport.http.HttpAdapter.dump=true
JDK 內建 JAX-WS 的話用上面那組。如果是 standalone 的 metro 套件,前綴要改成 com.sun.xml.internal.ws.*。版本沒對到就靜悄悄沒輸出——這是它最討厭的地方。
開起來大概長這樣:
---[HTTP request - https://example.com/UserService.asmx]---
SOAPAction: "http://tempuri.org/GetUserInfo"
Content-Type: text/xml; charset=utf-8
<?xml version="1.0" ?>
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">...
---[HTTP response - 200]---
<?xml version="1.0" ?>
<soap:Envelope ...>...
優點是不用改 code。缺點是格式固定、混在 stdout、要關掉得重啟。production 環境通常用不上。
方法二:SOAPHandler(推薦)
SOAPHandler<SOAPMessageContext> 是 JAX-WS 給的攔截器介面。在送出前、收到後都會呼叫,你能在裡面拿到完整的 SOAPMessage。
import jakarta.xml.ws.handler.soap.SOAPHandler;
import jakarta.xml.ws.handler.soap.SOAPMessageContext;
import jakarta.xml.ws.handler.MessageContext;
import jakarta.xml.soap.SOAPMessage;
import java.io.ByteArrayOutputStream;
import java.util.Set;
public class XmlLoggingHandler implements SOAPHandler<SOAPMessageContext> {
@Override
public boolean handleMessage(SOAPMessageContext context) {
boolean outbound = (Boolean) context.get(
MessageContext.MESSAGE_OUTBOUND_PROPERTY);
dump(context.getMessage(), outbound);
return true;
}
@Override
public boolean handleFault(SOAPMessageContext context) {
// 伺服端回 Fault 時走這裡,預設行為跟 handleMessage 一樣
dump(context.getMessage(), false);
return true;
}
@Override
public void close(MessageContext context) {}
@Override
public Set<javax.xml.namespace.QName> getHeaders() {
return null; // 不處理特定 Header
}
private void dump(SOAPMessage msg, boolean outbound) {
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
msg.writeTo(out);
System.out.println(
(outbound ? ">>> Outbound SOAP" : "<<< Inbound SOAP") + ":\n"
+ out.toString("UTF-8") + "\n");
} catch (Exception e) {
e.printStackTrace();
}
}
}
掛到 stub 上:
UserService service = new UserService();
UserServiceSoap port = service.getUserServiceSoap();
BindingProvider bp = (BindingProvider) port;
List<Handler> chain = bp.getBinding().getHandlerChain();
chain.add(new XmlLoggingHandler());
bp.getBinding().setHandlerChain(chain);
port.getUserInfo(123); // 現在每次呼叫都會印出 raw XML
這做法的好處是,可以把 XML 餵進 logger,按照 log level 控制要不要印;也能加 request id、做敏感欄位遮蔽。實務上送 production 都選這條路。
看到 XML 之後通常會發現什麼
最常見的問題是 namespace 不對。wsdl2java 在 WSDL 改版時很容易把舊 namespace 寫死在 generated class,對方升級之後你 stub 沒重產,送出去的元素 namespace 全錯——伺服端拿到一個空物件,因為 namespace 不 match 就被當成不存在的欄位丟掉。
接著常見的是 SOAPAction header 漏掉或不對。.NET ASMX 對這個 header 很龜毛,沒帶或字串對不上會直接 400。raw XML 印出來時,順便看一下 HTTP header,比較好抓。
還有一個是字串編碼。Content-Type 沒寫 charset=utf-8、但 body 是 UTF-8 的話,.NET 預設用 ISO-8859-1 解碼,中文整個變亂碼。
用純 HTTP POST 重現一次
知道 raw XML 長什麼樣之後,下一步是繞過 stub,自己組 HTTP POST。這在三個情境特別有用:
- 拆掉 stub 的中間層,確認問題到底在 Java 還是在伺服端
- 寫整合測試時,不想拉整套 JAX-WS 依賴
- 把同樣的 request 給 PM 用 curl 重現,方便回報
curl 版本
curl -v -X POST https://example.com/UserService.asmx \
-H 'Content-Type: text/xml; charset=utf-8' \
-H 'SOAPAction: "http://tempuri.org/GetUserInfo"' \
--data-binary @request.xml
SOAPAction 那層雙引號是 SOAP 1.1 規範要求的,不是打錯——值本身就要被雙引號包起來。--data-binary 比 -d 好,因為後者會把換行吃掉。
request.xml 就是前面看過的 Envelope。
Java HttpURLConnection 版本
String endpoint = "https://example.com/UserService.asmx";
String soapAction = "http://tempuri.org/GetUserInfo";
String body = """
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:web="http://tempuri.org/">
<soap:Body>
<web:GetUserInfo>
<web:userId>123</web:userId>
</web:GetUserInfo>
</soap:Body>
</soap:Envelope>
""";
HttpURLConnection conn = (HttpURLConnection) new URL(endpoint).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "text/xml; charset=utf-8");
conn.setRequestProperty("SOAPAction", "\"" + soapAction + "\"");
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
int status = conn.getResponseCode();
InputStream is = status < 400 ? conn.getInputStream() : conn.getErrorStream();
String response = new String(is.readAllBytes(), StandardCharsets.UTF_8);
System.out.println("HTTP " + status);
System.out.println(response);
幾個容易忽略的地方:
SOAPAction的值要自己加雙引號("\"" + soapAction + "\"")。漏了 .NET 會回 400。- 失敗的時候要從
getErrorStream()讀,不是getInputStream()——getInputStream()在 5xx 時會直接拋 IOException,連 Fault body 都拿不到。 - Content-Type 一定要含
charset=utf-8。
JDK 11 以後也可以用 HttpClient,邏輯一樣,少了 try-with 的麻煩:
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Content-Type", "text/xml; charset=utf-8")
.header("SOAPAction", "\"" + soapAction + "\"")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(resp.statusCode());
System.out.println(resp.body());
SOAP 1.2 / WCF 的不同之處
如果對方是 WCF 而且開了 SOAP 1.2 binding,幾個東西要換:
| 項目 | SOAP 1.1(ASMX) | SOAP 1.2(WCF) |
|---|---|---|
| Envelope namespace | http://schemas.xmlsoap.org/soap/envelope/ | http://www.w3.org/2003/05/soap-envelope |
| Content-Type | text/xml; charset=utf-8 | application/soap+xml; charset=utf-8 |
| SOAPAction | HTTP header | 併入 Content-Type 的 action 參數 |
| Fault 結構 | <faultcode>/<faultstring> | <Code>/<Reason> |
WCF 的 SOAPAction 寫在 Content-Type 裡:
Content-Type: application/soap+xml; charset=utf-8; action="http://tempuri.org/IUserService/GetUserInfo"
光是搞錯這個,就足夠卡住一整個下午。
小結
SOAP 看起來像個被時代淘汰的東西,但在企業內網、政府服務、銀行 API 裡到處都是。Java 呼叫 .NET 的場景尤其常見,一邊是基層服務,一邊是接整合的人。
碰到這種整合時,stub 是工具,不是抽象層。它幫你省下手寫 XML 的時間,但出問題那一刻,你還是得跳下去看 Envelope 裡到底裝了什麼。
把 raw XML 印出來、能用 curl 重現一次——這兩件事學會了,往後不管對方是 .NET、是 PHP、還是某個老到沒人敢動的 Java 5 服務,你都能在十分鐘內把問題定位到「是請求送錯」還是「對方解析錯」。剩下的,就只是 XML 改改而已。