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などのモダンな開発ツールを勢いよく取り入れて、より効率よく開発を回し、コードの健全性を維持していきたいと思います。

開発環境構築スクリプトのCIをGitHub Actionsで回す

小ネタですが、開発環境の構築はスクリプト化して、CIを回そうという話です。

開発環境を構築することは年にそう何回もあるわけではないですが、スクリプトを一発叩いて必要なツールが揃うようにしておくと便利です。私は素朴にシェルスクリプトで書いています。好きな言語で書けばいいと思いますが、macOSは将来的にRubyやPythonといったスクリプト言語を排除しようとしていて、不安ですね。Ansibleみたいなのを使ってもいいと思います。私はちょっと苦手で…

あくまで私用のスクリプトなので使わないでください。

このスクリプトを叩いてしまえば、iTerm2やVim、tmux、自分のdotfilesの配置と言語処理系のインストール、Google ChromeやSlackのインストールを行ってくれます。モダンなプロジェクトならdockerさえあればいいんでしょうが、なかなかそういうわけにはいかないですよね。

この環境構築スクリプトを作り始めてから、普段使っている自作cliツールのHomebrewルールをきちんと書くようになりました。当初は環境セットアップ時にもビルドすればいいのではと思っていたのですが、コンパイルに時間のかかる言語だとセットアップにも時間がかかってしまうので、やはりGitHub Releasesからビルド済みのバイナリを落としてくるだけのほうが構築時は楽ですね。

あと、dotfilesでbootstrap的なことをやるのはあまりおすすめしません。もともとdotfilesを配置してzshを叩けば色々とプラグインをとってくるみたいな処理を.zshrcに書いていたのですが、環境構築のスクリプトを作ったらそちら側にまとめられますし、一度セットアップしたらプラグインディレクトリのチェックなどは不要になるので、rcファイルは大幅に削減できました。

環境構築スクリプトを育てていると、どうしても今のPCの環境には適用できるけれど実は新規PCには適用できなくなっているということは起きてしまいます。まっさらな状態からセットアップすることはめったにありませんからね。具体的にはディレクトリのないところにsymlinkを貼るとか、セットアップの前の方で入れているツールを後の方で使っているのだけどPATHが通っていないとか、そういうケースです。

そこでCIを回しましょうという話です。最近、GitHub Actionsのmacos-latestでCIを回すようになりました。実際にCIを回したら、必要なディレクトリを作るのを忘れていたり、諸々通らないことが発覚しました。

CIを回すと、環境構築にかかるおよその時間がわかります。もちろんスペックの違いや、GitHubとのレイテンシの違い (Actionsはcloneが異常に速い) などもありますが、概ね20分でセットアップが完了するようです。一つコマンド叩けば概ね普段の環境が立ち上がるのはやはり良いですね。

おしまい

jqのGo実装 gojq を作りました! ― スタックマシン型インタープリタによるイテレータセマンティクスの実装

jqはとても便利なコマンドです。 JSONを返すAPIを実装するときや、SaaSAPIから特定の情報を抜き出してシェル変数に代入するときなど、web開発や運用には欠かせないツールとなっています。

しかし、私にとってjqのクエリを一発で書くのは容易ではなく、思い通りの出力が得られないことがよくありました。 難しいエラーメッセージに悩まされて、jqで書くのを諦めて別の言語で書き直すこともありました。 jqの十八番と思える場面で使いこなせないのは、なかなか悔しいものがあります。

ツールを使うのが難しいなら、同じものを作ってしまえばよいのです。

  • jqの全ての機能を実装する
  • jqを言語としてきちんと書けるようになる
  • jqを完全に理解する

jqの全ての機能を自分で実装してしまえば、jqがどういうものか、クエリがどのように処理されるのか、詳しくなれるはずです。 jqを得意な言語と言えるようになって、クエリを自由自在に書けるようになるはず。 そう考えた私は、Go言語でjqを実装することにしました。

すでにjqの大部分の機能は実装し終わり、わりと満足しています。 おそらくjqの機能をここまでカバーした別実装は他にはないと思います。 jqのかなり深いところまで理解できましたし、ビルトイン関数も全て頭に入ったので、リファレンスを見なくても複雑なクエリをすばやく書けるようになりました。

しかし、gojqを実装する過程では、処理系の設計ミスによる書き直しやjqならではのスタックの難しさなど、様々な困難がありました。 jqの言語としての特徴を説明した上で、gojqの実装過程で学んだことについて説明していきたいと思います。

jqの特徴

多くの人は、JSONを絞り込むツールとしてjqを使っていると思います *1

 % echo '{"foo":{"bar":42}}' | jq '.foo.bar'
42
 % echo '[{"id":1,"name":"alice"}, {"id":2,"name":"bob"}, {"id":3,"name":"charlie"}]' \
     | jq -r '.[] | select(.id == 2) | .name'
