💾

System.Xml.Serialization.XmlSerializerで色々なXMLをデシリアライズするサンプル

2022/03/24に公開

概要

XMLで作成された様々なデータを、Unity、C#、.NETのXmlSerializer(System.Xml.Serialization.XmlSerializer)を組み合わせてゲームデータとして取り込むデシリアライズ処理を作った際の備忘録です。ご参考にどうぞ。

  • XML
  • Unity、C#、.NET
  • System.Xml.Serialization.XmlSerializer
  • [XmlRoot]、[XmlElement]、[XmlAttribute]、[XmlText]、[XmlArray]、[XmlArrayItem]

C#でXMLをデシリアライズする方法

そもそも、UnityとC#の環境下でXMLをデシリアライズする手段は何通りか用意されています。

  • System.Xml.Serialization.XmlSerializer
  • System.Xml.XmlDocument
  • System.Xml.Linq.XDocument

どの方法を採用するかは様々な環境要因があり、一概にこれという答えはありません。当記事では、今回は表題のとおり、「XmlSerializer」を使用する方法をご紹介します。

前準備

まずは、C#でXMLをはじめとしたテキストデータを取り込む処理を作成します。以下のスクリプトをUnity上の適当なGameObjectにアタッチします。 /* 自作クラスの型 */ と記載の部分は後ほど作成した自作クラスで置き換えてください。(Nullチェックとか大分省略しています。)

XmlImporter.cs
using UnityEngine;
using System;
using System.IO;
using System.Xml.Serialization;

public class XmlImporter : MonoBehaviour
{
    void Start()
    {
	string xmlFilePath = ""; // 例: Assets/Texts/sample1.xml
        LoadXMLFile( xmlFilePath );
    }

    private void LoadXMLFile( string path )
    {
        try
        {
            using(FileStream fileStream = new FileStream( path, FileMode.Open ));
            {
                XmlSerializer serializer  = new System.Xml.Serialization.XmlSerializer( typeof( /* 自作のクラス型 */ ) );
                /* 自作のクラス型 */ deserializeData = (/* 自作のクラス型 */)serializer.Deserialize( fileStream );
		// deserializeDataをよしなに処理する
            }
        }
        catch( Exception exception )
        {
            Debug.Log( exception );
        }
    }
}

オーソドックスな使い方

XmlSerializerの最もオーソドックスな使い方は、XMLデータ構造を忠実に再現したクラス(前述の/* 自作クラスの型 */に該当)をC#で定義し、適切なC#の属性を使って各フィールドに紐づけることです。

sample1.xml
<?xml version="1.0" encoding="UTF-8"?>

<item id="12345">
	<name>薬草</name>
	<price>100</price>
	<quantity>1.5</quantity>
</item>
Item.cs(sample1.xmlの構造を忠実に再現したクラス)
using System.Xml.Serialization;

[XmlRoot( "item" )]
public class Item
{
    [XmlAttribute( "id" )]
    public int id;

    [XmlElement( "name" )]
    public string name;

    [XmlElement( "price" )]
    public int price;

    [XmlElement( "quantity" )]
    public float quantity;
}

上記をもとに、前述のXmlImporter.csを加工後、xmlFilePath変数にsample1.xmlのファイルパスを指定して実行してみます。以下は加工後のコードを抜粋したものです。(動作確認のため、Debug.Logに出力しています)

XmlImporter.cs(コード加工後抜粋)
XmlSerializer serializer  = new System.Xml.Serialization.XmlSerializer( typeof( Item ));
Item deserializeData = ( Item )serializer.Deserialize( fileStream );
Debug.Log("Item's Attribute:id="+deserializeData.id);
Debug.Log("Item's Child Element:name="+deserializeData.name);
Debug.Log("Item's Child Element:price="+deserializeData.price);
Debug.Log("Item's Child Element:quantity="+deserializeData.quantity);

続けて、もう少々複雑な形状のXMLをデシリアライズしてみます。

親子間(入れ子)構造を上手に取り込む

では、XMLがもう少し複雑な場合はどうでしょうか?

sample2.xml
<recipe>
	<dishname>スクランブルエッグ</dishname>
	<material quantity="2.0" unit="egg">たまご</material>
	<procedure>
		<step>フライパンにたまごを割り入れて火にかける</step>
	</procedure>
</recipe>

このようなケースでは、クラスを複数用意し、それらの関係性でXMLデータ構造を忠実に再現します。

Recipe.cs(sample2.xmlの構造を忠実に再現したクラス)
using System.Xml.Serialization;

[XmlRoot( "recipe" )]
public class Recipe
{
    [XmlElement( "dishname" )]
    public string dishname;

    [XmlElement( "material" )]
    public Material material;

    [XmlElement( "procedure" )]
    public Procedure procedure;
}

public class Material
{
    [XmlAttribute( "quantity" )]
    public float quantity;
    
    [XmlAttribute( "unit" )]
    public string unit;
    
