束にしてくりんなGo! (bundle, CLI)

(1)(2)(3)(0)んなGo。

ここ数日で MSYS と Go の話をしているが、本人的にはこれは地続きである、信じられんかもしれんけれど。

「レスキュー環境」という言い回しをちょっとしたけれど、これは「本日時点での Windows 版 Go」の、ある種制約とも言える特徴こそが今はメリットと考えることも出来る、ということを言っている。

Windows では「DLL」という言い方だが、すなわち「ダイナミックに実行時にロード(orリンク)するライブラリ」というのは、歴史的には Unix そのものや Windows そのものよりはいずれも少し新しく、いわば「先進的機能」である。すなわち小部分ごとの独立性を高め、各々それぞれごとのメンテナンスが可能となる「ステキな」機能、というわけだ、たとえばエラい人が作ったライブラリのバグも、静的に取り込めば「永遠にオレのもの」だが、ダイナミックロードであればエロい部分だけを更新出来て、オレ本体には影響しない。そこが「ステキ」だというわけだ。

一方でこの分離独立志向が問題となるケースもある。無論「依存ランタイムライブラリの欠落」が問題となりうるケースだ。巨大なソフトウェアとなるとランタイムライブラリへの依存が20だの30だのあることがあるが、そうしたものの一つでも何らかの原因で欠けてしまうと、まぁ高確率で「起動さえ出来ない」。ことそれがシステム(OS)にとって重要な機能である場合などは、「ハードディスクの特定のセクタが壊れて一部のライブラリが読み取り不能になる」ような異常事態のことにも備えなければならない。UNIX では、このような一式はたとえば「/usr/bin ではなく /usr/sbin に」置かれてきた。ここに置かれるものは原則として実行時依存ライブラリを持たずに単独で動作する。(欠落だけが問題なのではない。特定のバージョン依存が問題になることも多い。たとえば zlib のように ABI を枯らす気のない馬鹿なライブラリ管理を相手にしていると、本当に動的ロードが馬鹿らしく思えて仕方なくなるだろう。)

「本日時点での Windows 版 Go」は、今時点でのワタシの少ない体験によれば、どうやら「原則静的リンク」をしているように思えている。/usr/sbin のノリのものとは厳密には違う(kernel.dll には依存するので)が、少なくともワタシは「動的リンク」にする術は知らない。これは無論「永遠に他人のバグとお友達」という、要は「制約」でもあるし、今言ってきたようなメリットでもある。すなわち「持ち運びには便利なことがある」ということ。たとえば特殊なランタイムライブラリのインストールを要求する、などといったことは不要で、作った EXE を USB メモリ(やフロッピーディスク)にぶっ込んで、なんなら入れっぱなしのまま使える、ということである。(MSYS の話でワタシがよく強調するメリットと共通である。)

本日時点の、と言っている通りで、これはきっと将来的には「解消する制約」なのだと思う。今時点ではそもそも Windows 版は「DLL を作る」ことが出来ない(cgo が正しく動作しない)と思う。少なくとも一年かもうちょっと前に試みた際には全然ダメだった。おそらく全部が静的リンクな今の状態は、Windows 版 cgo の整備とともに解消していくんだろうと思う。

既に気付いているだろうが、「ひとのバグはおれのもの、おれのバグはおれのもの」というデメリットだけでなく、静的リンクには「EXE が巨大になる」という問題もある。この問題に、主に「埋め込みOS」を主食にしていた人々が好んで使っていたアプローチがある。発想としてはかなり「おバカ」でもあるんだけれど、一応ちゃんと目的通りに機能するアプローチである。それが、プログラムとしては「bundle」、それとともに、セットで不可欠な機能の「ハードリンク」、この2つを使う。

bundle そのものが今も手に入るかは知らないし興味もない。これはまぁ要するに「C で書かれたプログラムの一個に固めちゃう君」で、要はこういう main を生成する:

 1 /* ... */
 2 int main(int ac, char** av)
 3 {
 4     char** newav = /*avを複製*/
 5     if (!strcomp(av[0], "cat")) {
 6         cat_main(ac, newav)
 7     } else if (!strcomp(av[0], "ls")) {
 8         ls_main(ac, newav)
 9     } /* ...以下延々... */
10 }

こうやってcat も ls も同じ EXE 内に内蔵し、それを自身の名前で処理をディスパッチする、ということをする。bundle は個々の main をリネームしつつこのディスパッチャを書いてくれる、みたいなものだったと思う。こういうのを例えば「フロッピーディスクに収まる BSD」みたいなことをしたい人たちが好んで使っていたというわけだ。もちろんこれには「ハードリンク」が必要である。同じ内容の EXE がハードディスクに複製してあるだけではちっとも美味しくない。実体は一つ、と出来るからこそこれをするわけである。つまり…、FAT16/FAT32 ではこのアプローチは効果がほとんどないからね、注意。少なくともサイズを小さくすることが至上命題の場合は、このアプローチは FAT16/FAT32 相手には何の解決にもならない。


前置きはここまでである。本日のネタは、Go でこの「bundle」のようなことをしたい、ということと、そのためのコマンドラインパースについて、だ。

「レスキュー用の、ちょっとしたミニ UNIX」、まぁ考えることは皆一緒で、まさにこれをやりたい人たちは一定数みつかる。uxtools with golang とかだったっけかな、そんな検索をしたかと思う。どれも未完だし、完成しててもワタシ自身はそれほど食指は動かないのはそう、「結局一番欲しい bash がないやんけ」に落ち着き、「なんだ、やっぱ MSYS しかないのか」という結論になるからだ。けれども、「レスキュー用の、ちょっとしたお便利ツール群、持ち運びに便利」の最初に考えるツール群が Unix のものに似ていれば、話としてはわかりやすかろうと思う、ので、例は UNIX コマンドもどきから選ぼうと思う。


まずそもそも「ハードリンク」、UNIX コマンドでは「-s オプション」を付けない ln については、os.Link で実現出来る。「-s オプション」を付ける ln (シンボリックリンク)は os.Symlink。どちらも確かに NTFS5 機能で Windows でも実現出来るはずだし、go のドキュメントも Windows で使えないとは言ってない。出来る…のかな? ただ特にシンボリックリンクについては注意してな。以前書いたことがあるけれど、今 Windows 10 になってようやっとエクスプローラがこれを「識別」出来るようにしてくれたが、それまではリンクなのか実体なのかを区別する術がなかった。ので、「リンクを切りたいだけなのに中身が消える」という事故は防げなかったし、今でもこれへのサポートはシステム全体で十分とは言えないので、慎重に考えること。

ひとまず UNIX の ln と touch の2つ。後者はこれを参考に。コマンドラインパースは、標準ライブラリの flagで:

uxmodokeys.go
 1 package main
 2 
 3 import (
 4     "os"
 5     "flag"
 6     "path/filepath"
 7     "time"
 8     "fmt"
 9 )
10 
11 func ln_main() error {
12     var sym bool
13     fs := flag.NewFlagSet(os.Args[0], flag.PanicOnError)
14     fs.SetOutput(os.Stdout)
15     fs.BoolVar(&sym, "s", false, "as symbolik link")
16     fs.Parse(os.Args[1:])
17     src := fs.Arg(0)
18     dst := fs.Arg(1)
19     if !sym {
20         fmt.Printf("hardlink: %s -> %s\n", src, dst)
21         return os.Link(src, dst)
22     } else {
23         fmt.Printf("symboliclink: %s -> %s\n", src, dst)
24         return os.Symlink(src, dst)
25     }
26 }
27 
28 func touch_main() error {
29     var nocreate bool
30     fs := flag.NewFlagSet(os.Args[0], flag.PanicOnError)
31     fs.SetOutput(os.Stdout)
32     fs.BoolVar(&nocreate, "c", false, "do not create any files")
33     fs.Parse(os.Args[1:])
34     if len(fs.Args()) > 0 {
35         for i := 0; i < len(fs.Args()); i++ {
36             filename := fs.Arg(i)
37             _, err := os.Stat(filename)
38             if err == nil {
39                 now := time.Now()
40                 os.Chtimes(filename, now, now)
41             } else if !nocreate {
42                 f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0644)
43                 f.Close()
44                 if err != nil {
45                     return err
46                 }
47             }
48         }
49     }
50     return nil
51 }
52 
53 func main() {
54     base := filepath.Base(os.Args[0])
55     base = base[:len(base) - len(filepath.Ext(base))]
56     var err error
57     if base == "ln_" {
58         err = ln_main()
59     } else if base == "touch_" {
60         err = touch_main()
61     }
62     if err != nil {
63         panic(err)
64     }
65 }

ワタシは「本物の偽物UNIX」(MSYS)を持っているので「ln_」のように名前を変形したけれど、本物と同じがいいならお好みで。「EXEがデカいぜ」問題は ln でハードリンクして解決するのだぞ。ln については、Windows でも(NTFSなら)動くね、問題ない。(ただし symlink はディレクトリについては Windows でも動くが、ファイル相手の場合は単なるコピーとして振る舞うことに注意。これは NTFS5 の junction がディレクトリエントリにしか対応しないため。)

ひとまずここまでで「os.Argv[0] 名によるディスパッチ」部分と、個々のサブ機能単位でのオプション解析について示せたことになる。

これで話を終わってもいいんだけれど、「コマンドラインパース」の話なわけね、今回のネタは。一つ前で flag モジュールがイケてないので argparse 使いたいぜ、と言ったけれど、具体的にどう不満なのかはちゃんと言ってない。でっかい不満が2つ、細かくはもっといっぱい、だがまずその2つ:

  1. GNU getopt 流儀と違って、位置引数とオプションの順番を入れ替えられない
  2. GNU getopt の「いいところ」の解釈の誤解

2つ目が伝わりにくいと思うんだが、たとえばね、「--destdir=hoge」みたいなのを long 形式と呼んでいて、これは GNU マナーだと、必ずハイフンふたつなの。そして、GNU 以前の従来型オプション(getopt)はこれは、基本的には全部が「一文字」で表現するしきたり。たとえば「-d」とか。で、この短い形式は(引数なしのフラグオプションどうしなら)「くっつけて」使えるようになってる。つまり「-b」と「-x」が提供されている場合、ユーザは「-bx」のように指示出来る。この形式のハイフンは必ず一つ。Go デベロッパが知らないはずはないと思うんだけれど、けれどもこの flag モジュールの説明(例)は、以下のように非常に迷惑なもの:

1 func init() {
2     const (
3         defaultGopher = "pocket"
4         usage         = "the variety of gopher"
5     )
6     flag.StringVar(&gopherType, "gopher_type", defaultGopher, usage)
7     flag.StringVar(&gopherType, "g", defaultGopher, usage+" (shorthand)")
8 }

これを「--gopher_type」(ハイフンふたつ)と「-g」(ハイフンひとつ)だと、UNIX 歴が長い人ほど思いたいんだけれど、もちろんそんなことはなく、「-gopher_type」(ハイフンひとつ)と「-g」(ハイフンひとつ)である。(flag.StringVar(&gopherType, "-gopher_type",...)としても解決しないよ、念のため。)この「getopt_long」を誤解してるんじゃないのか、だけで十分ワタシには「使えねー」て気分なので、ショート形式の「結合」が出来るのかどうかには興味ない。出来ない気がするよね。

そういうわけで一つ前のネタで見つけることが出来る argparse などを使いたい、てことね。良さげに見えるのはやはり argparse (Command line argument parser inspired by Python’s argparse module) であろうが、cmdr (A POSIX/GNU style, getopt-like command-line UI Go library)も良さそうに思える。

