piątek, 23 marca 2012

Apache Camel i integracja usług SOAP.


Wyzwanie

Postanowiłem wykorzystać Apache Camel do integracji pomiędzy systemami, które wystawiają usługi SOAP. Nie było możliwości, aby systemy komunikowały się aktywnie (nawiązując komunikację na zasadzie client-server), pozostała konieczność wstawienia pomiędzy nie kolejnego komponentu, który inicjowałby integrację rzeczonych usług. Komponent integrujący został zrealizowany jako trasa integracyjna zrealizowana z użyciem Camel'a i osadzona na kontenerze ServiceMix.

W przykładzie posłużę się fikcyjnymi usługami, które dla podniesienia poziomu trudności wykonane zostały jako usługi ASMX uruchamiane z użyciem Mono pod Linux'em.

Usługa ServiceExport

Pierwsza z usług wystawia metodę ExportCatalog, która pozwala wyeksportować zawartość katalogu z częściami.


Komunikat ExportCatalog zakłada przekazanie parametru, którym jest rok, za który należy wyeksportować katalog.

Schemat żądania ExportCatalog:
<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
>
  <soap:Body>
    <ExportCatalog xmlns="http://devbox/2012/03/ServiceExport/">
      <year>int</year>
    </ExportCatalog>
  </soap:Body>
</soap:Envelope>

Odpowiedź ExportCatalogResponse zawiera element //ExportCatalogResponse/ExportCatalogResult/Parts, który jest elementem, który będzie służył budowie żądania dla usługi ServiceImport.

Schemat odpowiedzi ExportCatalogResponse:
<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
>
  <soap:Body>
    <ExportCatalogResponse xmlns="http://devbox/2012/03/ServiceExport/">
      <ExportCatalogResult>
        <Parts>
          <Part>
            <ID>string</ID>
            <Name>string</Name>
            <Description>string</Description>
          </Part>
          <Part>
            <ID>string</ID>
            <Name>string</Name>
            <Description>string</Description>
          </Part>
        </Parts>
      </ExportCatalogResult>
    </ExportCatalogResponse>
  </soap:Body>
</soap:Envelope>

Usługa ServiceImport

Kolejna z usług wystawia metodę ImportCatalog, która służy umieszczeniu zawartości katalogu z częściami w docelowym systemie, który ją wystawia.


Żądanie ImportCatalog zawiera element //ImportCatalog/Catalog/Parts, który jest identyczny z elementem Parts wyłuskanym z odpowiedzi ExportCatalogResponse.

Schemat żądania ImportCatalog:
<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
>
  <soap:Body>
    <ImportCatalog xmlns="http://devbox/2012/03/ServiceImport/">
      <Catalog>
        <Parts>
          <Part>
            <ID>string</ID>
            <Name>string</Name>
            <Description>string</Description>
          </Part>
          <Part>
            <ID>string</ID>
            <Name>string</Name>
            <Description>string</Description>
          </Part>
        </Parts>
      </Catalog>
    </ImportCatalog>
  </soap:Body>
</soap:Envelope>

W odpowiedzi dostajemy potwierdzenie w formie statusu, które dla wartości 0 oznacza sukces.

Schemat odpowiedzi ImportCatalogResponse:
<?xml version="1.0" encoding="utf-16"?>
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:xsd="http://www.w3.org/2001/XMLSchema"
      xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
>
  <soap:Body>
    <ImportCatalogResponse xmlns="http://devbox/2012/03/ServiceImport/">
      <status>int</status>
    </ImportCatalogResponse>
  </soap:Body>
</soap:Envelope>

Komponent integracyjny

Założeniem było utworzenie komponentu w sposób jak najłatwiejszy. Udało się tworząc całą trasę z wykorzystaniem tylko 1 pliku context.xml. Ten plik można osadzić w katalogu deploy instancji ServiceMix, bądź traktować jako element projektu maven i uruchamiać testowo trasę poleceniem mvn camel:run. Pomijam szczegóły tworzenia takiego projektu, zakładam znajomość archetypów maven.

W pierwszym etapie konieczne jest skonfigurowanie 2 par endpoint'ów:

  • endpoint'y CXF, które fizycznie wiązane są usługami i muszą mieć wskazanie na adres usługi, jej specyfikację WSDL oraz wskazania na konkretny port i service z WSDL,
  • endpoint'y Camel'a, które są wykorzystywane jako elementy trasy integracyjnej i wiążą się bezpośrednio z pierwszą parą endpoint'ów.
Na uwagę zasługuje fakt, że endpoint'y Camel'a mają ustawiony format danych na PAYLOAD, co oznacza, że na trasie integracyjnej można się posługiwać samą treścią komunikatów SOAP z pominięciem koperty. Treść komunikatów w zależności od potrzeb może być traktowana jako String albo drzewo DOM.


