Skip to content

Instantly share code, notes, and snippets.

@sheepla
Last active June 24, 2024 08:12
Show Gist options
  • Save sheepla/4fcb01d4ed7ae682817edd83f403b8c5 to your computer and use it in GitHub Desktop.
Save sheepla/4fcb01d4ed7ae682817edd83f403b8c5 to your computer and use it in GitHub Desktop.
.NET Core F#でコマンドラインツールを作ろう

.NET Core F#でコマンドラインツールを作ろう

これは何

F#の初級者がクロスプラットフォームで自己完結型単一実行ファイルとしてデプロイできるコマンドラインアプリケーションをサクッと作れるようになるためのガイド。 細かい文法要素は省略している。

対象読者は他言語経験がある程度あってF#のチュートリアルを終えたくらいの人を想定している。 自身が関数型プログラミングに入門してから日が浅いので、関数型プログラミングに慣れていなくても(たぶん?)大丈夫。

環境

下記の環境で検証している。

  • OS: Arch Linux
  • .NET Core SDK v7.0.111
  • FSharp.SystemCommandLine v0.17.0-beta4
  • エディタ: Neovim
$ dotnet --list-sdks
7.0.111 [/usr/share/dotnet/sdk]

$ dotnet --list-runtimes
Microsoft.NETCore.App 7.0.11 [/usr/share/dotnet/shared/Microsoft.NETCore.App]

.NETって何・F#って何

.NETの概要

.NETの実装

F#の概要

F# - Wikipedia

F#を知ってほしい

F#でコマンドラインツールを作るうれしさ

F#はOCaml由来の簡潔で堅牢な構文を持ち、見通しの良いプログラムを書ける。 また、.NETと統合されているためC#で書かれた広範なライブラリ群の機能をそのまま呼び出すことができる。

.NETに乗っかっているとWindowsの各機能へのアクセスが楽にできるため、 Windowsのシステム管理ツールやセキュリティツールの開発にF#を採用すると便利そうだなと思っている。

後述するビルドオプションをセットすれば自己完結型の単一実行ファイルを発行でき、デプロイや配布が楽になる。

F#に入門する

公式のチュートリアルをどうぞ。

F# チュートリアル - Hello World を 5 分間で

F#のツアー

F#言語リファレンス

「F# for Fun and Profit」やWikibooksもおすすめ。

F# for Fun and Profit

F# Programming on Wikibooks

.NET SDKをインストールする

.NETのSDK(開発キット)は、下記のページからダウンロードできる。 今回は .NET Core 7.0をインストールする。

.NETのダウンロード

Visual Studio 用 .NET SDK をダウンロードする - Microsoft

インストールガイドはこちらから。

Windows、Linux、および macOS に .NET をインストールする

対話環境でF#を試してみる(fsi/F#インタラクティブ)