bob

jqはJSONを絞り込むだけではなく、JSONイテレータを出力することもできます。

 % jq -n '1, 2, [3], {"foo":4}'
1
2
[
  3
]
{
  "foo": 4
}
 % echo '{"foo":[1,2,3]}' | jq '.foo[]'
1
2
3
 % echo '{"foo":[1,2,3]}' | jq -c '..'
{"foo":[1,2,3]}
[1,2,3]
1
2
3

いきなり出力がたくさんのJSONになって手に負えなくなる、そういう経験はないでしょうか。 そうなった場合は、いかなるときでも [ ] で囲うことで配列に戻すことができます。

 % echo '{"foo":[1,2,3]}' | jq -c '[..]'
[{"foo":[1,2,3]},[1,2,3],1,2,3]
 % echo '{"foo":[1,2,3]}' | jq -c '[.foo[]]'
[1,2,3]

実は、jqには配列そのものの構文はありません。 [1,2,3] は配列に見えますが、これは 1,2,3 というイテレータに、[ ] という配列構築子を適用させているに過ぎません *2

 % jq -n '1,2,3'
1
2
3
 % jq -n -c '[1,2,3]'
[1,2,3]
 % echo '{"foo":[1,2,3]}' | jq -c '[.foo[], 4, 5]'
[1,2,3,4,5]

jqは、JSONイテレータを扱う言語です。 jqのクエリのあらゆるもの、nullfalsetrue{}1,2,3(1, 2) * (3, 4) など、全てJSONイテレータとして捉える事ができます。 "hello, world" のように一つの文字列を返すイテレータもあれば、empty のように何も返さないイテレータdef f: 1, f; f のように無限に 1 を返すイテレータなど、様々なものがあります。

 % jq -n '(1, 2) * (3, 4)'
3
6
4
8
 % jq -n -c '[range(1;5) * range(1;5)]'
[1,2,3,4,2,4,6,8,3,6,9,12,4,8,12,16]
 % jq -n -c 'def f: 1, f; [limit(10; f)]'
[1,1,1,1,1,1,1,1,1,1]
 % jq -n -c '[[0,1] | while(.[0]<100; [.[1], .[0]+.[1]]) | .[0]]'
[0,1,1,2,3,5,8,13,21,34,55,89]

(1, 2) * (3, 4)3, 6, 4, 8 になる、こんなに面白い言語は他にあるでしょうか。

jqで最も大事な演算子は何でしょうか。 それは , (コンマ演算子) です。 jqを理解すること、それはすなわちコンマ演算子を理解することであり、それが生み出すイテレータがどのように評価されていくかを理解することです。 JSONイテレータを処理する言語であるという認識がないと、jqのコードを書くことはできません。

イテレータの話は少し置いておいて、関数の話をしておこうと思います。 これまでも例に出していますが、jqでは関数を定義できます。

 % echo '[1,2]' | jq 'def f(a; b): a * 10 + b; f(.[]; .[])'
11
21
12
22
 % jq -n -c 'def f($n): if $n == 0 then empty else $n, f($n - 1) end; [f(10)]'
[10,9,8,7,6,5,4,3,2,1]

クエリには、常に入力があることに気をつけてください。 n倍するという関数は、次のように書けます。

 % echo '1 2 3' | jq 'def ntimes($n): . * $n; ntimes(3)'
3
6
9

平均値を取る関数は、次のように書けます。

 % echo '[1, 3, 6]' | jq 'def mean: add / length; mean'
3.3333333333333335

引数をとらないように見える関数でも、常に入力があることを意識する必要があります *3

たかがJSONを処理するだけのツールなのに関数定義なんて必要なのか、そのように思われるかもしれません。 しかし、mapselect という絞り込みの必需品たちは、jqの関数として実装されています。

def map(f): [.[] | f];

def select(f): if f then . else empty end;

keystostring のようにC言語で実装されている関数もありますが、mapselectfirstallmap_valuessubなど、多くのビルトイン関数がjqで実装されています (参考)。 .. というすべての要素を辿って出力するフィルターさえ、次のように定義されるrecurse関数へのエイリアスとなっています。

def recurse: recurse(.[]?);
def recurse(f): def r: ., (f | r); r;

多くのビルトイン関数がjqで実装されているという事実は大切なことです。 C言語で書く関数や構文をできるだけ限定し、メモリーリークなどのCで書くことに起因するバグを減らすのみならず、ビルトイン関数自体が処理系のテストケースとなっています。 jq .. と書いただけでも、上記のrecurse関数のコンパイル、関数内の関数定義、引数 f の処理、 .[]? の処理など、様々なことが処理系の中で起きているのです。

jqクエリの処理系を作ること、それはjqのビルトイン関数がきちんと動く処理系を実装することでもあるのです。 そして、jqで書かれたビルトイン関数のパフォーマンスを改善することが、すなわち言語処理系としてのパフォーマンスを改善することにつながるのです。