Konfiguracja endpoint'ów
<cxf:cxfEndpoint id="cxfExportEndpoint"
                 address="http://192.168.1.104:8080/Export.asmx"
                 wsdlURL="http://192.168.1.104:8080/Export.asmx?wsdl=0"
                 endpointName="export:ExportSoap"
                 serviceName="export:Export"
                 xmlns:export="http://devbox/2012/03/ServiceExport/">
    <cxf:features>
        <bean class="org.apache.cxf.feature.LoggingFeature"/>
    </cxf:features>
</cxf:cxfEndpoint>

<cxf:cxfEndpoint id="cxfImportEndpoint"
                 address="http://192.168.1.104:8080/Import.asmx"
                 wsdlURL="http://192.168.1.104:8080/Import.asmx?wsdl=0"
                 endpointName="import:ImportSoap"
                 serviceName="import:Import"
                 xmlns:import="http://devbox/2012/03/ServiceImport/">
    <cxf:features>
        <bean class="org.apache.cxf.feature.LoggingFeature"/>
    </cxf:features>
</cxf:cxfEndpoint>

<camel:endpoint id="importEndpoint"
                uri="cxf:bean:cxfImportEndpoint?dataFormat=PAYLOAD"/>
<camel:endpoint id="exportEndpoint"
                uri="cxf:bean:cxfExportEndpoint?dataFormat=PAYLOAD"/>

Kolejnym krokiem jest konfiguracja trasy integracyjnej. Trasa składa się z 3 dużych kroków:

  1. uruchomienie wymiany danych komponentem timer (w przykładzie co 6s),
  2. przygotowanie żądania i wywołanie usługi ServiceExport,
  3. przygotowanie żądania i wywołanie usługi ServiceImport.

<camel:camelContext id="asmx-context"
                    xmlns="http://camel.apache.org/schema/spring">
    <route id="integration">
        <from uri="timer://test?period=6000"/>

        <setHeader headerName="operationName">
            <constant>ExportCatalog</constant>
        </setHeader>
        <setHeader headerName="operationNamespace">
            <constant>http://devbox/2012/03/ServiceExport/</constant>
        </setHeader>
        <setBody>
            <constant><![CDATA[
                <ExportCatalog xmlns="http://devbox/2012/03/ServiceExport/">
                    <year>2012</year>
                </ExportCatalog>
            ]]></constant>
        </setBody>
        <to ref="exportEndpoint"/>

        <setHeader headerName="operationName">
            <constant>ImportCatalog</constant>
        </setHeader>
        <setHeader headerName="operationNamespace">
            <constant>http://devbox/2012/03/ServiceImport/</constant>
        </setHeader>
        <setBody>
            <xquery><![CDATA[
                <ImportCatalog xmlns="http://devbox/2012/03/ServiceImport/">
                    <Catalog>{
                        //*:Parts
                    }</Catalog>
                </ImportCatalog>
            ]]></xquery>
        </setBody>
        <to ref="importEndpoint"/>
    </route>
</camel:camelContext>

Oba kroki przygotowania żądania są podobne i składają się na nie:

  1. ustawienie nagłówków operationName i operationNamespace, które informują CXF o tym, co ma zostać wywołane w usłudze, 
  2. oraz ustawienie treści komunikatu.

Żądanie ExportCatalog jest bardzo proste i w przykładzie zakładam jego niezmienność.
Żądanie ImportCatalog jest bardziej skomplikowane, gdyż zależy ono od treści odpowiedzi z pierwszego wywołania. Aby jak najprościej przełożyć komunikat ExportCatalogResponse na ImportCatalog posłużyłem się szablonem XQuery. Szablon ten zakłada zbudowanie elementu <Catalog/> w oparciu o wyrażenie XPath //*:Parts, które znajduje element Parts w uzyskanej wcześniej odpowiedzi.

Podsumowując


Zaprezentowane rozwiązanie jest przede wszystkim proste, składa się na nie 1 plik XML. Dzięki wskazywaniu zarówno adresów usług i ich WSDL, oraz wykorzystaniu formatu danych PAYLOAD nie ma konieczności klasycznego generowania kodu klienckiego usług. Tym samym zyskuje się pewną odporność rozwiązania na zmiany w usługach: o ile element Parts nie może zmienić nazwy, to zmiany w strukturze jego zawartości nie mają wpływu na działanie integracji.

Co ja bym zrobił bez Camel'a ?

środa, 14 marca 2012

Apache CXF vs WCF i TransportWithMessageCredentials

Apache CXF ma problem w komunikacji z Windows Communication Framework (WCF). Konkretnie chodzi o przypadek, w którym klient CXF wywołuje usługę WCF wykorzystującą schemat zabezpieczeń TransportWithMessageCredentials. W moim scenariuszu używam generacji Timestamp w połączeniu z Signature i BinarySecurityToken. Problem objawia się, gdy WCF próbuje parsować nagłówek <wsse:security/>. Wywołanie zwykle kończy się błędem:

