Backend

RxJava dla JAX-WS. Poznajcie mój skrypt do automatycznego generowania wrapperów

RxJava

Niedawno miałem niewątpliwą przyjemność pracować z SOAPowymi Web Services. Używałem narzędzia wsimport, aby wygenerować Javowe klasy proxy zgodne ze standardem JAX-WS. W teorii w nowoczesnych systemach takie Web Services już nie występują, ale w praktyce nieraz się z nimi spotykamy. Załóżmy dodatkowo, że nasza ładna i przejrzysta aplikacja używa RxJavy. Jest reaktywna i asynchroniczna, podoba nam się.

#top_oferta: Senior Ceph Engineer

25000 - 30000 pln

Aplikuj

Patryk Lenza. Co-founder of Pattern Match / Solutions Architect and Engineer. Pragmatyczny pasjonat wszystkiego co wiąże się z wytwarzaniem oprogramowania. Uwielbia przeskakiwać pomiędzy technologiami i poznawać nowe rzeczy. Stara się wykorzystywać technologię do rozwiązywania biznesowych problemów. Zwolennik prostoty i przejrzystości, które prowadzą do elegancji.


Niestety interfejs wygenerowanych klas proxy bardzo odstaje od reszty naszego kodu, a my bardzo chcielibyśmy móc wywoływać te usługi traktując je jako typowe źródła Rx Observable/Completable. Jeśli wszystko to czego potrzebujemy to jednorazowe zaciągnięcie pliku WSDL i wygenerowanie z niego klasy proxy… to nie ma problemu. Po prostu utwórzmy sobie niewielkie klasy-wrappery, które otoczą proxy JAX-WS przez Observable/Completable i użyjmy je. Jednak, gdy usługi, które konsumujemy są aktywnie rozwijane i/lub musimy konsumować wiele różnych endpointów wystawiających osobne WSDLe — wtedy takie manualne tworzenie i aktualizowanie wrapperów stanie się zarówno męczące, jak i podatne na błędy.

I to właśnie mnie spotkało. Po początkowym zniechęceniu i małym poddenerwowaniu, postanowiłem sklecić mały i prosty skrypt do automatycznego generowania takich wrapperów. Mały komentarz zanim przedstawię pełne rozwiązanie: jest to szybki i brudny hack. Doskonale zdaję sobie sprawę, że idealnie i profesjonalnie można by to rozwiązać stosując procesory javowych adnotacji lub zaawansowane użycie silnika szablonów, lub wręcz tworząc osobne narzędzie podobne do wsimport. Potrzebowałem jednak czegoś na szybko. I jako bonus miałem okazję podłubać trochę w Gradle’u i jego potencjale do obsługi skryptów w Groovy’m.

Rozwiązanie

Całość kodu można zobaczyć na naszym repo w GitHub. Wszystko jest w zasadzie proste. Jedyne co musimy zrobić to wygenerować klasy proxy poprzez wsimport i następnie przeanalizować je używając paru regexów. Finalnie, mając już dane z analizy, wypełniamy krótkie szablony i zapisujemy wynik do Javowych plików. Zatem do dzieła.

Generowanie plików proxy JAX-WS

Niestety z powodów NDA nie mogę pokazać prawdziwych plików WSDL. Udało mi się jednak znaleźć dość skomplikowany ogólnodostępny przykład: http://ws.cdyne.com/emailverify/Emailvernotestemail.asmx?wsdl — na początek wygenerujmy dla niego klasy proxy. Upewnijmy się, że w katalogu, w którym obecnie jesteśmy mamy podkatalogi tmp i src/main/java — ich brak powoduje mało czytelny błąd wykonania wsimport. Odpalamy:

wsimport 'http://ws.cdyne.com/emailverify/Emailvernotestemail.asmx?wsdl' -keep -d tmp -s src/main/java

