Spring Boot x SOAP に入門してみる

はじめに

こんにちは。アソビューでエンジニアをやっています。小池です。 アソビューへJoinしてそろそろ半年になります。初めてのテックブログです。今回はSpring BootでSOAPに入門してみたいと思います。
APIとして昨今あえてSOAPを選択することは少ないかもしれませんが、SOAPで提供されているAPIも世の中にはまだまだ存在するのでSOAPを使う機会も少なからずあるかと思います。この記事でSOAPに入門していきましょう。
記事中にはサンプルコードを載せますが、コード全体もGitHubで公開してますのでご参考ください。

やること

今回はSpring Boot x SOAPで簡単なAPIを作ります。せっかくなので、サーバー側とクライアント側の両方を実装していこうと思います。
SpringにはSpring Web Services (Spring-WS) という機能があり、これを使うとSOAPのサービスを簡単に実装することができます。今回もSpring-WSを使って実装していきます。

やってみようと思った経緯

直近の案件でSOAPを使う機会があったのが大きな理由です。実は前職でもSOAPは使ったことはあるのですが、Spring-WSを使って実装するのは初めてでした。アソビュー内にも多少ナレッジがあったものの、それを参考に雰囲気で実装したのでよくわかっていない部分も多い状態でした。
また、今までSOAPのクライアント部分の実装しかしたことがなく、勉強のためにもサーバー/クライアント一貫でやってみようと思いました。

やらないこと

  • Spring/Spring Boot/Gradleに関する説明
  • SOAPやWSDL, JAXBなど関連する仕様についての細かい説明
  • 環境構築などの説明

環境

  • Java21
  • Spring Boot 3
  • Gradle
  • リクエストの確認はPostmanを利用しています

SOAPサーバーを作ってみる

まずはSOAPサーバーを作ってみます。

xsdファイルの作成

まずはAPIのインターフェイス定義となるxsdファイルを書いていきます。xsdファイルはXML Schemaというスキーマ言語で書いていきます。

okomes.xsd

<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="http://www.example.com/okomes" xmlns:tns="http://www.example.com/okomes" elementFormDefault="qualified">
    <xs:element name="getHinshuRequest">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="id" type="xs:int" />
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:element name="getHinshuResponse">
        <xs:complexType>
            <xs:sequence>
                <xs:element name="hinshu" type="tns:hinshu" />
            </xs:sequence>
        </xs:complexType>
    </xs:element>

    <xs:complexType name="hinshu">
        <xs:sequence>
            <xs:element name="id" type="xs:int" />
            <xs:element name="name" type="xs:string" />
        </xs:sequence>
    </xs:complexType>
</xs:schema>

今回はお米の品種を取得するインターフェイス定義を書いてみました。

xsdファイルからJavaのクラスを自動生成

次にxsdファイルからサービスの実装で利用するJavaクラスを自動生成します。今回はGradleで自動生成します。

build.gradle (一部抜粋)

ext.jaxbSourceDir = "${buildDir}/generated/sources/jaxb"

configurations {
    jaxb
}

dependencies {
    jaxb("org.glassfish.jaxb:jaxb-xjc")
}


task genJaxb {
    ext.schema = "src/main/resources/okomes.xsd"

    outputs.dir jaxbSourceDir

    doLast() {
        project.ant {
            taskdef name: "xjc", classname: "com.sun.tools.xjc.XJCTask", classpath: configurations.jaxb.asPath
            mkdir(dir: jaxbSourceDir)

            xjc(destdir: jaxbSourceDir, schema: schema) {
                arg(value: "-wsdl")
                produces(dir: jaxbSourceDir, includes: "**/*.java")
            }
        }
    }
}

上記は作成したxsdファイルを指定してJavaのクラスを生成するGradleタスクです。genJaxbタスクが実行されると指定したディレクトリにJavaクラスが生成されます。

WebServiceの設定

