Export/Import-ClixmlとToString()の困った関係
今回も誰得シリーズです。
PowerShell でクラスを使ったコードを書いた上にそのデータを Export/Import-Clixml で保管、読みだし再利用しようなんてことは誰もやろうとしていないのだと思いますが、やると便利なうえに ConvertTo/From-Json よりも速かったりします。なので Json の替りに XML でデータを保存するようになったのですが、データが ToString() メソッドを持っている場合困ったことになります。
余談 Import-Clixml と ConvertFrom-Json の速度比較
ちなみにどのくらい違うかというと、
class FileInfo {
[string]$Name;
[string]$FullName;
[DateTime]$LastWriteTime;
[DateTime]$CreationTime;
$Length;
}
というようなクラスを作り、C:\Windows 以下のファイル1万件分をデータとして格納したファイルで試してみると、
Code Average Ratio
---- ------- -----
Import-Clixml .\t.xml |%{ [FileInfo]::New($_) } 0.26 237.72
Import-Clixml .\t.xml 0.11 100.00
Get-Content .\t.json |ConvertFrom-Json |%{ [FileInfo]::New($_) } 0.39 358.27
Get-Content .\t.json |ConvertFrom-Json 0.29 266.09
という感じです。単にデータをロードするだけだと手元の環境でJSONはXMLの2.7倍の時間がかかります。ロードするだけならXML一択じゃない?
余談2 参照がある場合
Clixmlのもう一つのメリットは参照データがある場合の取り扱いです。Export-Clixml は参照渡しならぬ「参照保存」するのに対して、ConvertTo-Json は値渡しならぬ「値保存」するので、ConvertFrom-Json でデータを戻しても元のデータに戻りません。
例えば
class Owner {
[string]$Name;
[int]$RefCount;
}
class ACL {
[string]$Path;
[Owner]$Owner;
}
こういった構造でファイルACLとファイル所有者を管理しているとして、Export/Import-Clixml の場合、以下のように参照データの ACL.Owner はそのまま参照データとしてロードできるので、
code> $d = import-Clixml .\t.xml
code> $d.Owners
Name RefCount
---- --------
NT SERVICE\TrustedInstaller 346
NT AUTHORITY\SYSTEM 151
BUILTIN\Administrators 204
code> $d.ACLs[0]
Path Owner
---- -----
Microsoft.PowerShell.Core\FileSystem::C:\Windows\Fonts\8514fix.fon Owner
code> $d.ACLs[0].Owner.RefCount++
code> $d.Owners
Name RefCount
---- --------
NT SERVICE\TrustedInstaller 347
NT AUTHORITY\SYSTEM 151
BUILTIN\Administrators 204
このように値を操作することもできます。が、JSONでは参照データが値として保存されてしまうので、値の操作が期待通りにはなりません。
code> $j = Get-Content .\t.json |ConvertFrom-Json -Depth 10
code> $j.Owners
Name RefCount
---- --------
NT SERVICE\TrustedInstaller 346
NT AUTHORITY\SYSTEM 151
BUILTIN\Administrators 204
code> $j.ACLs[0].Owner.RefCount++
code> $j.ACLs[0].Owner
Name RefCount
---- --------
NT SERVICE\TrustedInstaller 347
code> $j.Owners
Name RefCount
---- --------
NT SERVICE\TrustedInstaller 346
NT AUTHORITY\SYSTEM 151
BUILTIN\Administrators 204
やっぱり XML しかなくない?
こんな問題に当たるのは世界中で俺くらいやろと思ったこともありましたが、なんと10年前に同じことにはまった人がいました。
この時は ToString() を再定義するという解決案になっているのですが、ToString() が凝った内容だとそんなに単純にいかないのですよ…。
ToString 問題とは
とりあえずテスト用のコード(クラス)はこちら。
class Person {
[string]$Name;
[int]$Age;
Person($n, $a) {
$this.Name = $n
$this.Age = $a
}
[string]ToString() { return "$($this.Name)($($this.Age))" }
}
テストドはこんな感じで。
using module .\Person.psm1
function printp($a) {
write "Person Name:$($a.Name),Age:$($a.Age) --> $a"
}
#変数作成
$p = [Person]::New('Hanako', 20)
#表示
printp $p
#内容を更新 歳を増やす
write "Changing Hanako +1"
$p.Age++
#表示
printp $p
#XMLに保存
write "save to xml"
$p |Export-Clixml -Encoding utf8 d.xml
#XMLから読みだして表示
write "loading data back"
$p = Import-Clixml d.xml
printp $p
# データを更新して表示
write "Changing Hanako +1"
$p.Age++
printp $p
なんのことはない、名前と歳を持つPersonクラスの年齢を変えて表示しているだけです。
実行結果はこちら。
code> .\Test.ps1
Person Name:Hanako,Age:20 --> Hanako(20)
Changing Hanako +1
Person Name:Hanako,Age:21 --> Hanako(21)
save to xml
loading data back
Person Name:Hanako,Age:21 --> Hanako(21)
Changing Hanako +1
Person Name:Hanako,Age:22 --> Hanako(21)
最初の +1 はもちろん問題なく動いているわけですが、Import-Clixml のあとは +1 して実際のデータが更新されていても ToString() が前の値を返しています!
これは XML ファイルに <ToString>Hanako(21)</ToString>
なんていうデータが入っていてこれをロードすると ToString がメソッドとしてオブジェクトに追加されるから。
code> $p = Import-Clixml .\d.xml
code> $p|gm
TypeName: Deserialized.Person
Name MemberType Definition
---- ---------- ----------
GetType Method type GetType()
ToString Method string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string …
Age Property System.Int32 {get;set;}
Name Property System.String {get;set;}
メソッドになっているおかげで呼び出しもできるのですが、逆に簡単に変更できず、Stack Overflow では Add-Member -Force で強制的に書き換える案になったっぽい。XMLでもらってきたデータをあれこれするだけならまぁ良いかもしれんが、それだけじゃないんですわ。
例として簡単にしていますが、実際にはもっとプロパティあるわけです。この Person の例でも住所や電話番号、メアド、各種IDなど入れたくなるデータはたくさんあったりして Add-Member の引数にさらっと書くような内容ではないことも。
結局どうしたか:ソリューション
Import-Clixml はデータ構造をそのまま復元してくれるので楽なわけですが、実際には PSObject であってクラスのインスタンスではないというのが根本の原因です。参照だけしている分には PSObject で無問題ですがデータの書き換えは不可(というかやっかい)、加えてクラスのメソッドも使えない、と。ならば本来のクラスに戻せば良いでしょう。
$p = [Person]::New($p.Name, $p.Age)
ですね。
インスタンス変数が多くあると面倒ですし順番間違いなど厄介なので、実際にはオブジェクトを受け取るコンストラクターを作るのがい良いでしょう。
Person($o) {
($this | Get-Member -MemberType Property).Name |? { $_ -in ($o | Get-Member -MemberType Property).Name } |%{
$this.$_ = $o.$_
}
}
テスト
code> $p = import-Clixml .\d.xml
code> $p
Name Age
---- ---
Hanako 21
code> $p.age++
code> $p
Name Age
---- ---
Hanako 22
code> "$p"
Hanako(21)
code> $p = [person]::new($p)
code> "$p"
Hanako(22)
やれやれ…
Discussion