Go言語の依存管理ツールを作って、開発環境構築を覚えた

@eaglesakura です。

基本的にAndroid世界の片隅でコードを書いている私ですが、テスト用のサーバーをGAE/Goで構築しようかという目論見のもと、Go言語の学習もはじめました。Go言語は半年くらい前にHello World!を一度書いて以来、まったく触れてないです。

「そろそろ本格的にGoやろうかなー」みたいに考えたとき、色々障害となりそうなことを最初に排除しようとして苦労した話です。

あと、CygwinメインのWindowsユーザー以外はたぶん普通に構築できます。

# IDEの選定

記憶力が低い私にとって、IDE的な機能（せめてIntelliSense、ブレークポイント、変数ウォッチ）がない環境は非常につらいです。

Android StudioのようにGoの開発環境決定版のようなものは2017Q1時点でなさそうですが、Atom, Visual Studio Code, Intellij+Goあたりが多いようです。
慣れの問題から考えて、Intellij+Goプラグインを選択しました。IntelliSense、デバッガ、ブレークポイントを始めとして、基本的な機能はかなり揃っています。

# Go言語のCygwin/コマンドプロンプト問題

Go言語世界のみで、お一人様前提に構築を行なうのであれば、さほど問題はありません。

ですが、Go言語を使ったプロジェクトはGAE/Goを始めとしたサーバーサイド系が多いため、「プロジェクト」という点を考えると幾つかの問題が起こります。

例として、セットアップ等やビルド確認のためのスクリプトがあります。

私はAndroidアプリを中心にお仕事をしているので、基本的にはMac/Windows共にbashを前提にシェルスクリプトを書いておけば大きな問題は起こりませんでした。Go言語も基本的にCygwinから `go get` 等のコマンドが使えますが、cgo(C言語と組み合わせた)場合に問題が起こります。

cgoが含まれているとコンパイルが飛躍的に長くなり、 `go install` 等で事前ビルドを行なうことでビルド時間短縮が可能になります。
ですが、cygwinに含まれているgccコンパイラは一部のビルドオプションが使えず、代わりにMingwを使う場合があります。

Cygwin+Mingwを使い、 `go install` して生成されたstatic libraryは、Intellij/GOプラグインからリンクすることができません。Intellijの環境が、通常のWindows環境（つまりコマンドプロンプト）を前提としているためです。

コマンドプロンプトから `go install` しておけばこの問題は発生せず、Intellijでもビルドが正常に行なえます。ですが、逆にcygwinから `go builid` した際に問題が発生するようになりました。

GOはライブラリ等のインストール先を `GOPATH` に記述されたパスで解決します。微妙に差異がある２環境（Cygwin/プロンプト）を同居させようという点で問題が起きているようです。

そこで、Cygwinとコマンドプロンプト＆IDE環境で `GOPATH` を切り替えることにしました。

# GOPATH の設置場所問題

Go言語の仕様上、 `GOPATH` は複数ディレクトリを指すことができます。ですが、 `go get` で取得できるソースコードの配置先は「最初に記述されたパス」と定められています。

通常は問題になりにくいかもしれませんが、ライブラリがバージョンアップした場合に厄介です。
あるプロジェクトは古いバージョンに依存している、新規のプロジェクトは新しいバージョンに依存している、等の状況が発生することが予想されます。

そこで、プロジェクトごとに `GOPATH` を完全に切り替えるという方針を取ることにしました。
場所はプロジェクトごとに `root/.gopath/cygwin` `root/.gopath/windows` をそれぞれ作成して使い分けることで cgo 依存問題を回避しています。
また、リポジトリにそれらが含まれて他人に迷惑がかからないよう、 `.gitignore` にそれらのパスを追加しています。

```
# .gitignore
/.gopath
```

問題点として、cygwinでフルパスを単純設定すると `/cygdrive/c/path/to/project` のようなUnixライクなパス設定になってしまう（Go言語はC:\のWindows形式でパスを与えなければならない）ので、 `cygpath` コマンドでの変換も必要になります。