と、慣れている argparse を試みたのだが、結論から言えばこれはダメ。いや、限定的にはいいんだけど、つまりは「不十分」で大変惜しい。何かというと「オプション以外の位置引数」についてなんにもしてくれない。これはもう touch の方で既にダメ。「-s」以外の全ての引数はファイル名たちであるとして扱わなければいけないんだけど、この argparse はそうしたニーズを完全に忘れている模様。「世界はオプションで出来ている!」…なわけない。どうしたもんかと思ってたが、書き換えてくれた人がいる。まさに「ポジショナルやがれ」な issue から@hellflameに。おぉ、これよこれ、「–」と書けばオプションだし、そうしなければ位置引数、これこそが Python の argparse のノリ。なんでこの通りにやらんのよ、ってことだと思うんだ、ワシは。どかな?:

 1 package main
 2 
 3 import (
 4     "os"
 5     // go get -u -v github.com/hellflame/argparse
 6     "github.com/hellflame/argparse"
 7     "path/filepath"
 8     "time"
 9     "fmt"
10 )
11 
12 func ln_main() error {
13     parser := argparse.NewParser(os.Args[0], "ln", nil)
14     var sym *bool =  parser.Flag(
15         "s", "symbolic", &argparse.Option{Help: "as symbolik link"})
16     var src *string = parser.String(
17         "", "src", &argparse.Option{Positional: true})
18     var dst *string = parser.String(
19         "", "dst", &argparse.Option{Positional: true})
20     if e := parser.Parse(os.Args[1:]); e != nil {
21         fmt.Println(e.Error())
22         return e
23     }
24     if !*sym {
25         fmt.Printf("hardlink: %s -> %s\n", *src, *dst)
26         return os.Link(*src, *dst)
27     } else {
28         fmt.Printf("symboliclink: %s -> %s\n", *src, *dst)
29         return os.Symlink(*src, *dst)
30     }
31 }
32 
33 func touch_main() error {
34     parser := argparse.NewParser(os.Args[0], "touch", nil)
35     var nocreate *bool =  parser.Flag(
36         "c", "nocreate", nil)
37     var targets *[]string =  parser.Strings(
38         "", "targets", &argparse.Option{Positional: true})
39     if e := parser.Parse(os.Args[1:]); e != nil {
40         fmt.Println(e.Error())
41         return e
42     }
43     for _, filename := range *targets {
44         _, err := os.Stat(filename)
45         if err == nil {
46             now := time.Now()
47             os.Chtimes(filename, now, now)
48         } else if !*nocreate {
49             f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0644)
50             f.Close()
51             if err != nil {
52                 return err
53             }
54         }
55     }
56     return nil
57 }
58 
59 func main() {
60     base := filepath.Base(os.Args[0])
61     base = base[:len(base) - len(filepath.Ext(base))]
62     var err error
63     if base == "ln_" {
64         err = ln_main()
65     } else if base == "touch_" {
66         err = touch_main()
67     }
68     if err != nil {
69         panic(err)
70     }
71 }

うん、これはいいね、完全ではないんだけれど、ほぼ問題ない。無論 Python と Go の違いによる「Go ならでは」の鬱陶しさはあるけれど、やりたいことはひとまず出来そうだ。

一応念押ししておくけど「問題ない」の意味は上で説明した「オプションと位置引数の順序非依存」も含めて、ね。たとえば:

作った exe に ln_.exe、touch_.exe 名でハードリンクしてるとして
1 [me@host: goexpr]$ ./ln_.exe -s aaa bbb  # フォルダ aaa を指す symlink bbb を作る
2 [me@host: goexpr]$ ./ln_.exe aaa bbb --symbolic # フォルダ aaa を指す symlink bbb を作る

不完全、というのは、「曖昧でないなら省略可」つまり「--sym」が GNU getopt や Python の argparse では許されるんだけれど、この hellflame 版はそこまでやってないみたい。

うん、まぁ…、ここまで出来ていれば、標準の flag よりはずっといいと思う、ので、今回はここで満足しとく。


と、話を終わらせるつもりだったのだが、「未完成の、あんましうれしかない go-coreutils」を見返していて、どうも github.com/ogier/pflag が最初から答えだったんじゃないかしら、と思い始め、実際やってみた:

 1 package main
 2 
 3 import (
 4     "os"
 5     // go get -u -v github.com/ogier/pflag
 6     "github.com/ogier/pflag"
 7     "path/filepath"
 8     "time"
 9     "fmt"
10 )
11 
12 func ln_main() error {
13     sym := pflag.BoolP("symbolic", "s", false, "as symbolik link")
14     ousage := pflag.Usage
15     pflag.Usage = func() {
16         ousage()
17         os.Exit(1)
18     }
19     pflag.Parse()
20     src := pflag.Arg(0)
21     dst := pflag.Arg(1)
22     if !*sym {
23         fmt.Printf("hardlink: %s -> %s\n", src, dst)
24         return os.Link(src, dst)
25     } else {
26         fmt.Printf("symboliclink: %s -> %s\n", src, dst)
27         return os.Symlink(src, dst)
28     }
29 }
30 
31 func touch_main() error {
32     nocreate := pflag.BoolP("nocreate", "c", false, "do not create any files")
33     ousage := pflag.Usage
34     pflag.Usage = func() {
35         ousage()
36         os.Exit(1)
37     }
38     pflag.Parse()
39     for _, filename := range pflag.Args() {
40         _, err := os.Stat(filename)
41         if err == nil {
42             now := time.Now()
43             os.Chtimes(filename, now, now)
44         } else if !*nocreate {
45             f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE, 0644)
46             f.Close()
47             if err != nil {
48                 return err
49             }
50         }
51     }
52     return nil
53 }
54 
55 func main() {
56     base := filepath.Base(os.Args[0])
57     base = base[:len(base) - len(filepath.Ext(base))]
58     var err error
59     if base == "ln_" {
60         err = ln_main()
61     } else if base == "touch_" {
62         err = touch_main()
63     }
64     if err != nil {
65         panic(err)
66     }
67 }

標準ライブラリの flag より遥かに良いというのは hellflame/argparse と同じで、なおかつ、hellflame/argparse よりもエンドユーザ目線では一つさらに上乗せで良い。つまり上で文句を言ったショート形式の場合のミックス(「-b」と「-x」を「-bx」と書けるなど)が対応されている。残念ながら「曖昧でないなら省略が許される」はこれもやってはくれないんだけれど、まぁそこはひとまず「標準より何百倍も良いのでヨシとしよう」としとく。

ただ。Goプログラマ目線だと、結構致命的に使いにくい部分はある。

まずはコマンドラインパースのバリデーションに関して。hellflame/argparse は位置引数の有無も含めてのバリデーションになるんだけれど、こちらはそうではなくて、「オプショナル or NOT」という、世界を二分してくれるだけ。ので、ワタシの ln の例だと「src」「dst」が間違いなく指定されているかどうかは ogier/pflag の範疇外で自力で調べる必要がある(と思う)。無論「世界を二分してくれる」ことが最も重要なことで、それすらしてくれなかった標準のほうの flag よりはずっと簡単にそれを出来るのではあるから、うん、まぁ「ありがとう」とは思う。惜しい…、よね。

あと同じところで「パースエラー時」の制御の仕方がちょっと気持ち悪い。「Usage() を出力したくなるということはパースエラーに違いないよね」としてオーバライドすることでしか「パースエラーならば処理を進めない」にならないみたい。普通に Parse() がエラーを返すようにすりゃいいのに、なんでなんだよ。

もう一つある。Parse に引数を渡せない。つまりこれ、「package main に実際に渡されたコマンドラインのパースしか出来ない」ということになる。つまりこれ、例えば「なんらかの子プロセスへのラッパープログラム」を書きたいとして、そのために「子プロセスのためのコマンドラインパース」を書けない、ということ。あといわゆる「サブコマンド」も出来ないよね。

