タイダログ

もっと怠けますか? (y/n)

PowerShell の Cmdlet を F# で書く

副題「PowerShell + F# でフォルダサイズの一覧を取得する」

1, 2年前に PowerShell の Cmdlet を F# で書けると知り、挫折を繰り返すこと幾星霜(2年ですね)、ついに成功したので手順やつまずいた点などを残します。

推し×推し。尊い

全面的にこちらのページを参考にしています。ロゴが天才的。

medium.com

作業環境

開発に必要なもの

この記事で扱う内容

Cmdlet を F# で作成する基本的な方法と、フォルダサイズを取得する Cmdlet を作成する方法を紹介します。この Cmdlet は Windows PowerShellPowerShell のどちらでも動作します。

また、操作は極力 PowerShell 上で dotnet コマンドを用いて行います。実行したコマンドがそのまま操作手順のメモになって記録が楽だからです。

完成品

この記事では、このリポジトリstep1 を扱います。現在 step4 まで公開していますのでどうぞご覧ください。

github.com

手順

始めに基本的な手順を確認します。ほぼ冒頭のページの通りです。PowerShell にコピペして実行してみてください。

作業用ディレクトリを作成

まずは作業用ディレクトリを作ってそこに移動します。

変数 $slnName はフォルダ名やファイル名に使用します。そのため、フォルダ名等に付けられる範囲ならなんでもいいです。Cmdlet の名前はこれとは別に付けます。

$slnName = "FSharpCmdletStudy"
mkdir $slnName
cd $slnName

ソリューションとプロジェクトを作成

次に、ソリューションとクラスライブラリプロジェクトを作成します。

  • ソリューション:プロジェクトの容れ物
  • プロジェクト:実際に作るプログラム(と愉快な仲間たち)

と認識しています。

プロジェクトには様々な種類があります。PowerShell の Cmdlet は「クラスライブラリ」という種類だそうですので dotnet new classlib でプロジェクトを作成します。

dotnet new sln
dotnet new classlib --language "F#" --framework "netstandard2.0" --output src/$slnName

https://learn.microsoft.com/ja-jp/dotnet/core/tools/dotnet-new

dotnet new classlib にいくつかオプションが付いています。--language "F#" は見たまんまです。

--framework "netstandard2.0" を忘れないように注意してください。これは .NET Standard 2.0 というフレームワークを対象にして Cmdlet を作成するためのものです。これを付けないと別のフレームワーク.NET Framework 4.8 や .NET 6.0 など)を対象にしてしまうことがあり、その場合 Windows PowerShell または PowerShell のいずれかでしか動作しなくなります。私はこれがわかっていなかったがために、ターゲットフレームワーク .NET 6.0 になってしまい、Windows PowerShell で動作しなくて頭を抱えていました。

SDK スタイル プロジェクトでのターゲット フレームワーク - .NET | Microsoft Learn

ソリューションにプロジェクトを紐付け

ソリューション(容れ物)にプロジェクトを紐付けます。

dotnet sln add src/$slnName/$slnName.fsproj

PowerShell Cmdlet 作成用のライブラリを追加

プロジェクトに、PowerShell Cmdlet 作成用のライブラリ PowerShellStandard.Library を追加します。

dotnet add src/$slnName/$slnName.fsproj package PowerShellStandard.Library

これにより Cmdlet 作成が可能になります。あとは Library.fs にコードを書くだけです。

Cmdlet のコードを書く

Library.fs に Cmdlet のコードを書きます。実際には、以下のコードを PowerShell 上で実行し、Out-FileLibrary.fs に出力します。

@"
namespace $slnName

open System
open System.Management.Automation

[<Cmdlet(VerbsData.Out, "Twice")>]
type OutTwiceCommand () =
    inherit PSCmdlet ()

    [<Parameter>]
    member val InputString : string = "" with get, set

    override x.EndProcessing () =
        sprintf "%s %s" x.InputString x.InputString
        |> x.WriteObject
        base.EndProcessing ()
"@ | Out-File ./src/$slnName/Library.fs -Encoding utf8

Cmdlet の名前、Parameter、(今回はないけど BeginProcess と)End。書き方は PowerShell の Advanced Function とほぼ同じですね。

Build & Publish

Library.fs の編集が終わったら、ビルドしてパブリッシュします。

dotnet build
dotnet publish

この時点で拡張子が .dll な DLL ファイルができました。これを Import-Module でインポートしたら Cmdlet を使用できます。

PowerShell で実行

ビルドしてパブリッシュしたらインポートしようぜぇ!

Import-Module ./src/$slnName/bin/Debug/netstandard2.0/publish/$slnName.dll

実行してみましょう。

Out-Twice "hey!"

# 実行結果
hey! hey!

F# で PowerShell の Cmdlet を作れました。やったね!

フォルダサイズを取得する Cmdlet

いよいよフォルダサイズを取得する Cmdlet を F# で書いてみます。

これまでこのブログでは4回にわたってフォルダサイズを取得する PowerShell スクリプトを書いてきましたが、とうとうその話に F# が加わります。感慨深い。

taidalog.hatenablog.com

taidalog.hatenablog.com

taidalog.hatenablog.com

taidalog.hatenablog.com

新しいファイルにソースコードを書く

変数 $slnName の中身とカレントディレクトリは先ほどから変わっていないという前提です。

@"
namespace $slnName

open System
open System.IO

[<Class>]
type Size(byte : int64) =

    let byteFloat = byte |> float

    member val B : float = byteFloat
    member val KB : float = byteFloat / 1024.
    member val MB : float = byteFloat / 1024. / 1024.
    member val GB : float = byteFloat / 1024. / 1024. / 1024.
    member val TB : float = byteFloat / 1024. / 1024. / 1024. / 1024.
    override _.ToString() : string = byte.ToString()


[<Class>]
type DirectoryInfoPlus(directoryInfo : System.IO.DirectoryInfo) =
    inherit System.IO.FileSystemInfo()

    let files : Collections.Generic.IEnumerable<FileInfo> = directoryInfo.EnumerateFiles()

    override _.Delete() : unit = directoryInfo.Delete()
    member _.GetType() : System.Type = directoryInfo.GetType()
    override _.ToString() : string = directoryInfo.ToString()

    member val Size : Size =
        files
        |> Seq.sumBy (fun x -> x.Length)
        |> fun x -> new Size(x)
    
    member val FileCount : int = files |> Seq.length

    override val Name = directoryInfo.Name
    override val FullName : string = directoryInfo.FullName
    member val Parent : System.IO.DirectoryInfo = directoryInfo.Parent
    override val Exists = directoryInfo.Exists
    member val Root : System.IO.DirectoryInfo = directoryInfo.Root
    member val Extension : string = directoryInfo.Extension
    
    member val CreationTime : System.DateTime = directoryInfo.CreationTime
    member val CreationTimeUtc : System.DateTime = directoryInfo.CreationTimeUtc
    member val LastAccessTime : System.DateTime = directoryInfo.LastAccessTime
    member val LastAccessTimeUtc : System.DateTime = directoryInfo.LastAccessTimeUtc
    member val LastWriteTime : System.DateTime = directoryInfo.LastWriteTime
    member val LastWriteTimeUtc : System.DateTime = directoryInfo.LastWriteTimeUtc
    
    member val Attributes : System.IO.FileAttributes = directoryInfo.Attributes


open System.Management.Automation

[<Cmdlet(VerbsDiagnostic.Measure, "Directory")>]
type MeasureDirectoryCommand () =
    inherit PSCmdlet ()

    [<Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true)>]
    member val DirectoryInfo : System.IO.DirectoryInfo = null with get, set

    override this.EndProcessing () =
        match this.DirectoryInfo with
        | null -> ()
        | _ ->
            this.DirectoryInfo
            |> (fun x -> new DirectoryInfoPlus(x))
            |> this.WriteObject
        base.EndProcessing ()
