exeねばなりません←未完の別解 (Go!)

EXEでないのは万死に値する、と脅迫されている、として。

もとの python による解が「不自然」なのは、当たり前のことだが「python そのものを使わない」ことにならないからだ。「EXEであればいい」というニーズに対して「python に依存する」という要求が釣り合わない。

そうでない解を得たいのならば、一番考えやすいのは、従来であれば「C で作ってしまう」ことだった。ただのランチャだもの、簡単だろうさ、と、まぁ色んな OSS が実際にそれをやってるし、とてつもなく苦労してきた。特に「Windows 9x」が健在だった頃が本当にヒドいものだったが、今だって非常に面倒くさいことには変わりはない。というワタシのこの意見を疑うなら、たとえば java のランチャ(java.exe、javaw.exe)や CPython の Windows 版だけに含まれる py.exe のソースコードをじっくり鑑賞してみるといい。Unix でのプロセス起動のシンプルさに比して、驚くほど鬱陶しい手続きが必要なのかについて知って悶絶するがいい。(emacs なんかもこの苦労をし続けてるひとつ。)

まぁこれまでは確かにそうだった、のだけれどもさ。

Go がね、コンパイル型の、いわゆる Windows ネイティブを作れる子、なのよね。java とか .net みたいな VM 方式でもなく、python や ruby, perl のようなスクリプトでもない、そう、「みんな大好き EXE」を作る、作れる。

今ワタシにとっての Go は「試用期間」中で、実力把握してる最中なので、確定的な評価の類はあまり言わない。とにかく今は「EXE を作れる」ことだけに着目する。とするならば、別解はこんなかなぁと:

 1 package main
 2 
 3 import (
 4     "fmt"
 5     "os"
 6     "os/exec"
 7     "io/ioutil"
 8     //"syscall"  // "syscall.Exec" which i want to use does not support Windows...
 9 )
10 
11 func main() {
12     args := append([]string{"./msvcc.sh"}, os.Args[1:]...)
13     cmd := exec.Command("bash", args...)
14     cmdOut, _ := cmd.StdoutPipe()
15     cmd.Start()
16     outBytes, _ := ioutil.ReadAll(cmdOut)
17     cmd.Wait()
18     fmt.Println(string(outBytes))
19 }

(→gomsvcc-0.0.1.zip)
コメントアウトしてる「syscall」がほんとは使いたかったやつで、正直このストレスはお馴染みのやつ。python も今でこそ色々「Windows でだけ出来ないこと」が減ってきてるが、たとえば python 2.3 とかの頃って、ほんとに「Windows では使えない」ものだらけで、もちろん subprocess まわりにそうしたものがたくさんあった。そして、このおなじみの「いらり」が、C で作るよりマシか、と問われれば、もちろん C で作るよりは遥かに楽。

もちっと高レベルのラッパーがあると楽なのだがなぁ。python で言うところの「subprocess.check_call」みたいな包み方のものはどうもなさげで、標準出力・標準エラー出力のキャプチャと出力は、なんとか自力で処理しなければならなそう。見ての通り、ワタシが今やってみたやつはそこをやってない。幸い Go は多重化が簡単なので、おそらくちゃんとやったとしてもコードはそれほど膨らまないだろうとは思う。…んだけどね、まだわからんことだらけでな。もちっと鍛錬が必要だ、ワタシには。

あ、「評価はあまり書かない」といいつつ、ちょっとだけ。「面白い」。これだけは確か。面白いの内容は、「小さくて気が利くヤツだ」。C 言語並みに覚えることが少ない、とてもシンプルな言語、だと思う。それと「Windows ではまだかなり不自由」であることも言っとく。シェアドライブラリを作れる「cgo」が Windows…というか「MSVC」で全く使えない、てことまでは突き止めたとこ。(gcc は使えるので、msys2 や cygwin だと使えるんだよね。)


