副題「PowerShell + F# でフォルダサイズの一覧を取得する」
1, 2年前に PowerShell の Cmdlet を F# で書けると知り、挫折を繰り返すこと幾星霜(2年ですね)、ついに成功したので手順やつまずいた点などを残します。
推し×推し。尊い。
全面的にこちらのページを参考にしています。ロゴが天才的。
作業環境
- Windows 10 Home 21H2 (OS Build 19044.2486)
- .NET SDK 7.0.101
- F# 7.0
- Visual Studio Code 1.74.3
- Windows PowerShell 5.1.19041.2364
- PowerShell 7.2.8
開発に必要なもの
- dotnet SDK
- F#
- Visual Studio Code などのエディタ
この記事で扱う内容
Cmdlet を F# で作成する基本的な方法と、フォルダサイズを取得する Cmdlet を作成する方法を紹介します。この Cmdlet は Windows PowerShell と PowerShell のどちらでも動作します。
また、操作は極力 PowerShell 上で dotnet コマンドを用いて行います。実行したコマンドがそのまま操作手順のメモになって記録が楽だからです。
完成品
この記事では、このリポジトリの step1 を扱います。現在 step4 まで公開していますのでどうぞご覧ください。
手順
始めに基本的な手順を確認します。ほぼ冒頭のページの通りです。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-File
で Library.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
、(今回はないけど Begin
と Process
と)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# が加わります。感慨深い。
新しいファイルにソースコードを書く
変数 $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-Module
で PowerShell インスタンスにロックされるため、再度コンパイルしても 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 の中身は、引数として受け取った DirectoryInfo
を DirectoryInfoPlus
のコンストラクタに渡して DirectoryInfoPlus
を返すだけです。質素。
結び
推し (PowerShell) の Cmdlet を推し (F#) で書くという、最高に尊い活動をしました。本当は推し EXCEL 関数にまで話を広げて COUNTIF 関数 × PowerShell Cmdlet × F# をしようとしたのですが、スクリプトブロックの扱い方がよくわからないので寝かせてあります。
何はともあれ、これで PowerShell と F# を両方同時に感じながらパソコンに向かうことができます。やったね!