xsdファイルが作成できたらWebService側 (Spring-WS側) の設定をして、WSDLの仕様でWebServiceを公開します。

@EnableWs
@Configuration
public class WebServiceConfig extends WsConfigurerAdapter {

    @Bean
    ServletRegistrationBean<MessageDispatcherServlet> messageDispatcherServlet(ApplicationContext applicationContext) {
        MessageDispatcherServlet servlet = new MessageDispatcherServlet();
        servlet.setApplicationContext(applicationContext);
        servlet.setTransformWsdlLocations(true);
        return new ServletRegistrationBean<>(servlet, "/ws/*");
    }

    // Bean name = 'okomes' -> okomes.wsdl で公開される
    @Bean
    DefaultWsdl11Definition okomes(XsdSchema okomesSchema) {
        DefaultWsdl11Definition wsdl11Definition = new DefaultWsdl11Definition();
        wsdl11Definition.setPortTypeName("Okomes"); 
        wsdl11Definition.setLocationUri("/ws");
        wsdl11Definition.setTargetNamespace("http://www.example.com/okomes");
        wsdl11Definition.setSchema(okomesSchema);
        return wsdl11Definition;
    }

    @Bean
    XsdSchema okomesSchema() {
        return new SimpleXsdSchema(new ClassPathResource("okomes.xsd"));
    }
}

WsConfigureAdapterを継承したConfigurationを作成し、@EnableWsでSpring-WSの機能を有効化します。

MessageDispatcherServletのBeanを設定することでSOAP WebServiceがサーブレット形式で公開できます。MessageDispatcherServletはWebServiceのWSDL定義を配信する役割も担っています。

DefaultWsdl11DefinitionのBean定義でスキーマ定義 (xsdファイル) からWSDL定義を作成しています。このDefaultWsdl11DefinitionのBean名が公開されるWSDL名となります。(サンプルだとokomes.wsdlとなる)

Applicationを起動してGET http://localhost:8080/ws/okomes.wsdlのリクエストを送ると、WSDLファイルが配信されていることを確認できます。

WSDLとは

Web Service Description Languageの略で、SOAPなどを利用して実装されるWebServiceの仕様が記述されたもので、WebServiceの提供側 (サーバー側) はWSDLファイルを配信し、利用側 (クライアント側) はWSDLファイルの仕様を読み、そのWebServiceを利用します。

WSDLファイルの配信について

上記のようにxsdファイルから簡単にWSDLファイルを自動生成して配信することが出来ます。この手法は<dynamic-wsdl>と呼ばれており、実際のproduction環境での使用は推奨されていません。
もし、スキーマ定義 (xsdファイル) が変更された場合に、クライアントが参照するWSDLファイルも自動的に変更されてしまうからです。
production環境では以下のような<static-wsdl>の手法が推奨されており、これは生成済みのWSDLファイルを静的に配信します。

    @Bean
    SimpleWsdl11Definition okomes() {
        return new SimpleWsdl11Definition(new ClassPathResource("okomes.wsdl"));
    }

サーバー側エンドポイントとロジックを実装

最後にサーバー側のエンドポイントとロジックを簡単に書いてサーバー側の実装は完了です。

@Endpoint
public class OkomeEndpoint {
    private static final String NAMESPACE_URI = "http://www.example.com/okomes";

    private final OkomeRepository okomeRepository;

    public OkomeEndpoint(OkomeRepository okomeRepository) {
        this.okomeRepository = okomeRepository;
    }

    @PayloadRoot(namespace = NAMESPACE_URI, localPart = "getHinshuRequest")
    @ResponsePayload
    public GetHinshuResponse getHinshu(
            @RequestPayload GetHinshuRequest request) throws Exception {
        GetHinshuResponse response = new GetHinshuResponse();
        Hinshu hinshu = okomeRepository.findHinshu(request.getId());
        response.setHinshu(hinshu);

        return response;
    }
}