2021-04-30追記:
「もちっと高レベルのラッパーがあると」ではなかった。「stdout, stderr のキャプチャが不要なので」という発想が正解。というか頭では理解してはいたんだけれどもさ、正解のコード実例に辿り着けなかった。こういうこと:

 1 package main
 2 
 3 import (
 4     "os"
 5     "os/exec"
 6     //"syscall"  // "syscall.Exec" which i want to use does not support Windows...
 7 )
 8 
 9 func main() {
10     args := append([]string{"./msvcc.sh"}, os.Args[1:]...)
11     cmd := exec.Command("bash", args...)
12     cmd.Stdout = os.Stdout
13     cmd.Stderr = os.Stderr
14     cmd.Run()
15 }

(→gomsvcc-0.0.2.zip)
うん、これで済むなら、python の解の何億倍かはいくらか正解だ。デメリットがあるとすれば、Go はほとんどが静的リンクなので、exe がデカくなってしまうことくらい。それを許容できるなら、「EXE でないとは何事だ」に対する第一候補にして良かろう。

ちなみに初学者段階ではよく経験することなんだけれど、ワタシはこの正解を導く前にはこれの正解が「並列化の練習」になると思っていたので、この簡単過ぎる正解に実はかなりガッカリした。目玉だと思うんだよね、Go の並列化。まぁしょうがないので、それはそれで別途お勉強しようか。


2021-05-07追記:
「未完の」といって始めたネタが完成してしまった場合に、こうやって追記で完成させるか別ネタとして独立させるかは悩ましいところだ。まぁ所詮はどーでもいいネタだからなぁ…、追記で。

さすがに不慣れで時間がかかったが、これが目指したものである:

shexecuty.go というきゃわわなねーみんぐにしたのよ
 1 package main
 2 
 3 import (
 4     "os"
 5     "os/exec"
 6     "path/filepath"
 7     "strings"
 8     //"syscall"  // "syscall.Exec" which i want to use does not support Windows...
 9 )
10 
11 func main() {
12     mypath_noext := os.Args[0][:len(os.Args[0]) - len(filepath.Ext(os.Args[0]))]
13     mypath_noext = filepath.ToSlash(mypath_noext)
14     basename := filepath.Base(mypath_noext)
15     shell, luesst := os.LookupEnv(strings.ToUpper(basename) + "_SHELL")
16     if !luesst {
17         shell = "bash"
18     }
19     scrext, lueest := os.LookupEnv(strings.ToUpper(basename) + "_SCREXT")
20     if !lueest {
21         scrext = ".sh"
22     }
23     args := append([]string{mypath_noext + scrext}, os.Args[1:]...)
24     _, lookbinErr := exec.LookPath(shell)
25     if lookbinErr != nil {
26         panic(lookbinErr)
27     }
28     cmd := exec.Command(shell, args...)
29     cmd.Stdout = os.Stdout
30     cmd.Stderr = os.Stderr
31     cmd.Run()
32     os.Exit(cmd.ProcessState.ExitCode())
33 }

(→ shexecuty-0.0.1.zip)
「汎用にした」のだけが目立つと思うが、「ちゃんとした」のもかなり大事。今度のはちゃんとスクリプトの終了コードで死ぬようになってる。ここをちゃんとしないと、場合によっては全く実用にならない。

「汎用にした」は、もちろんこういうこと:

 1 [me@host: ~]$ unzip shexecuty-0.0.1.zip
 2 [me@host: ~]$ cd shexecuty
 3 [me@host: shexecuty]$ go build
 4 [me@host: shexecuty]$ ls -1
 5 go.mod
 6 shexecuty.exe
 7 shexecuty.go
 8 [me@host: shexecuty]$ # ↓全然違う名前でどこかに置く
 9 [me@host: shexecuty]$ ln shexecuty.exe /usr/local/bin/my_boguscript.exe
10 [me@host: shexecuty]$ # なぜなら、これ↓が「exeでないとは何事だ」と叱られたスクリプトだからだ
11 [me@host: shexecuty]$ cat /usr/local/bin/my_boguscript.sh
12 #! /bin/sh
13 something what we want to do
14 exit $?
15 [me@host: shexecuty]$ # ↓bash じゃなく ksh を使いたいとして
16 [me@host: shexecuty]$ MY_BOGUSCRIPT_SHELL=ksh my_boguscript "a long time tin"

みたいなこと。欲張るならまだ出来ることはあるが、一応最小限の汎用性ね。

なお、「欲張るなら」の一つに、「win32/win64 を意識する」もあるのだが、これは go だけでは無理ではないかという気がする。まぁ go でなくても別に簡単なわけではないんだけれども、純粋に C/C++ で作るならこれはクロスコンパイラ環境さえ準備出来れば可能なので…、ちょっと残念な気分にはなる。多少ね。

「EXEでないと海に飛び込むから」と言われてしまうような状況を考えると、もしかしたら環境変数じゃなくて設定ファイル方式にした方が取りまわししやすいかもしれんなぁ、とかね、色々ある。せっかく包んだのに、この包んだ外側にさらに何かしたくなってしまうような、つまり「このEXEを包むシェルスクリプト」が欲しくなってしまうようではあかんわけさね、だってそれ、たぶん「このEXEを包むシェルスクリプト、を包むEXE」が必要になるんだろうから。

ちなみに、「EXEねば」と脅かされている、というのが本当かどうか、一応確認した方がいい。典型的には「非MSYS(or cygwin)対MSYS(or cygwin)」という関係が成立すると概ね「EXEねば」という強迫が発生し得て、普通の Windows Python ユーザが選択する「公式 CPython」は MSYS/Cygwin でないので、要するに(たとえば)「subprocess」がターゲットにしているサブプロセス呼び出しがほぼ「プロセスは EXE でなければならない」ことを要求するハメになる。ただほんとに「絶対に EXE そのものでないとダメ」なのかといえば、「みんな大好き BAT ファイル」を起動出来るケースが少しばかりある:

 1 # -*- coding: utf-8 -*-
 2 import io
 3 import os
 4 import subprocess
 5 
 6 with io.open("my_nanika.bat", "w") as fo:
 7     print("@ECHO OFF", file=fo)
 8     print("ECHO Watashi am geronious.", file=fo)
 9 
10 # execute my bat by "os.system"
11 os.system("my_nanika")
12 
13 # or, by shell=True (will success if your python is CPython built with msvc)
14 subprocess.check_call(".\\my_nanika", shell=True)

すなわち、こういうケースで「EXEねばなりません」と考えずに「BATにあらずんば人にあらず」と考えることは出来て、そしてかなり多くの OSS がそう考え、Unix 由来のアプリケーションで書かれたシェルスクリプトを「みんな大好き MS DOS」に移植しようと試みるのだ。当然これは苦行でしかなく、「.bat」と特定してない限りはワタシはいつでも「EXEねばならぬ」と考えたいパターンである。そう、DOS/Windows の悪い癖「勝手に拡張子を補う」ことを、ここでは利用しない手はないと思う。せっかくこうやって EXE にするのが簡単になったんだから、ね。


2022-02-25追記:
「case II」とでもいうか、shexecuty では不満なケース、てやつを考えてみた。「ヤダ」条件はつまりはこういうことだ:

  1. ラッパー「スクリプト」を書きたくない、もしくは
  2. 「特定のシェル依存がヤダ」

shexecuty てのはつまり「shexecuty → ターゲットとするプログラムをラップするスクリプト → ターゲットとするプログラム」という包み方なわけだけど、この真ん中を端折りたい、てことね。ワタシは常に MSYS bash をあてにしてしまうけれど、皆が皆こういう快適な偽 Unix を持っているわけではなくて、「だからといって BAT なんか書いてられるかばーかばーか」てハナシ。shexecuty 相当のプログラム自身がラッパーとして機能するようにしたい、と。

Unix もしくは「Unix もどき」だけを相手にしてる場合は、シェル機能の alias やシェル関数なんかも使いたい状況であって、実際それで事足りることも多いわけだけれど、「Windows なので」てわけだ、多くはそれをあてに出来ないし、そもそも Unix/Unix もどきのケースですら「alias やシェル関数」はそのシェルからしか使えない、つまり、たとえば python から subprocess 経由で「その alias やシェル関数をあてにする」には「そのように」区別してプログラムする必要がある(具体的には「shell=True」を subprocess に伝える)、てことで、発想そのものは Unix/Unix もどきでも全く役に立たないわけでもない。

そう、「Python から ffmpeg を呼び出す」みたいなことはワタシは本当によくやるんだけれど、この「python から呼び出す ffmpeg」が、Python プログラムを変えることなく置き換わって欲しい、てことなのよ、たとえば。ありがちなのは「いつでも -hide_banner を付けたい」「-c:v libx265 固定で使いたい」みたいなこと。

とりあえず Windows 前提のもの:

exemimicry.go
  1 package main
  2 
  3 import (
  4     "io"
  5     "os"
  6     "os/exec"
  7     "text/template"
  8     "bytes"
  9     "strings"
 10     "path/filepath"
 11     "fmt"
 12     "reflect"
 13     "github.com/clbanning/mxj/v2"
 14 )
 15 
 16 const (
 17     CONF_DEFAULT = `{{/*
 18    configuration of exemimicry.go.
 19    basically this file is a json-like format.
 20 */}}
 21 {
 22     "ll": {
 23         "prog": "ls",
 24         "args": [["-l", "--color=always"], []]
 25     },
 26     "ffmpeg": {
 27         "prog": "ffmpeg",
 28         "args": [["-hide_banner"], ["-c:v", "libx265"]]
 29     },
 30     "ffplay": {
 31         "prog": "ffplay",
 32         "args": [["-hide_banner"], ["-autoexit"]]
 33     }
 34 }
 35 `
 36 )
 37 
 38 func getcmdl() (string, []string) {
 39     mypath, _ := filepath.Abs(filepath.Clean(os.Args[0]))
 40     mypath_noext := mypath[:len(mypath) - len(filepath.Ext(mypath))]
 41     mypath_noext = filepath.ToSlash(mypath_noext)
 42     basename := filepath.Base(mypath_noext)
 43     //
 44     getconf := func () string {
 45         home := os.Getenv("USERPROFILE")
 46         if home == "" {
 47             home = os.Getenv("HOME")
 48         }
 49         confpath := filepath.Join(home, ".exemimicry.go.conf.json")
 50         _, err := os.Stat(confpath)
 51         var f *os.File
 52         if err != nil {
 53             f, _ = os.OpenFile(confpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0744)
 54             fmt.Fprintf(f, "%s\n", CONF_DEFAULT)
 55             f.Close()
 56         }
 57         f, _ = os.Open(confpath)
 58         defer f.Close()
 59         cont, _ := io.ReadAll(f)
 60         return string(cont)
 61     }
 62     conftmpl, err := template.New("exemimicryconf").Parse(getconf())
 63     if err != nil {
 64         panic(err)
 65     }
 66     var tmplout bytes.Buffer
 67     var data interface{}
 68     err = conftmpl.Execute(&tmplout, data)
 69     if err != nil {
 70         panic(err)
 71     }
 72     conf, err := mxj.NewMapJson([]byte(tmplout.String()))
 73     if err != nil {
 74         panic(err)
 75     }
 76     tosarr := func (v interface{}) []string {
 77         resi := reflect.ValueOf(v)
 78         var res []string
 79         for i := 0; i < resi.Len(); i++ {
 80             res = append(res, fmt.Sprint(resi.Index(i)))
 81         }
 82         return res
 83     }
 84     hasExt := func (file string) bool {
 85         i := strings.LastIndex(file, ".")
 86         if i < 0 {
 87             return false
 88         }
 89         return strings.LastIndexAny(file, `:\/`) < i
 90     }
 91     getexts := func (prog string) []string {
 92         if hasExt(prog) {
 93             return []string{""}
 94         }
 95         var exts []string
 96         x := os.Getenv(`PATHEXT`)
 97         if x != "" {
 98             for _, e := range strings.Split(strings.ToLower(x), `;`) {
 99                 if e == "" {
100                     continue
101                 }
102                 if e[0] != '.' {
103                     e = "." + e
104                 }
105                 exts = append(exts, e)
106             }
107         } else {
108             exts = []string{".com", ".exe", ".bat", ".cmd"}
109         }
110         return exts
111     }
112     findExecutable := func (prog string) string {
113         if filepath.IsAbs(prog) {
114             return prog
115         }
116         for _, dir := range filepath.SplitList(os.Getenv("path")) {
117             dir, _ = filepath.Abs(dir)
118             dir = filepath.Clean(dir)
119             for _, e := range getexts(prog) {
120                 if e != "" && e[0] != '.' {
121                     e = "." + e
122                 }
123                 fwh := filepath.Join(dir, prog + e)
124                 if fwh == mypath {
125                     continue
126                 }
127                 _, sterr := os.Stat(fwh)
128                 if sterr == nil {
129                     return fwh
130                 }
131             }
132         }
133         panic(fmt.Sprintf("findExecutable: unknown command (%s)", prog))
134     }
135     if conf[basename] != nil {
136         m := conf[basename].(map[string]interface{})
137         prog := m["prog"].(string)
138         argsi := m["args"].([]interface{})
139         pre := tosarr(argsi[0])
140         post := tosarr(argsi[1])
141         targetexe := findExecutable(prog)
142         var args []string
143         args = append(args, pre...)
144         args = append(args, os.Args[1:]...)
145         args = append(args, post...)
146         return targetexe, args
147     } else {
148         targetexe := findExecutable(basename)
149         return targetexe, os.Args[1:]
150     }
151 }
152 
153 func main() {
154     shell, args := getcmdl()
155     cmd := exec.Command(shell, args...)
156     cmd.Stdout = os.Stdout
157     cmd.Stderr = os.Stderr
158     cmd.Stdin = os.Stdin
159     cmd.Run()
160     os.Exit(cmd.ProcessState.ExitCode())
161 }

ビルドしたら包みたい exe の名前にハードリンク(わからないならコピーでもおk)して使う。設定ファイル「.exemimicry.go.conf.json」に従う:

.exemimicry.go.conf.json
 1 {{/*
 2    configuration of exemimicry.go.
 3    basically this file is a json-like format.
 4 */}}
 5 {
 6     "ll": {
 7         "prog": "ls",
 8         "args": [["-l", "--color=always"], []]
 9     },
10     "ffmpeg": {
11         "prog": "ffmpeg",
12         "args": [["-hide_banner"], ["-c:v", "libx265"]]
13     },
14     "ffplay": {
15         "prog": "ffplay",
16         "args": [["-hide_banner"], ["-autoexit"]]
17     }
18 }

「prog」にフルパスを与えればそれをそのまま使い、名前だけ与えた場合は「環境変数PATHから探す」のだが、無論「実体が exemimicry.exe であるもの」を拾わないようにしてる(exec.LookPath は使えないのよ、だからちょっとコードが長いの)。

ほとんど「alias で出来ること」しかやってない、と考えるとかなり涙がちょちょ切れそうになるが、まぁ Windows 生活なんてそんなもんだ。

ちなみに「.cmd」「.bat」が実在していた場合はこれはたぶん意図した動きはしない。これらだったら cmd.exe に渡す、みたいなスペシャルな扱いをしないといけないんだけど、ワタシ自身そういうケースを exemimicry.exe で措置しようとは思わないので。それと、コマンドライン引数は「頭とお尻」への追加しか出来ない、つまり:

1 ffmpeg -hide_banner -y -i hoge.mkv -vf scale=1920:-1 out.mkv -c:v libx265
2 ^^^^^^^^^^^^^^^^^^^                                          ^^^^^^^^^^^^

ということは出来るけれど、任意な位置に、てのはやってないしやるつもりもない。つまり、引数の指定順が問題となるようなコマンド相手にはこの exemimicry は使えない。なんというかそういうおバカなコマンド相手にこそこういうラッパーを使いたくなりそうな気はするけれど、出来ないものは仕方ないよ、諦めような。(無理すれば出来る、としてもだ、それ、config での指示が非常に鬱陶しいものになるはずよ。)


2022-02-26追記:
昨日のやつ、「「.cmd」「.bat」が実在していた場合」の件。やっぱちょっと放置は気持ち悪かったんで Gist にあげがてら:

cscript も、てことなんだけどさ、だったら .ps1 だの .py だの、それこそ .sh だの…、となりがちなのだけれどもね、けどそういう「汎用」ではなく個々にということであれば、.exemimicry.go.conf.json にそう書けばいいと思うわけよねワタシは。たとえば:

.exemimicry.go.conf.json
 1 {{/*
 2    configuration of exemimicry.go.
 3    basically this file is a json-like format.
 4 */}}
 5 {
 6     "ll": {
 7         "prog": "ls",
 8         "args": [["-l", "--color=always"], []]
 9     },
10     "ffmpeg": {
11         "prog": "ffmpeg",
12         "args": [["-hide_banner"], ["-c:v", "libx265"]]
13     },
14     "ffplay": {
15         "prog": "ffplay",
16         "args": [["-hide_banner"], ["-autoexit"]]
17     },
18     "mufufu": {
19         "prog": "py",
20         "args": [["-3", "c:/Users/hhsprings/eroscripts/mufufu.py"], []]
21     }
22 }

みたいに。
cscript/wscript 問題や powershell のも、同じ要領で個々には出来る、のはわかるよね?


2022-03-12追記:
exemimicry.go を使って嬉しい例がちょっと面白かったんで追記。

多くの OSS は:

  1. Unix 向けに最初に作られ
  2. Windows 向けには「DOSねば」とするかもしくは「powershellっしょ」でなくば「cygwinとけばいいっしょ」

と考えてしまっているのね、そこに「MSYS」はまず十中八九考慮されない。これが問題になることはそんなには多くはないのよ、大抵はただの小さなランチャに過ぎないんで、「cygwin にしかないもの」をバリバリ使ってるスクリプトが OSS の配布物中に入ってることは、「そんなにはない」。

久しぶりに喰らったよ、「cygpath なんかないぞ」問題。うげぇ。

これ、もとは「cygwin」の持ち物だけれど、msys2 もこれを持ってるんで…。というかワタシは msys2 を検証用には「インストールして、だけれどもパスを通してない」ので、MSYS と msys2 を共存インストールしてるので、てことだけど、こうね:

.exemimicry.go.conf.json
 1 {{/*
 2    configuration of exemimicry.go.
 3    basically this file is a json-like format.
 4 */}}
 5 {
 6     "cygpath": {
 7         "prog": "C:/msys64/usr/bin/cygpath.exe",
 8         "args": [[], []]
 9     },
10     "ll": {
11         "prog": "ls",
12         "args": [["-l", "--color=always"], []]
13     },
14     "ffmpeg": {
15         "prog": "ffmpeg",
16         "args": [["-hide_banner"], ["-c:v", "libx265"]]
17     },
18     "ffplay": {
19         "prog": "ffplay",
20         "args": [["-hide_banner"], ["-autoexit"]]
21     }
22 }

「MSYS はパスを通してるが MSYS2 にはパスを通してない」というのが今の場合のポイントね。んで exemimicry.exe を /usr/local/bin/cygpath.exe とかにハードリンク、な。

具体的に何が cygpath を要求したか、は、これは textlint。ランチャスクリプトとして、Windows 用には cygwin/msys2 用と powershell 用が同梱されてた。

textlint の話はそのうち気が向いたらするかも。ここではないけど。


2022-03-13追記:
MSYS の最も乱暴で制御不可能な「MSYS でないヤツなんか死んじまえ」、すなわち「非 MSYS アプリケーションに渡すコマンドライン引数の先頭が文字が / ならば問答無用で c:/path/to/msys/1.0 にお置き換えるオレ、えらいだろ」、これへの措置をするためにこの exemimicry.exe を使いたいよな、と思って、その拡張をしといた。これを使うには:

.exemimicry.go.conf.json
 1 {{/*
 2    configuration of exemimicry.go.
 3    basically this file is a json-like format.
 4 */}}
 5 {
 6     "wsl": {
 7         "prog": "C:/Windows/System32/wsl.exe",
 8         "args": [[], []],
 9         "revert_msysroot": true
10     },
11     "cygpath": {
12         "prog": "C:/msys64/usr/bin/cygpath.exe",
13         "args": [[], []]
14     },
15     "ll": {
16         "prog": "ls",
17         "args": [["-l", "--color=always"], []]
18     },
19     "ffmpeg": {
20         "prog": "ffmpeg",
21         "args": [["-hide_banner"], ["-c:v", "libx265"]]
22     },
23     "ffplay": {
24         "prog": "ffplay",
25         "args": [["-hide_banner"], ["-autoexit"]]
26     }
27 }

問答無用で置き換えられたものを、仕方なく問答無用で戻す、としてるってことね。