.NET SDKにはfsi (F#インタラクティブ)というREPL環境が備わっており 対話的にF#プログラムを実行できる。

F#による対話型プログラミング

F#インタラクティブは dotnet fsi で起動できる。式の末尾には ;; を入力すること。 終了時は Ctrl-D を押下するとREPL環境を抜けられる。

$ dotnet fsi

Microsoft (R) F# インタラクティブ バージョン F# 7.0 のための 12.4.0.0
Copyright (C) Microsoft Corporation. All rights reserved.

ヘルプを表示するには次を入力してください: #help;;

> let foo = "F#";;
val foo: string = "F#"

> printfn "Hello, %A" foo
- ;;
Hello, "F#"
val it: unit = ()

>

プロジェクトを作成する

F#のコンソールアプリケーションを作成してみる。 プロジェクトとなるディレクトリを作り、その中でdotnet new console -lang=F# を実行する。

$ mkdir SampleCommandLineApp && cd SampleCommandLineApp
$ dotnet new console -lang=F#

実行すると、メインプログラム Program.fs およびプロジェクトファイル SampleCommandLineApp.fsprojが生成される。

世界に挨拶する

プロジェクトを作成すると、すでにHello, World!のコードが生成されているので何も書かずに世界に挨拶できる。

$ cat Program.fs

// For more information see https://aka.ms/fsharp-console-apps
printfn "Hello from F#"

dotnet run を実行すると Hello from F# と出力される。

FSharp.SystemCommandLineパッケージを追加する

.NET、特にC#でコマンドラインアプリケーションを作成するには、 System.CommandLineというパッケージがよく使われる。 System.CommandLineは執筆時点ではプレビュー段階だが、 高機能なコマンドラインアプリケーションを作るのによく使われる機能がひと通り備わっている。

チュートリアル: System.CommandLine の概要

また、F#のコマンドラインライブラリとしてはArgu, docopt.fs, FSharp.CommandLineなどがあるが、 今回はSystem.CommandLineのF#用バインディングであるFSharp.SystemCommandLineを使ってみる。 System.CommandLineの機能が使え、それでいて簡潔で分かりやすい書き方ができるので気に入った。

パッケージを追加するには dotnet add package コマンドを使う。 非安定版を使うので --prerelaseフラグを付けて実行する。

$ dotnet add package FSharp.SystemCommandLine --prerelease

プログラムを記述する

エディタを開いてプログラムをProgram.fsに記述していく。

F#にはコンピュテーション式 という仕組みがあり、これによりライブラリの呼び出し時に特定のドメインに特化した簡潔な書き方ができる。

F# と遊ぼう! パイプライン・シーケンス・コンピュテーション式

FSharp.SystemCommandLineでは、コマンドを表すコンピュテーション式に説明文・位置引数・オプション・ハンドラ関数・サブコマンドを埋め込んでいく形でプログラミングしていく。

まずはFSharp.SystemCommandLine などの名前空間を open しておく。

open System
open System.IO
open FSharp.SystemCommandLine

F#のプログラムでは暗黙的なエントリーポイントが使用されるが、 メインとなる関数に[<EntryPoint>] 属性を付けることでそこからプログラムが開始となる。

コンソール アプリケーション - F#のドキュメント

[<EntryPoint>]
let main (argv: string[]) : int =
    let handler (text: string, flag: bool) :int =
        printfn "Running rootCommand!"
        printfn "text: %A, flag: %A" text flag
        0

    rootCommand argv {
        description "Sample command line application"

        inputs (
            Input.OptionRequired<string>([ "-t"; "--text" ], "Some text"),
            Input.Option<bool>([ "-f"; "--flag" ], "Some flag")
        )

        setHandler handler
        // addCommand listCommand
        // addCommand installCommand
        // addCommand removeCommand
    }
  • description にはヘルプメッセージに表示させるための簡単なコマンドの説明文を書く。
  • inputs には入力パラメータ、つまりコマンドラインオプションや位置引数の定義を書く。
  • ハンドラ関数は引数としてそれぞれの入力パラメータを受け取り、終了ステータスとしてint型の値を返す。

オプションや引数を定義する

FSharp.SystemCommandLineの Input 型には、コマンドラインオプションを表す Option<'T>' や位置引数を表す Argument<'T> というメソッドが定義されている。

src/FSharp.SystemCommandLine/Inputs.fs

オプションや引数を定義するには、次のような式を inputs ( ... ) に追加する。

// -t または --text という名前の、後ろに文字列をとるオプション
Input.Option<string>([ "-t"; "--text" ], "Some text")

// -c または --count という名の、後ろに数値をとる必須のオプション
Input.OptionRequired<int>([ "-c"; "--count" ], "Some count")

// -f または --flag という名前の、後ろに値を取らないフラグ
Input.Option<bool>([ "-f"; "--flag" ], "Some flag")

// 複数の値をとる位置引数
Input.Argument<string array>("names", "Some names"),

基本的な使い方をまとめると次のようになる。

  • 型引数に stringint を指定すると後ろに値を取るオプションを定義できる。
  • 型引数に bool を指定すると後ろに値を取らないフラグを定義できる。
  • 型引数に string array のように配列を指定すると 複数の値を取るオプションや位置引数を定義できる。
  • 型引数に System.IO.FileInfo 型や System.IO.DirectoryInfo 型を指定して ファイルシステムのパスとして解釈させることもできる。
  • Option<'T> の代わりに OptionRequired<'T>を使うと必須のオプションにできる。
  • Option<'T> の代わりに OptionMaybe<'T> (Maybeモナド)を使うと ハンドラの関数の引数をoption型で受け取ることができる。

Maybeモナド、F#のoption型についてはこちら。

オプション - F#言語ガイド

F#プログラマのためのMaybeモナド入門

inputs ( ... ) の定義を変更したら、型エラーになってしまうのでハンドラ関数の引数も併せて変更すること。

サブコマンドを記述する

パッケージマネージャーに似た「list」「install」「remove」という名前を持つサブコマンドを実装してみる。 サブコマンドは command コンピュテーション式で定義する。

let listCommand: CommandLine.Command =
    let handler (count: int) =
        printfn "Running listCommand!"
        printfn "count: %A" count
        0

    command "list" {
        description "Show a list of something"
        inputs (Input.Option<int>([ "-c"; "--count" ], "Some count"))
        setHandler handler
    }

let installCommand: CommandLine.Command =
    let handler (files: FileInfo array) =
        printfn "files: %A" files
        0

    command "install" {
        description "Install something"
        inputs (Input.Argument<FileInfo array>("files", "File names"))
        setHandler handler
    }

let removeCommand: CommandLine.Command =
    let handler (names: string array, force: bool) =
        printfn "names: %A, force: %A" names force
        0

    command "remove" {
        description "Remove something"

        inputs (
            Input.Argument<string array>("names", "Some names"),
            Input.Option<bool>([ "-f"; "--force" ], "Remove something forcefully")
        )

        setHandler handler
    }

それぞれのサブコマンドを定義したら、rootCommandコンピュテーション式内の addCommand でサブコマンドを追加する。

    // ...
    rootCommand argv {
        description "Sample command line application"

        setHandler handler

        inputs (
            // ...
        )

        addCommand listCommand
        addCommand installCommand
        addCommand removeCommand
    }

実行する

System.CommandLineでアプリケーションを作成すると、ヘルプメッセージ、-?, -h, --helpオプション、バージョンオプションなどが自動で生成される。

プログラムを実行するには、dotnet run コマンドを使用する。 引数は -- の後ろに指定したものがアプリケーションへ渡される。

$ dotnet run -- --help

Description:
  Sample command line application

Usage:
  SampleCommandLineApp [command] [options]

Options:
  --version                     Show version information
  -?, -h, --help                Show help and usage information
  -t, --text <text> (REQUIRED)  Some text
  -f, --flag                    Some flag

Commands:
  list             Show a list of something
  install <files>  Install something
  remove <names>   Remove something
$ dotnet run -- install ./path/to/file1 ./path/to/file2
files: [|./path/to/file1; ./path/to/file2|]

ヘルプメッセージや指定した引数が出力されればOK!

自己完結型の単一実行ファイルとしてビルドする

作ったツールを誰かに使ってもらう際、個別にランタイムをインストールしてもらうよりも単一の実行ファイルでそのまま実行できる形でビルドし配布した方が都合が良い場合も多い。

今回は、.NETのランタイムに依存しない、ネイティブライブラリを内包した単一の実行ファイルとしてビルドしてみる。

.NETのビルドオプションを変更するにはプロジェクトファイルSampleCommandLineApp.fsprojを編集し、PropertyGroup配下にエントリを追加する。

なお、「ReadyToRunコンパイル」を行うとアプリケーションの読み込み時に Just-In-Time (JIT)コンパイラで行う必要がある作業量を減らすことにより、起動時のパフォーマンスが向上する。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>

    <!-- 自己完結型の実行ファイルとしてビルド -->
    <SelfContained>true</SelfContained>
    <PublishSingleFile>true</PublishSingleFile>

    <!-- 自己展開されるネイティブライブラリを実行ファイルに埋め込む -->
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>

    <!-- 実行ファイルを圧縮する -->
    <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>

    <!-- ReadyToRunコンパイルを行い、アプリケーションの起動時間と待機時間を向上させる) -->
    <PublishReadyToRun>true</PublishReadyToRun>

    <!-- ターゲットとなるランタイム識別子(RID) -->
    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  </PropertyGroup>

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

  <ItemGroup>
    <PackageReference Include="FSharp.SystemCommandLine" Version="0.17.0-beta4" />
  </ItemGroup>