Katalog tmp nie interesuje nas w przeciwieństwie do źródeł, które zostaną wygenerowane do src/main/java/com/cdyne/ws. Ten post nie dotyczy jednak automatyzacji odpalania wsimport, dlatego kontynuujmy dalej, ale warto zaznaczyć, że Gradle ma odpowiednie pluginy, a również samodzielnie możemy podpiąć wywołanie do kodu skryptu, który tutaj omawiamy. Polecam przejrzeć wygenerowane pliki proxy. Jest ich dość sporo a ich kod jest typowy dla klas proxy — mocno techniczny i pełen powiązań z biblioteką JAX-WS.

Rozszerzanie pliku build.gradle o task, który generuje pliki RxJavy

Zmiany w pliku build.gradle są proste i krótkie:

group 'com.pattern-match'
version '1.0-SNAPSHOT'

apply plugin: 'java'
apply plugin: 'application'
mainClassName = "com.patternmatch.WebServiceCaller"

sourceCompatibility = 1.8

repositories {
    mavenCentral()
}

dependencies {
    compile group: 'io.reactivex.rxjava2', name: 'rxjava', version: '2.1.13'
    testCompile group: 'junit', name: 'junit', version: '4.12'
}

GroovyScriptEngine gse() {
    def pathToFolderOfToolScripts = "${projectDir}/tools/"
    def gse = new GroovyScriptEngine([pathToFolderOfToolScripts] as String[])
    gse
}

task genrx {
    doLast {
        def serviceDirValue = propertyOrDefault("serviceDir", "src/main/java/com/cdyne/ws")
        def rxFileDirValue = propertyOrDefault("rxFileDir", "src/main/java/com/patternmatch")
        def rxFileNameValue = propertyOrDefault("rxFileName", "Backend.java")
        def rxFilePackageNameValue = propertyOrDefault("rxFilePackageName", "com.patternmatch")

        gse().run('rx.groovy', new Binding(['serviceDir'       : serviceDirValue,
                                            'rxFileDir'        : rxFileDirValue,
                                            'rxFileName'       : rxFileNameValue,
                                            'rxFilePackageName': rxFilePackageNameValue]))
    }
}

String propertyOrDefault(String propertyName, String defaultValue) {
    project.hasProperty(propertyName) ? project.property(propertyName) : defaultValue
}

Mamy tu task o nazwie genrx, który wykonuje nasz skrypt tools/rx.groovy przekazując mu 4 parametry. Te parametry możemy albo przekazać z linii poleceń, albo jeśli ich nie podamy zostaną pobrane domyślne wartości (drugi parametr 4 wywołań propertyOrDefault). Ja ustawiłem te domyślne wartości na dopasowane do przykładowego pliku WSDL oraz zadanego katalogu. W realnym scenariuszu wystarczy ustawić je na zgodne z plikiem, nad którym pracujemy, co wyeliminuje konieczność przekazywania ich do skryptu z linii poleceń.

Aby wykonać skrypt bez parametrów i z domyślnymi wartościami:

./gradlew genrx

A jeśli chcemy przekazać parametry:

./gradlew genrx -PrxFileName=Service.java -PserviceDir=/Users/me/projects/blog/rxjava_soapws_final/src/main/java/com/package/ -PrxFileDir=src/main/java/com/package -PrxFilePackageName=com.package

Przyda się krótki opis parametrów:

  1. serviceDir — katalog, do którego wsimport wygenerował klasy proxy. Może rozpoczynać się od / i wtedy jest ścieżką absolutną lub bez / i wtedy jest to podkatalog dla punktu wywołania Gradle.
  2. rxFileDir — katalog, do którego wygenerowane zostaną klasy RxJavy. Analogicznie z / jak dla parametru powyżej.
  3. rxFileName — wrappery wygenerowane zostaną do pliku o takiej nazwie. Należy również podać rozszerzenie na przykład Backend.java.
  4. rxFilePackageName — nazwa pakietu, w którym znajdą się wygenerowane wrappery Rx.

Skrypt rx.groovy — główne kroki z lotu ptaka

Generowanie pliku z wrapperami Rx Javy składa się z kilku głównych kroków:

