2011年5月16日

Azure Table Storageに非サポート型のプロパティを保存する

標準のAzure Table Storageにおいては以下の型のみが保存可能な型としてサポートされており、これ以外のプロパティとして持つエンティティを保存しようとするとエラーになってしまいます。

EDM型 CLR型 詳細
Edm.Binary byte[] バイトの配列 (サイズは最大 64 KB)
Edm.Boolean bool ブール値
Edm.DateTime DateTime UTC 時刻として表現された 64 ビット値 (サポートされている値の範囲は 1/1/1601 ~ 12/31/9999)
Edm.Double double 64 ビットの浮動小数点値
Edm.Guid Guid 128 ビットのグローバル一意識別子
Edm.Int32 Int32 or int 32 ビットの整数
Edm.Int64 Int64 or long 64 ビットの整数
Edm.String String UTF-16 でエンコードされた値 (サイズは最大 64 KB)

ただ、この制限に従って実際にシステムを作ろうとすると、ビジネスロジック層がドメインモデルとして設計されている場合に、ドメインオブジェクト群をストレージへ保存する際、そのオブジェクト構造をフラットに展開してからデータを保存しなければなりません。また、そのデータを読み込む時もフラットな構造から元のドメインオブジェクトに戻す処理が必要です。

データを読み書きするためにいちいちこんなことはやってられないので、今回はStorage Client APIをカスタマイズして、オブジェクト構造をそのままTable Storageへ保存できるようにします。

非サポート型プロパティへの対応方法

今回の非サポート型プロパティへの対応を行う際のエンティティクラスのサンプルとして、以下のPersonクラスを用います。Personクラスには、名前を表すNameプロパティとプロフィールを示すPersonProfile型のProfileプロパティがあります。

そして、PersonProfileクラスは、AgeおよびList<String>型のSampleCollectionといったプロパティから構成されています。

	public class Person : TableServiceEntity {

		public string Name {
			get;
			set;
		}

		public PersonProfile Profile {
			get;
			set;
		}
	}

	public class PersonProfile {

		public int Age {
			get;
			set;
		}

		public List<String> SampleCollection {
			get;
			set;
		}
	}

このようなPersonエンティティを標準のStorage Client APIでAzure Tableに保存しようとすると、以下のようなHTTPリクエストが投げられ、InvalidInputエラーが返ってきてしまいます。

<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<entry xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices"
         xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
         xmlns="http://www.w3.org/2005/Atom">
  <title />
  <author>
    <name />
  </author>
  <updated>2011-04-22T06:29:42.8547219Z</updated>
  <id />
  <content type="application/xml">
    <m:properties>
      <d:Name>Shunichi Takagi</d:Name>
      <d:PartitionKey>PARTITION</d:PartitionKey>
      <d:Profile>
        <Age p1:type="Edm.Int32"
               xmlns:p1="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" 
               xmlns="http://schemas.microsoft.com/ado/2007/08/dataservices">27</Age>
      </d:Profile>
      <d:RowKey>537ab28b-d80c-42fb-abc8-27a36b4b16cf</d:RowKey>
      <d:Timestamp m:type="Edm.DateTime">0001-01-01T00:00:00</d:Timestamp>
    </m:properties>
  </content>
</entry>

