PHPのserialize()
を通してシリアライズされた文字列をC#でオブジェクト化して使いたいという場面に遭遇した。
具体的にはWordPressによって保存されたアップロード画像のメタデータをC#で読み込もうとした時、そのデータがPHPでシリアライズされていて使えなかったのだ。
このメタデータはWordPressのwp_postmeta
テーブルに_wp_attachment_metadata
というキーで保存されている。
その値は以下のような文字列だ。
これがPHP内の話であれば、この文字列をunserialize()
に通すことで簡単にオブジェクトが取得できる。
しかしC#にはそれに対応する処理が用意されていない。
調べてみると同じようなことを考えた人がいたようで、C#によるデシリアライズ処理の実装がいくつか見つかった。
しかし作られたのがかなり昔のようで、ArrayList
などが使われていたりする。
書式のチェックもかなりざっくりとしかしていないようなので、作り直してみることにした。
まずはPHPでシリアライズされた文字列の構造を確認する。
PHPclass Hoge { public $id = 100; public $name = 'ほげ'; } enum Piyo { case Pi; case Pipi; } $data = [ 'null' => null, 'int' => 123, 'boolTrue' => true, 'boolFalse' => false, 'float' => 3.141592, 'string' => 'ABC1230', 'stringJp' => '日本語', 'array'=> [10, 20, 30], 'hashtable' => [ 'key1' => 'value1', 'key2' => 'value2', 3 => 123 ], 'enum' => Piyo::Pi, 'hoge' => new Hoge() ]; echo serialize($data);
可読性を上げるために適宜改行とスペースを入れてみる。
実際のシリアライズデータには以下のような改行やスペースは含まれておらず、このような改行やスペースの入った文字列をデシリアライズしようとするとエラーになる。
実際の処理では必ずPHPのserialize()
から取得したままの文字列を使用すること。
PHPでシリアライズされたデータは基本的にデータの種類、データの長さ、データの値の3つの要素を組み合わせて表現される。
データの種類はi:
(整数値)、s:
(文字列)、a:
(配列)など、それぞれのデータの先頭に付いていて、続くデータがどのような種類のデータかを表している。
データの長さは文字列や配列などの長さをもつデータに対して付与されている。
そしてこれらに続いて、それぞれに対応するデータの値(nullを表すN;は実際の値を持たない)が書かれている。
ではひとつずつ詳しく見ていきたい。
N;
はそれ単体でnull
値を表す。
これに限り後続に実際の値を表す文字列は何も現れない。
i:
はint型(整数型)を表し、続いてその値を持つ。
PHPにおいて整数値は実行環境によってサイズが変わるため、64bitになる場合がある。 C#ではintとlongで整数値を使い分けているが、PHPにはその区別がない。 そのためC#で読み込む場合は、その値がC#のintの範疇に収まるかどうか判断する必要がある。
b:
はbool型(論理型)を表し、続いてその値を持つ。
この時の値は1
(true)か0
(false)で表される。
値としてtrue
やfalse
は使われないので注意が必要だ。
d:
はfloat型(浮動小数点型)を表し、続いてその値を持つ。
PHPにおけるfloat型は基本的に64bitだが、環境に依存するとも書かれている。 C#では32bitならfloat型、64bitならdouble型と分かれているが、C#で読み出す時はdouble型固定にしても良いだろうと思う。
s:
はstring型(文字列型)を表し、それに続いて文字列の長さ(7:
)、最後に文字列の値を持つ。
ここで注意しなければならないのは、7:
といった文字列の長さは文字数ではなくバイト数だということだ。
上の例にs:9:"日本語";
とあるように、文字数としては3文字だが、実際の長さは9:
と設定されている。
実際の値を切り出す際にはその点に注意する必要がある。
また値の中に"
、:
、;
などが含まれていても、これらは特にエスケープされない。
PHPecho serialize('ABC"D:E;F');
そのため単純に"
が現れたら値の取得を終了する、といった処理をしてしまうと不具合の原因になる。
指定されたバイト数を元に正しく文字列を切り出す必要がある。
a:
は配列を表し、それに続いて配列の要素数(4:
)、最後に{
と}
で囲まれた配列要素の一覧を持つ。
PHPの配列には0
から始まる連続した値をキーに持つ通常の配列と、整数値や文字列をキーに持つ連想配列がある。
これらは内部的には同じものとして扱われ、シリアライズされた文字列でも同じa:
で表される。
これをC#で扱う際には通常の配列なのか連想配列なのかを判断し、適切な型で復元する必要がある。
このPHPの連想配列はキーに文字列と整数値を混在させて持つことを許容しているが、C#ではそのような型を定義するのは難しい。
キーの値を厳密に残そうとするならばDictionary<object, object?>
などで代用するしかないだろうか。
しかし幸いにもPHPの連想配列ではキーの値として文字列の"1"
と整数値の1
を区別せず、いずれも整数値の1
として扱う。
なので同じ数字を表す文字列と整数値のキーが同時に存在することはないはずだ。
そのためキーに整数値が指定されていても、それを全て文字列に変換し、キーを文字列に統一することは可能だと考える。
であればDictionary<string,object?>
やExpandoObject
を使うことも可能になる。
E:
はenum型(列挙型)を表し、それに続いて列挙子を表す文字列の長さ(7:
)、最後に列挙子を表す文字列を持つ。
この例では"Piyo:Pi"
が列挙子を表す値だが、これをC#からデシリアライズするなら元となるPHPの列挙型と同等の定義がC#側にも必要になる。
しかしPHPの列挙型は文字列値を指定できるが、C#では整数値に限定される。
その辺りをうまく吸収するコードを考えるのも良いが、必要がないなら無視かエラーとするのが良いかもしれない。
O:
はクラスオブジェクトを表し、それに続いてクラス名を表す文字列の長さ(4:
)、クラス名("Hoge":
)、オブジェクトの持つフィールドの数(2:
)、最後に{
と}
で囲まれたフィールド値の一覧を持つ。
この例では"Hoge":
がそのオブジェクトのクラス名を表すが、これをC#からデシリアライズするなら元となるPHPのクラスと同等の定義がC#側にも必要になる。
簡単な構造のクラスならそれも良いが、汎用的に作ろうと思うとかなり煩雑になるだろう。
よく使われるDateTime
などもクラスになるので、値をシリアライズすると同じように展開されてしまう。
特に日付は使用頻度の高いものではあるが、ISO形式の文字列としてシリアライズした方が間違いがないと思われる。
これらを踏まえ、C#からPHPのシリアライズ文字列をデシリアライズするためのコードを作成してみた。
なお以下のコードはC# 10で作成してあるので、古いバージョンのC#では一部修正が必要となる。
C#using System.Dynamic; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using PHPArrayEntry = System.Collections.Generic.KeyValuePair<string, object?>; namespace Tabiji.Code.Utility; public static class PHP { public static object? Deserialize(string text) { var deserializer = new PHPDeserializer(text); return deserializer.Execute(); } private class PHPDeserializer { private static Regex NumberValuePattern = new(@"\G(?<value>[^;]+);"); private static Regex LengthPattern = new(@"\G(?<value>[^:]+):"); private static Regex NullPattern = new(@"\GN;"); private static Regex IntPattern = new(@"\Gi:"); private static Regex DoublePattern = new(@"\Gd:"); private static Regex BoolPattern = new(@"\Gb:"); private static Regex StringPattern = new(@"\Gs:"); private static Regex ArrayPattern = new(@"\Ga:"); private static Encoding UTF8 = new UTF8Encoding(); private static NumberFormatInfo NumberFormat = new() { NumberGroupSeparator = "", NumberDecimalSeparator = "." }; private string Text { get; set; } private int CurrentIndex { get; set; } public PHPDeserializer(string text) { this.Text = text; } public object? Execute() { if (this.Text == "") return ""; this.CurrentIndex = 0; return this.Read(); } private object? Read() { Match match; match = this.MatchTypeIdentifier(NullPattern); if (match.Success) return null; match = this.MatchTypeIdentifier(IntPattern); if (match.Success) return this.ReadIntValue(); match = this.MatchTypeIdentifier(DoublePattern); if (match.Success) return this.ReadDoubleValue(); match = this.MatchTypeIdentifier(BoolPattern); if (match.Success) return this.ReadBoolValue(); match = this.MatchTypeIdentifier(StringPattern); if (match.Success) return this.ReadStringValue(); match = this.MatchTypeIdentifier(ArrayPattern); if (match.Success) return this.ReadArrayValue(); throw new InvalidDataException("invalid format"); } private Match MatchTypeIdentifier(Regex pattern) { var match = pattern.Match(this.Text, this.CurrentIndex); if (match.Success) this.CurrentIndex += match.Length; return match; } private string ReadNumberString() { var match = NumberValuePattern.Match(this.Text, this.CurrentIndex); if (match.Success) { this.CurrentIndex += match.Length; return match.Groups["value"].Value; } throw new InvalidDataException("invalid number value format"); } private string ReadLengthString() { var match = LengthPattern.Match(this.Text, this.CurrentIndex); if (match.Success) { this.CurrentIndex += match.Length; return match.Groups["value"].Value; } throw new InvalidDataException("invalid number value format"); } private string ReadChar() { return this.Text.Substring(this.CurrentIndex++, 1); } private void Skip(string text) { var subText = this.Text.Substring(this.CurrentIndex, text.Length); if (text != subText) throw new InvalidDataException("invalid format"); this.CurrentIndex += text.Length; } private object ReadIntValue() { var numberString = this.ReadNumberString(); if (int.TryParse(numberString, out int intValue)) return intValue; if (long.TryParse(numberString, out long longValue)) return longValue; throw new InvalidDataException("invalid integer value format"); } private object ReadDoubleValue() { var numberString = this.ReadNumberString(); if (double.TryParse(numberString, NumberStyles.Number, NumberFormat, out double doubleValue)) return doubleValue; throw new InvalidDataException("invalid double value format"); } private object ReadBoolValue() { var value = this.ReadNumberString(); if (value == "1") return true; if (value == "0") return false; throw new InvalidDataException("invalid bool value format"); } private object ReadStringValue() { var lengthString = this.ReadLengthString(); if (!int.TryParse(lengthString, out int length)) throw new InvalidDataException("invalid string length format"); this.Skip("\""); var value = new StringBuilder(); var byteCount = 0; while (byteCount < length) { var c = this.ReadChar(); byteCount += UTF8.GetByteCount(c); value.Append(c); } if (byteCount != length) throw new InvalidDataException("invalid string length format"); this.Skip("\";"); return value.ToString(); } private object ReadArrayValue() { var lengthString = this.ReadLengthString(); if (!int.TryParse(lengthString, out int length)) throw new InvalidDataException("invalid string length format"); var list = new List<PHPArrayEntry>(); var isArray = true; this.Skip("{"); for (int n = 0; n < length; n++) { var key = this.Read(); var value = this.Read(); if (key is not string && key is not int) throw new InvalidDataException("invalid array key"); if (key is not int || (int)key != n) isArray = false; var keyString = key.ToString(); if (keyString == null) throw new InvalidDataException("invalid array key"); var pair = new PHPArrayEntry(keyString, value); list.Add(pair); } this.Skip("}"); if (isArray) { return Array.ConvertAll(list.ToArray(), item => item.Value); } else { var obj = new ExpandoObject(); var dic = (IDictionary<string, object?>)obj; foreach (var item in list) dic[item.Key] =item.Value; return obj; } } } }
使う時は以下のようになる。
C#var serializedText = @"a:2:{s:6:""SomeId"";i:123;s:8:""SomeName"";s:15:""何かの名前"";}"; dynamic? obj = PHP.Deserialize(serializedText) as ExpandoObject; if (obj == null) return; var id = obj.SomeId; var name = obj.SomeName;
当コードは自由に使用してもらっても構わないが、以下の点を理解した上で各自の責任において使用していただきたい。