Go言語 で Gitサーバー を書いてみた。

Backlogチームのnabe_です。もっぱら仕事はJavaとScala、最近の趣味は Go言語 です。今回、 Go言語 で nulab/go-git-http-xfer という Git ライブラリ を書いたので紹介させていただきます。

go言語 Git

役割

このライブラリを使うと、GitのリモートリポジトリへHTTPでアクセスするためのサーバーを作ることができます。HTTPアクセス自体は、BacklogやGithub等のGitをホスティングしているサービスであれば概ねサポートしているので、普段あまり気にすることはないかと思いますが、独自にGitを運用している場合、リモートリポジトリの前に clone、push、fetch  等で発生するHTTP通信を捌く仕組みを、なにかしら用意しなければなりません。

動機

私自身まだまだGoのニュービーなのですが、兎に角手を動かさないと始まらないし、どうせだったら仕事に役に立つものが作りたい、といった動機で作ってみました。

ちなみに、他の言語でも実装されていて、

といったライブラリもあります。この2つの実装はGoで実装する上でとても参考になりました。

仕組み

オンライン作図ツールのCacooで描いた図を用意しました。

実装したのはGopherがいる箇所です。ざっくりイメージとしては、クライアントからのclone、push、fetch で発生するHTTPリクエストを受け取ったサーバーが、Gitのサブコマンド upload-pack もしくは  receive-pack を叩いて、その結果をGitの転送プロトコルに従ってレスポンスとして返却するといった流れになります。

go言語 Git

補足ですが、このようなGitでネットワーク越しにデータを受け渡しするための規約として Smart Protocol というものがあります。(6、7年前のGitのサブコマンドは HTTPで通信する際に Dumb Protocol  というものを使っていました。)

Smart Protocol: 1.6.6 <= git –version
Dumb Protocol: git –version <= 1.6.5

今回は、Smart Protocol をベースにサブコマンドで発生する通信を例に仕組みをご説明いたします。

参考:

git clone

git clone を 実行したときに、内部で発生する通信の様子です。

まずは 1 のGETリクエストです。パスの末尾に /info/refs 、クエリパラメーターにservice=git-upload-pack という文字列を付与しています。このリクエストを受け取ったサーバーは、Gitのサブコマンドの upload-pack に適切なオプションを付与して実行します。upload-pack は、各参照 (ref) の一覧(すべてのブランチとタグ)を現時点で指し示すGitオブジェクトのID (SHA1ハッシュの値) とともに出力するので、HTTPレスポンスにその一覧を含めてクライアントに返します。

なにもコミットされていなければここで終了です。

続けて、 2 の POSTリクエストです。パスの末尾に /git-upload-pack を付与します。リクエストのBODYには、1 で得た情報を元に、必要とするGitオブジェクトのIDと既にローカルリポジトリに持っているGitオブジェクトのIDが含まれています。このリクエストを受け取ったサーバーは、Gitのサブコマンドの upload-pack にリクエストのBODYを含めて実行します。upload-pack は クライアントが要求したGitオブジェクトだけを持つ圧縮されたパックファイルを出力するので、HTTPレスポンスにその結果を含めてクライアントに返します。

この圧縮されたパックファイルを元にクライアントがワークツリーを展開します。

git clone
クライアントが git clone でダウンロードするのに対して、サーバーではアップロードと名の付くサブコマンドを実行していますが、これはあくまでサーバーからクライントに向けてアップロードすると言った意味合いになります。

git push

git push を 実行したときに、内部で発生する通信の様子です。

1 のリクエストは、git clone とほぼ同じですが、クエリパラメーターには  service=git-receive-pack という文字列を付与しています。サーバーは、Gitのサブコマンドの receive-pack を実行して、各参照の一覧を返却します。

ローカルリポジトリに変更(追加/削除/更新)がなければ、ここで終了です。

続けて、2 の POSTリクエストでは、パスの末尾に /git-receive-pack を付与します。BODYには 1 の情報を元に、変更があった各参照(ref)が示す古いGitオブジェクトのIDと新しいGitオブジェクトのIDの一覧が含まれます。サーバーは、Gitのサブコマンドの receive-pack にリクエストのBODYを含め、新しいGitオブジェクトのIDで対象の参照 (ref) を更新し、HTTPレスポンスでその結果 (success or failure) を返します。

git push

使い方

このライブラリは、ひとつの構造体 GitHTTPXfer を提供します。この構造体は標準パッケージのinterface http.Handle に遵守して、

 func ServeHTTP(ResponseWriter, *Request)

を実装しています。これにより、利用する側は標準の http.ListenAndServe 等の標準のリスナーを利用して、HTTPで待ち受けるWebアプリとして起動することができます。

Basic

インスタンスを生成する際に、コンストラクタ関数には渡す必須のパラメーターとして、

  • 第一引数の gitRootPath にはリモートリポジトリを配置するディレクトリのルートパスを設定します。
  •  第二引数の gitBinPath にはGitコマンドのバイナリファイルのパスを指定します。

以下、動作する最低限のコードです。

package main

import (
	"log"
	"net/http"

	"github.com/nulab/go-git-http-xfer/githttpxfer"
)

