実行可能なアセンブリーをサブコマンド化する


コマンドを作るような目的で, F# を使って実行可能ファイル (*.exe) をという人は多いでしょう。しかしこういった exe ファイルが増えてくると管理が煩雑になるので,パッケージングしたくなります。理想的には機能ごとにまとめて git や aptitude のようにサブコマンド化してやりたくなります。

ILRepack というツールを使うと複数のアセンブリーをまとめて 1 つのアセンブリーにしてくれます[A]NuGet でライブラリーが公開されているのでこれを利用します。

この記事のソースコードの全体は BitBucket にあります

以下の手順で行います。

  1. サブコマンドにしたいアセンブリーを実行するアセンブリーを作成する。
  2. アセンブリーを 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]

脚注

  1. ILRepack は ILMerge を置き換える目的で作成されています。 API は ILMerge をもとに作られているので, ILMerge からの移行も容易です。 ILMerge は実行ファイルとライブラリーが 1 つのアセンブリーになっていますが, ILRepack は実行ファイルとライブラリーが分離されているので利用しやすくなっています。 []
  2. もしかして既に同様のパッケージはあるのかも。 []