Spring Web Service ─ Client 端實作
前言
先前在 Spring Web Service ─ Web Service 簡介與 Server 端實作 創建了書籍服務的 Server 端,本篇將會以 Spring Web Service (以下簡稱 Spring-WS)實作 Web Service Client 端呼叫書籍服務取得資料。
建立Web Service Client
1. 添加依賴
在 Spring 專案添加 Maven 依賴 spring-boot-starter-web-services 和 httpclient,前者是 Spring-WS 的集成套件、後者是用於設定連線逾時時間相關物件。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web-services</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
Spring-WS 是由多個 Spring 模組組成,它們的依賴關係如下:
- Spring-WS Core(spring-ws-core.jar):
提供 Server 端訊息調度和服務端點,以及 Client 端 Web Service 模板的模組。 - Spring XML(spring-xml.jar):
提供各種 XML 的支持類。 - Spring OXM(spring-oxm.jar):
提供 Java 物件和 XML 物件互相轉換的工具模組。 - Spring-WS Support(spring-ws-support.jar):
提供一些額外支援的模組,如 JMS(Java Message Service)或是 Email 等。 - Spring-WS Security (spring-ws-security.jar):
提供與 Core 模組與 Spring-Security 的集成,像是對 SOAP 訊息添加令牌(Token)、簽名(Sign)和加解密,以及用 Spring Security 實作身份驗證和授權。
2. 生成 Java 物件
首先從 Server 端下載 WSDL 文件並放入 resources 資料夾中(src/main/resources/book.wsdl)。
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:sch="http://www.example.com/book" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="http://www.example.com/book" targetNamespace="http://www.example.com/book">
<wsdl:types>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified" targetNamespace="http://www.example.com/book">
<xs:element name="getBookRequest">
<xs:complexType>
<xs:sequence>
<xs:element name="isbn" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="getBookResponse">
<xs:complexType>
<xs:sequence>
<xs:element name="book" type="tns:book"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:complexType name="book">
<xs:sequence>
<xs:element name="isbn" type="xs:string"/>
<xs:element name="name" type="xs:string"/>
<xs:element name="author" type="xs:string"/>
<xs:element name="publishing" type="xs:string"/>
<xs:element name="edition" type="xs:int"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
</wsdl:types>
<wsdl:message name="getBookRequest">
<wsdl:part element="tns:getBookRequest" name="getBookRequest">
</wsdl:part>
</wsdl:message>
<wsdl:message name="getBookResponse">
<wsdl:part element="tns:getBookResponse" name="getBookResponse">
</wsdl:part>
</wsdl:message>
<wsdl:portType name="BookPort">
<wsdl:operation name="getBook">
<wsdl:input message="tns:getBookRequest" name="getBookRequest">
</wsdl:input>
<wsdl:output message="tns:getBookResponse" name="getBookResponse">
</wsdl:output>
</wsdl:operation>
</wsdl:portType>
<wsdl:binding name="BookPortSoap11" type="tns:BookPort">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
<wsdl:operation name="getBook">
<soap:operation soapAction=""/>
<wsdl:input name="getBookRequest">
<soap:body use="literal"/>
</wsdl:input>
<wsdl:output name="getBookResponse">
<soap:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="BookPortService">
<wsdl:port binding="tns:BookPortSoap11" name="BookPortSoap11">
<soap:address location="http://localhost:8080/bookService"/>
</wsdl:port>
</wsdl:service>
</wsdl:definitions>
這裡提供兩種生成方式,一是用 maven plugin、二是用 command line 方式產生。
- 用 maven plugin 方式:
<generateDirectory> ─ 生成後的 Java 物件目標路徑。
<generatePackage> ─ 生成後的 Java 物件目標資料夾。
<schemaDirectory> ─ WSDL 檔所在位置或網址。
<schemaIncludes> ─ 包含的文件名稱。
<plugin>
<groupId>org.jvnet.jaxb2.maven2</groupId>
<artifactId>maven-jaxb2-plugin</artifactId>
<version>0.14.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<schemaLanguage>WSDL</schemaLanguage>
<generateDirectory>${project.basedir}/src/main/java</generateDirectory>
<generatePackage>com.example.demo.model</generatePackage>
<schemaDirectory>${project.basedir}/src/main/resources</schemaDirectory>
<schemaIncludes>
<include>book.wsdl</include>
</schemaIncludes>
</configuration>
</plugin>
- 用 command line 方式:
wsimport -keep -d [目標路徑] -p [目標 package 路徑] [ wsdl 路徑或網址]
用 command line 生成的資料夾多了 Interface 和 Service,但在 Spring-WS 實作上不會用到、刪除亦可,兩者生成的 Java 檔差異如下:
3. 創建 Client 服務
Client 服務可以繼承 Spring 的 WebServiceGatewaySupport 來實現,其中使用 WebServiceTemplate 的 marshalSendAndReceive 方法執行與 Web Service 的 SOAP 訊息交換;若不想繼承 WebServiceGatewaySupport ,也可以用創建 WebServiceTemplate 的 Bean 的方式來實做 Client 服務。
在訊息交換的過程中,Spring-OXM 模組負責將物件序列化(marshalling,物件轉為 XML)和反序列化(unmarshalling,XML 轉為物件),使我們不用費心於如何組成 XML 訊息並可以專注業務邏輯的實作。
在訊息交換的流程中我們捕捉了三種常見 Exception,
ConnectException:連線被拒絕,例如當 Server 端關閉時。
ConnectTimeoutException:連線途中或是等待可用的連線中逾時。
SocketTimeoutException:Server 處理回應中逾時。
package com.example.demo;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import org.apache.http.conn.ConnectTimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ws.client.core.support.WebServiceGatewaySupport;
import com.example.demo.model.GetBookRequest;
import com.example.demo.model.GetBookResponse;
public class BookClient extends WebServiceGatewaySupport {
private static final Logger LOGGER = LoggerFactory.getLogger(BookClient.class);
public GetBookResponse getBook(String isbn) {
GetBookRequest request = new GetBookRequest();
request.setIsbn(isbn);
GetBookResponse response = null;
try {
response = (GetBookResponse) getWebServiceTemplate().marshalSendAndReceive(request);
} catch (Exception ex) {
if (ex.getCause() instanceof ConnectException) {
LOGGER.error("Connect error ...", ex);
} else if (ex.getCause() instanceof ConnectTimeoutException) {
LOGGER.error("Connect time out error ...", ex);
} else if (ex.getCause() instanceof SocketTimeoutException) {
LOGGER.error("Read time out error ...", ex);
} else {
LOGGER.error("Other error ...", ex);
}
}
return response;
}
}
4. 配置 Client 服務
Jaxb2Marshaller:
我們為序列化工具設定 JAXB 物件(Java Architecture for XML Binding,將 Java 類映射為 XML 的表示方式)的位置,也就是方才我們從 WSDL 生成 Java物件的資料夾路徑。
WebServiceMessageSender:
為了避免等待 Server 端連線或是回應時間過長導致後續流程受阻,這裡用 WebServiceMessageSender 的實作類 HttpComponentsMessageSender 來控制等待的時間,setConnectionTimeout 表示與 Server 建立連線時的等待時間、setReadTimeout 表示等待 Server 回應的時間,單位都是毫秒(milliseconds)。
接者將以上配置設定到 Client 物件:
setDefaultUri 設定Server 端的地址,即與 WSDL 檔中 <soap:address location> 的值相同 ;setMarshaller、setUnmarshaller 設定序列化工具;setMessageSender 設定逾時時間;至於 setInterceptors 則是為了印出 SOAP 訊息所加的攔截器。
package com.example.demo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.oxm.jaxb.Jaxb2Marshaller;
import org.springframework.ws.client.support.interceptor.ClientInterceptor;
import org.springframework.ws.transport.WebServiceMessageSender;
import org.springframework.ws.transport.http.HttpComponentsMessageSender;
@Configuration
public class WebClientConfig {
@Bean
public Jaxb2Marshaller marshaller() {
Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
marshaller.setContextPath("com.example.demo.model");
return marshaller;
}
@Bean
public WebServiceMessageSender webServiceMessageSender() {
HttpComponentsMessageSender sender = new HttpComponentsMessageSender();
sender.setConnectionTimeout(5 * 1000);
sender.setReadTimeout(5 * 1000);
return sender;
}
@Bean
public BookClient wsClient(Jaxb2Marshaller marshaller, WebServiceMessageSender sender) {
BookClient client = new BookClient();
client.setDefaultUri("http://localhost:8080/bookService");
client.setMarshaller(marshaller);
client.setUnmarshaller(marshaller);
client.setMessageSender(sender);
ClientInterceptor[] ci = {new LoggingInterceptor()};
client.setInterceptors(ci);
return client;
}
}
5. 測試
@Autowired 設定好的 Client 物件後,呼叫 getBook 方法取得書籍資料。
package com.example.demo;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.example.demo.model.Book;
import com.example.demo.model.GetBookResponse;
@SpringBootTest
class WebServiceClientApplicationTests {
private static final Logger LOGGER =
LoggerFactory.getLogger(WebServiceClientApplicationTests.class);
@Test
void contextLoads() {}
@Autowired
BookClient client;
@Test
public void getBook() {
GetBookResponse response = client.getBook("9789570841008");
if (response != null) {
Book resBookData = response.getBook();
LOGGER.debug("Book Name:{} author:{} Currency:{} Population:{}", resBookData.getName(),
resBookData.getAuthor(), resBookData.getPublishing(), resBookData.getEdition());
} else {
LOGGER.debug("無法取得資訊...");
}
}
}
6. Logging SOAP Message
當我們需要看到 SOAP 訊息印在 Console Log 中,有兩種方式可以達成,一是在 properties 檔案加入配置,另一是設定攔截器。
properties 配置:
此方法是設定 WebServiceTemplate 內建的 logResponse 方法打印 Log,優點是設定快速,缺點是它只有在 Response 成功返回時才會一併印出 SOAP 請求和回應訊息。
logging.level.org.springframework.ws.client.MessageTracing.sent=DEBUG
logging.level.org.springframework.ws.client.MessageTracing.received=TRAC
設置攔截器:
對比 properties 攔截器就靈活許多,藉由實做 ClientInterceptor 的方法對 SOAP 的請求回應再次加工。一次完整的請求回應會經過 handleRequest、handleResponse(或handleFault)、afterCompletion;若是出現 TimeoutException 或是 ConnectException 則是只有 handleRequest 和 afterCompletion。由於攔截器可以設置多個,返回的布林值是 True 時表示要繼續下個攔截器。
package com.example.demo;
import java.io.ByteArrayOutputStream;
import java.net.ConnectException;
import java.net.SocketTimeoutException;
import org.apache.http.conn.ConnectTimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ws.client.WebServiceClientException;
import org.springframework.ws.client.support.interceptor.ClientInterceptor;
import org.springframework.ws.context.MessageContext;
public class LoggingInterceptor implements ClientInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(LoggingInterceptor.class);
@Override
public boolean handleRequest(MessageContext messageContext) throws WebServiceClientException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream();) {
messageContext.getRequest().writeTo(out);
String outStr = new String(out.toString("UTF-8"));
LOGGER.info("== req == messageContext:{}", outStr);
} catch (Exception e) {
LOGGER.error("error...", e);
}
return true;
}
@Override
public boolean handleResponse(MessageContext messageContext) throws WebServiceClientException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream();) {
messageContext.getResponse().writeTo(out);
String outStr = new String(out.toString("UTF-8"));
LOGGER.info("== res == messageContext:{}", outStr);
} catch (Exception e) {
LOGGER.error("error...", e);
}
return true;
}
@Override
public boolean handleFault(MessageContext messageContext) throws WebServiceClientException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream();) {
messageContext.getResponse().writeTo(out);
String outStr = new String(out.toString("UTF-8"));
LOGGER.info("== fault == messageContext:{}", outStr);
} catch (Exception e) {
LOGGER.error("error...", e);
}
return true;
}
@Override
public void afterCompletion(MessageContext messageContext, Exception ex)
throws WebServiceClientException {
if (ex != null) {
if (ex instanceof ConnectException) {
LOGGER.info("== aftercomplete == do with Connect error...");
} else if (ex instanceof ConnectTimeoutException) {
LOGGER.info("== aftercomplete == do with Connect timeout error...");
} else if (ex instanceof SocketTimeoutException) {
LOGGER.info("== aftercomplete == do with Read timeout ...");
} else {
LOGGER.error("== aftercomplete == do with other error ...", ex);
}
} else {
LOGGER.info("== aftercomplete == do something ...");
}
}
}
我們同時設定兩種 Logging 方式來做比較,在 Log 中 MessageTracing.sent、.received 是 properties 設定印出的 SOAP 訊息、LoggingInterceptor 則是來自攔截器的 Log,可以看到在 Server 回應逾時的 Log 中,由於 Client 端沒有等到 Response 報錯了,因此 MessageTracing 沒有印出 .received SOAP 訊息,LoggingInterceptor 則是有印出 SOAP 請求訊息可以幫助除錯。
成功的請求回應 Log:
com.example.demo.LoggingInterceptor : == req == messageContext:<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Header/><SOAP-ENV:Body><ns2:getBookRequest xmlns:ns2="http://www.example.com/book"><ns2:isbn>9789861856216</ns2:isbn></ns2:getBookRequest></SOAP-ENV:Body></SOAP-ENV:Envelope>
o.s.ws.client.MessageTracing.sent : Sent request [SaajSoapMessage {http://www.example.com/book}getBookRequest]
o.s.ws.client.MessageTracing.received : Received response [<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Header/><SOAP-ENV:Body><ns2:getBookResponse xmlns:ns2="http://www.example.com/book"><ns2:book><ns2:isbn>9789861856216</ns2:isbn><ns2:name>冰與火之歌:權力遊戲</ns2:name><ns2:author>喬治馬汀</ns2:author><ns2:publishing>2011</ns2:publishing><ns2:edition>1</ns2:edition></ns2:book></ns2:getBookResponse></SOAP-ENV:Body></SOAP-ENV:Envelope>] for request [<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Header/><SOAP-ENV:Body><ns2:getBookRequest xmlns:ns2="http://www.example.com/book"><ns2:isbn>9789861856216</ns2:isbn></ns2:getBookRequest></SOAP-ENV:Body></SOAP-ENV:Envelope>]
com.example.demo.LoggingInterceptor : == res == messageContext:<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Header/><SOAP-ENV:Body><ns2:getBookResponse xmlns:ns2="http://www.example.com/book"><ns2:book><ns2:isbn>9789861856216</ns2:isbn><ns2:name>冰與火之歌:權力遊戲</ns2:name><ns2:author>喬治馬汀</ns2:author><ns2:publishing>2011</ns2:publishing><ns2:edition>1</ns2:edition></ns2:book></ns2:getBookResponse></SOAP-ENV:Body></SOAP-ENV:Envelope>
com.example.demo.LoggingInterceptor : == aftercomplete == do something ...
Server 回應逾時 (Read Timeout)的 Log:
在 Server 端我們刻意拖延回應時間來模擬回應逾時的效果,Client 端會拋出WebServiceIOException cause by SocketTimeoutException。
com.example.demo.LoggingInterceptor : == req == messageContext:<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"><SOAP-ENV:Header/><SOAP-ENV:Body><ns2:getBookRequest xmlns:ns2="http://www.example.com/book"><ns2:isbn>9789861856216</ns2:isbn></ns2:getBookRequest></SOAP-ENV:Body></SOAP-ENV:Envelope>
o.s.ws.client.MessageTracing.sent : Sent request [SaajSoapMessage {http://www.example.com/book}getBookRequest]
com.example.demo.LoggingInterceptor : == aftercomplete == do with Read timeout ...
com.example.demo.BookClient : Read time out error ...
org.springframework.ws.client.WebServiceIOException: I/O error: Read timed out; nested exception is java.net.SocketTimeoutException: Read timed out
......
Caused by: java.net.SocketTimeoutException: Read timed out
......
專案資料夾結構:
結語
以上我們使用 Spring-WS 實作 Web Service 的 Client 端,從 WSDL 生成 JAXB 物件,以及配置 Client 物件的序列化工具、Timeout 時間和攔截器的設定,搭配先前實作的 Server 端收發 SOAP 訊息,並且將完整的 SOAP 請求回應打印在 Console Log 上。Spring-WS 將 SOAP 訊息交換變得簡單,使我們可以更專注於業務邏輯上。
參考資料
Spring-WS Docs
Baeldung — Invoking a SOAP Web Service in Spring
oKong — SpringBoot | 第三十三章:Spring web Servcies集成和使用
Oracle — Java™ API for XML Web Services (JAX-WS) 2.0
Stackoverflow─How can I make Spring WebServices log all SOAP requests?
Spring WS — Client Timeout Example