    [XmlText()]
    public string text;
}

public class Procedure
{
    [XmlElement( "step" )]
    public string step;
}

上記をもとに、前述のXmlImporter.csを加工後、xmlFilePath変数にsample1.xmlのファイルパスを指定して実行してみます。以下は加工後のコードを抜粋したものです。(動作確認のため、Debug.Logに出力しています)

XmlImporter.cs(コード加工後抜粋)
XmlSerializer serializer  = new System.Xml.Serialization.XmlSerializer( typeof( Recipe ));
Recipe deserializeData = ( Recipe )serializer.Deserialize( fileStream );
Debug.Log("Recipe's Child Element:dishname="+deserializeData.dishname);
Debug.Log("Recipe>Material's Attribute:quantity="+deserializeData.material.quantity);
Debug.Log("Recipe>Material's Attribute:unit="+deserializeData.material.unit);
Debug.Log("Recipe>Material's innerText="+deserializeData.material.text);
Debug.Log("Recipe>Procedure's Child Element:step="+deserializeData.procedure.step);

ここまでで、4種類のC#の属性が登場しているので、筆者の理解で整理します。C#とXMLで言葉が混合するため、XMLに関係する用語には、XML文字を頭につけます。

<XML要素 XML属性="XML値">XML内容</XML要素>
	
<XML親要素 XML属性="XML値">
	</XML子要素 XML属性="XML値">XML内容</XML子要素>
	</XML子要素 XML属性="XML値">
		</XML孫要素 XML属性="XML値">XML内容</XML孫要素>
	</XML子要素>
</XML親要素>
属性名 効果
XmlRoot() 引数に指定した文字列と一致する最上位のXML要素を直後のクラスに紐付け。
XmlAttribute() 現クラスをXML要素と見立てた場合に、引数に指定した文字列と一致するXML属性を直後のフィールドに紐付け。
紐付けられたフィールドにはXML値が代入される。
該当するXML属性が存在しない場合、指定したフィールドにはそのデータ型の初期値(例:0)が代入される。
XmlElement() 現クラスをXML親要素と見立てた場合に、引数に指定した文字列と一致するXML子要素を直後のフィールドに紐付け。
紐付けられたフィールドが一般的な組み込み型(例:int,string)である場合、XML内容が代入される。
紐付けられたフィールドがオブジェクトの場合、XML子要素(及び、そのXML孫要素など)の情報がオブジェクトに引き渡される。
該当するXML子要素が存在しない場合、直後のフィールドにはそのデータ型の初期値(例:0)が代入される。
XmlText() 現クラスをXML要素と見立てた場合に、そのXML内容を直後のフィールドに代入

ここまでを踏まえて、もう少々特殊な形状のXMLをデシリアライズしてみます。

リストを取り込む

XML要素の繰り返しをC#のListでデシリアライズ可能です。これを行いたい時には、C#のスクリプト内でC#の属性であるXmlArray()とXmlArrayItem()を利用します。

sample3.xml
<shop>
	<list>
		<shopitem>
			<name>ひのきのぼう</name>
			<price>5</price>
			<description>攻撃力2</description>
		</shopitem>
		<shopitem>
			<name>こんぼう</name>
			<price>30</price>
			<description>攻撃力7</description>
		</shopitem>
		<shopitem>
			<name>どうのつるぎ</name>
			<price>100</price>
			<description>攻撃力12</description>
		</shopitem>
	</list>
</shop>
Shop.cs(sample3.xmlの構造を忠実に再現したクラス)
using System.Collections.Generic;
using System.Xml.Serialization;

[XmlRoot( "shop" )]
public class Shop
{
    [XmlArray("list")]
    [XmlArrayItem("shopitem")]
    public List<ShopItem> items;
    
    public class ShopItem
    {
        [XmlElement( "name" )]
        public string name;
    
        [XmlElement( "price" )]
        public int price;
    
        [XmlElement( "description" )]
        public string description;
    }
}
XmlImporter.cs(コード加工後抜粋)
XmlSerializer serializer = new System.Xml.Serialization.XmlSerializer(typeof(Shop));
Shop deserializeData = (Shop)serializer.Deserialize(fileStream);
foreach(Shop.ShopItem item in deserializeData.items)
{
    Debug.Log("Shop's Child Element List:ShopItem="+item.name+","+item.price+","+item.description);
}

少々いじってみると次のやり方でも同様な動作が実現できました。前述のコードの方が見通しが良いのかな?と感じます。

sample3'.xml
<shop>
	<shopitem>
		<name>ひのきのぼう</name>
		<price>5</price>
		<description>攻撃力2</description>
	</shopitem>
	<shopitem>
		<name>こんぼう</name>
		<price>30</price>
		<description>攻撃力7</description>
	</shopitem>
	<shopitem>
		<name>どうのつるぎ</name>
		<price>100</price>
		<description>攻撃力12</description>
	</shopitem>
