ps1xml のスキーマを書いた話

はじめに

本記事は PowerShell Advent Calendar 2018 の 4 日目です。
PowerShell Advent Calendar 2018 は寄稿して頂ける方を絶賛募集中です。よろしくお願いいたします。
qiita.com

TL; DR

  • ps1xml を書こうぜ。
  • RELAX NG はいいぞ。

ps1xml とは

ps1xml とは、PowerShell と共に利用する、特殊な XML ファイルです。
あからじめ PowerShell に同梱されているものもありますし、ユーザーが作成したモジュールを配布する際に同梱することもできます。

PowerShell に同梱されているものは、Windows PowerShell のインストール ディレクト*1にあります。
ただし、このファイルは過去との互換性のためにあるもので、Windows PowerShell 5.1 では読み込まれないのだそうです。PowerShell Core でも同様です。
ユーザーが自作したものは読み込まれますので、サンプルとしては有用です。

blog.shibata.tech
blog.shibata.tech

ps1xml には、types.ps1xml と format.ps1xml という2つのタイプがあります。

モジュール作りの一環としてこれらを書くにあたって、エラー チェックや入力補完をしてくれる XML スキーマが無いと厳しいので書いたよ、というのが、本記事の趣旨です。
書いたスキーマはここに置いてあります。

github.com

types.ps1xml

PowerShell .NET のオブジェクト モデル上に構築されており、パイプラインを通じて、コマンド間を .NET オブジェクトが流れるという点が、文字列をやり取りする Linux 等のシェルと比較した際の最大の特徴であるということは、今更言うまでもありません。
PowerShell は型に緩い言語ではありますが、.NET オブジェクトである以上、何らかの型はあります。
PowerShell が採用している型システムは、.NET の共通型システム(CTS:Common Type System)を拡張した、拡張型システム(ETS:Extended Type System)というものです。

types.ps1xml は、ETS 上での型を定義するためのファイルです。
型を定義するということは、大雑把に言うと、型名と、その型に属するメンバーを定義するということです。
まったくゼロから独自の型を定義することもできますし、.NET (CTS) の型にメンバーを追加することもできます。

PowerShell のインストール ディレクトリには、以下の 3 つのファイルがあります。

  • types.ps1xml
  • typesv3.ps1xml
  • getevent.types.ps1xml

Microsoft の公式資料としてはこのへんになるのでしょうか。
ただ、どうも詳細なリファレンスはリンク切れになっているようです。

以下の記事でも触れていますので、参考にしてみてください。
tech.blog.aerie.jp

format.ps1xml

format.ps1xml は、PowerShell コンソール上におけるオブジェクトの表示方法を定義するファイルです。
PowerShell でオブジェクトを整形して表示するコマンドとして、以下の 4 つがあります。

Format-Table と Format-List はよく使いますし、明示的に使わないとしても、暗黙のうちに使われることがありますから、目にする機会が多いと思います。
Format-Wide はマイナーで、あまり使ったことがありません。Format-Custom に至っては、一度も使ったことがありません…。

まぁともかく、PowerShell には、この 4 種類のフォーマットがあるということです。
format.ps1xml は、オブジェクトの型と、これらのフォーマットの組み合わせに対して、表示方法を定義します。

PowerShell のインストール ディレクトリには、以下のようなファイルがあります。

  • DotNetTypes.format.ps1xml
  • FileSystem.format.ps1xml
  • Registry.format.ps1xml
  • etc ...

Microsoft の公式資料としては、このへん。

以下の記事でも簡単に触れています。
tech.blog.aerie.jp

こちらも参考にどうぞ。
blog.shibata.tech

XML スキーマ言語

XMLスキーマを定義する言語はいくつかあります。
メジャーなところだと以下の 2 つ。

W3C XML Schema

XML スキーマといえばこれ。名前もそのまんまです。
XML それ自体の仕様を定義している W3C が策定したスキーマ言語です。
スキーマ ファイルの代表的な拡張子は *.xsd。
Visual Studio でも編集や検証に標準で対応していますし、.NET で使う XML ファイルも、大抵のスキーマVisual Studio に同梱されています。
また、PowerShell コマンドのヘルプを書く場合に PSMaml という XML ベースの言語を使うことがありますが、このスキーマXML Schema で書かれています。

公式サイトはこちら。主な仕様はこちらです。

興味がある方はこの辺をご覧ください。
tech.blog.aerie.jp

RELAX NG

W3C XML Schema の対抗仕様として策定された、もうひとつのスキーマ言語です。
XML SchemaW3C で仕様化されていますが、RELAX NGOASIS で標準化され、ISO 規格にもなっています。

Visual Studio でサポートしていないのをはじめ、使える環境が少ないのが悩ましい点です。
スキーマファイルの典型的な拡張子は *.rng のようです。

公式サイトはこちら

今回は RELAX NG

冒頭にも掲載しましたが、リポジトリはこちらにあります。
github.com

