巨大なXMLをMSXMLでスキーマ検証していると、DOMでは詳細なHRESULTが返っていたのに、SAXに切り替えた途端すべての検証エラーが「0x80004005(E_FAIL)」に丸められてしまう――そんな悩みを解消するために、本記事では「なぜそうなるのか」と「どうやってエラー種別を復元・分類するか」を、msxml6r.dllのメッセージテーブルをダンプする実装まで含めて詳しく解説します。
MSXML DOM から SAX に切り替えたときに起こること
まず前提として、MSXMLでXMLスキーマ検証を行う方法には大きく分けて次の2つがあります。
- DOM検証:
IXMLDOMDocument/IXMLDOMSchemaCollectionを使う - SAX検証:
ISAXXMLReader/ISAXContentHandler/ISAXErrorHandlerを使う
DOMはメモリ上にXML全体を構築するため、巨大なXMLではメモリ消費やパフォーマンスが問題になりがちです。そのため、ストリーミングで処理できるSAXバリデーションに切り替える、というのはよくある選択肢です。
しかしDOM検証では、エラー時に IXMLDOMParseError::errorCode から
0xC00CE014のような「エラー種別が分かる」HRESULT
が取得できていたのに、SAXに切り替えた途端、どんな検証エラーも
0x80004005 (E_FAIL)という「何が起きたか分からない汎用エラー」
しか返ってこない、という現象に直面します。
ユーザー向けのメッセージやログで「構造不正」「属性値不正」「必須要素の欠落」といった分類をしたいのに、HRESULTだけ見るとすべて同じE_FAIL。これは運用上かなり困ります。
なぜ SAX バリデーションでは 0x80004005 しか返らないのか
MSXMLのSAXインターフェイスでは、多くの検証エラーが E_FAIL (0x80004005) に丸められます。エラーの詳細は、HRESULTではなくテキストメッセージ側に寄せられているためです。
典型的なパターンとしては、
ISAXXMLReader::parseの戻り値が0x80004005ISAXErrorHandler::error/fatalErrorに渡されるメッセージ文字列に詳細な内容
となります。つまり、SAXでは「HRESULTでエラー種別を判断する」という設計自体が破綻しており、現実的には
HRESULTではなく、エラーメッセージ文字列でエラー種別を判別する
という発想の転換が必要になります。
アプローチの転換:HRESULTではなくメッセージで分類する
では、どのようにメッセージ文字列でエラー分類を行えばよいでしょうか。
- SAXのエラーコールバックで取得できるのはテキストのみ
- このテキストはMSXML内部のメッセージテーブルから引かれている
であれば、そのメッセージテーブルを事前にダンプし、
- メッセージID → メッセージテンプレート の一覧
を作っておけば、SAXで取得したメッセージをもとに「これはどのテンプレートか?」を推測し、エラー種別(カテゴリ)を割り当てることができます。
結論から言うと、MSXML 6 のメッセージは リソース専用DLL msxml6r.dll に格納されています。このDLLの「メッセージテーブルリソース」を列挙することで、全メッセージを機械的に取り出すことが可能です。
MSXMLのエラーメッセージはどこにあるのか
MSXML 6では、バイナリ本体の msxml6.dll とは別に、メッセージ専用のリソースDLLである msxml6r.dll が用意されています。エラーメッセージや警告メッセージは基本的にこのDLL内のメッセージテーブルに格納されています。
環境によって、DLLの実体パスは次のように分かれます。
| 条件 | 例示パス | 備考 |
|---|---|---|
| 64bit OS / 64bitプロセス | C:\Windows\System32\msxml6r.dll | 通常はこちら |
| 64bit OS / 32bitプロセス | C:\Windows\SysWOW64\msxml6r.dll | 32bitアプリはこちらを見る |
| 多言語OS | C:\Windows\System32\xx-XX\msxml6r.dll.mui など | 言語別MUIに文字列が入る場合あり |
実装では、パスをハードコードするのではなく、
GetSystemDirectoryやGetSystemWow64Directoryを用いて組み立てる- OS言語に応じてMUI側を参照する可能性も考慮する
といった工夫を入れておくと堅牢です。
リソースDLLからメッセージテーブルを列挙する全体像
C++で msxml6r.dll のメッセージテーブルを列挙する手順は以下のようになります。
| ステップ | 概要 |
|---|---|
| 1 | LoadLibraryEx で msxml6r.dll をデータファイルとして読み込む |
| 2 | FindResource で RT_MESSAGETABLE リソースを取得する |
| 3 | LoadResource → LockResource でメッセージテーブルの生データを得る |
| 4 | MESSAGE_RESOURCE_DATA / MESSAGE_RESOURCE_BLOCK を解釈し、ID範囲(LowId..HighId)を列挙する |
| 5 | 各IDに対して FormatMessage を呼び出し、メッセージ文字列を取得する |
| 6 | IDと文字列のペアをマップに格納し、CSV等で永続化する |
それぞれのステップを順番に見ていきます。
msxml6r.dll をデータファイルとして読み込む
LoadLibraryEx に LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE を指定して、コード実行ではなくリソース参照用としてロードします。
#include <windows.h>
#include <string>
HMODULE LoadMsxmlResourceModule()
{
wchar_t systemDir[MAX_PATH] = {};
if (!::GetSystemDirectoryW(systemDir, MAX_PATH)) {
return nullptr;
}
std::wstring path = systemDir;
if (!path.empty() && path.back() != L'\\') {
path += L'\\';
}
path += L"msxml6r.dll";
HMODULE h = ::LoadLibraryExW(
path.c_str(),
nullptr,
LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_IMAGE_RESOURCE
);
return h;
}
32bitプロセスであれば、必要に応じて GetSystemWow64DirectoryW を使ったり、フォールバックで SysWOW64 側を探したりする処理を追加するとよいでしょう。
メッセージテーブルリソースを取得する
メッセージテーブルはリソースタイプ RT_MESSAGETABLE として格納されています。まずはハンドルを取得します。
HMODULE hMod = LoadMsxmlResourceModule();
if (!hMod) {
// エラーハンドリング
return;
}
HRSRC hResInfo = ::FindResourceW(
hMod,
MAKEINTRESOURCEW(1), // 通常ID=1のことが多いが、必要に応じて列挙も検討
RT_MESSAGETABLE
);
if (!hResInfo) {
// エラーハンドリング
return;
}
HGLOBAL hResData = ::LoadResource(hMod, hResInfo);
if (!hResData) {
// エラーハンドリング
return;
}
void* pRes = ::LockResource(hResData);
DWORD size = ::SizeofResource(hMod, hResInfo);
pRes がメッセージテーブルの生バイト列になります。このバッファを MESSAGE_RESOURCE_DATA 構造体として解釈します。
MESSAGE_RESOURCE_DATA / BLOCK を走査してID範囲を取得する
メッセージテーブルのレイアウトは、Windowsのヘッダ <winnt.h> に定義されている MESSAGE_RESOURCE_DATA と MESSAGE_RESOURCE_BLOCK で表現されます。
MESSAGE_RESOURCE_DATA::NumberOfBlocks… ブロック数MESSAGE_RESOURCE_BLOCK::LowId/HighId… 含まれるメッセージIDの範囲
この情報を使って、「メッセージIDの候補範囲」を網羅的に列挙します。
#include <winnt.h>
#include <map>
#include <vector>
std::map<DWORD, std::wstring> DumpMessagesFromModule(HMODULE hMod)
{
std::map<DWORD, std::wstring> result;
HRSRC hResInfo = ::FindResourceW(hMod, MAKEINTRESOURCEW(1), RT_MESSAGETABLE);
if (!hResInfo) {
return result;
}
HGLOBAL hResData = ::LoadResource(hMod, hResInfo);
if (!hResData) {
return result;
}
void* pRes = ::LockResource(hResData);
if (!pRes) {
return result;
}
auto* pData = reinterpret_cast<MESSAGE_RESOURCE_DATA*>(pRes);
MESSAGE_RESOURCE_BLOCK* pBlock = &pData->Blocks[0];
for (DWORD i = 0; i < pData->NumberOfBlocks; ++i) {
DWORD lowId = pBlock[i].LowId;
DWORD highId = pBlock[i].HighId;
for (DWORD id = lowId; id <= highId; ++id) {
LPWSTR pText = nullptr;
DWORD len = ::FormatMessageW(
FORMAT_MESSAGE_ALLOCATE_BUFFER |
FORMAT_MESSAGE_FROM_HMODULE |
FORMAT_MESSAGE_IGNORE_INSERTS,
hMod,
id,
0, // 言語ID(0=ユーザー既定)
reinterpret_cast<LPWSTR>(&pText),
0,
nullptr
);
if (len > 0 && pText) {
std::wstring msg(pText, len);
// 末尾の改行などをトリムしておくと扱いやすい
while (!msg.empty() && (msg.back() == L'\r' || msg.back() == L'\n')) {
msg.pop_back();
}
result[id] = msg;
::LocalFree(pText);
}
}
}
return result;
}
ここで重要なのは、MESSAGE_RESOURCE_DATA 自体の中身を詳細にパースしなくても、LowId / HighId の範囲を使って FormatMessage を総当たりするだけで、最終的な文字列を取得できる点です。
存在しないIDに対して FormatMessage を呼ぶと0が返るだけなので、不要なIDは自動的にスキップされます。
ID→メッセージ文字列マップをCSVなどで永続化する
取得したマップは、そのままメモリ内だけで使うよりも、一度ファイルに出力しておくと後から分析しやすくなります。例えば、簡単なCSVとして保存する場合は次のようなコードになります。
#include <fstream>
#include <iomanip>
void SaveMessagesToCsv(const std::map<DWORD, std::wstring>& messages,
const std::wstring& filePath)
{
std::wofstream ofs(filePath);
ofs.imbue(std::locale(""));
ofs << L"IdHex,IdDec,Message" << L"\n";
for (const auto& kv : messages) {
DWORD id = kv.first;
const std::wstring& msg = kv.second;
ofs << L"0x"
<< std::setw(8) << std::setfill(L'0')
<< std::uppercase << std::hex << id
<< L"," << std::dec << id
<< L",\"" << msg << L"\"" << L"\n";
}
}
こうして一度CSV化しておけば、Excelやテキストエディタで「特定のフレーズを含むメッセージ」を検索し、どのメッセージがどのようなエラーを表しているのかを目視で整理できます。
SAX エラーを ID→メッセージマップで分類する
次に、SAXバリデーション中に実際に発生したエラーを、先ほど生成したマップ情報に基づいてどのように分類するかを見ていきます。
ISAXErrorHandler でエラー情報を取得する
MSXMLのSAXでは、エラー情報は ISAXErrorHandler 経由で受け取ります。実装のイメージは次のようになります。
class SaxErrorHandler : public ISAXErrorHandler
{
public:
// COM 実装(IUnknown)は省略
HRESULT STDMETHODCALLTYPE error(
ISAXLocator *pLocator,
const wchar_t *pErrorMessage,
HRESULT hrErrorCode) override
{
HandleError(pLocator, pErrorMessage, hrErrorCode, L"error");
return S_OK;
}
HRESULT STDMETHODCALLTYPE fatalError(
ISAXLocator *pLocator,
const wchar_t *pErrorMessage,
HRESULT hrErrorCode) override
{
HandleError(pLocator, pErrorMessage, hrErrorCode, L"fatal");
return S_OK;
}
HRESULT STDMETHODCALLTYPE ignorableWarning(
ISAXLocator *pLocator,
const wchar_t *pErrorMessage,
HRESULT hrErrorCode) override
{
HandleError(pLocator, pErrorMessage, hrErrorCode, L"warning");
return S_OK;
}
private:
void HandleError(ISAXLocator* locator,
const wchar_t* message,
HRESULT hr,
const wchar_t* level)
{
long line = 0, col = 0;
if (locator) {
locator->getLineNumber(&line);
locator->getColumnNumber(&col);
}
// ここで message に含まれるテキストをもとに分類
ClassifyAndLogError(level, line, col, message, hr);
}
void ClassifyAndLogError(const wchar_t* level,
long line,
long col,
const std::wstring& msg,
HRESULT hr)
{
// 実装は後述
}
};
ここで重要なのは、HRESULT(hr) はほぼ常に 0x80004005 である一方、message にはかなり詳細な英語メッセージが入っている、という点です。
メッセージテキストからカテゴリを決定する基本方針
実運用では、SAXで取得したメッセージ文字列を直接メッセージテーブルと突き合わせるのではなく、あらかじめ代表的なメッセージごとに「分類ルール」を設計しておくのがおすすめです。
例えば、テーブル化すると次のようなイメージになります。
| 代表メッセージ例 | 分類カテゴリ | 用途 |
|---|---|---|
| 「The element ‘X’ has invalid child element ‘Y’.」 | 構造不整合(予期しない子要素) | XMLのツリー構造の誤りとして扱う |
| 「The required attribute ‘X’ is missing.」 | 必須属性欠落 | 入力不足エラーとしてユーザーに提示 |
| 「The ‘X’ attribute is invalid – The value ‘Y’ is invalid.」 | 属性値不正 | 値のフォーマット/制約違反として扱う |
| 「The element ‘X’ is invalid – The value ‘Y’ is invalid.」 | 要素値不正 | 要素のテキスト内容のエラーとして扱う |
メッセージテーブルのダンプ結果を眺めながら、「この種のメッセージが出たらどのカテゴリに入れるか?」というルールセットを作っていくイメージです。
実装例として、単純な部分一致による分類を書くと次のようになります。
enum class ErrorCategory
{
Unknown,
Structure_InvalidChild,
Attribute_Missing,
Attribute_InvalidValue,
Element_InvalidValue,
};
ErrorCategory ClassifyByMessage(const std::wstring& msg)
{
if (msg.find(L"has invalid child element") != std::wstring::npos) {
return ErrorCategory::Structure_InvalidChild;
}
if (msg.find(L"required attribute") != std::wstring::npos &&
msg.find(L"is missing") != std::wstring::npos) {
return ErrorCategory::Attribute_Missing;
}
if (msg.find(L"attribute") != std::wstring::npos &&
msg.find(L"is invalid") != std::wstring::npos) {
return ErrorCategory::Attribute_InvalidValue;
}
if (msg.find(L"element") != std::wstring::npos &&
msg.find(L"is invalid") != std::wstring::npos) {
return ErrorCategory::Element_InvalidValue;
}
return ErrorCategory::Unknown;
}
ここでのキモは、どのフレーズに着目すれば安定して分類できるかを、メッセージテーブルの全一覧を見ながら設計できる点です。メッセージテーブルをダンプしていない状態で同じことをやろうとすると、「そもそもどんなメッセージが出るのか」が分からず、不安の残る実装になってしまいます。
メッセージテーブルを使った高度な突合ロジック
もう一歩踏み込むと、SAXが返すエラー文字列と、メッセージテーブル内のテンプレート文字列をパターンマッチさせて、より厳密に「どのメッセージIDに対応するか」を推測することも可能です。
プレースホルダと実際の値の違いを吸収する
メッセージテーブル内の文字列には、多くの場合 %1, %2 などのプレースホルダが含まれています。
- メッセージテーブル側:
「The element ‘%1’ has invalid child element ‘%2’.」 - SAXエラー側:
「The element ‘Order’ has invalid child element ‘TotalPrice’.」
このように、テンプレートと実際の文字列の間には「置換済みかどうか」の違いがあります。これを吸収する方法として、次のような手順が考えられます。
- テンプレート文字列側で、
%1や%2を正規表現のワイルドカードに変換する - シングルクォートで囲まれた名前部分を正規表現
'[^']+'として扱う - 生成した正規表現を、SAXのエラーメッセージに対して適用する
これにより、
- テンプレート:
「The element'%1'has invalid child element'%2'.」 - 実際のメッセージ:
「The element'Order'has invalid child element'TotalPrice'.」
を正確に対応付けることが可能になります。メッセージIDまで把握できれば、「このメッセージIDは構造エラー」「このメッセージIDは属性エラー」といったより細やかな分類テーブルを作成することもできます。
実際の分類テーブル設計イメージ
最終的に目指す構成としては、例えば次のようなデータ構造です。
| メッセージID | テンプレート | 分類カテゴリ | ユーザー向け日本語メッセージ例 |
|---|---|---|---|
| 0xC00CE01D | The element ‘%1’ has invalid child element ‘%2’. | 構造不整合 | 要素「%1」に許可されていない子要素「%2」が存在します。 |
| 0xC00CE01E | The required attribute ‘%1’ is missing. | 必須属性欠落 | 要素に必須属性「%1」が指定されていません。 |
| 0xC00CE01F | The ‘%1’ attribute is invalid – The value ‘%2’ is invalid. | 属性値不正 | 属性「%1」の値「%2」が不正です。 |
このようなテーブルを一度作っておけば、SAXのエラー処理で「どのカテゴリか」「ユーザーにはどう説明するか」を簡単に決めることができます。
ローカライズ環境での注意点
FormatMessage(FORMAT_MESSAGE_FROM_HMODULE) は、デフォルトでは呼び出し側のスレッド/プロセスの言語設定に応じたメッセージを返します。そのため、多言語サーバーやグローバル展開を行うシステムでは、次のような問題が発生し得ます。
- あるサーバーでは英語メッセージ、別のサーバーでは日本語メッセージが返る
- クライアントPC上で実行するとき、環境によってメッセージの言語が変わる
この状況でテキストベースの分類を行うと、言語の違いによって一致判定に失敗してしまう可能性があります。
対策としては、次のような方針が考えられます。
- 検証処理を行うサーバーのOS言語を固定し、すべて英語メッセージで扱う
- 環境ごとにメッセージテーブルをダンプし、言語別に分類ルールを用意する
FormatMessageのdwLanguageIdに特定の言語ID(例:MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US))を明示指定する
いずれの方式を採るにせよ、実運用で使うメッセージ言語をそろえることが安定した分類の条件になります。
MSXMLバージョンアップとメッセージテーブルの再生成
MSXML 6のメッセージIDやメッセージ文言は、製品バージョンによって変更される可能性があります。普段はあまり変わらないとはいえ、サービスパックやOSビルド更新によって微妙に変わることもあり得ます。
そのため、
- アプリケーションのインストーラやデプロイ時に
- 自動でメッセージテーブルをダンプしてマップを再生成する
というバッチ処理を組み込んでおくと将来的に安心です。
例えば、次のようなフローを用意できます。
- セットアップ時に小さなコンソールツールを実行し、
msxml6r.dllからメッセージテーブルをダンプ - 生成したCSVをアプリのリソースフォルダに配置
- 起動時または初回利用時にCSVを読み込み、ID→テンプレート→分類情報のマップを構築
これにより、「新しいOSで一部メッセージが変わったが、分類コード側が古いパターンしか知らない」といったズレを最小化できます。
HRESULTベースの厳密分類がどうしても必要な場合の代替案
ここまで述べてきたように、SAXバリデーションでは「メッセージ文字列ベースの分類」が現実解です。ただし、「どうしてもHRESULT(0xC00CE014など)ベースで厳密な分類がしたい」という要件もあり得ます。
その場合の落としどころとしては、次のような「二段構え」の方式があります。
- 巨大なXML全体はSAXでストリーミング検証し、エラーの行・列位置を取得する
- 問題箇所だけを切り出した小さなXMLを生成し、DOM検証で再度パースする
IXMLDOMParseError::errorCodeから詳細なHRESULTを取得する
つまり、
- 一次判定:SAXで「どこにエラーがあるか」を素早く検知
- 二次判定:DOMで「そのエラーはどのHRESULTに相当するか」を詳細分析
という役割分担です。巨大XML全体をDOMに載せるとメモリやパフォーマンス的に厳しくても、問題周辺部分だけを抜き出したミニXMLであれば十分現実的な負荷で再検証できます。
実務運用のチェックリスト
ここまでの内容を、実際にシステムへ組み込む際のチェックリストとしてまとめておきます。
- msxml6r.dll の読み込み
- 32bit/64bitのパスを正しく切り替えているか
- MUI環境も含めて、想定言語のDLLを参照できているか
- メッセージテーブルの列挙
RT_MESSAGETABLEを見つけられているかMESSAGE_RESOURCE_DATA/BLOCKの範囲を正しく走査しているかFormatMessage(FORMAT_MESSAGE_FROM_HMODULE)で全メッセージを取得できているか
- ID→メッセージマップの永続化
- CSVやJSONなど、自分たちのチームが扱いやすい形式で保存しているか
- バージョンアップ時に再生成できる仕組みがあるか
- SAX エラー処理
ISAXErrorHandlerで行・列番号とメッセージを取得しているか- メッセージテキストを正規化(改行削除など)した上で分類ロジックに回しているか
- 分類結果(構造エラー/属性エラー/警告など)をログやUIに分かりやすく出力しているか
- ローカライズ・多言語対応
- どの言語のメッセージを基準に分類ルールを作るかを決めているか
- サーバー側のOS言語設定と矛盾していないか
- DOMとの二段構え検証(必要な場合)
- 重い処理になりすぎない範囲で、問題箇所のDOM再検証を行える仕組みがあるか
- DOM側のHRESULTとSAX側のメッセージ分類結果を紐付けられるログ設計になっているか
まとめ:SAXの0x80004005問題を「メッセージテーブル」で乗りこなす
巨大なXMLのスキーマ検証をMSXML SAXに切り替えると、HRESULTがすべて 0x80004005 (E_FAIL) になってしまい、「エラー種別が分からない」という問題にぶつかりがちです。
しかし、MSXML 6のエラーメッセージが msxml6r.dll のメッセージテーブルに網羅されていることを利用し、
- リソースDLLを
LoadLibraryExで読み込む RT_MESSAGETABLEをFindResource→LoadResource→LockResourceで取得するMESSAGE_RESOURCE_DATA/MESSAGE_RESOURCE_BLOCKのID範囲を走査し、FormatMessageで文字列を取得する- 全メッセージの「ID→テンプレート」マップをCSVなどにダンプする
という手順を踏めば、SAXが返すエラーテキストをもとに、
- 構造不整合
- 必須属性欠落
- 属性値不正
- 要素値不正
- その他
といった実務で使いやすいカテゴリへ分類することができます。
また、ローカライズやバージョン差異への備えとして、
- メッセージ生成言語をサーバー側で統一する
- デプロイ時にメッセージテーブルを自動ダンプして再生成する
といった工夫をしておくと、将来的な保守コストも大きく削減できます。
「SAXでは0x80004005しか返らないからエラー分類は無理」と諦める必要はありません。メッセージテーブルを味方につけて、テキストベースの分類戦略に切り替えることで、DOM時代と遜色ないレベルの障害切り分けとユーザーフレンドリーなエラーメッセージを実現できます。

コメント