連載
» 2017年04月19日 05時00分 UPDATE

.NET TIPS:XmlSerializerを使ってシリアライズ/デシリアライズするには?[C#/VB]

XmlSerializerクラスでシリアライズ/デシリアライズを行うと、デシリアライズに失敗することがある。その回避策を含め、XmlSerializerクラスの使い方を説明する。

[山本康彦,BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載目次

 オブジェクトをシリアライズする(ファイルなどに書き込める形にする)/デシリアライズする(シリアライズしたものから元のオブジェクトを復元する)ために、.NET Frameworkにはさまざまな方法が用意されている。その中でXmlSerializerクラス(System.Xml.Serialization名前空間)は、シリアライズ可能な型がわりとプリミティブなものに制限されてはいるものの、シリアライズ結果がXMLフォーマットの文字列で可読性に優れていることから、アプリの設定を保存するというような用途でよく使われている。

 本稿では、XmlSerializerクラスを使ったシリアライズ/デシリアライズの方法と注意点を解説する。

 なお、XmlSerializerクラスは.NET Frameworkのバージョン1.1からあるものだが、本稿はそれ以降の内容も含んでいる。サンプルコードをそのまま試すには、Visual Studio 2015(またはそれ以降)が必要である。

XmlSerializerクラスを使ってシリアライズ/デシリアライズするには?

 次のサンプルコードのようにする。ポイントは、デシリアライズ時にXmlReaderクラス(System.Xml名前空間)を使い、適切なXmlReaderSettingsオブジェクト(System.Xml名前空間)を与えることだ(その理由は後述する)。

using System.IO;
using System.Text;
using System.Xml.Serialization;
using static System.Console;

// シリアライズ対象のクラス
public class Sample
{
  public int Id { get; set; }
  public string Text { get; set; }
}

class Program
{
  static void Main(string[] args)
  {
    // シリアライズ先のファイル
    const string xmlFile = @".\Sample.xml";
    // シリアライズするオブジェクト
    var obj = new Sample { Id = 7, Text = "@IT" }; // (1)

    // シリアライズする
    var xmlSerializer1 = new XmlSerializer(typeof(Sample));
    using (var streamWriter = new StreamWriter(xmlFile, false, Encoding.UTF8))
    {
      xmlSerializer1.Serialize(streamWriter, obj);
      streamWriter.Flush();
    }
    // 書き出されたファイルの内容(一部に改行を入れている):
    // <?xml version="1.0" encoding="utf-8"?>
    // <Sample xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    //         xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    //   <Id>7</Id>
    //   <Text>@IT</Text>
    // </Sample>

    // デシリアライズする
    var xmlSerializer2 = new XmlSerializer(typeof(Sample));
    Sample result;
    var xmlSettings = new System.Xml.XmlReaderSettings()
    {
      CheckCharacters = false, // (2)
    };
    using (var streamReader = new StreamReader(xmlFile, Encoding.UTF8))
    using (var xmlReader
            = System.Xml.XmlReader.Create(streamReader, xmlSettings))
    {
      result = (Sample)xmlSerializer2.Deserialize(xmlReader); // (3)
    }
    WriteLine($"{result.Id}, {result.Text}");
    // 出力:7, @IT

#if DEBUG
    ReadKey();
#endif
  }
}

Imports System.Console
Imports System.IO
Imports System.Text
Imports System.Xml.Serialization

' シリアライズ対象のクラス
Public Class Sample
  Public Property Id As Integer
  Public Property Text As String
End Class

Module Module1
  Sub Main()
    ' シリアライズ先のファイル
    Const xmlFile As String = ".\Sample.xml"
    ' シリアライズするオブジェクト
    Dim obj = New Sample With {.Id = 7, .Text = "@IT"} ' (1)

    ' シリアライズする
    Dim xmlSerializer1 = New XmlSerializer(GetType(Sample))
    Using streamWriter = New StreamWriter(xmlFile, False, Encoding.UTF8)
      xmlSerializer1.Serialize(streamWriter, obj)
      streamWriter.Flush()
    End Using
    ' 書き出されたファイルの内容(一部に改行を入れている):
    ' <?xml version="1.0" encoding="utf-8"?>
    ' <Sample xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    '         xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    '   <Id>7</Id>
    '   <Text>@IT</Text>
    ' </Sample>

    ' デシリアライズする
    Dim xmlSerializer2 = New XmlSerializer(GetType(Sample))
    Dim result As Sample
    Dim xmlSettings = New System.Xml.XmlReaderSettings() _
    With {
      .CheckCharacters = False ' (2)
    }
    Using streamReader = New StreamReader(xmlFile, Encoding.UTF8)
      Using xmlReader = System.Xml.XmlReader.Create(streamReader, xmlSettings)
        result = CType(xmlSerializer2.Deserialize(xmlReader), Sample) ' (3)
      End Using
    End Using
    WriteLine($"{result.Id}, {result.Text}")
    ' 出力:7, @IT

#If DEBUG Then
    ReadKey()
#End If
  End Sub
End Module

XmlSerializerクラスを使ってシリアライズ/デシリアライズするコンソールアプリの例(上:C#、下:VB)
コメント中の記号「(1)」「(2)」「(3)」については後述する。
StreamWriterクラス/StreamReaderクラスを使ったファイルの読み書きについては、「.NET TIPS:ファイルにテキストを書き込むには?[C#、VB]」や「.NET TIPS:テキスト・ファイルの内容を読み込むには?[C#、VB]」を参照してもらいたい。
また、C#コードの冒頭から4行目にある「using static System.Console;」という書き方は、Visual Studio 2015からのものだ。詳しくは、「.NET TIPS:構文:クラス名を書かずに静的メソッドを呼び出すには?[C# 6.0]」をご覧いただきたい。同様な機能がVBには以前から備わっており、「.NET TIPS:VB.NETでクラス名を省略してメソッドや定数を利用するには?」で解説している。
WriteLineメソッドに出てくる先頭に「$」記号が付いた文字列については、「.NET TIPS:数値を右詰めや0埋めで文字列化するには?[C#、VB]」の後半(「C# 6.0/VB 14で追加された補間文字列機能を使用する」)を見てほしい。
「Main」メソッド末尾にReadKeyメソッドを置く意味は、「.NET TIPS:Visual Studioでコンソール・アプリケーションのデバッグ実行時にコマンド・プロンプトを閉じないようにするには?」をご覧いただきたい。

XMLとして不正な文字が入っているときの対処

 前掲のコードでXmlReaderクラスを使っている理由は、データの中にXMLとして不正な文字が入っていた場合に対処するためである。

 XMLで使用できる文字は、決められている。規格にない文字は、XMLとしては不正なのである。例えば、TAB/CR/LFを除く制御コードは不正なのだ。

 XMLとして不正な文字が入っているときに、XmlReaderクラスを使わずにStreamReaderオブジェクトをXmlSerializerオブジェクトに直接渡したり(すると、XmlSerializerクラスは内部的にXmlReaderクラスを既定の状態で生成して使用する)、あるいは、XmlReaderオブジェクトを作るときに適切なXmlReaderSettingsオブジェクトを渡さないと、デシリアライズに失敗して例外が発生する。

 そのことを、先のコードを書き換えて確かめてみよう。

 まず、XMLとして不正な文字をデータに入れる。コメント「(1)」のところを、次のように変更する。「\u001a」「ChrW(&H1A)」とは制御コードEOFのことだ(昔はテキストファイルの終端記号としてよく使われた)。

// シリアライズするオブジェクト
// var obj = new Sample { Id = 7, Text = "@IT" }; // (1)
// ↓
var obj = new Sample { Id = 7, Text = "@\u001aIT" };

' シリアライズするオブジェクト
' Dim obj = New Sample With {.Id = 7, .Text = "@IT"} ' (1)
' ↓
Dim obj = New Sample With {.Id = 7, .Text = $"@{ChrW(&H1A)}IT"}

XMLとして不正な文字をデータに入れる(上:C#、下:VB)

 次に、コメント「(2)」と付けてある行をコメントアウトする(次のコード)。「CheckCharacters = false」を指定しないということは、XMLとして不正な文字をチェックさせるということだ。チェックするのが、XmlReaderクラスとXmlSerializerクラスの既定の動作である(そして、XmlSerializerクラスにはこの動作を変更する方法が用意されておらず、XmlSerializerクラスに与えるXmlReaderクラスの側で動作を変更しなければならない)。

var xmlSettings = new System.Xml.XmlReaderSettings()
{
  //CheckCharacters = false, // (2)
};

Dim xmlSettings = New System.Xml.XmlReaderSettings() _
'With {
'  .CheckCharacters = False  ' (2)
'}

「CheckCharacters = false」の指定をコメントアウトする(上:C#、下:VB)

 これで実行してみよう。シリアライズは成功して、ファイルにも次のように書き出される。

<?xml version="1.0" encoding="utf-8"?>
<Sample xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <Id>7</Id>
  <Text>&#x1A;IT</Text>
</Sample>

XMLとして不正な文字が入っていてもシリアライズは成功する(出力されたXMLファイル)
一部、改行を入れている。

 しかし、デシリアライズはコメント「(3)」のところで例外が発生する(次の画像)。

XMLとして不正な文字が入っているとデシリアライズに失敗する(Visual Studio 2015) XMLとして不正な文字が入っているとデシリアライズに失敗する(Visual Studio 2015)
コメント「(3)」のところでSystem.InvalidOperationException例外が発生している。その例外を調べると、「'\u001a' (16 進数値 0x1A) は無効な文字です」といったメッセージが入っている(赤枠内)。
XMLファイルにXMLとして不正な文字が入っているとき、XmlReaderクラスは既定ではこのように例外を発生させる。これを回避するには、冒頭に示したコードのように「CheckCharacters = false」という設定を与える。

 この例外に遭遇する確率は、恐らくずいぶんと小さいだろう。シリアライズする元データにXMLとして不正な文字が入ってくることはまずないからだ。気を付けていないと、テスト時に不具合を発見できず、何年も運用してから不具合が出るなどということにもなりかねない。「XmlSerializerでデシリアライズするときはXmlReaderを使って不正な文字を許容させる」と覚えておこう。

 最後に、コメント「(2)」の行の先頭に付けたコメントを外して「CheckCharacters = false」の指定を有効にし、もう一度実行してみよう。今度はデシリアライズにも成功するはずだ。正しくデシリアライズされたかどうかは、「foreach(var c in result.Text) WriteLine($"{(int)(c):X4}");」(C#)などとして確認できる。

シリアライズとデシリアライズの非対称性

 「シリアライズ時にもXmlWriterオブジェクトを渡すようにして、対称にした方がよいのでは?」と思われた読者もいるかもしれない。もちろんそうしても構わない。

 ただし、XmlSerializerクラスのソースコードを見ると分かるが、Streamオブジェクトを渡したとき、内部的にはXmlTextReaderオブジェクト/XmlTextWriterオブジェクトが生成される。内部的にXmlTextWriterオブジェクトに設定しているオプション(ソースコードの299行目付近)とは異なる設定を与えたい場合に限って、シリアライズ時にXmlWriterオブジェクトを渡す意味がある(可読性の観点から、意味はなくてもXmlWriterオブジェクトを渡すようにするという判断も大いにありだと思う)。

 なお、XmlReaderSettingsクラスXmlWriterSettingsクラスは.NET Framework 2.0で導入されたものであるため、.NET Framework 1.1からあるXmlSerializerクラスでは使われていない。そのため、XmlSerializerクラスのデシリアライズ時には、空白などを適切に読み飛ばすために「xmlReader.Normalization = true;」としている(ソースコードの376行目付近)。しかしこの設定により、XMLとして不正な文字もチェックするようになるため、シリアライズできたオブジェクトがデシリアライズできないこともあるという非対称性が生じているのである。


ジェネリックな非同期バージョン

 XmlSerializerクラスは.NET Frameworkのバージョン1.1からあるため、デシリアライズの結果はObject型で返される。いちいちキャストするのは煩わしいものだ。また、あちこちにデシリアライズするコードを書いていると上述の注意点を忘れてしまうかもしれない。

 そこで、次のコードのようにジェネリックなメソッドとしてまとめておくとよいだろう。せっかくの機会なので、非同期バージョンにしておいた。

using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Serialization;

……省略……

// 排他ロックに使うSemaphoreSlimオブジェクト
// (プロセス間の排他が必要なときはSemaphoreオブジェクトに変える)
static System.Threading.SemaphoreSlim _semaphore
  = new System.Threading.SemaphoreSlim(1, 1);

// シリアライズする
static async Task SerializeAsync<T>(T data, string filePath)
{
  await _semaphore.WaitAsync(); // ロックを取得する
  try
  {
    var xmlSerializer = new XmlSerializer(typeof(T));
    using (var streamWriter = new StreamWriter(filePath, false, Encoding.UTF8))
    {
      await Task.Run(() => xmlSerializer.Serialize(streamWriter, data));
      await streamWriter.FlushAsync();  // .NET Framework 4.5以降
    }
  }
  finally
  {
    _semaphore.Release(); // ロックを解放する
  }
}

// デシリアライズする
static async Task<T> DeserializeAsync<T>(string filePath)
{
  await _semaphore.WaitAsync(); // ロックを取得する
  try
  {
    var xmlSerializer = new XmlSerializer(typeof(T));
    var xmlSettings = new System.Xml.XmlReaderSettings()
    {
      CheckCharacters = false,
    };
    using (var streamReader = new StreamReader(filePath, Encoding.UTF8))
    using (var xmlReader = System.Xml.XmlReader.Create(streamReader, xmlSettings))
    {
      return await Task.Run(() => (T)xmlSerializer.Deserialize(xmlReader));
    }
  }
  finally
  {
    _semaphore.Release(); // ロックを解放する
  }
}

Imports System.IO
Imports System.Text
Imports System.Xml.Serialization

……省略……

' 排他ロックに使うSemaphoreSlimオブジェクト
' (プロセス間の排他が必要なときはSemaphoreオブジェクトに変える)
Private _semaphore As System.Threading.SemaphoreSlim _
  = New System.Threading.SemaphoreSlim(1, 1)

'シリアライズする
Async Function SerializeAsync(Of T)(data As T, filePath As String) As Task
  Await _semaphore.WaitAsync() ' ロックを取得する
  Try
    Dim XmlSerializer = New XmlSerializer(GetType(T))
    Using streamWriter = New StreamWriter(filePath, False, Encoding.UTF8)
      Await Task.Run(Sub() XmlSerializer.Serialize(streamWriter, data))
      Await streamWriter.FlushAsync()  ' .NET Framework 4.5以降
    End Using
  Finally
    _semaphore.Release() ' ロックを解放する
  End Try
End Function

' デシリアライズする
Async Function DeserializeAsync(Of T)(filePath As String) As Task(Of T)
  Await _semaphore.WaitAsync() ' ロックを取得する
  Try
    Dim xmlSerializer = New XmlSerializer(GetType(T))
    Dim xmlSettings = New System.Xml.XmlReaderSettings() _
    With {
      .CheckCharacters = False
    }
    Using streamReader = New StreamReader(filePath, Encoding.UTF8)
      Using xmlReader = System.Xml.XmlReader.Create(streamReader, xmlSettings)
        Return Await Task.Run(Function() CType(xmlSerializer.Deserialize(xmlReader), T))
      End Using
    End Using
  Finally
    _semaphore.Release() ' ロックを解放する
  End Try
End Function

シリアライズ/デシリアライズするジェネリックなメソッドの例(上:C#、下:VB)
シリアライズするSerializeAsyncメソッドには、型引数としてシリアライズするオブジェクトの型を与え(第1引数の型と同じで良ければ省略可能)、引数としてシリアライズするオブジェクトと保存先となるファイルのパスを与える。
デシリアライズするDeserializeAsyncメソッドには、型引数として復元するオブジェクトの型を与え、引数としてシリアライズしたファイルのパスを与える。DeserializeAsyncメソッドは型引数で指定された型のオブジェクトを返すので、返値はキャストせずにそのまま使える。
どちらも非同期メソッドなので、呼び出すところにawaitキーワードが(そして呼び出し側のメソッドのシグネチャにはasyncキーワードが)必要である。
また、どちらのメソッドも、ファイルの読み書きで例外を出す可能性がある。呼び出し側でtry〜catchしてほしい。
なお、SemaphoreSlimオブジェクト(System.Threading名前空間)を使ったスレッド間の排他ロックについては、「.NET TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]」をご覧いただきたい。

まとめ

 XmlSerializerクラスでデシリアライズするときは、XMLファイルの読み込みにXmlReaderクラスを明示的に使う。XmlReaderオブジェクトを作るときには、「CheckCharacters = false」オプションを忘れずに付ける。

利用可能バージョン:.NET Framework 1.0以降(サンプルコードにはそれ以降の機能/構文も含む)
カテゴリ:クラス・ライブラリ 処理対象:シリアライズ
使用ライブラリ:XmlSerializerクラス(System.Xml.Serialization名前空間)
使用ライブラリ:XmlReaderクラス(System.Xml名前空間)
使用ライブラリ:XmlReaderSettingsクラス(System.Xml名前空間)
関連TIPS:ファイルにテキストを書き込むには?[C#、VB]
関連TIPS:テキスト・ファイルの内容を読み込むには?[C#、VB]
関連TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]
関連TIPS:構文:クラス名を書かずに静的メソッドを呼び出すには?[C# 6.0]
関連TIPS:VB.NETでクラス名を省略してメソッドや定数を利用するには?
関連TIPS:数値を右詰めや0埋めで文字列化するには?[C#、VB]
関連TIPS:Visual Studioでコンソール・アプリケーションのデバッグ実行時にコマンド・プロンプトを閉じないようにするには?


「.NET TIPS」のインデックス

.NET TIPS

Copyright© 1999-2017 Digital Advantage Corp. All Rights Reserved.

@IT Special

- PR -

TechTargetジャパン

この記事に関連するホワイトペーパー

Focus

- PR -

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。