PowerShellTypeDefinitions.rng が types.ps1xml、PowerShellFormatDefinitions.rng が format.ps1xml のスキーマになっています。

拡張子は *.rng ですね。はい、RELAX NG です。
今回、初めて書いてみたんですが、書きやすいです。XML Schema には戻りたくないくらい。
とはいえ、さすがにメモ帳で書くのは無理なので、Oxygen XML Editor というツールを買いました。

RELAX NG の特に気に入った点は、「順不同」が書きやすいということです。

たとえば、このように書くと、foo 要素の後に bar 要素が来なければならないという意味になりますが…

<element name="foo">
  <text/>
</element>
<element name="bar">
  <text/>
</element>

これを <interleave> で囲むだけで、foo と bar のどちらが先に来てもよいという意味になります。

<interleave>
  <element name="foo">
    <text/>
  </element>
  <element name="bar">
    <text/>
  </element>
</interleave>

さらに、foo と「bar または baz のどちらか」が順不同で出現するのは、こう書けます。とても直感的です。

<interleave>
  <element name="foo">
    <text/>
  </element>
  <choice>
    <element name="bar">
      <text/>
    </element>
    <element name="baz">
      <text/>
    </element>
  </choice>
</interleave>

XML Schema だとこうはいきません。
一応、<all> という要素を使うことで、順不同は表現できるのですが、all はとても制約が強く、使いづらい要素なのです。
たとえば 3 つ目の例は、all では書けません(all の中に choice を置くことができません)。つまり、XML Schema では書けません。詰みます。

そして、この「順不同」という性質は、特にプログラミング用途では重要です。
なぜなら、オブジェクトのメンバーには順序が無いからです。

ps1xml も、読み込まれた後は PowerShell 内部では .NET のオブジェクトになります。
types.ps1xml の内容は TypeData オブジェクトになりますし、format.ps1xml の内容は PSControl の派生オブジェクトになります。

XML を HTML のような文書記述言語だと考えるなら、要素の順番は重要です。
しかし、最終的にデシリアライズされてオブジェクトになるのなら、順不同でもよいでしょう。オブジェクトのメンバーをどれから初期化しようが、最終的に同じ状態になればよいからです。
そして、XML というのは往々にして、HTML のような人が読むための文書記述よりも、コンピューターが処理するための構造化されたデータの記述に使われることが多い言語です。
今回スキーマを書くにあたって参考にした PowerShellソースコードでも、順不同に読み込めるようになっています。
自分で書く場合でも、経験上、順不同に書ける方が、格段に書きやすいです。

というわけで、特にオブジェクトにデシリアライズされることを意図した XML に対しては、RELAX NG の方が適していると思います。
このスキーマを試してみたいという方には、環境を選ぶ結果になってしまって申し訳ありません。

Schematron

PowerShellFormatDefinitions.sch というファイルがあります。
この拡張子 *.sch というのは、Schematron という言語で書かれたスキーマ ファイルです。
RELAX NG と Schematron は相互補完的なもので、ともに文書スキーマ定義言語(DSDL:Document Schema Definition Language)の一部分です。
types.ps1xml の方は、PowerShellTypeDefinitions.rng に Schematron が埋め込まれています。

なお、Schematron と XML Schema を組み合わせて利用することも可能です。

スキーマ ファイルの使い方

ps1xml ファイルに、これらのスキーマ ファイルを関連付けてエラー チェックをするには、xml-model 処理命令を使います。
こんな感じです。

<?xml version="1.0" encoding="utf-8"?>
<?xml-model href="PowerShellFormatDefinitions.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="PowerShellFormatDefinitions.sch" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<Configuration>
   ...
</Configuration>

PowerShellTypeDefinitions.rng のように、Schematron を含んだ RELAX NG の場合、以下のように、同一のファイルを schematypens を変えて 2 回リンクする必要があるようです。

<?xml version="1.0" encoding="utf-8"?>
<?xml-model href="PowerShellTypeDefinitions.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="PowerShellTypeDefinitions.rng" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<Types>
   ...
</Types>

このへんは、使用するチェック ツールによっても異なってくるかもしれません。

上記の GitHub リポジトリを直接参照して頂いても構いませんが、これらのファイルが消えない保証は致しかねますので、その点だけご留意ください。

<?xml version="1.0" encoding="utf-8"?>
<?xml-model href="https://raw.githubusercontent.com/aetos382/Aerie.PowerShell.Schemas/f195731306fbc932d42940d031b2eba0e65138eb/PowerShellFormatDefinitions.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="https://raw.githubusercontent.com/aetos382/Aerie.PowerShell.Schemas/f195731306fbc932d42940d031b2eba0e65138eb/PowerShellFormatDefinitions.rng" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<Types>
   ...
</Types>

型に別名をつける方法

