MSXML SAXで0x80004005しか返らない問題を解決する:msxml6r.dllメッセージテーブルからXML検証エラーを分類する方法

巨大な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 の戻り値が 0x80004005
  • ISAXErrorHandler::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.dll32bitアプリはこちらを見る
多言語OSC:\Windows\System32\xx-XX\msxml6r.dll.mui など言語別MUIに文字列が入る場合あり

実装では、パスをハードコードするのではなく、

  • GetSystemDirectory や GetSystemWow64Directory を用いて組み立てる
  • OS言語に応じてMUI側を参照する可能性も考慮する

といった工夫を入れておくと堅牢です。

リソースDLLからメッセージテーブルを列挙する全体像

C++で msxml6r.dll のメッセージテーブルを列挙する手順は以下のようになります。

ステップ概要
1LoadLibraryEx で msxml6r.dll をデータファイルとして読み込む
2FindResource で RT_MESSAGETABLE リソースを取得する
3LoadResource → LockResource でメッセージテーブルの生データを得る
4MESSAGE_RESOURCE_DATA / MESSAGE_RESOURCE_BLOCK を解釈し、ID範囲(LowId..HighId)を列挙する
5各IDに対して FormatMessage を呼び出し、メッセージ文字列を取得する
6IDと文字列のペアをマップに格納し、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 &lt;winnt.h&gt;
#include &lt;map&gt;
#include &lt;vector&gt;

std::map&lt;DWORD, std::wstring&gt; DumpMessagesFromModule(HMODULE hMod)
{
    std::map&lt;DWORD, std::wstring&gt; 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&lt;MESSAGE_RESOURCE_DATA*&gt;(pRes);
    MESSAGE_RESOURCE_BLOCK* pBlock = &amp;pData-&gt;Blocks[0];

    for (DWORD i = 0; i &lt; pData-&gt;NumberOfBlocks; ++i) {
        DWORD lowId  = pBlock[i].LowId;
        DWORD highId = pBlock[i].HighId;

        for (DWORD id = lowId; id &lt;= 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&lt;LPWSTR&gt;(&amp;pText),
                0,
                nullptr
            );

            if (len &gt; 0 &amp;&amp; pText) {
                std::wstring msg(pText, len);

                // 末尾の改行などをトリムしておくと扱いやすい
                while (!msg.empty() &amp;&amp; (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 &lt;fstream&gt;
#include &lt;iomanip&gt;

void SaveMessagesToCsv(const std::map&lt;DWORD, std::wstring&gt;&amp; messages,
                       const std::wstring&amp; filePath)
{
    std::wofstream ofs(filePath);
    ofs.imbue(std::locale(""));

    ofs &lt;&lt; L"IdHex,IdDec,Message" &lt;&lt; L"\n";
    for (const auto&amp; kv : messages) {
        DWORD id = kv.first;
        const std::wstring&amp; msg = kv.second;

        ofs &lt;&lt; L"0x"
            &lt;&lt; std::setw(8) &lt;&lt; std::setfill(L'0')
            &lt;&lt; std::uppercase &lt;&lt; std::hex &lt;&lt; id
            &lt;&lt; L"," &lt;&lt; std::dec &lt;&lt; id
            &lt;&lt; L",\"" &lt;&lt; msg &lt;&lt; L"\"" &lt;&lt; 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-&gt;getLineNumber(&amp;line);
            locator-&gt;getColumnNumber(&amp;col);
        }

        // ここで message に含まれるテキストをもとに分類
        ClassifyAndLogError(level, line, col, message, hr);
    }

    void ClassifyAndLogError(const wchar_t* level,
                             long line,
                             long col,
                             const std::wstring&amp; 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&amp; 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 &amp;&amp;
        msg.find(L"is missing") != std::wstring::npos) {
        return ErrorCategory::Attribute_Missing;
    }
    if (msg.find(L"attribute") != std::wstring::npos &amp;&amp;
        msg.find(L"is invalid") != std::wstring::npos) {
        return ErrorCategory::Attribute_InvalidValue;
    }
    if (msg.find(L"element") != std::wstring::npos &amp;&amp;
        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. テンプレート文字列側で、%1 や %2 を正規表現のワイルドカードに変換する
  2. シングルクォートで囲まれた名前部分を正規表現 '[^']+' として扱う
  3. 生成した正規表現を、SAXのエラーメッセージに対して適用する

これにより、

  • テンプレート:
    「The element '%1' has invalid child element '%2'.」
  • 実際のメッセージ:
    「The element 'Order' has invalid child element 'TotalPrice'.」

を正確に対応付けることが可能になります。メッセージIDまで把握できれば、「このメッセージIDは構造エラー」「このメッセージIDは属性エラー」といったより細やかな分類テーブルを作成することもできます。

実際の分類テーブル設計イメージ

最終的に目指す構成としては、例えば次のようなデータ構造です。

メッセージIDテンプレート分類カテゴリユーザー向け日本語メッセージ例
0xC00CE01DThe element ‘%1’ has invalid child element ‘%2’.構造不整合要素「%1」に許可されていない子要素「%2」が存在します。
0xC00CE01EThe required attribute ‘%1’ is missing.必須属性欠落要素に必須属性「%1」が指定されていません。
0xC00CE01FThe ‘%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ビルド更新によって微妙に変わることもあり得ます。

そのため、

  • アプリケーションのインストーラやデプロイ時に
  • 自動でメッセージテーブルをダンプしてマップを再生成する

というバッチ処理を組み込んでおくと将来的に安心です。

例えば、次のようなフローを用意できます。

  1. セットアップ時に小さなコンソールツールを実行し、msxml6r.dll からメッセージテーブルをダンプ
  2. 生成したCSVをアプリのリソースフォルダに配置
  3. 起動時または初回利用時にCSVを読み込み、ID→テンプレート→分類情報のマップを構築

これにより、「新しいOSで一部メッセージが変わったが、分類コード側が古いパターンしか知らない」といったズレを最小化できます。

HRESULTベースの厳密分類がどうしても必要な場合の代替案

ここまで述べてきたように、SAXバリデーションでは「メッセージ文字列ベースの分類」が現実解です。ただし、「どうしてもHRESULT(0xC00CE014など)ベースで厳密な分類がしたい」という要件もあり得ます。

その場合の落としどころとしては、次のような「二段構え」の方式があります。

  1. 巨大なXML全体はSAXでストリーミング検証し、エラーの行・列位置を取得する
  2. 問題箇所だけを切り出した小さなXMLを生成し、DOM検証で再度パースする
  3. 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時代と遜色ないレベルの障害切り分けとユーザーフレンドリーなエラーメッセージを実現できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次