channelによる実装と挫折

3月にjoのGo実装を作ってまもなく、jqをGoに移植したいという気持ちが高まっていました。 網羅的にjqの機能を知りたい、プログラミング言語としてきちんとjqのコードを書けるようになりたい、そのためには自分で処理系を作らずにはいられないと思い至ったのです。

jqがJSONイテレータを扱う言語であるという淡い認識ができた頃、Goのchannelで実装できるのではないかと考えるようになりました。 JSONをデコードしたものは絶対にchannelにならないので、channelを使えばJSONイテレータを区別できます。

言語の処理系を、全く別の言語の機能を用いて作るというのは興味深いことだと思いませんか *4。 Go言語の特徴の一つであるchannelを用いたインタープリタが、jqのテストケースを全てパスしたら面白いに違いない。 どうせ実用上はそこまでパフォーマンスは求められないだろうし、Go言語で書いたらそこまで酷い速度にはならないだろう。 そう考えた私は、構文木を直接評価する形の素朴なインタープリタを実装していきました *5

"hello, world" は文字列を一つ返すイテレータといいましたが、何らかの値をイテレータに変換するには次のような関数を用いて変換できます。

func unitIterator(v interface{}) <-chan interface{} {
    c := make(chan interface{}, 1)
    defer func() {
        defer close(c)
        c <- v
    }()
    return c
}

配列構築子は、中のクエリを全て消費して一つの配列を返します (エラーハンドリングは省略しています)。

func (env *env) applyArray(x *Array, c <-chan interface{}) <-chan interface{} {
    a := []interface{}{}
    for v := range env.applyQuery(x.Query, c) {
        a = append(a, v)
    }
    return unitIterator(a)
}

for文でイテレートできるのは、channelを使ったイテレータの良いところだと思います。 二項演算子の評価は、両辺をイテレートして関数の評価結果をイテレータに流すといった具合です。

func binopIterator(l <-chan interface{}, r <-chan interface{}, fn func(l, r interface{}) interface{}) <-chan interface{} {
    d := make(chan interface{}, 1)
    go func() {
        defer close(d)
        ls := reuseIterator(l)
        for r := range r {
            for l := range ls() {
                d <- fn(l, r)
            }
        }
    }()
    return d
}

左辺をイテレートするときに reuseIterator なるものを使っていることに注意してください。 例えば (1, 2) * (3, 4) では、初回のループで 3, 6、次のループで 4, 8 を出力するので、左辺のイテレータは何度もイテレートしないといけないのです。 このためには、左辺は何度も最初から消費し直せるようにしなければなりません。 reuseIterator を実装するのは楽しいので、読者への課題とします *6

改訂2版 みんなのGo言語

改訂2版 みんなのGo言語

channelベースのイテレータを使った実装は、ある時点まではかなりうまく行っていました。 reduceforeach というjq特有のシンタックスも実装できたときには満足していましたが、徐々にある問題が見えてきました。

パフォーマンスがあまりにもひどいのです。 jq -n 'range(10000)' は0.1秒もかかりませんが、gojqだと4分もかかってしまうのです。 なぜ数字を順番に出力するだけなのにこんなにかかるのかと思われるかもしれませんが、range関数は次のように定義されており、この関数を実行しないといけません。

def range($n): 0 | while(. < $n; . + 1);

def while(cond; update):
  def _while: if cond then ., (update | _while) else empty end;
  _while;

このwhile関数というのは面白い関数です。 jqにはループ構文がありませんが、再帰を使えばループを表現できます *7。 つまりjqが反復処理ができる言語であることの証左となっているわけです *8

こんなにパフォーマンスが悪いのは、channelを使ったのが良くなかったのでしょうか。 イテレータはchannelを使わずとも次の値を返すメソッドを持つ構造体で実装できます。 全てのchannelをやめて構造体に実装を変更してみましたが、それでも40秒ほどまでしか縮まりませんでした *9。 4分と比較すればchannelを使うのをやめた分パフォーマンスは良くなっていますが、それでも0.1秒もかからない本家を思い出したら比べ物になりません。

gojqの実装を始めた当初、jqの全てがJSONイテレータであるというセマンティクスは、Go言語のchannelによって直接表現できるに違いないという思いがありました。 それは概ね当たっており、jqの多くのテストケースをパスする処理系を作ることができました。 しかし、評価時にあらゆるものをchannelで扱うのは相当なオーバーヘッドがあることが明らかになってきました。 channelをやめることで数倍の速度改善が得られましたが、jqと比較したら何千倍も遅い処理系です。 実際のユースケースではこのような複雑なクエリは評価されないとは言え、特定のケースで何千倍も遅い後発のインタープリタはどう頑張っても魅力的には思えません。

構文木を直接たどって実行する評価器は大きなオーバーヘッドを伴います。 変数も実行時に名前で探さなくてはいけませんし、構文をたどるときに多くの分岐処理が必要になります。 構文が複雑になるほど、つまり構文木が深くなればなるほど、実行時のパフォーマンスは落ちていきます。 関数の実行処理も複雑になりますし、構文上のエラーを素早く返すこともできません。

このパフォーマンスの問題には、ずいぶん悩まされました *10。 何週間もかけて作った処理系のパフォーマンスがあまりに酷くて、参ってしまったのです。 また一から処理系部分を書き直すのか、それともgojqプロジェクトをここで諦めるのか。 せっかく積み上げてきたコードを捨てるのも忍びなく、かと言ってここまで作ってきた処理系を見放すのもできず、落ち込んで二週間ほどgojqのコードを触れない時期がありました。

スタックマシンによる実装

実行時に構文木をたどっていては、jqの速さには到底追いつけません。 そもそも追いつけないとかそういうレベルですらなく、range(10000)に何十秒もかかっているようではお話になりません。 やはりインタープリタの処理系は、構文木を実行時にたどっているようでは速度が出ないのです。 私はそれまでに書いてきた評価器を捨てて、スタックマシン方式のインタープリタに書き直す決心をしました。

仮想マシンの簡単な説明をしておきます。 例えば 1 * 2 + 3 * 4 を評価したいとしましょう。 スタックマシン方式のインタープリタでは、この式の構文木を次のような命令列に変換します。

0: push 1
1: push 2
2: call multiply
3: push 3
4: push 4
5: call multiply
6: call add
7: ret

実行時には、スタックを用意して命令を順番に解釈していきます。

[]
[ 1 ]         # 0: push 1
[ 1, 2 ]      # 1: push 2
[ 2 ]         # 2: call multiply
[ 2, 3 ]      # 3: push 3
[ 2, 3, 4 ]   # 4: push 4
[ 2, 12 ]     # 5: call multiply
[ 14 ]        # 6: call add
[]            # 7: ret => output: 14

とてもシンプルな例ですが、全てのスタックマシン方式のインタープリタ言語はこの延長にあるものです。 Ruby (YARV) もPython (CPython) もJVMもWebAssemblyも、みんなスタックマシンを使っています *11仮想マシンの命令セットをどう設計するかが、そのインタープリタ言語を特徴づけています。

jqもスタックマシン方式のインタープリタ言語です。 他の言語と大きく違うのは、スタックの状態を保存して復帰できるということです。

例えば (1, 2) * (3, 4) を評価することを考えましょう。 まずjqが面白いのは、右辺を先に評価するということです *12。 そして、jqではカンマ演算子fork という命令にコンパイルします *13

0: fork 3
1: push 3
2: jump 4
3: push 4
4: fork 7
5: push 1
6: jump 8
7: push 2
8: call multiply
9: ret

この命令列を実行すると、次のようにスタックは変化します。

[]
[]        # 0: fork 3 => forks: [ (3, []) ]
[ 3 ]     # 1: push 3
[ 3 ]     # 2: jump 4
[ 3 ]     # 4: fork 7 => forks: [ (3, []), (7, [3]) ]
[ 3, 1 ]  # 5: push 1
[ 3, 1 ]  # 6: jump 8
[ 3 ]     # 8: call multiply
[]        # 9: ret => output: 3
[ 3 ]     # backtrack => restore stack ([3]) and pc (7), forks: [ (3, []) ]
[ 3, 2 ]  # 7: push 2
[ 6 ]     # 8: call multiply
[]        # 9: ret => output: 6
[]        # backtrack => restore stack ([]) and pc (3), forks: []
[ 4 ]     # 3: push 4
[ 4 ]     # 4: fork 7 => forks: [ (7, [4]) ]
[ 4, 1 ]  # 5: push 1
[ 4, 1 ]  # 6: jump 8
[ 4 ]     # 8: call multiply
[]        # 9: ret => output: 4
[ 4 ]     # backtrack => restore stack ([4]) and pc (7), forks: []
[ 4, 2 ]  # 7: push 2
[ 8 ]     # 8: call multiply
[]        # 9: ret => output: 8
[]        # backtrack => no forks, exit

fork命令はオペランドに復帰時のプログラムカウンタ (pc) の位置を持っており、実行時にはスタックの状態と一緒に保存します。 この状態は値のスタックとは別のスタックにpushされます。 バイトコードのいちばん最後まで来たら、forkされた状態を復帰してpcをセットします。 復帰すべき状態がなくなったらプログラムを終了します。

コンマ演算子fork命令にコンパイルされます。 この命令の実行時には、スタックの保存と復帰、pcの移動が行われます。 これによって、 (1, 2) * (3, 4)3, 6, 4, 8 となるという挙動を実装しているのです。

問題は、スタックの状態をどのように保存するかということです。 プログラムにもよりますが、スタックは長く伸びることがあります。 fork命令のたびにスタックを深くコピーしていては、良いパフォーマンスが出るようには思えません。

保存と復帰できるスタックは、片方向連結リスト (singly-linked list) によって実装できます。 スタック上に連結リストを構築し、先頭の index と、forkによって後で使われるかもしれない最大のindex (ここでは limit と呼ぶことにします) を持っておきます。 値をpushするときは、 indexlimit よりも大きければ index + 1 に、そうでなければ limit + 1 に値を保存します。 fork時は、 limitindex に更新して、 indexlimit の組をforkのスタックに保存します。 indexlimit を戻すだけでスタックの状態は復帰します。 このスタックの実装はjqgojqで少し異なるので、ぜひ見比べてみてください。

jqにおいて最も大事な演算子, (コンマ演算子) であると述べたのを覚えているでしょうか。 この演算子は、そのまま fork命令にコンパイルされます。 この命令こそがjqの処理系の要であり、そしてjqをjqたらしてめているのです。 この演算子の実装を理解してようやくjqを理解したと言えるのだと思います。

gojqをスタックマシン方式のインタープリタに書き換えた結果、range(10000)の実行時間が0.2秒ほどになりました。 40秒から0.2秒ですから、200倍の改善、channelを使った実装と比べると1000倍の改善です。 jqが0.1秒程度ですから悪くありません。 rangeのパフォーマンス改善はすなわちwhile関数のチューニングであり、末尾再帰の最適化も必須でした。 このような最適化を行うのは私も初めてのことであり、とても勉強になりました。

ビルトイン関数の事前パース

jqは多くのビルトイン関数を持っています。 これらの関数は、jqがクエリを実行するときに必ずパースされています。 jq .foo のような簡単なクエリだとしても、必ず全てのビルトイン関数のパースが行われています。

gojqを作る前から、これは無駄ではないかという思いを持っていました。 jqの実際のユースケースを考えると、関数の九割九分は使われていないと思います。 jqのパーサーがいくらパフォーマンスがよくても、実行時に常にパースされるという性質があると、関数は増やさないでおこうという方向に動いてもおかしくありません。 便利な関数はどんどん増えてほしいものです。

gojqでは、コンパイル前にビルトイン関数をパースしてしまい、構文木をバイナリに埋め込むようにしています。 ここで問題になるのが、どのように構文木をバイナリに埋め込むかということです。

当初は構文木を何らかのデータフォーマットに変換して埋め込み、実行時にdecodeするという方式を考えていました。 しかしこれではjqに抱いていた問題をあまり解決できていません。 そしてあまり良さそうなフォーマットが見つかりませんでした。 gobはこういうことをするのには良いフォーマットだと思いますが、0 (数値) や空文字へのポインタをうまく扱えないので諦めました。

結局、ビルトイン関数の構文木をGo言語のソースコードとして出力し、コンパイルすることにしました。 そのためにastgenというライブラリを作って使っています。

package main

import (
    "go/printer"
    "go/token"
    "os"

    "github.com/itchyny/astgen-go"
)

type X struct {
    X int
    Y *Y
    Z Z
}

type Y struct {
    M string
}

type Z struct {
    S string
    I int
    B bool
}

func main() {
    x := &X{
        Y: &Y{"hello"},
        Z: Z{B: true},
    }
    n, err := astgen.Build(x)
    if err != nil {
        panic(err)
    }
    printer.Fprint(os.Stdout, token.NewFileSet(), n)
}
&X{Y: &Y{M: "hello"}, Z: Z{B: true}}

値へのポインタは次のように出力されます。

type X struct {
    S *string
    I *int
    B *bool
}

func main() {
    s := "hello"
    i := 42
    b := true
    x := X{&s, &i, &b}
    n, err := astgen.Build(x)
    if err != nil {
        panic(err)
    }
    printer.Fprint(os.Stdout, token.NewFileSet(), n)
}
(func(h string, x int, t bool) X {
    return X{S: &h, I: &x, B: &t}
})("hello", 42, true)

関数やチャンネルはもちろん扱えませんが、gojqの構文木程度ならばかなりうまく扱えています (生成コードはこちらです)。 gobと異なり、組み込み型へのポインタやポインタへのポインタなどもうまく扱えます *14

func main() {
    s := "hello"
    p := &s
    pp := &p
    for _, x := range []interface{}{s, p, pp, &pp} {
        n, err := astgen.Build(x)
        if err != nil {
            panic(err)
        }
        printer.Fprint(os.Stdout, token.NewFileSet(), n)
        fmt.Println()
    }
}
"hello"
(func(h string) *string {
    return &h
})("hello")
(func(h string) **string {
    h1 := &h
    return &h1
})("hello")
(func(h string) ***string {
    h1 := &h
    h11 := &h1
    return &h11
})("hello")

go generateでコード生成を行うのは、Go言語ならではです。 コード生成はGoの楽しみの一つですね。

処理系を書き換えたことで、ビルトイン関数をコンパイルしたバイトコードをバイナリ埋め込んでしまうという最適化もありえます。 ただ、これは行き過ぎた最適化ではないかと感じており踏み切れていません。 構文木をコードで埋め込むくらいがいいバランスではないかと思っています。

jqを超えて次のステージへ

gojqは、jqの完全なクローンを目指しているわけではありません。 jqの良くない部分は修正し、より良いcliツールを目指していきたいと思っています。

jqの数字の実装は64bit浮動小数点数であり、任意精度整数をサポートしていません。 JSONを絞り込むだけのツールに任意精度の整数なんていらないでしょうと思われるかもしれませんが、17桁を超えるIDの数字や、ナノ秒の情報など、数字の精度が落ちては困る場面はたまによくあります *15

gojqは任意精度の整数をサポートしています。 入力のJSONの中の整数の精度を保持するだけではなく、任意精度整数での演算をサポートしています。

 % echo '{"foo": 4722366482869645213696}' | jq .foo
4722366482869645000000
 % echo '{"foo": 4722366482869645213696}' | gojq .foo
4722366482869645213696
 % jq -n '123456789 * 987654321'
121932631112635260
 % gojq -n '123456789 * 987654321'
121932631112635269
 % gojq -n '123456789123456789 * 987654321987654321'
121932631356500531347203169112635269
 % gojq -n '987654321987654321 % 123456789123456789'
9000000009
 % gojq -n -r 'def fact($n): if $n < 1 then 1 else $n * fact($n - 1) end; range(40;51) | "\(.)! = \(fact(.))"'
40! = 815915283247897734345611269596115894272000000000
41! = 33452526613163807108170062053440751665152000000000
42! = 1405006117752879898543142606244511569936384000000000
43! = 60415263063373835637355132068513997507264512000000000
44! = 2658271574788448768043625811014615890319638528000000000
45! = 119622220865480194561963161495657715064383733760000000000
46! = 5502622159812088949850305428800254892961651752960000000000
47! = 258623241511168180642964355153611979969197632389120000000000
48! = 12413915592536072670862289047373375038521486354677760000000000
49! = 608281864034267560872252163321295376887552831379210240000000000
50! = 30414093201713378043612608166064768844377641568960512000000000000

現在は四則演算と剰余、比較演算子しかサポートしていませんが *16、わりと楽しめるのではないかと思います。 例えば Project EulerのProblem 162^{1000} の各桁の和を求めよという問題ですが、gojqを使うと次のように解くことができます。

 % gojq -n 'def pow2($n): if $n < 1 then 1 else 2 * pow2($n - 1) end;
     pow2(1000) | tostring | explode | map(. - 48) | add'

Project EulerのProblem 20100! の各桁の和ですね。

 % gojq -n 'def fact($n): if $n < 1 then 1 else $n * fact($n - 1) end;
     fact(100) | tostring | explode | map(. - 48) | add'

オーバーフローを心配することなく整数を自由に計算できるのは安心感がありますね。 Project Eulerをjqで解くチャレンジをした人はこれまでいたのでしょうか。 私もjqのポテンシャルを証明するために、何問か解いてみようと思います。

まとめ

jqのGo言語実装を作りました。 ほぼ全ての関数を実装しており、エイリアスを貼ってもほとんどの人は気が付かないと思います。 クエリが誤ったときのエラーメッセージを改善しており、クエリを書いたりjqを理解するための手助けになるのではないかと思っています。

jqは完全に理解しました。 もう自信を持ってjqのクエリを書けるようになったのです。 数か月前はjqのエラーメッセージに悩まされていたので、gojqを作ってよかったと思います。

バイトコードへのコンパイルは、本質的には再帰関数を一つのループに変換することです。 再帰的に構文木をたどって評価を行う処理系は、実装はわかりやすくて楽ですが後々に大きな禍根を残すことになります。 セマンティクスを正確に分析し、バイトコードと評価器に落とすのは、楽しい作業です。 JSONイテレータというセマンティクスは、保存と復帰のできるスタックとforkという命令に対応していることがわかりました。

インタープリタを実装する上で最も大事なことは、スタックとバイトコードデバッグできるようにしておくことです。 特にjqにはforkというスタックの保存と復帰という複雑な動きがあります。 出力コードが間違っているとスタックがめちゃくちゃになったりクラッシュしたりするので、デバッグできるようにしておくことは大事です。

プログラミング言語の処理系を作るのは、とても楽しいことだと思います。 インタープリタ言語を一つ触っていれば、大まかな構造はどの言語も同じなので、処理系のコードが読みやすくなります。 みなさんもぜひ、言語処理系に挑戦してみてください。

Go言語でつくるインタプリタ

Go言語でつくるインタプリタ

  • 作者:Thorsten Ball
  • 発売日: 2018/06/16
  • メディア: 単行本(ソフトカバー)

itchyny.hatenablog.com

itchyny.hatenablog.com

*1:そもそもjqを言語として見ている方が少ないのではと思っています。jqはプログラミング言語であり、スタックマシン型のインタープリタ言語です。-fオプションを使うとjqのソースコードを読み込むこともできますし、import文もありますので再利用可能なjqスクリプトを書くこともできます。

*2:これはjqの設計において大胆で、かつ優れた判断だと思います。なかなか思いつきません。JSONを扱うのだからJSONの配列も構文に組み込んでしまいそうになります。実際には、中にクエリがないことを確認してバイトコードの出力前に配列を構築するという最適化処理が入っています。この処理はかなり複雑なので、gojqでは実装していません。

*3:この平均値の書き方はJ言語のmonadic forkにそっくりなのですが、なかなか賛同が得られません。jqの構文上の制約から、monadic forkのかなり限定的な部分に限られていますし、hookはありません。J言語のforkは良い機能ですが、hookはわかりにくいですよね。

*4:jqの初期バージョンがHaskellによる実装だったことを知る人は少ないでしょう。この初期の実装において、コンマ演算子がJQ Monadのmplusであるということはかなり興味深いことです。

*5:この判断が後の悲劇に繋がります。

*6:答えは iterator.go にあります。

*7:このwhile関数は、末尾再帰の最適化を行わないとパフォーマンスがひどくなります。この意味で、jqの処理系は、末尾再帰最適化が求められています。しかし、そもそも構文木をたどっている実装では、末尾再帰最適化とかそういう高尚なレベルにすら到達していません。

*8:どちらかというとuntil関数のほうが重要かもしれません。変数をJSONのオブジェクトに格納して、それを更新する処理を update に、終了条件を cond に書けば、あらゆる繰り返し処理をjqでエミュレートできます。実際、Brainfuckのインタープリタをjqで書いた例ではuntilが使われています。break や continue も、それを管理するためのフラグと if then else end に分解すれば除去できるはずです。

*9:goroutineを立ててchannelに値を入れていくイテレータの実装を、構造体に状態を持たせてNextで次の値を返すように修正するのは、かなり困難なものでした。詳しくは該当のコミットを参照してください。

*10:実はパフォーマンス以外にもクリアできない課題がありました。それはpath関数です。path関数は、引数を評価したときにたどったJSONのパスを返す関数です。|= や += といった更新系の演算子は全てpath関数を用いて実装されており、この関数がないとjqの機能の半分は作れないと言っても過言ではありません。path関数は他の関数と比べるとかなり実装が難しいことがわかり、インタープリタの方式をjqと同じものに変更するのに十分な動機となりました。構文木を直接評価するインタープリタのままでも実装できた可能性はありますが、パフォーマンスの問題もあって、このまま突き進むよりも一旦インタープリタそのものを見直したほうが良いと判断したのです。

*11:Luaの実装がスタックマシンからレジスタマシン方式に変更されたことは興味深い事実です。The Implementation of Lua 5.0を参照してください。また、Ruby (YARV) がなぜレジスタマシン方式を取らなかったかはこちらのスライドで触れられています。

*12: (1,2|debug) * (3,4|debug) のようなコードで確認してみてください。

*13:実際に出力されるコードから少し簡略して書いています。実際のバイトコードは jq -n --debug-trace --debug-dump-disasm '(1, 2) * (3, 4)' で確認してみてください。gojqではmake build-debugでデバッグビルドを行うことができます。

*14:当初は構文木上で数値を float64 のポインタで扱っており、0.0を表現するためにはgobは利用できませんでした。現在は後述の任意精度整数を表現するために、数値は構文木では string としており、組み込み型へのポインタはgojqには必須ではなくなっています。しかし拙作のastgenよりもgobを使うメリットがありませんし (実行バイナリのサイズくらいか)、実行時のパフォーマンスが落ちるのは嫌なので、astgenを今も使っています。

*15:実際、jqのissue218には多くのコメントが寄せられています。頻出するわけではありませんが、困る場面ではめちゃくちゃ困るという感じですね。そのようなAPIが一つあれば、そのAPIに対してはずっとjqは使えないということになりますから。

*16:数学関数に渡すと浮動小数点数に変換されます。また、除算で割り切れないときも浮動小数点数になります。

Makefileの変数展開はレシピの実行前に行われる

makeなんてよく使うものだから分かっているつもりだったけど実はよく分かっていなかったのが、変数展開がどのタイミングで行われるかということ。

itchyny.hatenablog.com

Makefileでの := は simply expanded variable といって一度しか展開されないが、 = は参照するたびに展開される。

DATE = $(shell date)

.PHONY: all
all:
   @echo $(DATE)
   @$(shell sleep 3)
   @echo $(DATE)
   @$(shell sleep 3)
   @echo $(DATE)

これは、 $(DATE) を参照するたびに展開されるから、値はどんどん変わっていく。

Thu Apr 4 20:13:21 JST 2019
Thu Apr 4 20:13:24 JST 2019
Thu Apr 4 20:13:27 JST 2019

ここまでは知っていたのだけど、どのタイミングで展開されるのかというのを知らなくて、次のようなケースで悩んでしまった。

FILE := /tmp/example.txt
$(shell echo 'Old contents' > $(FILE))
CONTENTS = $(shell cat $(FILE))

.PHONY: all
all:
  echo $(CONTENTS)
  echo 'New contents' > $(FILE)
  echo $(CONTENTS)

ファイルに書き込んだ後に $(CONTENTS) を参照しているから、最後の行は New contents と表示する… わけではない。

echo Old contents
Old contents
echo 'New contents' > /tmp/example.txt
echo Old contents
Old contents

こうなる理由だが、Makefile$(...) の展開はレシピを実行する前に行われるからだ。上の例だと、レシピ内のすべての $(CONTENTS)$(FILE) が展開されてから、3つのコマンド実行が行われる。

次の例を考えてみよう。

FILE := /tmp/sample.txt

.PHONY: all
all:
   @echo $(shell date)
   @$(shell sleep 3)
   @date > $(FILE)
   @$(shell sleep 3)
   @echo $(shell date)
   @$(shell sleep 3)
   @cat $(FILE)

レシピ3行目の date > $(FILE) (正確にはすでに展開されているので date > /tmp/sample.txt) が実行されるのは、全ての sleep (2, 4, 6行目) が行われた後なので、ファイルに書かれた日付が最も (5行目の echo $(shell date) よりも) 新しくなる。

では、ファイル書き込みがあってその最新の内容を取りたいときはどうすればいいか。レシピが実行される前に展開されても、まだ cat は実行されない状態にするには次のようにすれば良い。

FILE := /tmp/example.txt
$(shell echo 'Old contents' > $(FILE))
CONTENTS = $$(cat $(FILE)) # instead of $(shell cat $(FILE))

.PHONY: all
all:
  echo $(CONTENTS)
  echo 'New contents' > $(FILE)
  echo $(CONTENTS)

そう、シェルの展開に変えてしまうのだ。 (もうこうなると := を使っても同じになる)

echo $(cat /tmp/example.txt)
Old contents
echo 'New contents' > /tmp/example.txt
echo $(cat /tmp/example.txt)
New contents

多くの場合は $(shell ...) が使えるのでこれを使っていたが、これが使えないケースがあることに気がついて少しワクワクした。

stackoverflow.com めっちゃ混乱してstackoverflowに投げたら解決した。

GNU Make 第3版

GNU Make 第3版

joのGo実装 gojo を作りました!

joというJSONを組み立てるコマンドがあって、これは2016年からある便利なCLIツールなのですが、昨日急に思い立ってGo実装を作りました。

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

brewでもインストールできます。

brew install itchyny/tap/gojo

使い方はこんな感じ。

 $ gojo foo=bar qux=quux
{"foo":"bar","qux":"quux"}
 $ gojo -p foo=bar qux=quux
{
  "foo": "bar",
  "qux": "quux"
}
 $ gojo -a foo bar baz
["foo","bar","baz"]
 $ seq 10 | gojo -a
[1,2,3,4,5,6,7,8,9,10]
 $ gojo -p foo=$(gojo bar=$(gojo baz=100))
{
  "foo": {
    "bar": {
      "baz": 100
    }
  }
}
 $ curl -s https://httpbin.org/post -X POST \
    -H 'Content-Type: application/json' \
    -d "$(gojo foo=bar baz=$(gojo qux=128))" | jq .json
{
  "baz": {
    "qux": 128
  },
  "foo": "bar"
}

joはそれ自身と組み合わせて深いJSONを作るという考え方がとてもかわいくていいですね。

必要な機能はそんなに多くないので、思い立って三時間ほどで概ね実装できました。jo自体はあまり便利かどうか分からず使ったことはない*1のですが、自分で作ってみると意外と使い所があるかもと思っています。

GoのJSONは順序が失われてしまうので、順序を保存するためにorderedmapというライブラリーを使っています (が、若干バグを見つけたので報告しました)。

小さいツールなのでそこまで大したことはやってませんが、Go modulesやFunctional option patternを使ったりして今どきのGoの構成になっていると思うので、参考にしていただけると嬉しいです。

*1:実はjoのリリース当初にcontributeはしたことがあるんですが、正直言うと作者の設計方針が好きになれなかった