"@ | Out-File ./src/$slnName/MeasureDirectory.fs -Encoding utf8

プロジェクトファイルを編集する

いま作成した MeasureDirectory.fs ファイルは、まだコンパイルの対象になっていません。コンパイルして Cmdlet として利用するためには、プロジェクトに追加する必要があります。

そこでおもむろに src ディレクトリ内のプロジェクトファイル (.fsproj) をエディタで開き、以下のような箇所を探します。

<ItemGroup>
  <Compile Include="Library.fs" />
</ItemGroup>

見つけたら、以下のように一行追加します。<Compile Include="MeasureDirectory.fs" /> の部分です。

<ItemGroup>
  <Compile Include="Library.fs" />
  <Compile Include="MeasureDirectory.fs" /> #この行を追加した
</ItemGroup>

こうすることで MeasureDirectory.fsコンパイルの対象になります。

ターミナルを閉じてビルド

ビルドしてパブリッシュしてインポートしたいんですが、元ページにある通り、そのまますると上手くいきません。

NOTE: after running Import-Module on the assembly, you may find it difficult to run dotnet build again. Depending on your environment, replacing the assembly with the newly compiled assembly may not be possible because the file might be locked by the PowerShell instance that has loaded it.

https://medium.com/@natelehman/writing-powershell-modules-in-f-ed52704d97ed

最初に dotnet build した時の DLL ファイルが、Import-ModulePowerShell インスタンスにロックされるため、再度コンパイルしても DLL ファイルを上書きできないようです。

モジュールをロードした PowerShell をクローズして、新しく立ち上げてからビルドしてパブリッシュしてインポートしようぜぇ!

dotnet build
dotnet publish
Import-Module ./src/$slnName/bin/Debug/netstandard2.0/publish/$slnName.dll

説明

これまでに書いた、PowerShell でフォルダサイズを取得するスクリプトは、指定したパス以下の全てのディレクトリのサイズを取得するようになっていました。

今回 F# で書くにあたって「最小限の機能を持つ関数を組み合わせて大きなプログラムを作る」という考えに立ち返り、単一ディレクトリのサイズのみを取得するように変更しました。複数ディレクトリのサイズがほしければ引数として渡せばいいんです。

フォルダサイズを求めるのは簡単で、DirectoryInfo クラスのオブジェクトの EnumerateFiles() メソッドでファイルを列挙したら Seq.sumBy するだけです。うわぁ簡単すぎる。引き数として DirectoryInfo を受け取るようにしてしまえば、もうほとんどやることがありません。さすがに DirectoryInfo を受け取って float を返すだけではあまりに質素なので、DirectoryInfo にサイズの情報を持たせた DirectoryInfoPlus なるクラスを定義して、そのオブジェクトを返すようにしました。

DirectoryInfoPlus クラスは FileSystemInfo クラスを継承するようにしたのですが、思い返せば継承が初の試みだったのでここでもつまずきました。override がなくてずっと怒られていました。そしてこれが継承の正しい使い方なのか、いまだによくわかりません。

Measure-Directory Cmdlet の中身は、引数として受け取った DirectoryInfoDirectoryInfoPlus のコンストラクタに渡して DirectoryInfoPlus を返すだけです。質素。

結び

推し (PowerShell) の Cmdlet を推し (F#) で書くという、最高に尊い活動をしました。本当は推し EXCEL 関数にまで話を広げて COUNTIF 関数 × PowerShell Cmdlet × F# をしようとしたのですが、スクリプトブロックの扱い方がよくわからないので寝かせてあります。

何はともあれ、これで PowerShell と F# を両方同時に感じながらパソコンに向かうことができます。やったね!

参考