はじめに
SOAP XMLをWireMockで返したとき、XMLエディタではエラーがないのに、JAXBのフィールドがnullになったり、unmarshal時に期待する要素として認識されなかったりすることがあります。ルート要素の名前空間が一致しない場合は、UnmarshalExceptionになることもあります。
このとき原因になりやすいのが名前空間です。ns2のような接頭辞が付いているかではなく、要素がどの名前空間URIに属し、どのローカル名を持つかが重要です。
この記事では、prefix、default namespace、QNameを最小例で整理し、WireMockのSOAP fixtureを作るときにどこを確認すればよいかを説明します。
先に結論
XML要素を識別するとき、重要なのは接頭辞の文字列ではなく、QNameを名前空間宣言に照らして解決した結果です。
XML上では、svc:outPayloadのような表記をQNameと呼びます。QNameを解決すると、要素は名前空間URIとローカル名の組で識別できます。W3Cではこの組をexpanded nameと呼びます。本記事では分かりやすさのため、次の形で表します。
{名前空間URI}ローカル名
例えば、次の2つは接頭辞が違っても同じexpanded nameになります。
<ns1:outPayload xmlns:ns1="http://example.com/service" />
<service:outPayload xmlns:service="http://example.com/service" />
どちらも{http://example.com/service}outPayloadです。一方、xmlns:svcを宣言しただけの<outPayload>は名前空間なしの{}outPayloadです。JAXBが前者を期待していれば一致しません。
prefixは名前空間URIの別名である
XML Namespacesでは、名前空間はURI参照で識別されます。prefixは長いURIを毎回書かないための別名です。
<svc:outPayload xmlns:svc="http://example.com/service">
<svc:resultId>result-001</svc:resultId>
</svc:outPayload>
svcをns2やxに変えても、同じURIへ結び付いていれば、要素のexpanded nameは同じです。XMLの差分を見るときは、prefixの文字列だけでなくURIとローカル名を確認します。
宣言しただけでは、要素は名前空間に入らない
次のXMLでは、xmlns:svcはsvcをURIへ結び付ける宣言にすぎません。
<outPayload xmlns:svc="http://example.com/service">
<resultId>result-001</resultId>
</outPayload>
outPayloadとresultIdにはsvc:がなく、default namespaceもないため、どちらも名前空間なしです。
{}outPayload
{}resultId
名前空間付きにするにはprefixを付けます。
<svc:outPayload xmlns:svc="http://example.com/service">
<svc:resultId>result-001</svc:resultId>
</svc:outPayload>
この違いはXMLが整形式かどうかでは検出できません。どちらも解析できますが、JAXBが期待する要素は異なります。
default namespaceを使うと、接頭辞なし要素にもURIが付く
接頭辞を毎回書きたくない場合はdefault namespaceを使えます。
<outPayload xmlns="http://example.com/service">
<resultId>result-001</resultId>
</outPayload>
この場合、スコープ内の接頭辞なし要素はdefault namespaceに属し、両要素は{http://example.com/service}になります。xmlns:svc="..."とxmlns="..."を取り違えないことが重要です。
default namespaceは接頭辞なし属性には適用されない
default namespaceは接頭辞なし属性には適用されません。
<outPayload xmlns="http://example.com/service" status="ok">
<resultId>result-001</resultId>
</outPayload>
このXMLではoutPayloadとresultIdはサービスの名前空間に属しますが、statusは名前空間なしです。属性も名前空間に属させるなら、svc:statusのようにprefixを付けます。
SOAPでは複数の名前空間が同時に出る
SOAP XMLではEnvelope用、サービス用、共通型用など複数の名前空間が同時に出ます。
<soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:svc="http://example.com/report/service"
xmlns:com="http://example.com/common">
<soapenv:Body>
<svc:getReportResponse>
<svc:outPayload>
<com:resultId>result-001</com:resultId>
</svc:outPayload>
</svc:getReportResponse>
</soapenv:Body>
</soapenv:Envelope>
resultIdというローカル名が同じでもURIが違えば別要素です。要素名だけで比較せず、名前空間URIとローカル名を確認します。
JAXBが見ているのは見た目ではなくXMLの契約
Jakarta XML Bindingは、XMLとJavaオブジェクトを対応付けます。@XmlRootElementや@XmlElementのnameとnamespaceは、要素マッピングを決めます。
@XmlRootElement(name = "outPayload", namespace = "http://example.com/service")
public class OutPayload {
@XmlElement(name = "resultId", namespace = "http://example.com/service")
private String resultId;
}
このクラスが期待しているのは、名前空間付きのoutPayloadとresultIdです。fixtureを作るときは、見た目を手で整える前に、WSDL、XSD、生成クラスのアノテーション、ObjectFactoryを確認します。
XPathを書くときも名前空間を意識する
WireMockのmatchesXPathでSOAP bodyを条件にするときも、名前空間は影響します。ローカル名だけを見る//lineNumberは、意図せず別の名前空間の要素を拾うことがあります。
{
"matchesXPath": "//svc:lineNumber[text()='target-line']",
"xPathNamespaces": {
"svc": "http://example.com/report/service"
}
}
XPath側のprefixはWireMock設定内で使う別名であり、実際のSOAP XMLのprefix文字列と一致する必要はありません。同じURIを指定することが重要です。WireMockのXPathマッチングでは、xPathNamespacesをmatchesXPathと同じ階層へ置く形式が案内されています。
WireMock 3.12.0以降では、equalToXmlのnamespaceAwarenessで名前空間比較の扱いも設定できます。名前空間そのものを契約として固定したい場合は、STRICTを検討します。
名前空間の不一致を調べる順番
JAXBでフィールドの値が取れない、またはunmarshal時に期待する要素として認識されないときは、次の順で確認します。
- WSDLまたはXSDからルート要素のURIとローカル名を確認する
- JAXB生成クラスの
@XmlRootElementと@XmlElementを確認する - 実際のXMLの要素が同じexpanded nameになっているか確認する
- default namespaceとprefix宣言を取り違えていないか確認する
- 接頭辞なし属性の名前空間を確認する
- 正常に動くXMLとfixtureの差分を比較する
- 実際のクライアントでDTOが作られることをテストする
最小の変換テストを置く
名前空間の問題は、XMLの構文チェックだけでは防げません。実際にJAXBを通すテストを置きます。
@Test
void 正しい名前空間のXMLをDTOへ変換する() throws Exception {
JAXBContext context = JAXBContext.newInstance(OutPayload.class);
Unmarshaller unmarshaller = context.createUnmarshaller();
OutPayload payload = (OutPayload) unmarshaller.unmarshal(new StringReader("""
<svc:outPayload xmlns:svc="http://example.com/service">
<svc:resultId>result-001</svc:resultId>
</svc:outPayload>
"""));
assertThat(payload.getResultId()).isEqualTo("result-001");
}
単体の変換テストでは要素の名前空間URIとローカル名を固定し、結合テストではEnvelope、HTTP header、WireMock mappingを含む契約を確認します。
まとめ
- QNameはXML上の表記で、解決すると名前空間URIとローカル名の組になります
-
xmlns:svcはprefixを宣言するだけで、接頭辞なし要素をその名前空間へ入れません - default namespaceは接頭辞なし要素に適用されますが、接頭辞なし属性には適用されません
- JAXBで値が取れないときは、整形式ではなくWSDLや生成クラスが期待する名前空間URIとローカル名を確認します
- WireMockのfixtureも、見た目ではなく外部連携の契約としてテストします