まぁそういうわけで、hellflame/argparse と ogier/pflag は似たりよったりかなと思う。「70%くらいありがとう!! すばらしい、7割!!」くらい。そして繰り返すけれど「それでもなお標準の flag の何億倍も良い」てことだよ。


2022-03-13追記:
「実体が一つでハードリンクで複数の名前を名乗る」際に、「じゃぁお前は一体全体、誰なんだ」の主張がしづらいケースがあるのね。普通は自身のコマンドラインオプションで「–version」みたいなのとともに自己主張出来る機会が与えられる。上で例にしたものは、まぁそれで対応出来る範囲のものだった。

けれど、このネタの exemimicry はそれが通用しないパターンでな。これは何かほかのものを包むプログラムなので、コマンドラインインターフェイスは「包む対象のプログラムと原則同じ」であって欲しいわけ。つまり包まれる側に「–version」みたいなインターフェイスがないなら、exemimicry もそれを持てない、ので、「お前は誰だ?」に対して答える機会がない。

これはずっと困ってたんだけれど、ようやくみつけた、goversioninfo。公式の例がなんだか複雑なものだけあげて、一番基礎的なものを端折ってたんでちょっと迷走してしまったんだけれど、その「一番基礎的なもの」つまり「バージョン情報リソース埋め込み」だけを処理するのはこれだけなの:

  1. (ビルドのワークディレクトリ内で一回)go get github.com/josephspurrier/goversioninfo/cmd/goversioninfo
  2. 例えば以下のような「versioninfo.json」を作る(ファイル名固定):
    オリジナルのほぼ丸パクリだが、混乱しないように「goversioninfo__」としてる。
     1 {
     2     "FixedFileInfo": {
     3         "FileVersion": {
     4             "Major": 1,
     5             "Minor": 0,
     6             "Patch": 0,
     7             "Build": 0
     8         },
     9         "ProductVersion": {
    10             "Major": 1,
    11             "Minor": 0,
    12             "Patch": 0,
    13             "Build": 0
    14         },
    15         "FileFlagsMask": "3f",
    16         "FileFlags ": "00",
    17         "FileOS": "040004",
    18         "FileType": "01",
    19         "FileSubType": "00"
    20     },
    21     "StringFileInfo": {
    22         "Comments": "Test file.",
    23         "CompanyName": "josephspurrier",
    24         "FileDescription": "This is a hello world file.",
    25         "FileVersion": "v1.0.0.0",
    26         "InternalName": "goversioninfo__.exe",
    27         "LegalCopyright": "Copyright (c) 2019 Joseph Spurrier",
    28         "LegalTrademarks": "",
    29         "OriginalFilename": "main.go",
    30         "PrivateBuild": "",
    31         "ProductName": "goversioninfo__",
    32         "ProductVersion": "v1.0.0.0",
    33         "SpecialBuild": ""
    34     },
    35     "VarFileInfo": {
    36         "Translation": {
    37             "LangID": "0409",
    38             "CharsetID": "04B0"
    39         }
    40     },
    41     "IconPath": "",
    42     "ManifestPath": ""
    43 }
    
  3. main パッケージのソースに、これの埋め込み指示をする:
    main.go
    1 package main
    2 //go:generate goversioninfo
    3 func main() {
    4 }
    
  4. go generate
  5. go build

エクスプローラから「プロパティ→詳細」を参照するとたとえばこんな具合:

Windows 用、だとは思うんだけれど、よくわかんない。Unix でも使おうと思えば使えるのかなぁ? あと当然のことながら Windows の公式の仕様なので、LangID だのを適切にするために努力する必要はある。ともあれ Windows であれば、これでハードリンクしまくる execmimicry みたいなのでも「本来は誰なのか」がわかるようになる、てことだ。