</shop>
Shop'.cs
using System.Collections.Generic;
using System.Xml.Serialization;

[XmlRoot( "shop" )]
public class Shop
{
    [XmlElement( "shopitem" )]
    public List<ShopItem> items;
    
    public class ShopItem
    {
        [XmlElement( "name" )]
        public string name;
    
        [XmlElement( "price" )]
        public int price;
    
        [XmlElement( "description" )]
        public string description;
    }
}

XML内容とXML子要素のデータを同時に取り込む

ケースとしてはあまりないかもしれませんが、次のようなXMLファイルがあった場合にデータがどのように取り出されるか試してみます。

sample4.xml
<body>
	<paragraph>
		なぜか、<emphasis>ふいんき</emphasis>が変換できない
	</paragraph>
</body>
Body.cs(sample4.xmlの構造を再現したクラス)
using System.Xml.Serialization;

[XmlRoot( "body" )]
public class Body
{
    [XmlElement( "paragraph" )]
    public Paragraph paragraph;
    
    public class Paragraph
    {
        [XmlElement( "emphasis" )]
        public string emphasis;
    
        [XmlText()]
        public string innerText;
    }
}

XML親要素paragraphに対して、Pragraphクラスを作り、次のように処理してみます。

  • C#の属性であるXmlElement()を使って、XML子要素emphasisへの紐付け
  • C#の属性であるXmlText()を使って、XML親要素paragraphのXML内容の紐付け
XmlImporter.cs(コード加工後抜粋)
XmlSerializer serializer = new System.Xml.Serialization.XmlSerializer(typeof(Body));
Body deserializeData = (Body)serializer.Deserialize(fileStream);
Debug.Log("Body>Paragraph's Child Element:emphasis="+deserializeData.paragraph.emphasis);
Debug.Log("Body>Paragraph's Child Element:innerText="+deserializeData.paragraph.innerText);

実行してみると、XML子要素emphasisよりも前にあるテキストはXML親要素paragraphの内容としては処理されない事がわかりました。("なぜか、"の部分)

順不同で出現数も読めないデータを取り込む

XMLの親戚であるHTMLなどを例に挙げると、body要素の子要素にはh1要素、img要素、div要素をはじめとした多数の要素が順不同かつ0〜無限大出現するケースが存在します。こういったケースでは以下のようにポリモーフィズムなどを考慮したクラス設計をC#側で行ったところデシリアライズがうまく行きました。(なお、以下のXMLはHTMLに近づけ記述していますが、厳密なHTMLの構文は守っていません)

sample5.xml
<html>
	<h1>ここはひとつ目の見出しです</h1>
	<img alt="画像1" src="test.jpg" />
	<h1>ここはふたつ目の見出しです</h1>
	<img alt="画像2" src="test.gif" />
</html>
Html.cs
using System.Collections.Generic;
using System.Xml.Serialization;

[XmlRoot( "html" )]
public class Html
{
    [XmlElement("h1", typeof(Header))]
    [XmlElement("img", typeof(Image))]
    public List<PagePart> parts;
    
    public class PagePart{}
    
    public class Header:PagePart
    {
    	[XmlText()]
        public string innerText;
    }
    
    public class Image:PagePart
    {
	[XmlAttribute( "src" )]
        public string src;
	[XmlAttribute( "alt" )]
        public string alt;
    }
}

リストを取り込む方法の後半に紹介した方法をさらに改良したものです。C#の属性であるXmlElemet()は第1引数で指定したXML要素を検出した際に、第2引数で指定した型で直後のフィールドに紐づけることができるそうです。また、XmlElement()を2種類以上連続で記載することで、直後のフィールドがリストであったり、共通の継承元(例:Object)を有している型の場合、紐付けが成立するようです。

XmlImporter.cs(コード加工後抜粋)
XmlSerializer serializer = new System.Xml.Serialization.XmlSerializer(typeof(Html));
Html deserializeData = (Html)serializer.Deserialize(fileStream);
foreach (Html.PagePart part in deserializeData.parts)
{
    if (part is Html.Header)
    {
        Html.Header header = (Html.Header)part;
        Debug.Log("Html Child Element List:Header=" + header.innerText);
    }
    else if (part is Html.Image)
    {
        Html.Image image = (Html.Image)part;
        Debug.Log("Html Child Element List:Image=" + image.src + "," + image.alt);
    }
}

終わりに

今回記載したもの以外にも、様々なC#の属性が存在するそうです。この辺りをマスターできれば柔軟なデシリアライズだけでなく、ゲーム内データをXMLファイルへ出力(シリアライズ)する際にも役立ちそうです。また、どうしても今回のケースに当てはまらないデシリアライズが行いたい場合は、次のいずれかを採用した方法を検討すべきでしょう。

  • System.Xml.XmlDocument
  • System.Xml.Linq.XDocument

参考

以下のWebページを参考にさせていただきました。感謝。

Discussion