Cygwinではこのようにすることで `GOPATH` を固有に指定できます。

```bash
# Cygwinでは工夫が必要になる

# プロジェクトのrootに移動する
cd path/to/project

# 環境変数を書き換える
# GOPATHはWindows形式フルパスで存在させなければならない
# cgoを使うなら必要に応じてgccも書き換えなければならない
export GOPATH=`cygpath -w $PWD`\\.gopath\\cygwin
export CGO_ENABLED=1
export CC=x86_64-w64-mingw32-gcc
export CXX=x86_64-w64-mingw32-g++
```

Windowsのコマンドプロンプトではこうなります。

```cmd
REM 環境変数のGOPATHをプロジェクト固有に入れ替える

SET GOPATH=%CD%\.gopath\windows
SET PATH=%MINGW64_PATH%\bin;%PATH%
```

# GOPATH のデフォルト問題

GOPATHをプロジェクトごとに完全に切り替えてしまう
Go言語で作られたツール類は `go get` で手軽に使えるという利点があります。ですが、GOPATHを完全にプロジェクト依存にしてしまうと、そのツール類をどこにインストールするか？という問題が発生してしまいました。

結局、ツール類をインストールするために環境変数 `GOPATH` `GOBIN` `GOROOT` を設定した上で、開発プロジェクトごとに別途 `GOPATH` を指定するという方向に落ち着いています。

# GOPATH切替を行ってIntellijを使用する

Intellij/Goプラグインはプロジェクト固有の環境変数（GOPATH)を指定できないようです。
IDEが起動したプロセスに設定されているGOPATHを読み込み、セットアップされます。

それを利用し、次のようなバッチファイルを `/root/script/windows-intellij.bat` というパスに用意することで、「現在のプロジェクトにGOPATHを指定した状態でIntellijを起動する」ということを行えるようにしています。

バッチファイルの中身はこうなります。
バッチファイルを実行するとDOS窓が居座りますが、Intellij起動後はウィンドウを閉じても問題ありません。

```
@echo off

REM カレントディレクトリをプロジェクトのrootにする（正確には、バッチファイルが配置されているフォルダの１階層上にする）
CD /d %~dp0\..\

REM GOPATHをプロジェクト固有に書き換える
SET GOPATH=%CD%\.gopath\windows
SET PATH=%MINGW64_PATH%\bin;%PATH%

echo GOROOT=%GOROOT%
echo GOPATH=%GOPATH%

REM Intellijを起動する
%GO_IDEA_PATH%\bin\idea64.exe

```

# Goの依存管理ツール prjdep を作ることにした

GOPATHをCygwin/コマンドプロンプトを切り替える（１マシン・１プロジェクトに対して複数GOPATHが存在する）という都合上、既存の `dep` `godep` が正常に機能しませんでした。

そこで、Go言語開発の勉強を兼ねて依存解決ツール [prjdep](https://github.com/eaglesakura/prjdep) を作ることにしました。

目標は次の点です。

 1. Pure Golangであること
 1. 上記の特殊な `GOPATH` に対応可能であること（今見ているGOPATHに作用する）
 1. 1ファイルのみで管理できること
 1. `go get`のみでインストール可能であること
 1. CIを構築すること


## prjdep をインストールする

`go get github.com/eaglesakura/prjdep` でインストールできます。
私の環境だと次のようになります。
`$GOPATH/bin`にパスを通すのを忘れないようにしましょう。

```
$ which prjdep
/cygdrive/c/dev-home/tools/gopath/bin/prjdep
```

## prjdep でGOPATHの状態を保存する

`prjdep init` を実行すると、「コマンドが実行された時点のGOPATHの状態を保存」します。
具体的には次のような内容の `dependencies.json`がカレントディレクトリに書き出されます。

```json
{
  "Repositories": [
    {
      "ImportPath": "github.com/stretchr/testify",
      "Rev": "4d4bfba8f1d1027c4fdbe371823030df51419987",
      "Lang": "golang"
    },
    {
      "ImportPath": "github.com/urfave/cli",
      "Rev": "347a9884a87374d000eec7e6445a34487c1f4a2b",
      "Lang": "golang"
    }
  ]
}
```

## prjdep でGOPATHの状態を復元する

`prjdep restore` を実行すると、カレントディレクトリにある `dependencies.json` をロードし、次のコマンドを実行します。

```bash
# 全てのライブラリに対して書きを実行する

# ライブラリをgetする
go get -d ${ImportPath}
# 保存されたリビジョンを復元する
git checkout -f ${Rev}
# ライブラリをビルドする
go install ${ImportPath}
```

`prjdep` プロジェクト自体の依存管理も `prjdep restore` で行っています。

```yaml
# circle.yml抜粋
dependencies:
  override:
    - go version
    - go get github.com/eaglesakura/prjdep
    - prjdep restore
```

## 配布する上で引っかかったこと

`go get` で配布するためには、ローカルpackageが使えないというルールに引っかかりました。つまり、ツール内で次のような書き方は認められていないようです。

```go
import "./hoge/package"
```

また、ディレクトリ名がそのままツール名になるという `go get` のルールに従うため、リポジトリ名をそのままツール名に変更しています。

# 【おまけ】 cgoのデバッグ実行が行えない問題の無理矢理な回避

`cgo` を使ったライブラリをリンクした場合、Intellij付属のデバッガ [delve](https://github.com/derekparker/delve) が正常に起動できないという問題がありました( [issue](https://github.com/derekparker/delve/issues/631) )。

フォーラム等の情報を元にすると、まずはデバッグモードで起動したい場合に `-ldflags="-linkmode internal"` のビルドフラグを指定する必要があるようです。ただし、それを行った場合に上記の `delve` がバイナリ解析に失敗して正常起動できなくなる問題が発生します。

そこで、Delve自体を次のように改変してビルドし、Intellij組み込みのdelveと入れ替えることでブレークポイントは働くようになります（変数のウォッチは行えなかったので、諦めてPrintfデバッグしたほうが良さそうです）。

delveはPure Golangなソリューションなので、簡単に自前ビルドを行えます。

```diff
// https://github.com/derekparker/delve/blob/master/proc/proc.go

func (dbp *Process) getGoInformation() (ver GoVersion, isextld bool, err error) {
// Debug版の場合このバージョンチェックに失敗するが、そもそもPrint以外の用途に使っていないので多分大丈夫
-	vv, err := dbp.EvalPackageVariable("runtime.buildVersion", LoadConfig{true, 0, 64, 0, 0})
-	if err != nil {
-		err = fmt.Errorf("Could not determine version number: %v\n", err)
-		return
-	}
-	if vv.Unreadable != nil {
-		err = fmt.Errorf("Unreadable version number: %v\n", vv.Unreadable)
-		return
-	}
-
-	ver, ok := ParseVersionString(constant.StringVal(vv.Value))
-	if !ok {
-		err = fmt.Errorf("Could not parse version number: %v\n", vv.Value)
-		return
-	}

	rdr := dbp.DwarfReader()
	rdr.Seek(0)
	for entry, err := rdr.NextCompileUnit(); entry != nil; entry, err = rdr.NextCompileUnit() {
		if err != nil {
			return ver, isextld, err
		}
		if prod, ok := entry.Val(dwarf.AttrProducer).(string); ok && (strings.HasPrefix(prod, "GNU AS")) {
			isextld = true
			break
		}
	}
	return
}
```

# まとめ

Go言語とWindowsとCygwinとコマンドプロンプトとIntellijを悪魔合体させようとすると、開発環境構築が大変だね。

諦め is 肝心。
ドザーに幸あれ。