コマンドを作るような目的で, F# を使って実行可能ファイル (*.exe) をという人は多いでしょう。しかしこういった exe ファイルが増えてくると管理が煩雑になるので,パッケージングしたくなります。理想的には機能ごとにまとめて git や aptitude のようにサブコマンド化してやりたくなります。
ILRepack というツールを使うと複数のアセンブリーをまとめて 1 つのアセンブリーにしてくれます[A]。 NuGet でライブラリーが公開されているのでこれを利用します。
この記事のソースコードの全体は BitBucket にあります。
以下の手順で行います。
- サブコマンドにしたいアセンブリーを実行するアセンブリーを作成する。
- アセンブリーを ILRepack でマージする。
まず最初にサブコマンドにしたいアセンブリーを実行するアセンブリーを作成します。 F# PowerPack に含まれている FSharp.Compiler.CodeDom を使って動的にソースコードを生成します。
生成したいソースコードは以下のような感じになります。
1 2 3 4 5 | open System match (Environment.GetCommandLineArgs () |> Array.toList).Tail with | "subcommand1" :: _ -> Assembly1.Main () | "subcommand2" :: args -> Assembly2.Main (args) | _ -> printfn "不正なサブコマンド" |
べた書きすると以下のようになります。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | open System open System.CodeDom.Compiler open System.Reflection open Microsoft.FSharp.Compiler.CodeDom let getEntryPointInvocation assembly commandLineArgsName = let assembly = Assembly.ReflectionOnlyLoadFrom (assembly) let entryPoint = assembly.EntryPoint let getEntryPoint = sprintf "" "System.Reflection.Assembly.GetExecutingAssembly().GetType(" %s ").GetMethod(" %s ", System.Reflection.BindingFlags.Static ||| System.Reflection.BindingFlags.NonPublic)" "" entryPoint.DeclaringType.FullName entryPoint.Name if entryPoint.GetParameters().Length = 0 then sprintf "" "%s.Invoke(null, Array.empty) |> ignore" "" getEntryPoint else sprintf "" "%s.Invoke(null, Array.create 1 (box (Array.ofList %s))) |> ignore" "" getEntryPoint commandLineArgsName let createMainAssembly assemblyName subcommands = let sourceCode = let matching = "" "match (System.Environment.GetCommandLineArgs () |> Array.toList).Tail with" "" let unmatched = sprintf "| _ -> printfn \"\"\"subcommand is one of %A.\"\"\"" <| (Map.toList >> List.map fst) subcommands subcommands |> Map.map ( fun command assembly -> let invoke = getEntryPointInvocation assembly "args" sprintf "" "| " %s " :: args -> %s" "" command invoke ) |> Map.toList |> List.map snd |> fun matches -> matching :: matches @ [unmatched] |> String.concat Environment.NewLine use codeProvider = new FSharpCodeProvider () let parameters = CompilerParameters (GenerateExecutable = true , OutputAssembly = assemblyName) parameters.ReferencedAssemblies.AddRange (Map.toArray subcommands |> Array.map snd) codeProvider.CompileAssemblyFromSource (parameters, [|sourceCode|]) |
CodeDom でパターンマッチを組み立てる方法がわからなかったのでソースコードを組み立てています。汚いソースコードだなぁ。
ポイントは,サブコマンドにするコマンドは,マージ前には別のアセンブリーになっているため,エントリーポイントを呼び出すにはリフレクションを使わないとコンパイルできないということです。
実行時の問題としては,サブコマンドはエントリーポイントの引数 (args) を使うことを想定しており, Environment.GetCommandLineArgs
を呼ばれると予期せぬ動作が起こりえます。したがってサブコマンド化するアセンブリーでは Environment.GetCommandLineArgs
を呼ばないようにしなければなりません。
これを以下のように呼び出せばサブコマンドを呼び出すアセンブリーを作ることができます。ここでは仮に HelloWorld.exe を hello というサブコマンド名で呼び出し, CountArgs.exe を count というサブコマンド名で呼び出すようにします。
1 2 3 4 5 | open System.IO let temporaryAsseblyName = sprintf "%s.exe" <| Path.GetRandomFileName () let subcommands = Map.ofList [ "hello" , @ "HelloWorld.exe" ; "count" , @ "CountArgs.exe" ] createMainAssembly temporaryAsseblyName subcommands |> ignore |
次に動的にコンパイルして作成したアセンブリーと,サブコマンドを含むアセンブリーをマージして 1 つにします。ここでは nubs.exe という名前の実行ファイルを作成します。
1 2 3 4 5 6 7 8 | open ILRepacking let assemblies = temporaryAsseblyName :: (Map.toList subcommands |> List.map snd) |> List.toArray let repack = ILRepack () repack.SetTargetPlatform ( "v4" , null ) repack.SetInputAssemblies (assemblies) repack.OutputFile <- "nubs.exe" repack.Merge () |
これでサブコマンド hello と count を持つ単一の実行アセンブリー nubs.exe ができます。実行イメージは以下のような感じです。
1 2 3 4 5 6 7 8 | > .\nubs.exe hello Hello, world! > .\nubs.exe count No argument > .\nubs.exe count a b More than one arguments > .\nubs.exe subcommand is one of ["count"; "hello"]. |
最初にも述べましたが,ソースコードは BitBucket にあります。ちなみにプロジェクト名の Nub は小さな欠片という意味があり,サブコマンドを欠片に見立てているのと, sub の .NET 版ということで nub という 2 つの意味があります。もう少しまともにしたら NuGet などの別の方法で公開するかもしれません[B]。