</Project>

詳しくは下記のドキュメントを参照。

自己完結型展開ランタイムのロール フォワード - Microsoft Docs

単一ファイルのデプロイ - Microsoft Docs

ReadyToRunコンパイル - Microsoft Docs

dotnet publish コマンドを実行し、アプリケーションをビルドする。

$ dotnet publish -c Release

MSBuild version 17.4.8+6918b863a for .NET
  Determining projects to restore...
  All projects are up-to-date for restore.
  SampleCommandLineApp -> /home/sheepla/ghq/github.com/sheepla/sandbox/fsharp/SampleCommandLineApp/bin/Release/net7.0/linux-x64/SampleCommandLineApp.dll
  SampleCommandLineApp -> /home/sheepla/ghq/github.com/sheepla/sandbox/fsharp/SampleCommandLineApp/bin/Release/net7.0/linux-x64/publish/

ビルドが終わると、bin/Release/net<バージョン>/<ターゲット>/publish/ に実行ファイルが吐き出される。

ls -l bin/Release/net7.0/linux-x64/publish/SampleCommandLineApp

.rwxr-xr-x 41M sheepla 14 10月 22:36  bin/Release/net7.0/linux-x64/publish/SampleCommandLineApp

ターゲットとなるランタイムは dotnet publish コマンドの -r, --runtime オプションにて指定する。 ランタイムの識別子(RID)は下記から確認できる。