func main() {
	ghx, err := githttpxfer.New("/your/git/path", "/usr/bin/git")
	if err != nil {
		log.Fatal("GitHTTPXfer instance could not be created.", err)
		return
	}

	if err := http.ListenAndServe(":5050", ghx); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

Optional Parameters

また、任意で設定する関数型オプションを3つ提供しています。

  1. DisableUploadPack :
    clone fetch 等のデータをダウンロードする操作を無効にします。
  2. DisableReceivePack :
    push 等のデータをアップロードする操作を無効にします。
  3. WithoutDumbProto :
    古い規約の Dumb Protocol のハンドリングを除外します。

例えば、Readonlyで古い規約を無効にする場合は以下のようになります。

githttpxfer.New(
	"/your/git/path",
	"/usr/bin/git",
	githttpxfer.DisableReceivePack(),
	githttpxfer.WithoutDumbProto())

Custom Handler

コンストラクタ関数によって生成された GitHTTPXfer のインスタンスは、内部にルーターを持っておりhttpのリクエストに応じて適切な処理を実行します。デフォルトでは Smart Protocol、 Dumb Protocol に対応したハンドラが含まれていますが、その他特有の処理を追加することも可能です。例えば、Githubにもあるダウンロード機能や、Backlogにもあるプレビュー機能等を独自に実装して対応することも出来ます。

zip と tar のダウンロード機能は任意で利用できるように addon パッケージ内に実装しています。 

package main

import (
	"net/http"

	"github.com/nulab/go-git-http-xfer/addon/handler/archive"
	"github.com/nulab/go-git-http-xfer/githttpxfer"
)

func main() {

	ghx, err := githttpxfer.New("/your/git/path", "/usr/bin/git")
	if err != nil {
		log.Fatal("GitHTTPXfer instance could not be created.", err)
		return
	}

	ghx.Router.Add(githttpxfer.NewRoute(
		archive.Method,
		archive.Pattern,
		archive.New(ghx).Archive,
	))

	if err := http.ListenAndServe(":5050", ghx); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}

}

Event Hooks

GitHTTPXfer は内部にイベントエミッターを持っていて、いくつかのポイントでイベントを発火させています。例えば、アップロードする前になにか前処理を入れておきたい時は以下のイベントでハンドリングできます。git-hook に特有の情報を渡したいときは、ここでその値ををシェル変数に入れてやると、git-hookとして実行されるコマンドでも参照できるようになります。

ghx, err := githttpxfer.New("/data/git", "/usr/bin/git")
if err != nil {
    log.Fatal("GitHTTPXfer instance could not be created.", err)
    return
}

ghx.Event.On(githttpxfer.BeforeUploadPack, func(ctx githttpxfer.Context) {
	// before run service rpc upload.
	os.Setenv("SOMETHING_KEY", "something value")
})

ghx.Event.On(githttpxfer.BeforeReceivePack, func(ctx githttpxfer.Context) {
	// before run service rpc receive.
	os.Setenv("SOMETHING_KEY", "something value")
})

Middleware

Middleware パターンの関数で共通処理をはさむことも可能です。認証処理やアクセス制御等はMiddlewareで行うことを想定しています。

Middleware:  func(http.Handler) http.Handler を実装した関数。引数に http.Handler を受取り、戻り値として新しい http.Handler を返す。

func main() {
	ghx, err := githttpxfer.New("/data/git", "/usr/bin/git")
	if err != nil {
		log.Fatal("GitHTTPXfer instance could not be created.", err)
		return
	}
	
	handler := Logging(BasicAuth(ghx))

	if err := http.ListenAndServe(":5050", handler); err != nil {
		log.Fatal("ListenAndServe: ", err)
	}
}

func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		t1 := time.Now()
		next.ServeHTTP(w, r)
		t2 := time.Now()
		log.Printf("[%s] %q %v\n", r.Method, r.URL.String(), t2.Sub(t1))
	})
}

func BasicAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		username, password, ok := r.BasicAuth()
		if !ok || username != "nulab" || password != "DeaDBeeF" {
			RenderUnauthorized(w)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func RenderUnauthorized(w http.ResponseWriter) {
	w.Header().Set("WWW-Authenticate", `Basic realm="Please enter your username and password."`)
	w.WriteHeader(http.StatusUnauthorized)
	w.Write([]byte(http.StatusText(http.StatusUnauthorized)))
	w.Header().Set("Content-Type", "text/plain")
}

試用

お試しようにDockerfileで環境を用意しているので、以下のコマンドですぐに起動できます。

$ git clone https://github.com/nulab/go-git-http-xfer
$ cd go-git-http-xfer
$ docker build -t go-git-http-xfer .
$ docker run -it --rm \
    -v $PWD:/go/src/github.com/nulab/go-git-http-xfer -p 5050:5050 \
    go-git-http-xfer bash -c "go run ./example/main.go -p 5050"

$ git clone http://localhost:5050/example.git

Go言語 x Git サーバー まとめ

 nulab/go-git-http-xfer の使い方と、 その仕組みについて説明させていただきました。

これまで、Gitの通信の仕組みについて意識することはほとんどなかったのですが、 手を動かしながら少しづつ理解していくことで、その過程自体を楽しみながら進める事ができました。

また、作っている当初は、インテグレーションテストが面倒くさそうだなと思っていたのですが、全然そんなことはなく、公式のdockerイメージですぐにGoの環境を用意できるし、あとはDockerfileにちょこっとGitをインストールする部分だけ書いておけば、すぐにテスト環境を構築できました。おかげで、トライ&エラーでバンバン進めることができて、かつコミットする前に、go fmt や goimports さえ叩いておけば、コードスタイルすら気にせず、本質的なコードを書くことだけに集中できました。

BacklogでもGitのHTTP接続はサポートしているので、今後パフォーマンスや開発効率、運用しやすさなど比較してみて良い結果がでればと期待しております。そんなBacklogでは一緒に開発をしてくれるメンバーを募集しています。

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる