gojqのパーサーを書き直しました

jqJSONを絞り込むツールですが、実はれっきとしたプログラミング言語です。 算術演算子、論理演算子、分岐構文、trycatch、そして関数定義があり、ループは再帰関数で実装します。 単に .foo とか .[0] とかでJSONを辿るだけのツールだと思われている方は、builtin関数の定義を見ていただくと良いかと思います。 selectmapのように、よく使われる関数でさえ内部実装になっていない (Cで書かれていない) のは面白いですね。

jqのクエリを思ったように書けないという経験から、jqをより深く知るためにGo言語で再実装したのがgojqです。 去年の4月から開発を始め、8月にブログ記事を書きました。 jqのほぼすべての機能を実装しており、pure Goで書かれているのでGo言語のツールに簡単に組み込むことができます。

この記事公開以降も開発を続けています。

  • --arg, --argjsonの実装
  • YAML入出力オプションの実装
  • モジュールファイルの読み込みの実装
  • input, inputs関数の実装
  • try, catch|= 演算子を組み合わせたときのバグ修正
    • これはjq 1.6でもバグっている問題で、jq -nc '{"x": "10"} | map_values(tonumber? // .)'{"x":10} にならないといけない
  • ライブラリー用途の改善 (context対応・ドキュメントの充実)
  • 実行ファイルのサイズ改善 (v0.9.0: 7.86MiB, v0.10.4: 6.45MiB)

順調に機能開発が進んでいるように見えます。 しかしjqとの差異が小さくなるにつれて、徐々にクエリのパーサーに不満が出てきました。 パーサーは機能とは違ってユーザーからは直接は見えない場所ですから、どう実装しても自由なはずです。 逆に言うと、ユーザーへの大きなメリットでもなければ書き直そうとはなりません。 それでも書き直さなければならないほどの理由がありました。


gojqの開発当初より使っていたparticipleというライブラリーの紹介をします。 participleというのはGo言語でパーサーを作るためのライブラリーです。 LL(k)の文法をパースできます。

例えば、次のように構造体のタグに構文を書いてみます。

type Value struct {
    Object *Object `  @@`
    Array  *Array  `| @@`
    Number string  `| @Number`
    Str    string  `| @String`
    Null   bool    `| @"null"`
    True   bool    `| @"true"`
    False  bool    `| @"false"`
}

type Object struct {
    KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? "}"`
}

type ObjectKeyVal struct {
    Key string `@String ":"`
    Val *Value `@@`
}

type Array struct {
    Vals []*Value `"[" (@@ ("," @@)*)? "]"`
}

あとはレキサー (lexer) を実装しておけば、JSONをパースすることができます。 trailing commaを許したいな (それはもうJSONではないけど) と思ったら以下の修正をするだけです。

type Object struct {
-  KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? "}"`
+  KeyVals []*ObjectKeyVal `"{" (@@ ("," @@)*)? ","? "}"`
}

type Array struct {
-  Vals []*Value `"[" (@@ ("," @@)*)? "]"`
+  Vals []*Value `"[" (@@ ("," @@)*)? ","? "]"`
}

このライブラリーはとても便利で、素早くパーサーを作り上げることができますし、文法の変更も簡単です。 暗黙的なルールは少なく、structの定義から目線を少し右に動かすだけで文法を理解できます。

gojqではjqクエリのパースにparticipleをずっと使ってきました (JSONのパースはencoding/jsonを使っています) が、つらいことがいくつかありました。

  • 演算子の結合の強さを構造体によって解決する必要があるため、結合の強さの数だけ、構造体が深くなる
    • これによってparse時の構造体初期化やコード生成時に構文木を辿るときにオーバーヘッドがある
    • gojqはbuiltin関数を事前にパースしてコード生成してバイナリに含めているため、構造体が深いほど実行ファイルのサイズが大きくなる
      • コマンド実行するたびにbuiltin関数をパースするのは避けたい (jqはそうなっている)
  • パース時のパフォーマンスがyacc的なパーサーと比較すると遅い可能性がある
    • リフレクションを使っている以上はそのオーバーヘッドは避けようがないし、このタイプのパーサージェネレータの中ではかなり頑張っている方であることは強調しておきます
    • goyacc版を実際に作るまでは推測でしかなかったし、どれくらい遅いのかは検討もつかなかった
  • 文字列補間 (string interpolation) のパースができない (参考)
    • participleを使いつつ無理やり実装しようとして、内部の式も含めて文字列にマッチする正規表現生成していたが、文字列補間のネスト数に制限があったし ("\("\("\("\(1+2)")")")"のようなもの) 、実装も明らかにおかしかった。 participleを使ってパーサーを書き始めた頃はこのライブラリーだと文字列補間の実装ができないのに気がついていなかったし、そもそもjq言語に文字列補間があるのを知らなかった (気がする)。
  • jqがyaccを使っているので、その文法定義をLL(k)にわざわざ定義し直すのが面倒
    • どうしてもコーナーケースで差異がでてしまう。jqでエラーになるものがgojqでエラーにならなかったりする。
    • 演算子の強さのみならず結合の向き (や結合がないこと) なども構造体の定義を左右するのが面倒
    • goyaccならシンタックスと独立して構造体の形を定義できる

実装当初から積み上げてきたパーサーを捨てる決断をするのにかなり時間がかかりました。 そもそも最初にparticipleを選んだもの、yaccのような枯れた手法ではなく、モダンなパーサーライブラリーを使いたいという気持ちがあったからです。 長大な生成ファイルを (go getのために) リポジトリに含めなくて良いのも好きな側面です。 しかし、文字列補間をはじめ、participleを使っているがゆえに実装が歪んでしまっている場所が増えてきて、耐えられなくなってしまいました。 そして先日、ようやく決心してgoyaccでのパーサー再実装を行いました。

すべてのテストケースが新しいパーサーで通るまで丸2日、文字列補間を正しく実装するのに1日、そしてconflictの修正やパフォーマンス改善などを行っていたので4, 5日くらいかかりました。 participleでパーサーを作るときの体験があまりに良かったのでyaccスタイルのパーサーは避けていたのですが、今回改めてgoyaccを使ってみたらこれも悪くはないなという感想になりました。

  • participleを使っていたときは、ルールを手で書くなんて大変、participleはcoolと思っていたが、やってみたらそこまで大変ではなかった
    • Goにはunionがないのでtype assertionする必要があって少し面倒なのは仕方ない
      • interface{}を使わない手もあるが、メモリーを多く使うのでおすすめはしない
  • 演算子の結合はgoyaccに任せられるので、構造体を浅くできて最高
    • participle版ではExprの中にLogicの中にAndExprの中にCompareの中にArithの中にFactorの中にTermがありました。goyaccに切り替えたことで、これらの中間の構造体を一掃し、Termまたは二項演算子という形の構造体に統一できました。
    • 構造体の定義は、表現したいものの構造を表すベストな形を目指すべきで、シンタックスに左右されるべきではない
  • レキサーは手で書いたほうが良い
    • 文字列補間があるならなおさらそう
  • conflictを解決していたら大学時代を思い出した

文字列補間 (string interpolation) というのはパーサーを作る上でとてもおもしろいトピックです。 この機能があるかないかで、文字列をパースする難易度が大きく変わってきます。 JavaScript, Ruby, Python, Scala, C#など様々な言語がこの機能を備えています。

 % node -e 'console.log(`${1 + 2}`)'
3
 % ruby -e 'print "#{1 + 2}"'
3
 % python -c 'print(f"{1 + 2}")'
3
 % jq -nr '"\(1 + 2)"'
3
 % node -e 'console.log(`${`${`${`${`${1 + 2}`}`}`}`}`)'
3
 % jq -nr '"\("\("\("\("\(1 + 2)")")")")"'
3
 % jq -nr '"foo \("bar" + "bar") \("\("baz" * 3)")"'
foo barbar bazbazbaz

なぜこの文法のパースは難しいのでしょうか。 文字列補間のない言語の場合、レキサーは文字列を一つのトークンとして返すことができます。 おそらく、文字列トークンを表す正規表現を書くことができるでしょう。 しかし、文字列補間があると文字列自体が木になります。 文字列の中身をパースしているときは、空白文字をスキップしてはいけません。 文字列補間の中身に改行やコメントが入ることもあるでしょうし、ネストすることもできます。 そしてパースエラーが発生したときは、元のプログラムのコードでのオフセット位置を報告すべきです。 これらをきちんと実装するためには、やはりレキサーを手で書き、文字列の中身を丁寧に辿るのがよいと思います。

participleを使っていたときは正規表現で無理やり文字列のトークンを抜き出し、コード生成時に文字列補間を解釈していました。文字列補間がある場合は任意の文字列を表す正規表現はそもそも作ることができませんし (ネスト数を制限すればできるが奇妙な制約になる)、文字列補間内のクエリにシンタックスエラーがある場合に、その位置をユーザーに伝えるのが難しくなります。goyaccに乗り換えてようやく文字列補間をきれいに実装できたとき、このシンタックスをパースするときの「正しいやり方」を理解できました。

文字列補間の閉じトークンをどのように判別するかというのはおもしろい問題です。 私は当初、括弧の深さを計算し、文字列補完の中をたどっているときに、対応しない閉じ括弧が来たら文字列補間の閉じトークンを返すような実装をしていました。 しかしこれは全く不要でした。 他のシンタックスの綴じ括弧と文字列補間の閉じトークンは区別する必要はなく、閉じ括弧はそのまま返してしまえばよいという気付きがありました。 文字列補間の開きと閉じにそれぞれトークンを割り当てないといけないと考えてしまうと、レキサーが無駄に複雑になってしまいます。


パーサーを書き直したことで、速度も大幅に改善しました。 jq言語で書かれたソースコードのパースにかかる時間の比較です (participle版は最初計測ミスかと思いましたが合っていました)。 およそ30倍の速度改善を達成できました。 reflectionベースの実装なので多少はオーバーヘッドがあることは分かっていましたが、ここまで差がつくとは思っていませんでした。 jq, gojq (participle), gojq (goyacc) のパース時間比較

participle版のgojqを除いたグラフは次のようになります。 goyacc版のgojqは、jqと比較しても少し速くなっていることがわかります。

jq, gojq (goyacc) のパース時間比較

ソースコードをパースして終わりではありません。 jq言語には include 文がありますが、これはコード生成を行う必要があります。 以下のグラフは、パースとコード生成の両方を含む速度比較です。

jq, gojq (participle), gojq (goyacc) のinclude時間比較

jqはソースコードのサイズが増えると急に遅くなっていきます。 試しにフィッティングしてみると、コードサイズに対して三乗で時間がかかっているようです。 まだ正確な理由は理解できていないのですが、jqの関数名解決のパフォーマンスは良くないようです (参考: block_bind_self)。 jqのパフォーマンス改善の余地が見つかったのでよかったです。 修正方法が分かりしだいパッチを投げようと思います。 別実装の存在によって本家の改善が進むというのはよくある話ですね。


実は、gojqでの「作り直し」は二回目になります。 初期の実装では再帰下降型の処理系で、これをコード生成して最適化を行うインタープリターに書き直しています。 そして今回パーサーも書き直したので、一番最初の設計は大外れということになるかもしれません。 ベストな設計というのは最初からはなかなかできませんね。 しかし、これまでの積み上げがあったからこそ (カバレッジの高いテストケース)、わずか数日でパーサーを入れ替えることができたのだと思います。 処理系の再実装と比べるとかなり簡単でした。

このような書き換えを行うときのコツは、テストを落とさないようにコミットしていきつつ、切り替えたときに通るテストの数を数えていくことだと思います。 例えばパーサーを入れ替えるときは、新パーサーで試してダメだったら旧パーサーにフォールバックするという処理にしておきます。 テストが落ちないように新パーサーを実装していくのです。 ただし、このやり方だとどれくらいの割合のテストが新パーサーで通るようになったかがわからないので、新パーサーに完全に切り替えたブランチを作っておいて、そちらにmergeしていきつつテストを回していくのです。 通るテストは徐々に増えていき、全てが通ったら旧パーサーを捨てることができます。


participleは、今までgojqの開発を大いに支えてくれました。 シンタックスの全容を把握できていない段階では、素早くパーサーを書き換えていける利点があるでしょう。 しかし、パーサーの扱える構文は限られており、無理にパースしようとすると大変なことになります。 また構文木シンタックスが密結合だと様々な不具合が生じます。

yaccは古典的なツールですが、現代でも大いに活躍できるツールだと思います。 演算子の結合を宣言的に定義できるのはとても楽です。 生成されたパーサーの速度も魅力的です。 ちなみに今回goyaccを触って気になったところを修正するパッチを投げたら、すぐにレビューを頂いて次の日にはmergeしてくれました (cmd/goyacc: print newlines more consistently)。

gojqはv0.11.0で新しいフェーズに突入しました。 v0.10.4では6.45MiBあった実行ファイルのサイズを、4.91MiBまで削減することができました。 今回のリリースよりDocker Hubにもイメージを上げることにしました。 やるべきことは、まだまだあります。 パーサーはjqより少し速くなりましたが、処理系はjqよりも大幅に遅くなるクエリが存在することが分かっています。 gojqとの旅はもう少し続きそうです。

HomebrewのインストーラーをRubyからBashに書き直しました!

みなさんはHomebrewをお使いでしょうか。macOSをお使いの多くの開発者が使っていると思います。

HomebrewのインストーラーはRubyで書かれており、次のコマンドでインストールするようになっていました。

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

HomebrewがRubyに依存していることは良いのですが (formulaの書きやすさはRubyならでは)、インストーラーの話になると事情が変わってきます。HomebrewのインストールコマンドはmacOSの工場出荷状態でも動く必要があります。こういうものにRubyを使っているのはリスクがあります。

Homebrewの開発メンバーもインストーラーのRuby脱出問題は議論していて、Port installer to Bashというissueも立っていたのですが、数か月経っても誰も動き出す様子がありませんでした。 一月のある日、誰もBash化をやらないなら自分がやってしまおうという気持ちになったので書き直しました。

Rubyで書かれた小さなスクリプト (400行弱) とは言え、処理が1つでも抜けては困ります。 最初に git mv でファイルを移動し、 exit を少しずつずらしながら、順番にBashに書き換えていきました。 CIがすべての分岐を網羅しているわけではないので、細かいところは手でコマンドを打ったり変数の値を指定し実行してみて分岐に入るかどうかを確認したりしながら移行しました。

レビューの打ち返しをやってからしばらく反応がなかったのですが、先日ようやくマージされて、ホームページのインストールコマンドもBashに変わりました。 新しいコマンドは以下のようになります (引き続き以前のRubyのコマンドも使うことができますがWarningが表示されます)。

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

個人的な開発環境構築スクリプトBashスクリプトで書いており、RubyリスクはHomebrewをインストールするところだけだったので、これでようやくRuby依存をなくすことができました。 新しいmacOSRubyPythonが入っていなくても環境構築できそうです。 よかったですね。

Bashスクリプトを書く能力は現代となってはさほど重要ではありませんが、いざという時に役に立つこともあるので、数百行くらいなら書いちゃうぜという気持ちを持っているとお得です。 仮にBashが滅んだとしても、ログインシェル (zsh?) のスクリプトに書き直すのはRubyからBashに直すよりも簡単なはずなのですぐにできそうです。 Linuxbrewの取り込みも他の方の作業によって完了しています。 BashにしたことでLinuxインストーラーが統一されたのはめでたいですね。

おしまい!

ファイルをエディターで一括リネームするツールをGo言語で作った! ― 機能を増やさない信念と、OSSとの付き合い方

ファイルを一括でリネームしたいことはありませんか。私はあります。ということで作りました。

f:id:itchyny:20200109200059g:plain

インストールはHomebrew

brew install itchyny/tap/mmv

または以下のコマンドでできます。

go get github.com/itchyny/mmv/cmd/mmv

スクリーンショットではvimが起動していますが、 $EDITOR が設定されていればそれを使って編集することができます。

エディターでファイル名を編集して一括でリネームするというのは、新しい発想ではありません。 実際、多くのソフトウェア (特にファイラー) がこの機能を実装しています。

私はvimfilerの一括リネーム機能をよく使っていました。 特に不満はないのですが、ファイラーの付属機能である必要はないと考えて、またVimユーザーに限らず広く使ってもらえるように、独立したコマンドとして実装し直すことにしました。 一括リネームを実装するにはどういう技術的挑戦があるのか調べたかったという気持ちもありました。

実装

一括リネームと言ってもそんなシステムコールは当然ありませんから、順番にリネームしていくことになります。 簡単な一括リネームを実装すると次のようになります。

func Rename(files map[string]string) error {
    for src, dst := range files {
        if err := os.Rename(src, dst); err != nil {
            return err
        }
    }
    return nil
}

以上。終わりです。

巡回・スライシング

これで終わったらなんの挑戦もありませんが、厄介なケースがいくつかあります。 まずは巡回しているケースです。

a => b
b => c
c => a

そして、ずれていくケースです。

a => b
b => c
c => d

実際のユースケースとしてはあまりなさそうではあるものの、これらを全く考慮せずに実装してしまうと、ファイルを失う事故が起きてしまいます。 事故が起きないようにするには

  • 検知してエラーを吐く
  • リネームできるようにする

という2つの戦略が考えられます。

頭の中でツールの設計をしているときにこれらのケースをどう扱うかしばらく考えていたのですが、検知できるならリネームする順番もわかるだろうと考えて、最初からリネームできるように実装しました。

それではどのようにリネームすればいいのでしょうか。 まず巡回するケースは、一時的なファイル名を用意すれば、次の順にリネームすることができます。

a => tmp
c => a
b => c
tmp => b

ずれていくケースは、ずれの最後から逆順にリネームすればokです。

c => d
b => c
a => b

このようにすれば、リストにあるファイルを失うことなく、リネームを行うことができます (前提として、同じファイル名へのリネームと、同じファイル名からのリネームは排除しているとします)。

これは明らかにグラフの問題になっています。 巡回を判定するには辿ったノードをマークしていけばいいわけですし、ずれの最後から辿るというのはグラフの葉ノード (leaf node) から矢印を逆順に辿ることを意味しています。 グラフの分岐、つまり同じファイル名(への|からの)リネームを排除しているので、そんなに難しい処理ではありません。

いずれにしても、実際のユースケースではあまり起きないケースだろうなということは分かっています。 しかしファイル名を編集してリネームするUIでユーザーが行を入れ替えたときは、おそらくファイルの入れ替えを期待するだろうと考えて実装してみました。 一括リネームツールを作ろうとしていたらいつの間にかグラフの問題を解いていたというのは面白いですね。

機能を増やさない信念

このツールは、一括リネームを行うただそれだけのツールです。 エディターを立ち上げて、ファイル名の変更を読み取って、リネームしていくだけです。 ただリネーム先のディレクトリがなければ作るという挙動があり、これは以下のように画像を整理するときに便利だと思います。 f:id:itchyny:20200109222031g:plain

やりたかったことは既に実装できています。 このツールに関しては、他の複雑なことは実装しないと思います。 例えばファイルを削除したり、リネームの記録をどこかに保存してundoしたりといったことです。 これらはあまりに複雑で、あまりメンテナンスしたくありません *1。 そしてこれらの機能はmassrenが既に実装してますので、そういうものが欲しい人はmassrenに誘導すればいいかなと思っています。

機能を実装しないというのは、重要な設計指針の一つです。

ソフトウェアは機能が多くなるほど、ユーザーが学ばなければいけないことは増えていきます。 一括リネームなんてそもそもめったにやらないオペレーションなのに、そのツールにいろいろな機能があったとして使いこなせるでしょうか?

何かを作りはじめるときに大事なことは、全部入りを目指すのか、シンプルな独立ツールを目指すのかを決めるということです。 全部入りが悪いと言いたいわけではありません。 一貫したインターフェイスで様々なものを操作できる基盤ツールは素敵です。 素敵ですが、そういうソフトウェアを作るときはより慎重に設計しましょう。 達成したい目標が高いほど、設計の失敗が命取りとなることがあります。

シンプルな独立ツールを目指すのであれば、まずはできる限り機能を削ってみてください。 設定もユーザーとのインタラクションもできるだけ排除しましょう *2。 本当にやりたいことしかできない状態になっても、90%、いえ95%くらいの人はそれで十分でしょう。 ほとんど使われないような機能を足すことは、多くの人にとって難しい印象をもたせてしまいます。 機能が多く難しいという印象をもったツールは使わなくなってしまいます *3

UNIXという考え方―その設計思想と哲学

UNIXという考え方―その設計思想と哲学

ソフトウェアをメンテナンスする上で、複雑にならないように保つというのはとてもむずかしいことです。 著名なエンジニアから機能要望やpull requestが来て悩むこともあれば、取り込まないと書いたコメントがdown voteされることもあります。 しかし、機能を取り込んだ後にそれをメンテナンスするのは (多くの場合) あなた自身です。 安易に取り入れた機能に対して芋づる式に要望が増えたり、他の機能と干渉してバグを生んだりして、手に負えなくなることもあるでしょう *4。 理にかなっている機能要望かどうかを設計指針と照らし合わせて判断する力、そして時には機能を引く判断、これがOSSを長くメンテナンスしていくための生存戦略につながるのだと考えています。

まとめ

ファイルを一括でリネームするツールを作りました。

  • ファイル名の一覧をエディターで編集し、その結果をもとにファイルを移動する
  • 移動先のディレクトリが存在しなければ作成してからファイルを移動する

私にとっては既に十分であり、常用するツールになると思います。 そして今のこの気持ちを保ち続けることができたら、一年経ってもこのツールの機能をすべて思い出し、使いこなすことができるでしょう *5

最後になりましたが、様々なフィードバックをしていただいたvim-jpのみなさま、ありがとうございました。

*1:ファイルを削除するだけなのにそんなに複雑なのかと思われるかもしれません。ファイルの削除という危険な操作は、ユーザーによって様々な設定がされている場合があります。例えば単なる削除ではなくて~/.Trash/への移動にするとか、確認が欲しいとか、そういう話です。こういうものを実装し始めると、それは一括リネームツールの範疇を超えてしまいます。挙動をオンオフできるようにしたり、移動先を指定できるようにしたりといった要望が容易に想像つきます。

*2:逆に、すべてを設定できるようにするという発想もあります。茨の道ですが、そちらのほうが適切なケースもあるでしょう。しかし、設定同士が干渉したときの処理が複雑になり、メンテできなくなることが多い気がします。プログラミング言語ソースコードのフォーマッターは両極端で面白いです。

*3:標準のコマンドに関しては、機能が多くて難しくても使わざるを得ないのが現実でしょう。lsやpsのオプションを使いこなしている人はどれくらいいるのでしょうか。使いこなせなくても使うのは、どんな環境でも入っていて換えが効かないからでしょう。こういう分野で代替ツールがユーザー数を伸ばすのは至難の業です (batexaはすごい)。

*4:それはあなたの能力が低いからだと言われるかもしれませんが、その通りなのです。会社に勤めていると趣味OSSに割ける時間は限られていますし、睡眠は重要です (このブログを書くために多少睡眠を削っているわけですが…)。バグ報告や機能追加要望が多いと徐々にさばけなくなり、メンテ疲れしてしまいます。うまくいけば報酬を得てより多くの時間を割くとか、他の人にメンテナンスを委譲するとかできるかもしれませんが、多くの場合はうまく行かないので、自分が使える時間と実装能力、機能の需要やソフトウェアの品質などのバランスを取りながら、メンテナンスしていくことになります。自分に手に負えなくなりそうな機能であれば、要望を弾くのも大事です。時には他の代替ツールに引導を渡す判断が重要になる場面もあるかもしれません。

*5:3日前に誕生したばかりのツールです。ユーザーが増えて色々と言われるうちに気が変わって機能追加を始めるかもしれません。

2019年を振り返って

今年は仕事の部署異動があり気分一新したわけですが、思うようにパフォーマンスを出せず悩んでいたような気がします。前半もチームのために頑張っていた気がするんですがすべて忘れました。

今日は実家でgoreのGo modules対応をやってました。いい加減modules対応していないのやばいよなと思って一所懸命packagesパッケージのコードを読んでいます。まだ確認しないといけないパターンは沢山ありそうですが、年始にはマージする予定です (と書いて追い込んでおく)。goreは本当に便利なのでmodulesごときで死なせたくないですね…

今年はgojqを作れたのは大きいですね。これは本当にいいプロダクトなはずなんですが、宣伝がうまく行ってなくてイマイチですね。docker関連ツールの組み込みあたりを狙えたら本望なんですが、その前に英語で紹介記事を書かないとだめですね。しかしこれを作ったことで一年前よりも言語処理系に対するイメージがはっきりしてきたような気がします。jqのセマンティクスは結構特殊なんですけどね。

社内の諸事情から作ったgithub-migratorも便利なツールなのですが、ユースケースがニッチ過ぎてイマイチですね… 社内ドキュメントは書いてあるのでみんな移行してね。

今年は社用パソコンが変わってかなり快適になりました。自作cliツールのHomebrew tapリポジトリsetupリポジトリを整備できたのは大きいですね。環境セットアップが (理想的には) コマンド一つで立ち上がるようになりました。環境セットアップスクリプトCIを通っている安心感もありますね。

最近社内で圏論勉強会をやっていて、圏論に対する理解がかなり深まったのは大きな収穫です。随伴とか普遍的構成、極限あたりの考え方がだいぶ理解できて自信が付きました。普遍的構成の考え方ってプログラミングと相性がいいんですよね (一意の射が存在することが関数を定義できるのと同じなので)。ちゃんとプログラミングの世界に成果を持ち帰りたいところです。

旅行は夏に長崎に行きました。色づく世界の明日からやsolaの聖地を訪れることは良かったのですが、坂道の多い街なので歩きまわって疲れましたね。色づく世界の明日からのカットを回るにはもうちょっと下調べが必要でしたね。とある公園から山を降りるときにミスって墓地に迷い込んで大変でした。稲佐山からの夜景はめちゃくちゃきれいでした。

今年の良かったアニメは『ベルゼブブ嬢のお気に召すまま。』『やがて君になる』『かぐや様は告らせたい~天才たちの恋愛頭脳戦~』『女子高生の無駄づかい』『まちカドまぞく』『鬼滅の刃』あたりでしょうか。かぐや様の古賀葵さんめっちゃくちゃ良かったですね。ラジオ『令和最初の告RADIO』も良かった。

来年もいい一年になりますように、きっとなりますように。

錆兎「努力は、どれだけしても足りないんだよ。知ってるだろ、それはお前も」

鬼滅の刃 第四話

GitHub Enterprise から GitHub への移行ツールをGoで作りました!

弊社ではGitHub Enterprise (以下GHE) からGitHubへの移行が進んでいます。今年頭のプラン改変GitHub ConnectActionsAppsの充実などGitHubの機能強化が後押しとなりました。GHEのメンテナンスコストも徐々に重荷になってきていました。

リポジトリを移行するにあたって問題となるのが、これまでの歴史をどこまで新リポジトリに移行するかということです。もちろんgitのログはそのまま移行できますが、以下のようなものも移行したいと言われると色々と考えることが出てきます。

  • issueやpull requestのコメントやレビュー、ラベル
    • コードコメントからの参照もあるし、リポジトリ間も相互にリンクしている
      • 番号を維持したい
  • projectやmilestone
    • スプリントのフローが依存している
      • 今のカンバンをそのまま移行したい

これらをすべて移行するツールをGoで作りました。

f:id:itchyny:20191224220759j:plain

  • issueを番号を維持したまま移行します
    • 番号を維持することで、コメントのリンクはリポジトリのURLを変更するだけでOKです
    • ラベルもそのまま移行します
  • pull requestはissueに変換して移行します
    • レビューコメントもissueへのコメントに変換します
    • 番号が維持されるので、merged commitからのリンクも維持されます
  • プロジェクトやマイルストーンも、ほぼそのまま移行します
    • こちらも番号が維持されます
    • プロジェクトのautomationsはAPIで指定できないので移行しない
  • webhookの設定も移行します
    • webhookの設定は、手でやるとイベントを選ぶ必要があり面倒です

類似ツールとの比較

もともとfastlane社のissue移行ツールがありました。 issueのコメントを、それを書いた人が分かる形でリポジトリを移行する素晴らしいツールです。 issueやpull requestの移行にはIssue import APIを使っています。 tableタグでアイコンを出すなど、見た目の上でもかなり参考にしています。

issue番号を維持するというのはaereal/migrate-gh-repoにアイディアをもらいました。 issue間の相互リンク (#128のようなもの) やmerged commitのメッセージなど、issue (pull request) へのリンクが壊れると大変な箇所は意外とたくさんあります。 projectやmilestoneを移行するのにもissue番号が維持されているのは必要なことなのです (このことを気がつかせてくれました)。

issue・pull request番号を維持しつつコメントもレビューコメントもすべて移行する、それがgithub-migratorです。

リポジトリの歴史をどこまで捨ててよいかというのは様々な意見があると思います。 gitのログさえ残っていればよいという人もいれば、すべてのコメントやレビューに価値があり残すべきと考える人もいるでしょう。 GHEは当分運用されると分かっていても、いつか来る撤退の日に向けて、移行時にできるだけ情報を吸っておきたいというのが私の気持ちです。

実装

言語選択は、自分が書けて好きな (書いていて苦にならない) 言語と、社内で書ける人が多く手元で動かせる言語で共通集合を取ってGo言語一択でした。

GitHubAPIを叩く部分は自前でクライアントの実装を行っています。 golang/go-githubはIssue import APIに対応していない (undocumentedなAPIなので仕方ない) のと、構造体のメンバーがポインターだらけで使いにくいです。 APIクライアントは自前で作ったほうがAPIへの理解が深まるし、クライアントのドキュメントとにらめっこしなくてもよいし、リトライとかキャッシュとか変なところではまらなくてよいと思っています。

github-migratorは、issue一覧やコメント一覧、プロジェクトカード一覧など、様々なものの一覧APIを叩いています。 GitHubAPIは基本的にどんなリソースも100件ずつしか取れません。 ページングはレスポンスのLinkヘッダーを見て行います (参考)。

APIクライアントはページングのAPIをどのように扱ったらいいのでしょうか。 ユーザーとしては、ページングの切れ目を意識したくはありません (少なくとも今回のユースケースでは)。 そこで、イテレータを返してページングがユーザーに見えないようにしてみました。

// Issue represents an issue.
type Issue struct {
    ID int `json:"id"`
    // ...
}

// Issues represents a collection of issues.
type Issues <-chan interface{}

// Next emits the next Issue.
func (is Issues) Next() (*Issue, error) {
    for x := range is {
        switch x := x.(type) {
        case error:
            return nil, x
        case *Issue:
            return x, nil
        }
        break
    }
    return nil, io.EOF
}

// ListIssues lists the issues.
func (c *client) ListIssues(repo string, params *ListIssuesParams) Issues {
    // ...
}

使う側は、次のような感じです。

   issues := cli.ListIssues("sample/repo", &ListIssuesParams{})
    for {
        issue, err := issues.Next()
        if err != nil {
            if err == io.EOF {
                return nil
            }
            return err
        }
        fmt.Printf("%#v\n", issue)
    }

次のページとかページあたりの数とかを気にせず、一重のループで全件辿れるのは素敵です。 このやり方は基本的に全件たどりたいという今回のようなパターンには合うと思います (カーソルベースのページングには合わない)。

github-migratorはissueの番号を維持するように実装されています。 実はこれはそんなに簡単ではありません。 まずimportを順番に、エラーを確認しながら行う必要があります。 並列に作成して片方が失敗してしまうと、別のissueがその番号をとってしまいます。 そして番号は飛ぶことがあります。 issueやprojectが削除されたらその番号は欠番になりますし、issueはすでに別のリポジトリに移転されているかもしれません。 欠番は新しいリポジトリでも欠番でなくてはなりません (実はAPIでissueを削除することはできないため、github-migratorでは空のissueを作って削除は諦めています・projectは削除しています)。

リポジトリに何千件とissueがあると、そこそこ時間がかかってしまいます。 github-migratorは、いつ中断されても、同じコマンドで再開できるように設計されています。

GitHub間のリポジトリの移行ならば問題はないのですが、GHEからの移行で一番大きな問題になるのはユーザーの対応です。 IDが異なるユーザーがいるかも知れませんし、GHEに存在するユーザーがGitHubに存在しないかもしれません。 同じIDのユーザーが存在するのだけど実は別人の可能性もあります。 コメントを書いた人が同一IDの別人にリンクされたり、あるいはIDが異なる人のアイコンが出なかったりすると少し不便です。 こういう対応は人間にしかわかりませんから、ユーザーIDの対応を設定してもらうことにしました。 受け取った設定を元に、issueのauthorやメンションなどを置換するようにしています。

テスト

github-migratorは、APIを叩いて情報を収集し、APIを叩いて投稿するツールです。 移行元の情報と投稿する情報の対があれば、それが一つのテストケースになります。

Go言語ではテストケースを一覧で定義してループで回すTable driven testsスタイルが推奨されますが、構造体が複雑になるとこれさえ書くのが億劫になってきます。 そういうときはYAMLファイルにテストケースを書いてしまうのがおすすめです。 複雑な構造体を書く必要はありませんし、JSONとの変換のテストにもなっています (実はgojqでも同じようにテストケースを書いています)。

GitHub APIクライアントのモックはFunctional options patternで行っています。 必要なAPIのみをモックしたり、最初はサーバーが落ちていて2回叩いたらOKを返すなど (これは今回はやってませんが)、APIクライアントのモックには適した方法だと思っています。 APIクライアントを使うツールをテストするときは、頑張ってローカルにサーバーを立ててテストするよりも、モックしてしまうほうがよいでしょう。 テストをたくさん書いて通しても、本番サーバーに向けて落ちるものは落ちます (メソッドが間違ってたりヘッダーが足りなかったり)。 本番サーバー相当のバリデーションをテストに書くのはあまりにもナンセンスです (docker imageが提供されていないか探すほうがよい)。 作っているものがAPIクライアントlibraryそのものであり、その品質を高めたいという場合はサーバーを立ててもいいと思いますが、そうでない場合は頑張りすぎないほうが良いと思います。

まとめ

GitHubリポジトリ移行ツールをGoで作りました。 issueのコメントやレビュー、プロジェクトやマイルストーンなどをほぼすべてそのまま移行するツールです。

GitHub APIクライアントは自前で書く選択を取りました。 おかげでGitHubAPI v3にはそこそこ詳しくなったと思います。 自前で書くのはあまりおすすめできませんが、今回はundocumentedなAPIを叩く必要があり、また二週間ほどで作り上げる必要があったので (問題があったときにissueを立てて待ったりするのに律速されたくなかった)、すべて自分で書いてしまいました。 事前に必要なAPIが分かっていて、そんなに複雑なプロトコルでなければ、自前で書くのもアリだなと思いました。

私の所属しているチームのほぼすべてのリポジトリを今回作ったツールでGitHubに移行しました。 issueの移行もそうですが、projectsの移行をきちんと実装していたおかげで、スプリントの進行を妨げることなく (ポチポチとカンバンを作り直すことなく)、GitHubに移行することができました。 GitHub ActionsDependabotRenovateなどのモダンな開発ツールを勢いよく取り入れて、より効率よく開発を回し、コードの健全性を維持していきたいと思います。