.NET RID カタログ

ライブラリを見つける

GitHubのAwesomeリストにライブラリ・エディタのプラグインなどがまとまっている。

おわりに

F#楽しいよ!

open System
open System.IO
open FSharp.SystemCommandLine
let listCommand: CommandLine.Command =
let handler (count: int) =
printfn "Running listCommand!"
printfn "count: %A" count
0
command "list" {
description "Show a list of something"
inputs (Input.Option<int>([ "-c"; "--count" ], "Some count"))
setHandler handler
}
let installCommand: CommandLine.Command =
let handler (files: FileInfo array) =
printfn "files: %A" files
0
command "install" {
description "Install something"
inputs (Input.Argument<FileInfo array>("files", "File names"))
setHandler handler
}
let removeCommand: CommandLine.Command =
let handler (names: string array, force: bool) =
printfn "names: %A, force: %A" names force
0
command "remove" {
description "Remove something"
inputs (
Input.Argument<string array>("names", "Some names"),
Input.Option<bool>([ "-f"; "--force" ], "Remove something forcefully")
)
setHandler handler
}
[<EntryPoint>]
let main (argv: string[]) : int =
let handler (text: string, flag: bool) =
printfn "Running rootCommand!"
printfn "text: %A, flag: %A" text flag
0
rootCommand argv {
description "Sample command line application"
inputs (
Input.OptionRequired<string>([ "-t"; "--text" ], "Some text"),
Input.Option<bool>([ "-f"; "--flag" ], "Some flag")
)
setHandler handler
addCommand listCommand
addCommand installCommand
addCommand removeCommand
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<!-- 自己完結型の実行ファイルとしてビルド -->
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<!-- 自己展開されるネイティブライブラリを実行ファイルに埋め込む -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 実行ファイルを圧縮する -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- AOT(Ahead-Of-Timeコンパイルを行い、アプリケーションの起動時間と待機時間を向上させる) -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- ターゲットとなるランタイム識別子(RID) -->
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.SystemCommandLine" Version="0.17.0-beta4" />
</ItemGroup>
</Project>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment