SOAP 連携をテスト環境で問題なく動かしていたのに、本番環境で顧客が & や < を含むデータを送信した途端に、連携全体が壊れる。よくある話です。
System.Xml.XmlException: An error occurred while parsing EntityName. Line 5, position 23.
Java の場合はこのようなメッセージになります。
org.xml.sax.SAXParseException: The entity name must immediately follow the '&' in the entity reference.
これは本番環境で最も多い SOAP エラーの一つです。XML にはマークアップ構文として特別な意味を持つ文字が 5 つあり、ユーザー入力にこれらが含まれると XML が壊れます。この記事では、問題となる文字、各言語での正しいエスケープ方法、そして CDATA セクションや Base64 エンコーディングの使い分けを解説します。
5 つの特殊文字
XML は 5 つの文字を構文として予約しています。これらがテキスト内容や属性値に出現すると、パーサーはデータではなくマークアップとして解釈します。
| 文字 | 名称 | XML エンティティ | 問題 |
|---|---|---|---|
& | アンパサンド | & | エンティティ参照の開始と解釈される |
< | 小なり | < | タグの開始と解釈される |
> | 大なり | > | 一部のコンテキストでタグ解析を破壊する |
" | ダブルクォート | " | ダブルクォートで囲まれた属性値を破壊する |
' | シングルクォート | ' | シングルクォートで囲まれた属性値を破壊する |
壊れた SOAP リクエストの例を示します。
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateCompany xmlns="http://example.com/api">
<!-- これは壊れている — & がエスケープされていない -->
<CompanyName>株式会社A&B</CompanyName>
<!-- これは壊れている — < がエスケープされていない -->
<Description>売上 < 1億円</Description>
</CreateCompany>
</soap:Body>
</soap:Envelope>
修正後のバージョンです。
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CreateCompany xmlns="http://example.com/api">
<CompanyName>株式会社A&B</CompanyName>
<Description>売上 < 1億円</Description>
</CreateCompany>
</soap:Body>
</soap:Envelope>
解決策 1: 各言語での XML エンティティエスケープ
正しい修正方法は、特殊文字を XML エンティティに置換することです。主要な言語での実装方法を紹介します。
Java
import javax.xml.stream.XMLStreamWriter;
import javax.xml.stream.XMLOutputFactory;
import java.io.StringWriter;
// 方法 1: XMLStreamWriter (自動エスケープ)
StringWriter sw = new StringWriter();
XMLStreamWriter writer = XMLOutputFactory.newInstance().createXMLStreamWriter(sw);
writer.writeStartElement("CompanyName");
writer.writeCharacters("株式会社A&B"); // 自動的に & にエスケープされる
writer.writeEndElement();
writer.flush();
System.out.println(sw.toString());
// 出力: <CompanyName>株式会社A&B</CompanyName>
// 方法 2: Apache Commons Text
import org.apache.commons.text.StringEscapeUtils;
String escaped = StringEscapeUtils.escapeXml11("株式会社A&B");
// 方法 3: 手動置換 (非推奨だが一般的)
public static String escapeXml(String input) {
if (input == null) return null;
return input
.replace("&", "&") // 必ず最初に処理すること
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
重要: 手動置換する場合、& は必ず最初に処理してください。そうしないと < が &lt; に二重エスケープされます。
C# / .NET
using System.Security;
using System.Xml;
// 方法 1: SecurityElement.Escape (最もシンプル)
string escaped = SecurityElement.Escape("株式会社A&B");
// 方法 2: XmlWriter (自動エスケープ)
using var sw = new StringWriter();
using var writer = XmlWriter.Create(sw, new XmlWriterSettings { OmitXmlDeclaration = true });
writer.WriteStartElement("CompanyName");
writer.WriteString("株式会社A&B"); // 自動エスケープ
writer.WriteEndElement();
writer.Flush();
Console.WriteLine(sw.ToString());
Python
import xml.sax.saxutils as saxutils
from lxml import etree
# 方法 1: xml.sax.saxutils.escape
escaped = saxutils.escape("株式会社A&B")
# 結果: "株式会社A&B"
# 属性値の場合 (クォートもエスケープ)
escaped_attr = saxutils.quoteattr('彼は"こんにちは"と言った')
# 方法 2: lxml (シリアライズ時に自動エスケープ)
root = etree.Element("CompanyName")
root.text = "株式会社A&B"
print(etree.tostring(root, encoding="unicode"))
# 出力: <CompanyName>株式会社A&B</CompanyName>
PHP
<?php
// 方法 1: htmlspecialchars (ENT_XML1 フラグ付き)
$escaped = htmlspecialchars("株式会社A&B", ENT_XML1 | ENT_QUOTES, 'UTF-8');
// 方法 2: DOMDocument (自動エスケープ)
$doc = new DOMDocument('1.0', 'UTF-8');
$element = $doc->createElement('CompanyName');
$text = $doc->createTextNode('株式会社A&B');
$element->appendChild($text);
$doc->appendChild($element);
echo $doc->saveXML($element);
// 注意: htmlentities() は使わないこと
// HTML エンティティ (例: ) は XML では無効です
JavaScript / Node.js
// 方法 1: 手動エスケープ関数
function escapeXml(str) {
return str
.replace(/&/g, '&') // 必ず最初
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 方法 2: xmlbuilder2 (自動エスケープ)
const { create } = require('xmlbuilder2');
const doc = create().ele('CompanyName').txt('株式会社A&B').up();
console.log(doc.end({ headless: true }));
解決策 2: CDATA セクション
CDATA セクションは、内部のすべてのテキストをリテラルとして扱うよう XML パーサーに指示します。エスケープが不要になります。
<Description><![CDATA[売上 < 1億円 & 成長中。<b>太字</b>で強調。]]></Description>
パーサーは <![CDATA[ と ]]> の間のコンテンツをプレーンテキストとして読み取ります。
CDATA を使うべき場面:
- データに多数の特殊文字が含まれる (HTML コンテンツ、コードスニペット、数式)
- XML 内で生のテキストを人間が読みやすくしたい
- WSDL のスキーマで CDATA を受け入れると明示的に定義されている
CDATA を使うべきでない場面:
- データにリテラル文字列
]]>が含まれる可能性がある (CDATA セクションが終了してしまう) - SOAP サービスがその要素の CDATA をサポートしていない (ストリップするサービスがある)
- プログラムで XML を構築している場合は、言語の XML ライブラリを使うべき
Java での CDATA 例
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.newDocument();
Element description = doc.createElement("Description");
CDATASection cdata = doc.createCDATASection("売上 < 1億円 & 成長中");
description.appendChild(cdata);
C# での CDATA 例
using var sw = new StringWriter();
using var writer = XmlWriter.Create(sw);
writer.WriteStartElement("Description");
writer.WriteCData("売上 < 1億円 & 成長中");
writer.WriteEndElement();
解決策 3: Base64 エンコーディング
データがバイナリ (ファイル、画像) であったり、グローバル市場のユーザー入力のように予測不可能な内容を含む場合、Base64 エンコーディングで XML 関連の問題をすべて回避できます。
<FileContent>5qCq5byP5Lya56S+QSZC</FileContent>
import base64
raw_data = "株式会社A&B <特殊> \"クォート\" データ"
encoded = base64.b64encode(raw_data.encode('utf-8')).decode('ascii')
受信側のサービスがそのフィールドで Base64 エンコードされたデータを受け入れる設計でなければなりません。SOAP サービスが xsd:base64Binary でファイル添付を受け入れる場合には、この方法が標準的です。
Unicode とエンコーディングの問題
5 つの特殊文字とは別に、エンコーディングの不一致は別のクラスの XmlException エラーを引き起こします。日本語環境では特に注意が必要です。
XmlException: Invalid character in the given encoding. Line 1, position 1.
よくある原因:
- BOM (Byte Order Mark): XML ファイルに UTF-8 BOM (
EF BB BF) があるが、パーサーが想定していない。 - エンコーディング宣言の不一致: XML は
encoding="UTF-8"と宣言しているが、実際のバイト列が Shift_JIS や EUC-JP。 - 制御文字:
\x00-\x08,\x0B,\x0C,\x0E-\x1Fは XML 1.0 では無効。
修正: 無効な XML 文字を除去する
import re
def strip_invalid_xml_chars(text):
"""XML 1.0 で無効な文字を除去する。"""
return re.sub(
r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]',
'',
text
)
clean_data = strip_invalid_xml_chars(user_input)
public static String stripInvalidXmlChars(String input) {
if (input == null) return null;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c == 0x9 || c == 0xA || c == 0xD ||
(c >= 0x20 && c <= 0xD7FF) ||
(c >= 0xE000 && c <= 0xFFFD)) {
sb.append(c);
}
}
return sb.toString();
}
デバッグのヒント
本番環境で XmlException が発生した場合、以下の手順で問題を素早く特定できます。
1. 送信前に生の SOAP リクエストをログ出力する
curl -v -X POST \
-H "Content-Type: text/xml; charset=utf-8" \
-H "SOAPAction: http://example.com/CreateCompany" \
-d @request.xml \
https://example.com/service
2. 送信前に XML を検証する
from lxml import etree
xml_string = build_soap_request(user_data)
try:
etree.fromstring(xml_string.encode('utf-8'))
print("XML は有効です")
except etree.XMLSyntaxError as e:
print(f"無効な XML: 行 {e.lineno}, 列 {e.offset}: {e.msg}")
3. 問題のある文字を特定する
def find_problem_chars(text):
"""XML を壊す文字を検出する。"""
problems = []
for i, char in enumerate(text):
if char in '&<>"\'':
problems.append((i, char, "XML 特殊文字: エンティティエスケープが必要"))
elif ord(char) < 0x20 and char not in '\t\n\r':
problems.append((i, repr(char), "XML 1.0 で無効な制御文字"))
return problems
issues = find_problem_chars(user_input)
for pos, char, reason in issues:
print(f"位置 {pos}: {char} — {reason}")
SOAPless による解決
XML のエスケープエラーは、JSON を扱っていれば存在しないカテゴリのバグです。SOAPless を使えば、JSON リクエストボディを送信して JSON レスポンスを受信します。SOAPless がサーバー側で JSON から XML への変換を処理し、送信するすべての値に正しい XML エスケープを自動適用します。会社名、説明文、住所など、あらゆるフィールドの特殊文字は SOAP サービスに到達する前に適切にエスケープされます。
XML 文字列を構築する必要も、CDATA とエンティティエスケープの使い分けを考える必要も、XmlException のスタックトレースをデバッグする必要もありません。リクエストは {"CompanyName": "株式会社A&B"} と送るだけで、SOAPless が名前空間の処理、SOAP エンベロープの構築、エンコーディング宣言も含めてすべてを処理します。ダッシュボードで特殊文字を含むデータを事前にテストできるので、コードを書く前に動作を確認できます。