⚠️

Export/Import-ClixmlとToString()の困った関係

2024/12/24に公開

今回も誰得シリーズです。

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年前に同じことにはまった人がいました。

https://stackoverflow.com/questions/26705337/tostring-method-of-custom-powershell-objects-and-export-clixml

この時は 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