types.ps1xml にせよ、format.ps1xml にせよ、オブジェクトの型名をキーにして機能します。
ただし、いずれも object.GetType() メソッドによって取得できる具象型名が対象であり、インターフェイスの型名などを書いても機能しません。
かといって、全部の具象型名を書きたくはないということもあるでしょう。*2

そういう場合は、型に別名をつけて、その名前を対象にしてやることができます。

たとえば、C# でこんな風に書いているコードがあったとします。

protected override void ProcessRecord()
{
    ...
    this.WriteObject(foo);
}

ここで、foo の具体的な型名を ps1xml に書きたくないので、抽象的な別名をつけたいとしましょう。
こうやります。

protected override void ProcessRecord()
{
    ...
    var output = PSObject.AsPSObject(foo);
    output.TypeNames.Insert(0, "TypeNameAlias");

    this.WriteObject(output);
}

出力するオブジェクトを PSObject でラップしてやることで、別名をつけることができるようになります。

スクリプトでやる場合は、Add-Member コマンドを使います。

$foo | Add-Member -TypeName TypeNameAlias

こうすることで、.NET の型としては区別できない PSCustomObject であっても、用途に応じて任意の型名をつけることができます。

この名前に対して ps1xml を書いてやることができます。

...
<Type>
  <Name>TypeNameAlias</Name>
  <Members>
    ...
  </Members>
</Type>
...

モジュールの中で独自に定義したものでない型に対して ps1xml を書くこともできますが、そうすると、そのモジュールを読み込んでいるか否かで、他のオブジェクトの表示方法が変わってしまうことになります(そういう使い方を意図しているのであればアリだと思いますが)。
コマンドの出力として他者が定義した型を返す場合でも、別名をつけてやることで、そのコマンドから出力された場合にのみ、表示方法を変えることが可能になります。

型階層と ps1xml

PowerShell では、すべてのオブジェクトが、PSTypeNames という隠しプロパティを持っています(上記の C# のコードで別名をつける際に使用した TypeNames プロパティと同じものです)。
PSTypeNames は、デフォルトでは .NET の型階層を反映し、具体的な方から順に入っています(インターフェイスの型名は入りません)。
例えば以下のような型定義の場合

class Base { }

class Derived : Base { }

こうなります

PS> $derived = New-Object -TypeName Derived
PS> $derived.PSTypeNames

Derived
Base
System.Object

Add-Member の場合、先頭に追加されます。

PS> $derived | Add-Member -TypeName TypeNameAlias
PS> $derived.PSTypeNames

TypeNameAlias
Derived
Base
System.Object

型に ps1xml の内容を適用するにあたっては、これらの型名を先頭から順に見て行くのですが

  • types.ps1xml の場合は、型階層のすべての型名が処理対象となる
    • 基底型と派生型に同名のメンバーが定義されている場合は、より具体的な(PSTypeNames 内で先頭に近い)型に対して定義されたものの方が優先される
    • もともとの .NET 型と types.ps1xml に同名のメンバーが定義されている場合は、ps1xml の方が優先される
  • format.ps1xml の場合は、最初にマッチした型名が処理対象となる

という仕様のようです。

TypeNames についても、以下の記事でちょっと触れています。
tech.blog.aerie.jp

また、コマンドのパラメーターの型を PSTypeNames で制約することもできます。
tech.blog.aerie.jp

ps1xml ファイルの内容を適用する方法

ps1xml ファイルをモジュールに同梱して配布する場合は、モジュールのマニフェスト ファイル(*.psd1)に書いてやります。
こんな感じ。

# MyModule.psd1
...
TypesToProcess = @(
  'MyModule.types.ps1xml'
)

FormatsToProcess = @(
  'MyModule.format.ps1xml'
)
...

詳細は New-ModuleManifest コマンドを参照してください。

アドホックに適用する場合は、

を使用します。

モジュールに同梱せず、他者が定義した型に対して、自分好みのメンバー追加やフォーマット定義をしたい場合は、PowerShell プロファイル内でこれらのコマンドを呼び出せばよいでしょう。

おことわり

このスキーマPowerShell のパーサーのソースコードを見て書き起こしたものです。
一応、Windows PowerShell に同梱されている ps1xml ファイルに対しては、ほぼ 100% パスするように作ってあります。*3
ただ、完全なものであるという保証は致しかねます。

一応、参考にしたソースコードへのリンクを貼っておきます。

  • types.ps1xml は TypeTable.cs
  • format.ps1xml は typeDataXmlLoader.cs 他(partial クラスになっており、他にも同じフォルダー内にいくつかのソース ファイルがあります)。

このスキーマPowerShell モジュール制作の一助になれば幸いです。

*1:通常は C:\Windows\System32\WindowsPowerShell\v1.0

*2:具象型がすごく多いとか、private な型だとか…

*3:「ほぼ」なのは、PowerShell のパーサーは要素名の大文字小文字も不問とする仕様なのですが、そこまで追従するのは面倒くさすぎたためです。