写真 コラム 開発室
HOME開発室[C#] PHPでserialize()されたデータをC#で復元する
キーワード
WordPress PHP C#

[C#] PHPでserialize()されたデータをC#で復元する

[C#] PHPでserialize()されたデータをC#で復元する

PHPでserialize()されたデータをC#で使いたい

PHPのserialize()を通してシリアライズされた文字列をC#でオブジェクト化して使いたいという場面に遭遇した。

具体的にはWordPressによって保存されたアップロード画像のメタデータをC#で読み込もうとした時、そのデータがPHPでシリアライズされていて使えなかったのだ。 このメタデータはWordPressのwp_postmetaテーブルに_wp_attachment_metadataというキーで保存されている。 その値は以下のような文字列だ。

a:5:{s:5:"width";i:1600;s:6:"height";i:1067;s:4:"file";s:49:"journey-2020-winter-yakushima-20200210_114623.jpg";s:5:"sizes";a:3:{s:6:"medium";a:4:{s:4:"file";s:57:"journey-2020-winter-yakushima-20200210_114623-600x400.jpg";s:5:"width";i:600;s:6:"height";i:400;s:9:"mime-type";s:10:"image/jpeg";}s:5:"large";a:4:{s:4:"file";s:58:"journey-2020-winter-yakushima-20200210_114623-1200x800.jpg";s:5:"width";i:1200;s:6:"height";i:800;s:9:"mime-type";s:10:"image/jpeg";}s:9:"thumbnail";a:4:{s:4:"file";s:57:"journey-2020-winter-yakushima-20200210_114623-200x133.jpg";s:5:"width";i:200;s:6:"height";i:133;s:9:"mime-type";s:10:"image/jpeg";}}s:10:"image_meta";a:12:{s:8:"aperture";s:3:"5.6";s:6:"credit";s:0:"";s:6:"camera";s:10:"NIKON D850";s:7:"caption";s:0:"";s:17:"created_timestamp";s:10:"1581302783";s:9:"copyright";s:0:"";s:12:"focal_length";s:2:"24";s:3:"iso";s:4:"1250";s:13:"shutter_speed";s:17:"0.016666666666667";s:5:"title";s:0:"";s:11:"orientation";s:1:"0";s:8:"keywords";a:0:{}}}

これがPHP内の話であれば、この文字列をunserialize()に通すことで簡単にオブジェクトが取得できる。 しかしC#にはそれに対応する処理が用意されていない。

先人の残した移植コード

調べてみると同じようなことを考えた人がいたようで、C#によるデシリアライズ処理の実装がいくつか見つかった。

しかし作られたのがかなり昔のようで、ArrayListなどが使われていたりする。 書式のチェックもかなりざっくりとしかしていないようなので、作り直してみることにした。

シリアライズデータの構造を確認する

まずはPHPでシリアライズされた文字列の構造を確認する。

PHP
class 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);
a:11:{s:4:"null";N;s:3:"int";i:123;s:8:"boolTrue";b:1;s:9:"boolFalse";b:0;s:5:"float";d:3.141592;s:6:"string";s:7:"ABC1230";s:8:"stringJp";s:9:"日本語";s:5:"array";a:3:{i:0;i:10;i:1;i:20;i:2;i:30;}s:9:"hashtable";a:3:{s:4:"key1";s:6:"value1";s:4:"key2";s:6:"value2";i:3;i:123;}s:4:"enum";E:7:"Piyo:Pi";s:4:"hoge";O:4:"Hoge":2:{s:2:"id";i:100;s:4:"name";s:6:"ほげ";}}

可読性を上げるために適宜改行とスペースを入れてみる。

注意

実際のシリアライズデータは改行やスペースを許可していないので、実際に以下のような改行やスペースの入った文字列をデシリアライズしようとするとエラーになる。 実際の処理では必ずPHPのserialize()から取得したままの文字列を使用すること。

a:11:{ s:4:"null"; N; s:3:"int"; i:123; s:8:"boolTrue"; b:1; s:9:"boolFalse"; b:0; s:5:"float"; d:3.141592; s:6:"string"; s:7:"ABC1230"; s:8:"stringJp"; s:9:"日本語"; s:5:"array"; a:3: { i:0; i:10; i:1; i:20; i:2; i:30; } s:9:"hashtable"; a:3: { s:4:"key1"; s:6:"value1"; s:4:"key2"; s:6:"value2"; i:3; i:123; }, s:4:"enum"; E:7:"Piyo:Pi"; s:4:"hoge"; O:4:"Hoge":2: { s:2:"id"; i:100; s:4:"name"; s:6:"ほげ"; } }

シリアライズデータの基本的な見方

PHPでシリアライズされたデータは基本的にデータの種類データの長さデータの値の3つの要素を組み合わせて表現される。

データの種類i:(整数値)、s:(文字列)、a:(配列)など、それぞれのデータの先頭に付いていて、続くデータがどのような種類のデータかを表している。

データの長さは文字列や配列などの長さをもつデータに対して付与されている。

そしてこれらに続いて、それぞれに対応するデータの値(nullを表すN;は実際の値を持たない)が書かれている。

ではひとつずつ詳しく見ていきたい。

N; null値

N;

N;はそれ単体でnull値を表す。

これに限り後続に実際の値を表す文字列は何も現れない。

i:123; 整数値

i:123;

i:int型(整数型)を表し、続いてその値を持つ。

PHPにおいて整数値は実行環境によってサイズが変わるため、64bitになる場合がある。 C#ではintとlongで整数値を使い分けているが、PHPにはその区別がない。 そのためC#で読み込む場合は、その値がC#のintの範疇に収まるかどうか判断する必要がある。

b:1; 真偽値

b:1; // true b:0; // false

b:bool型(論理型)を表し、続いてその値を持つ。

この時の値は1(true)か0(false)で表される。 値としてtruefalseは使われないので注意が必要だ。

d:3.141592; 浮動小数点値

d:3.141592;

d:float型(浮動小数点型)を表し、続いてその値を持つ。

PHPにおけるfloat型は基本的に64bitだが、環境に依存するとも書かれている。 C#では32bitならfloat型、64bitならdouble型と分かれているが、C#で読み出す時はdouble型固定にしても良いだろうと思う。

s:7:"ABC1230"; 文字列値

s:7:"ABC1230"; s:9:"日本語";

s:string型(文字列型)を表し、それに続いて文字列の長さ(7:)、最後に文字列の値を持つ。

ここで注意しなければならないのは、7:といった文字列の長さは文字数ではなくバイト数だということだ。 上の例にs:9:"日本語";とあるように、文字数としては3文字だが、実際の長さは9:と設定されている。 実際の値を切り出す際にはその点に注意する必要がある。

また値の中に":;などが含まれていても、これらは特にエスケープされない。

PHP
echo serialize('ABC"D:E;F');
s:9:"ABC"D:E;F";

そのため単純に"が現れたら値の取得を終了する、といった処理をしてしまうと不具合の原因になる。 指定されたバイト数を元に正しく文字列を切り出す必要がある。

a:3:{...} 配列

a:3:{i:0;i:10;i:1;i:20;i:2;i:30;} // 通常の配列 a:3:{s:4:"key1";s:6:"value1";s:4:"key2";s:6:"value2";i:3;i:123;} // 連想配列

a:配列を表し、それに続いて配列の要素数(4:)、最後に{}で囲まれた配列要素の一覧を持つ。

PHPの配列には0から始まる連続した値をキーに持つ通常の配列と、整数値や文字列をキーに持つ連想配列がある。 これらは内部的には同じものとして扱われ、シリアライズされた文字列でも同じa:で表される。 これをC#で扱う際には通常の配列なのか連想配列なのかを判断し、適切な型で復元する必要がある。

このPHPの連想配列はキーに文字列と整数値を混在させて持つことを許容しているが、C#ではそのような型を定義するのは難しい。 キーの値を厳密に残そうとするならばDictionary<object, object?>などで代用するしかないだろうか。

しかし幸いにもPHPの連想配列ではキーの値として文字列の"1"と整数値の1を区別せず、いずれも整数値の1として扱う。 なので同じ数字を表す文字列と整数値のキーが同時に存在することはないはずだ。 そのためキーに整数値が指定されていても、それを全て文字列に変換し、キーを文字列に統一することは可能だと考える。 であればDictionary<string,object?>ExpandoObjectを使うことも可能になる。

E:7:"Piyo:Pi"; 列挙値

E:7:"Piyo:Pi";

E:enum型(列挙型)を表し、それに続いて列挙子を表す文字列の長さ(7:)、最後に列挙子を表す文字列を持つ。

この例では"Piyo:Pi"が列挙子を表す値だが、これをC#からデシリアライズするなら元となるPHPの列挙型と同等の定義がC#側にも必要になる。 しかしPHPの列挙型は文字列値を指定できるが、C#では整数値に限定される。 その辺りをうまく吸収するコードを考えるのも良いが、必要がないなら無視かエラーとするのが良いかもしれない。

O:4:"Hoge":2:{...} クラスオブジェクト

O:4:"Hoge":2:{s:2:"id";i:100;s:4:"name";s:6:"ほげ";}

O:クラスオブジェクトを表し、それに続いてクラス名を表す文字列の長さ(4:)、クラス名("Hoge":)、オブジェクトの持つフィールドの数(2:)、最後に{}で囲まれたフィールド値の一覧を持つ。

この例では"Hoge":がそのオブジェクトのクラス名を表すが、これをC#からデシリアライズするなら元となるPHPのクラスと同等の定義がC#側にも必要になる。 簡単な構造のクラスならそれも良いが、汎用的に作ろうと思うとかなり煩雑になるだろう。

よく使われるDateTimeなどもクラスになるので、値をシリアライズすると同じように展開されてしまう。 特に日付は使用頻度の高いものではあるが、ISO形式の文字列としてシリアライズした方が間違いがないと思われる。

C#でデシリアライズするコードを作成する

これらを踏まえ、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;

コード使用時の注意点

当コードは自由に使用してもらっても構わないが、以下の点を理解した上で各自の責任において使用していただきたい。

  • 正確にPHPのunserialize()関数を再現したものではない
  • 連想配列の整数値キーは文字列に変換している
  • 列挙型やクラスオブジェクトには対応していない
  • 文字コードはUTF8を前提にしている
  • 細かい動作検証はしていない
キーワード
WordPress PHP C#
シェアする
サイトマップ SITEMAP 写真データ販売中! STOCKPHOTO 写真のデータ販売について 写真 PHOTO
湘南ひらつか花火大会
深山唐松
衣笠草
天空の花
車百合
白山石楠花
写真の一覧へ
エリア
ネパール 北海道 屋久島 沖縄 北アルプス 石鎚山系 剣山地 鳥取大山 くじゅう連山 丹沢・大山 富士山
被写体
河川・湖沼 森林 雲・霧 石・岩 雪・氷 生物 植物 街・集落 鉄道 神社 寺院 人物 生活
季節
時間
夕方 マジックアワー
オレンジ・黄 ピンク・紫 茶色 虹色 錦繍
キーワード
石鎚神社
販売
Aflo PIXTA imagemart
タグ
トップ画像 傑作選
コラム PHOTO BLOG
コラムの一覧へ
カテゴリ
撮影 道具
開発室 DEVELOPMENT BLOG
開発室の一覧へ
カテゴリ
ASP.NET Core C# WordPress PHP TypeScript JavaScript
サイトマップ SITEMAP
スタジオ旅路
https://tabiji.gallery
渡邊 佑
tabiji.gallery (c) 2020 Yu Watanabe サイトマップ SITEMAP 写真データ販売中! STOCKPHOTO 写真のデータ販売について 写真 PHOTO
湘南ひらつか花火大会
深山唐松
衣笠草
天空の花
車百合
白山石楠花
写真の一覧へ
エリア
ネパール 北海道 屋久島 沖縄 北アルプス 石鎚山系 剣山地 鳥取大山 くじゅう連山 丹沢・大山 富士山
被写体
河川・湖沼 森林 雲・霧 石・岩 雪・氷 生物 植物 街・集落 鉄道 神社 寺院 人物 生活
季節
時間
夕方 マジックアワー
オレンジ・黄 ピンク・紫 茶色 虹色 錦繍
キーワード
石鎚神社
販売
Aflo PIXTA imagemart
タグ
トップ画像 傑作選
コラム PHOTO BLOG
コラムの一覧へ
カテゴリ
撮影 道具
開発室 DEVELOPMENT BLOG
開発室の一覧へ
カテゴリ
ASP.NET Core C# WordPress PHP TypeScript JavaScript