@Endpointを付与することで、SOAP WebServiceのエンドポイントとして公開することができます。

@PayloadRootでxsdファイルで定義したリクエスト (getHinshuRequest) を指定することで、そこへ対するリクエストをハンドリングしてくれます。
@RequestPayload/@ResponsePayloadでWebServiceのリクエスト/レスポンスとJavaクラスのマッピングをしてくれます。

レスポンスするデータに関しては以下のように簡易的に作ってみました。

@Component
public class OkomeRepository {

    private static Map<Integer, Hinshu> hinshues;

    static {
        hinshues = new HashMap<>();

        Hinshu koshihikari = new Hinshu();
        koshihikari.setId(1);
        koshihikari.setName("コシヒカリ");
        hinshues.put(1, koshihikari);

        Hinshu hinohikari = new Hinshu();
        hinohikari.setId(2);
        hinohikari.setName("ヒノヒカリ");
        hinshues.put(2, hinohikari);

        Hinshu akitaKomachi = new Hinshu();
        akitaKomachi.setId(3);
        akitaKomachi.setName("あきたこまち");
        hinshues.put(3, akitaKomachi);
    }

    public Hinshu findHinshu(int id) {
        return hinshues.get(id);
    }
}

ここまでの実装で、実際にリクエストを投げて確認してみたいと思います。

レスポンスが確認できました!

ちなみに、SOAPにはバージョンが存在し、バージョンによって指定するContent-Typeが異なります。Spring-WSのデフォルトバージョンはSOAP 1.1なので、Content-Typeにはtext/xmlを指定します。

SOAPクライアントを作ってみる

サーバー側が実装できたので、次はクライアント側を実装していきます。

サーバー側から配信されたWSDLファイルからJavaクラスを自動生成

クライアント側ではサーバー側から配信されるWSDL定義から、クライアントで実装するJavaクラスを自動生成します。これもGradleのタスクで行います。

build.gradle (一部抜粋)

ext.jaxwsSourceDir = "${buildDir}/generated/sources/jaxws"

configurations {
    jaxws
}

dependencies {
    jaxws 'com.sun.xml.ws:jaxws-tools:3.0.0',
        'jakarta.xml.ws:jakarta.xml.ws-api:3.0.0',
        'jakarta.xml.bind:jakarta.xml.bind-api:3.0.0',
        'jakarta.activation:jakarta.activation-api:2.0.0',
        'com.sun.xml.ws:jaxws-rt:3.0.0'
}


task wsimport {
    doLast {
        project.mkdir(jaxwsSourceDir)
        ant {
            taskdef(name: 'wsimport',
             classname: 'com.sun.tools.ws.ant.WsImport',
             classpath: configurations.jaxws.asPath
            )
            wsimport(
             keep: true,
             destdir: jaxwsSourceDir,
             extension: "true",
             verbose: true,
             wsdl: "http://localhost:8080/ws/okomes.wsdl",
             xnocompile: true,
             package: "com.example.wsdl"
            ) {
                xjcarg(value: "-XautoNameResolution")
            }
        }
    }
}

wsimportタスクを実行することでJavaクラスが自動生成されます。wsimportの設定で参照するwsdlファイルを指定しますが、上記はローカルでSOAPサーバーを起動して、WSDLファイルが配信されていることを前提としています。事前に用意したWSDLファイルをresourcesディレクトリなどに置いて、そのファイルを指定することもできます。

クライアントの設定

クライアントの実装の前にConfigurationクラスでクライアントのBeanなどを作っておきます。

@Configuration
public class ClientConfig {

    @Bean
    Jaxb2Marshaller marshaller() {
        Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
        marshaller.setContextPath("com.example.wsdl"); // 自動生成したクラスのパッケージを指定
        return marshaller;
    }

    @Bean
    OkomeClient okomeClient(Jaxb2Marshaller marshaller) {
        OkomeClient client = new OkomeClient();
        client.setDefaultUri("http://localhost:8080/ws");
        client.setMarshaller(marshaller);
        client.setUnmarshaller(marshaller);
        return client;
    }
}