//
// Main script steps
//
(baseServiceDirectory, serviceFilePath) = figureOutWebServiceDirectoryAndFileDetails()

(servicePackageName, serviceClassName, portClassName, getPortMethodName) = extractWebServiceDetails(serviceFilePath)

portFileBareContent = extractBareContentFromPortFile(baseServiceDirectory, portClassName)

publicMethodNamesParamsAndReturns = extractPublicMethodNamesParamsAndReturns(portFileBareContent)

rxMethods = wrapMethodsWithRxCall(portClassName, publicMethodNamesParamsAndReturns)

backendFilePath = generateRxServiceFile(rxMethods, servicePackageName, portClassName, getPortMethodName)

println "Successfully generated RX service file: ${backendFilePath}"

Wyszukiwanie pliku zawierającego JAX-WS Web Service

To dość przyziemna robota. Normalizujemy ścieżkę do katalogu z plikami proxy (przekazaną w parametrze) i odnajdujemy plik, który zawiera klasę rozszerzającą Service z JAX-WS. W zasadzie nic interesującego.

Wyciąganie detali z pliku z JAX-WS Web Service

List<String> extractWebServiceDetails(String serviceFilePath) {
    println "Loading WebService file: ${serviceFilePath}"
    String serviceFileContents = new File(serviceFilePath).text

    servicePackageName = (serviceFileContents =~ /package ([w.]+);/)[0][1]
    println "Found service package named ${servicePackageName}"

    serviceClassName = (serviceFileContents =~ /public class (w+)/)[0][1]
    println "Found service class named ${serviceClassName}"

    portClassName = (serviceFileContents =~ /(w+).class/)[0][1]
    println "Found service port named ${portClassName}"

    getPortMethodName = (serviceFileContents =~ /public w+ (getw+)(/)[0][1]
    println "Found get port method named ${getPortMethodName}"

    [servicePackageName, serviceClassName, portClassName, getPortMethodName]
}

Mając znaleziony plik z proxy możemy załadować jego zawartość. Na wstępie musimy znaleźć do jakiego pakietu należy klasa, jaka jest jej nazwa, jaka jest nazwa Portu SOAP WS i jaka metoda jest wykorzystywana do otrzymania instancji tego Portu. Port jest tutaj najbardziej interesującym nas elementem, gdyż to właśnie on wystawia metody, które wywołują konkretne usługi po stronie backendu. A jest to dokładnie to co chcemy opakować Rxami. Cztery dość proste regexy z grupami pozwolą nam wyciągnąć potrzebne informacje. Polecam przejrzeć plik proxy z Web Service, wtedy regexy będą wydawać się jeszcze prostsze. Jedyna ciekawsza rzecz wymagająca wyjaśnienia dotyczy Portu. W klasie z usługą znajduje się metoda, która pozwala na otrzymanie jego instancji. To zawsze jedyna metoda, która rozpoczyna się od prefixu get i w jej ciele można znaleźć nazwę klasy Portu:

    @WebEndpoint(name = "EmailVerNoTestEmailSoap")
    public EmailVerNoTestEmailSoap getEmailVerNoTestEmailSoap() {
        return super.getPort(new QName("http://ws.cdyne.com/", "EmailVerNoTestEmailSoap"), EmailVerNoTestEmailSoap.class);
    }

Stąd możemy dowiedzieć się w jakiej klasie zdefiniowany jest nasz Port — regex szuka ciągu znaków kończącego się na .class, aby otrzymać nazwę klasy oraz na metodę rozpoczynającą się od get, aby wiedzieć, co ma wywołać, aby otrzymać instancję.

Wyciąganie detali z klasy Portu

Na obecną chwilę znamy zatem nazwę klasy Portu i możemy załadować jej plik źródłowy. To najbardziej nas interesujący plik, gdyż tutaj są wywołania odpowiednich usług z backendu. Musimy go dobrze przemielić. Ja najpierw przygotowuję go sobie wycinając nieużyteczne komentarze, adnotacje czy puste linie:

String extractBareContentFromPortFile(baseServiceDirectory, portClassName) {
    portFilePath = baseServiceDirectory + portClassName + ".java"
    println "Applying RX Java goodness on file: ${portFilePath}"
    portFileContents = new File(portFilePath).text

    // remove annotations, comments, empty lines. Not strictly necessary but helpful
    // for any future manipulations and extractions
    annotationRegex = /(?s)@w+((.*?))/
    commentsRegex = /(?m)^(.*?)*(.*?)$/
    emptyLinesRegex = /(?m)^(?:[t ]*(?:r?n|r))+/
    portFileContentsStripped = portFileContents.
            replaceAll(annotationRegex, "").
            replaceAll(commentsRegex, "").
            replaceAll(emptyLinesRegex, "")
    portFileContentsStripped
}

Nadmienię, że nie jest to konieczne i reszta działa bez tego wycinania, ale po prostu lepiej analizuje i debuguje się potem taki uproszczony plik. Plik ładowany jest do jednego dużego Stringa, a następnie odpalanych jest parę regexów. (?s) nakazuje regexowi traktować znaki nowych linii jako wpadające w zakres dowolnego znaku, czyli . — wielolinijkowy String staje się dzięki temu jednym długim wejściem dla regexa. @w+((.*?)) szuka @, po którym następują znaki alfanumeryczne lub _ i potencjalnie pusta lista parametrów: (`, `). Operator .*? sprawia, że grupy przestają być zachłanne (greedy) zatem kończą dopasowanie przy pierwszym zamykającym ). Jest to niezwykle istotne, gdyż bez tego dopasowalibyśmy cały tekst aż do ostatniego ) w kodzie, a nas interesuje tylko ta jedna grupa parametrów adnotacji. (?m) zamienia regex w tryb wielolinii i pozwala używać ^ i $, aby znajdować w naszym długim stringu koniec i początek linii. Następnie standardowy wzorzec na wyszukanie końca linii: (?:r?n|r))+ — wymaga przynajmniej jednego n lub r lub rn — i będzie działał na różnych formatowaniach kodu.