このHTTPリクエスト内で問題なのが、<d:Profile>要素部分(15~19行目)で、この要素内にAgeプロパティがXMLとして展開されてしまっている点です。これはStorage Client APIが利用しているWCF Data Service側の実装によるもののようですが、Azure Table StorageのREST APIでは、このようなリクエスト内容はサポートされていません。
(Table Service API: Insert Entity http://msdn.microsoft.com/en-us/library/dd179433.aspx

そのため、Table Storage側でエラーが返ってきます。

そこで今回は、そのような非サポート型のオブジェクトをJSON文字列に変換し、Table Storage側にはString型のカラムとして保存させるようにして、非サポート型プロパティに対応します。

実装方法

該当のプロパティをJSONに変換してStringとして保存させるように、Storage Client APIをカスタマイズします。といってもStorage Client API内部に手を入れるわけではなく、TableServiceContextクラスのReadingEntityイベントおよびWritingEntityイベントを利用します。

これらのイベントは、TableServiceContextによる標準のエンティティシリアライズ・デシリアライズが行われた後に発生するもので、このイベント内でシリアライズ後のリクエストXMLやデシリアライズされたエンティティを修正することができます。

また、JSON化対象のプロパティを示すためにEmbedded属性を用意し、その属性が付与されたプロパティのみをJSON文字列化して保存させるようにします。JSON文字列を作るために、JSON.NETを使いました。

そして、作成したのが、以下のコードです。CloudTableClientの拡張メソッドとして定義することで、通常のTableServiceContextと同じように使えるようにしてみました。

using System;

namespace VariousEntities {
	public class EmbeddedAttribute : Attribute { }
}
using System;
using System.Linq;
using System.Xml.Linq;
using Microsoft.WindowsAzure.StorageClient;
using Newtonsoft.Json;

namespace VariousEntities {
	public static class CloudTableClientExtensions {

		private static XNamespace atom = "http://www.w3.org/2005/Atom";
		private static XNamespace m = "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata";
		private static XNamespace d = "http://schemas.microsoft.com/ado/2007/08/dataservices";

		public static TableServiceContext GetDataServiceContextEmbeddedable(
			this CloudTableClient tableClient) {

			var context = tableClient.GetDataServiceContext();

			context.IgnoreMissingProperties = true;

			context.ReadingEntity += (obj, e) => {
				var properties = from p in e.Entity.GetType().GetProperties()
								 let attr = p.GetCustomAttributes(typeof(EmbeddedAttribute), true)
								 where attr.Length > 0
								 select new {
									 Property = p, EmbeddedAttribute = attr
								 };

				foreach ( var p in properties ) {
					var name = String.Format("EMB_{0}", p.Property.Name);
					var node = e.Data.Element(atom + "content")
						  .Element(m + "properties")
						  .Element(d + name);

					if ( node != null ) {
						var value = JsonConvert.DeserializeObject(node.Value, p.Property.PropertyType);
						p.Property.SetValue(e.Entity, value, null);
					}
				}
			};

			context.WritingEntity += (obj, e) => {
				var xmlProperties = e.Data.Descendants(m + "properties").First();
				var properties = from p in e.Entity.GetType().GetProperties()
								 let attr = p.GetCustomAttributes(typeof(EmbeddedAttribute), true)
								 where attr.Length > 0
								 select new {
									 Property = p, EmbeddedAttribute = attr
								 };

				foreach ( var p in properties ) {
					var name = String.Format("EMB_{0}", p.Property.Name);
					var value = p.Property.GetValue(e.Entity, null);

					var node = e.Data.Element(atom + "content")
						  .Element(m + "properties")
						  .Element(d + p.Property.Name);
					var element = BuildElement(name, value);

					if ( node == null ) {
						xmlProperties.Add(element);
					} else {
						node.ReplaceWith(element);
					}
				}
			};

			return context;
		}

		private static XElement BuildElement(string name, object value) {
			var element = new XElement(d + name);

			if ( value == null ) {
				element.Add(new XAttribute(m + "null", "true"));
			} else {
				var msg = JsonConvert.SerializeObject(value);
				element.SetValue(msg);
			}

			return element;
		}
	}
}

WritingEntityイベント時の処理について

上記ソースコード中のWritingEntity時では、まず書き込みを行うエンティティ(e.Entity)内のプロパティからEmbedded属性が付与されたものを探しています。そして、Embedded属性のプロパティが見つかったら、その値をJSON文字列化し新しく作成したXML要素 "EMB_{元のプロパティ名}" の値としてセットしてます。

そして作成したXML要素 "EMB_{元のプロパティ名}" を標準のシリアライズで作成されたXMLの中に挿入もしくは差し替えています(もし、Embedded属性のついたプロパティが標準でシリアライズされてたら差し替え、無視されていた場合は挿入になります)。

ReadingEntityイベント時の処理について

ReadingEntity側でもWritingEntityと同様に、まずはエンティティ(e.Entity)内のプロパティからEmbedded属性が付与されたものを探します(L22-27)。そして、XML内の "EMB_{元のプロパティ名}" 要素の値をJSONデシリアライズし、見つかったプロパティに対してセットします。

また、19行目でTableServiceContextのIgnoreMissingPropertyをtrueにしています。この値をtrueにすることで、XML上の要素の中にエンティティクラスにマッピングできないものがあってもデシリアライズが成功するようになります。今回は、WritingEntity時に "EMB_{元のプロパティ名}" という新しい要素を足しているため、この設定を行わないとエラーが発生してしまいます。

使ってみる

上記でカスタマイズしたTableServiceContextを用いて、オブジェクト型をプロパティとして持つエンティティをTable Storageに保存するコードを以下に示します。

通常と異なる点は、25行目のGetDataServiceContextEmbeddedableメソッドの呼び出し部分のみ。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;
using System.Data.Services.Client;
using System.Data.Services.Common;
using System.Xml.Serialization;
using System.Xml.Linq;
using System.Xml;
using System.IO;
using System.Diagnostics;

namespace VariousEntities {
	class Program {

		private static readonly string TABLE_NAME = "Entities";

		public static void Main(string[] args) {
			var account = CloudStorageAccount.Parse(
				"UseDevelopmentStorage=true;DevelopmentStorageProxyUri=http://ipv4.fiddler");
			var tableClient = account.CreateCloudTableClient();

			var context = tableClient.GetDataServiceContextEmbeddedable();

			context.AddObject(TABLE_NAME, new Person {
				PartitionKey = "PARTITION",
				RowKey = Guid.NewGuid().ToString(),
				Name = "Shunichi Takagi",
				Profile = new PersonProfile {
					Age = 27,
					SampleCollection = new List<string> { "a", "b", "c" }
				}
			});

			context.SaveChanges();
		}
	}

	public class Person : TableServiceEntity {

		public string Name {
			get;
			set;
		}

		[Embedded]
		public PersonProfile Profile {
			get;
			set;
		}
	}

	public class PersonProfile {

		public int Age {
			get;
			set;
		}

		public List<String> SampleCollection {
			get;
			set;
		}
	}
}

このようなプログラムを用いてエンティティを保存してみると、以下のようなリクエストが発行されます(15行目に注目)。

<?xml version="1.0" encoding="utf-8"?>
<entry xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" 
         xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"
         xmlns="http://www.w3.org/2005/Atom">
  <title />
  <author>
    <name />
  </author>
  <updated>2011-04-22T07:16:01.443593Z</updated>
  <id />
  <content type="application/xml">
    <m:properties>
      <d:Name>Shunichi Takagi</d:Name>
      <d:PartitionKey>PARTITION</d:PartitionKey>
      <d:EMB_Profile>{"Age":27,"SampleCollection":["a","b","c"]}</d:EMB_Profile>
      <d:RowKey>00858002-893d-4710-ac16-bb22f6d818bf</d:RowKey>
      <d:Timestamp m:type="Edm.DateTime">0001-01-01T00:00:00</d:Timestamp>
    </m:properties>
  </content>
</entry>

またAzure Storage Explorerを用いてストレージ内部を見ると正しくJSON化された文字列が入っていることがわかる。

image

パフォーマンス

このようにWritingEntityおよびReadingEntity時にJSONのシリアライズ・デシリアライズを行うことで、オブジェクト型やコレクション型のプロパティを持たせることができます。ただこのような処理を追加したことによるパフォーマンスへの影響が気になるところです。

そこで、5回のエンティティ挿入を行った際にWritingEntityおよびReadingEntityの処理時間を計測したところ、以下のようになりました。

処理 処理時間 [ms]
WritingEntity 111
ReadingEntity 49
WritingEntity 0
ReadingEntity 0
WritingEntity 0
ReadingEntity 0
WritingEntity 0
ReadingEntity 0
WritingEntity 0
ReadingEntity 0

この結果を見ると、おそらくJSON.NETのシリアライザがエンティティの構造をキャッシュするなりしているようで、2回目以降はms単位では計測できないレベルの処理時間でした。実際に多種多様なエンティティをシリアライズした場合のパフォーマンスがどうなるかについては、少し疑問がありますが;

まとめ

以上、今回はWindows Azure Tableに対して標準ではサポートしていないオブジェクトやコレクション型、配列といった型のプロパティを保存させる方法について書きました。

このようなカスタマイズを行うことによって、ストレージへのデータ保存の際にオブジェクト構造をフラットに展開したりすることを考えずにTable Storageを使うことが出来るようになります。このようなカスタマイズが行われたStorage Client APIがあれば、よりTable Storageが扱いやすいものとなるのではないでしょうか?

0 件のコメント:

コメントを投稿