Jaxb2Marshallerには実際のSOAP通信で使うxml形式にマッピングするクラスを指定します。今回の場合マッピング対応するクラスはWSDLから自動生成しているクラスとなるので、自動生成先のパッケージを指定すれば良いです。

OkomeClientは作成したMarshallerを設定し、リクエストするエンドポイント (サーバー側で公開しているエンドポイント) を設定します。

クライアントの実装

続いてクライアントも実装します。

public class OkomeClient extends WebServiceGatewaySupport {

    public HinshuJson getHinshu(int id) {
        GetHinshuRequest request = new GetHinshuRequest();
        request.setId(id);

        GetHinshuResponse response = (GetHinshuResponse) getWebServiceTemplate()
                .marshalSendAndReceive(request, new SoapActionCallback("http://example.com/okomes/GetHinshuRequest"));

        Hinshu hinshu = response.getHinshu();

        return new HinshuJson(hinshu.getId(), hinshu.getName());
    }
}

WebServiceGatewaySupportを継承することでBeanとして公開され、WebServiceの機能が使えます。getWebServiceTemplate().marshalSendAndReceiveでリクエスト/レスポンスを行っています。

このすぐ後で記載しますが、今回はRestControllerを挟んで手元から確認しているので最後にJson用のクラスへ変換しています。

RestControllerを実装して確認

SOAPクライアントの実装もできたので、簡単なRestControllerを実装してSOAPクライアントを利用してみます。

@RestController
public class OkomeController {

    private final OkomeClient okomeClient;

    public OkomeController(OkomeClient okomeClient) {
        this.okomeClient = okomeClient;
    }

    @GetMapping("/okome/hinshu/{id}")
    public HinshuJson getHinshu(@PathVariable int id) {
        return okomeClient.getHinshu(id);
    }
}

Applicationを起動して動作を確認してみたいと思います。

レスポンスが確認できました!

おわりに

感想

SOAPに対しては苦手意識がずっとあったのですが、今回やってみて思ったよりも複雑ではない印象を受けました。難しいところはSpringがやってくれたりしているおかげかもしれません。

SOAPサーバー側の実装は初めてで、XML Schemaでの定義も初めて書きましたが、ここはやはり仕様の把握が難しいと思いました。ドキュメントも古いものしか残っていないと思うので、仕様を調べたりするのに苦戦しました。
また、SOAPの世界に関係する仕様 (WSDL, JAXB, XJC…) も多く、それらの仕様の理解や関係性の把握などはまだちゃんと出来ていないです。
それでも、SOAPに対する苦手意識が少しは取り除かれたとは思うので、よかったと思います。

ちなみに、執筆にあたってはChatGPTなどを活用する機会が多くありました。検索して出てきたページだと理解しづらいことでも、GPTがなんとなく理解しやすい感じで説明してくれるので助かりました。
それから、今回はお米をテーマにしたAPIを作っています。作成したのはお米の品種を返すAPIなのですが、テストデータを書いている時にGitHub Copilotが察したのか品種のデータを返し始めてくれたときはニッコリしました。

次やってみたいこと

  • エラーハンドリングの調整を試してみたい
  • リクエストの認証を試してみたい
  • 現状Javaクラスの自動生成部分はAntを利用しているので他Gradleパッケージで解決できないか試してみたい
  • JAXB Bindingを深掘りたい
    • 現状だと自動生成されたクラスを修正したい場合は、自動生成後に手で変更しないといけない
    • JAXBの機能で自動生成時に修正されるようにできると思うので試したい

SOAP入門の続編をお待ちください?

最後に

アソビューでは「生きるに、遊びを。」をミッションに、一緒に働くメンバーを募集しています!ご興味がありましたらお気軽にご応募いただければと思います!

www.asoview.com

参考資料