<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
   <s:Body>
      <s:Fault>
         <faultcode xmlns:a="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">a:InvalidSecurity</faultcode>
         <faultstring xml:lang="pl-PL">An error occurred when verifying security for the message.</faultstring>
      </s:Fault>
   </s:Body>
</s:Envelope>

WCF zakłada określoną kolejność elementów nagłówka . To czy jest to zgodne czy nie ze specyfikacją - nie wnikam. Niestety, biblioteka wss4j, która jest używana w Apache CXF (i nie tylko, bo Axis i SOAP UI również jej używają) nie pozwala na poziomie jakiejkolwiek konfiguracji wpłynąć na kolejność elementów tego nagłówka. Został zgłoszony błąd WSS-231 do biblioteki wss4j, ale nie wygląda na to, żeby miał zostać rozwiązany w najbliższej przyszłości.

Z pomocą przychodzi mechanizm interceptorów w Apache CXF, dzięki któremu możliwa jest modyfikacja wygenerowanego komunikatu żądania na poziomie drzewa DOM. Przykładowy kod:

package devbox.cxf;

import org.apache.cxf.binding.soap.SoapMessage;
import org.apache.cxf.binding.soap.interceptor.AbstractSoapInterceptor;
import org.apache.cxf.interceptor.Fault;
import org.apache.cxf.phase.Phase;
import org.apache.cxf.ws.security.wss4j.WSS4JOutInterceptor;
import org.w3c.dom.Node;

import javax.xml.soap.*;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class FixSecurityHeaderOutInterceptor extends AbstractSoapInterceptor {

    public FixSecurityHeaderOutInterceptor() {
        super(Phase.PRE_PROTOCOL);
        getAfter().add(WSS4JOutInterceptor.class.getName());
    }

    public void handleMessage(SoapMessage message) throws Fault {
        boolean isOutbound = message == message.getExchange().getOutMessage()
                || message == message.getExchange().getOutFaultMessage();

        if (isOutbound) {
            message.getInterceptorChain().add(new FixSecurityHeaderOutEndingInterceptor());
        }
    }

    public class FixSecurityHeaderOutEndingInterceptor extends AbstractSoapInterceptor {
        public FixSecurityHeaderOutEndingInterceptor() {
            super(Phase.POST_PROTOCOL_ENDING);
        }

        public void handleMessage(SoapMessage message) throws Fault {
            try {
                SOAPPart soap = (SOAPPart) message.getContent(Node.class);
                SOAPHeader header = soap.getEnvelope().getHeader();

                // wsse:Security
                SOAPHeaderElement security = null;
                Iterator iterator = header.examineAllHeaderElements();
                while (iterator.hasNext()) {
                    SOAPHeaderElement headerElement = (SOAPHeaderElement) iterator.next();
                    if (headerElement.getNodeName().equals("wsse:Security")) {
                        security = headerElement;
                        break;
                    }
                }

                // no security - no job
                if (security == null) {
                    return;
                }

                Map<String, SOAPElement> elementMap = new HashMap<String, SOAPElement>();

                Iterator childElements = security.getChildElements();
                while (childElements.hasNext()) {
                    SOAPElement element = (SOAPElement) childElements.next();
                    elementMap.put(element.getTagName(), element);
                    element.detachNode();
                }

                security.addChildElement(elementMap.get("wsu:Timestamp"));
                security.addChildElement(elementMap.get("wsse:BinarySecurityToken"));
                security.addChildElement(elementMap.get("ds:Signature"));
            } catch (SOAPException e) {
                throw new Fault(e);
            }
        }
    }
}

Kod sprowadza się do wyłuskania elementu nagłówka, zabrania z niego wszystkich interesujących elementów i powtórne ich dodanie w kolejności akceptowalnej przez WCF. Najtrudniejsze było trafienie w odpowiednie fazy, w których ma się wykonać ten kod. Metoda prób i błędów pozwoliła trafić w dziesiątkę. Zostało jeszcze wszystko skonfigurować:

<cxf:cxfEndpoint id="cxfProducerEndpoint" xmlns:cxf="http://camel.apache.org/schema/cxf"
address="${producerEndpointURI}" wsdlURL="wsdl/SomeService.wsdl">
        <cxf:inInterceptors>
            <ref bean="producerWSS4JInInterceptor"/>
        </cxf:inInterceptors>
        <cxf:outInterceptors>
            <ref bean="producerWSS4JOutInterceptor"/>
            <bean class="devbox.cxf.FixSecurityHeaderOutInterceptor"/>
        </cxf:outInterceptors>
</cxf:cxfEndpoint>

W przykładzie celowo pominięto szczegóły konfiguracji WSS4J, są bez znaczenia. Ważne jest, aby na końcu listy interceptorów OUT umieścić ten, który naprawia rzeczony nagłówek.