Wyciąganie publicznych metod web-service wraz z ich parametrami i typem zwracanym

To clue naszej ekstrakcji. Wystarczy jeden regex:

Matcher extractPublicMethodNamesParamsAndReturns(String portFileBareContent) {
    (portFileBareContent =~ /(?s)(?:r?n|r)s+public (.*?) (.*?)((.*?));/)
}

Szukamy tekstu, przed którym występuje nowa linia, następnie białe znaki i słowo public. Co dalej to łapiemy do grupy typ zwracany, nazwę metody i wszystko co jest wewnątrz ().

Opakowywanie znalezionych metod w Observable/Completable z RxJavy

Mamy więc dane wszystkich metod usługi. Nadszedł czas na wygenerowanie dużego stringa z kodem, który opakuje te wywołania w RxJavę. Możemy do tego użyć Groovy i jego silnika szablonów:

String wrapMethodsWithRxCall(String portClassName, Matcher publicMethodNamesParamsAndReturns) {
    def rxMethodTemplateString = '''
    public ${rxReturnType} ${rxMethodName}(${parameters}) {
        return ${rxWrapperCall}(() -> ${serviceMethodCall}(${originalParameters}));
    }
'''
    def rxMethodTemplate = new groovy.text.StreamingTemplateEngine().createTemplate(rxMethodTemplateString)

    rxMethods = StringBuilder.newInstance()
    for (i in 0..<publicMethodNamesParamsAndReturns.count) {
        returnType = publicMethodNamesParamsAndReturns[i][1]
        methodName = publicMethodNamesParamsAndReturns[i][2]
        parameters = publicMethodNamesParamsAndReturns[i][3]
        def binding = [
                rxReturnType      : makeRxReturnType(returnType),
                rxWrapperCall     : makeRxWrapperCall(returnType),
                rxMethodName      : methodName,
                parameters        : parameters,
                serviceMethodCall : portClassName.uncapitalize() + "." + methodName,
                originalParameters: paramsExtractor(parameters)
        ]
        rxMethods << rxMethodTemplate.make(binding)
        if (i < publicMethodNamesParamsAndReturns.count - 1) {
            rxMethods << newLineSeparator()
        }
    }
    rxMethods.toString()
}

Potrzebujemy, aby dla każdej znalezionej publicznej metody usługi, która ma typ zwracany inny niż void, wygenerował się kod wyglądający podobnie jak ten konkretny przykład:

    public Observable<ReturnIndicator> verifyEmail(
        String email,
        String licenseKey) {
        return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyEmail(email, licenseKey));
    }

Jest to tylko i wyłącznie wywołanie oryginalnej metody na instancji Portu opakowanej w Observable. Za chwilę wyjaśni się skąd wzięła się ta instancja.

Jeśli oryginalna metoda zwraca void powinniśmy zwrócić Completable zamiast Observable. RxJava 2.0 nie pozwala na zwracanie null, dlatego nie możemy użyć Observable<Void>, co kiedyś było praktykowane w RxJava 1.0. Przykładowy kod dla voida wyglądać powinien jak:

    public Completabled doAction(
        int startTime,
        int endTime) {
        return Completable.fromRunnable(() -> portInstance.doAction(startTime, endTime));
    }

Generowanie metod Rx to wykonywanie szablonu dla każdej znalezionej metody usługi. Detale tych znalezionych metod mamy w grupach złapanych z wcześniejszego regexa (w Matcher). Musimy tylko dla każdej metody przygotować zmienne, które staną się wejściem dla szablonu:

String makeRxReturnType(type) {
    type == "void" ?
            "Completable" :
            "Observable<${validGenericsType(type)}>"
}

String makeRxWrapperCall(type) {
    type == "void" ?
            "Completable.fromRunnable" :
            "Observable.fromCallable"
}

Dałoby się wprowadzić instrukcje if do środka szablonu i w odpowiednich przypadkach generować kod dla Observable lub Completable, ale podejście z prostym szablonem i dynamicznym wejściem wydało mi się czytelniejsze i łatwiejsze w przyszłej obsłudze.

Generowanie finalnego pliku z wywołaniem metod usługi w RxJavie

Na tym etapie mamy już wszystkie dane potrzebne do wygenerowania finalnego pliku źródłowego Javy. Wiemy do jakiego katalogu go generować, jaka ma być jego nazwa, jaki pakiet. Znamy nazwę klasy i pakiet oryginalnej usługi, portu i jak się dostać do instancji tego portu. No i oczywiście mamy kod metod Observable/Completable, które opakowują oryginalne wywołania. Do dzieła zatem:

String generateRxServiceFile(String rxMethods, String servicePackageName, String portClassName,
                             String getPortMethodName) {

    def rxFileTemplateString = '''
package ${rxFilePackageName};

import ${servicePackageName}.*;
import io.reactivex.Observable;
import io.reactivex.Completable;
import java.util.List;


public class ${rxServiceClassName} {
    private ${portClassName} ${portClassName.uncapitalize()};

    public ${rxServiceClassName}() {
        ${serviceClassName} service = new ${serviceClassName}();
        ${portClassName.uncapitalize()} = service.${getPortMethodName}();
    }

${rxMethods}
}
'''
    def rxFileTemplate = new groovy.text.StreamingTemplateEngine().createTemplate(rxFileTemplateString)

    def binding = [
            rxFilePackageName : rxFilePackageName,
            servicePackageName: servicePackageName,
            rxServiceClassName: rxServiceClassName(),
            portClassName     : portClassName,
            serviceClassName  : serviceClassName,
            getPortMethodName : getPortMethodName,
            rxMethods         : rxMethods
    ]
    backendFile = StringBuilder.newInstance()
    backendFile << rxFileTemplate.make(binding)
    if (!rxFileDir.endsWith("/")) {
        rxFileDir = rxFileDir + "/"
    }
    String backendFilePath = new File(figureOutRxFileDirectory() + rxFileName)
    File backend = new File(backendFilePath)
    backend.text = backendFile.toString()
    backendFilePath
}

Po raz kolejny wykorzystamy szablony Groovy. Szablon to treść pliku Javy. Przekazujemy do niego parametry, którymi są wszystkie detale, które zgromadziliśmy do tej pory. Wszystko wskakuje na swoje miejsce i formuje kompletny plik źródłowy.

Uwaga: wynikowy plik po cichu nadpisze istniejący!

Jak wygląda taki finalny plik?

package com.patternmatch;

import com.cdyne.ws.*;
import io.reactivex.Observable;
import io.reactivex.Completable;
import java.util.List;


public class Backend {

    private EmailVerNoTestEmailSoap emailVerNoTestEmailSoap;

    public Backend() {
        EmailVerNoTestEmail service = new EmailVerNoTestEmail();
        emailVerNoTestEmailSoap = service.getEmailVerNoTestEmailSoap();
    }

    public Observable<Integer> verifyMXRecord(
        String email,
        String licenseKey) {
        return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyMXRecord(email, licenseKey));
    }

    public Observable<ReturnIndicator> advancedVerifyEmail(
        String email,
        int timeout,
        String licenseKey) {
        return Observable.fromCallable(() -> emailVerNoTestEmailSoap.advancedVerifyEmail(email, timeout, licenseKey));
    }

    public Observable<ReturnIndicator> verifyEmail(
        String email,
        String licenseKey) {
        return Observable.fromCallable(() -> emailVerNoTestEmailSoap.verifyEmail(email, licenseKey));
    }

    public Observable<ArrayOfAnyType> returnCodes() {
        return Observable.fromCallable(() -> emailVerNoTestEmailSoap.returnCodes());
    }

}

Jest to normalny plik źródłowy Javy. Ma nawet konstruktor, który ukrywa tworzenie oryginalnej usługi i portu, dzięki czemu kod wywołujący nie będzie musiał troszczyć się o to i nie będzie tym zaśmiecony. Pozostawiam jako zadanie dodatkowe, aby nadbudować nad tą klasą interfejs lub pozwolić na wstrzykiwanie zależności przez drugi konstruktor, co sprawi, że wrapper będzie przyjaźniejszy w testowaniu.

Wywołanie wrappera w naszym ładny kodzie to już typowy kod używający RxJavy. Dla kompletności pełne wykorzystanie Observera:

public class WebServiceCaller {
    public static void main(String[] args) {
        Backend backend = new Backend();
        Observable<ReturnIndicator> integerObservable = backend.verifyEmail("myprivateemail@yahoo.com", "somelicense");
        integerObservable.subscribe(new Observer<ReturnIndicator>() {
            @Override
            public void onSubscribe(Disposable d) {
            }

            @Override
            public void onNext(ReturnIndicator returnIndicator) {
                System.out.println("Got results:");
                System.out.println(returnIndicator.getResponseText());
            }

            @Override
            public void onError(Throwable e) {
            }

            @Override
            public void onComplete() {
            }
        });
    }
}
)

I dostajemy wynik:

Got results:
Mail Server will accept email

Finisz

Pominąłem detale niektórych prostszych lub mniej ciekawych pomocniczych funkcji. Można je zobaczyć w pełnym kodzie na naszym repo w GitHub

Koniec końców miałem dużo frajdy tworząc ten mały tool. Pokazuje on potencjał jaki daje Groovy wraz z jego bardzo dobrą integracją z Gradle. Parę regexów i szablonów naprawdę może dużo!


najwięcej ofert html

Artykuł pierwotnie został opublikowany pattern-match.com.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/rxjava-dla-jax-ws-poznajcie-moj-skrypt-automatycznego-generowania-wrapperow/" order_type="social" width="100%" count_of_comments="8" ]