Go言語を使ってみて、個人的に引っかかったところと対処法。

こんにちは Backlog 開発チームの下川です。現在、Backlogは日本語版と英語版で分けていたドメインをbacklog.comに統合するプロジェクトを進めています。その一環で内部的に使用するAPIサーバーの実装に Go言語 を利用することになりました。過去に Go言語 を少しだけ使っていた筆者ですが、今回本格的に使ってみて引っかかった点とその対策を列挙してみました。

要約

必ずワークスペースの下で開発するものなのか

Goにはワークスペースというものがあります。ワークスペースはディレクトリで、GOPATH環境変数で指定した場所になります。未指定の場合は$HOME/goが使われます。

ワークスペースはbin, pkg, srcの3つのディレクトリから成り、それぞれ実行可能なプログラム、コンパイル済みのパッケージ、ライブラリやプログラムのソースコードを含みます。src内のコードがコンパイルされたものがpkgまたはbinに配置されます。サードパーティのライブラリはgo getで取得するとsrcの下にダウンロードされます。

Javaをたしなむ人であればローカルのmavenリポジトリのようなものと思ってもらえれば半分は正解ですが、Go言語ではそれだけでなく開発をするための場所でもあります。例えばhogeというプログラムを開発する場合、(ワークスペースのルート)/src/github.com/nulab/hogeといったディレクトリを作成して、そこで開発をします。

実際のところ、開発中のコードをワークスペース内に置くか外に置くかは好きにできるのですが、以下に書いたことなどの違いがありまして、結論としては、書き捨てのコードでなければワークスペースの下で開発するのがよいかと思います。

プログラム内の他パッケージの参照方法が違う

ワークスペース内のコードで他のパッケージを参照する場合、同一プログラム内のものであっても、ワークスペースのルートからのフルパスで指定します。それに対して、ワークスペース外にあるコードでは相対パスで参照することになります。

例として”hoge”というプログラム内に”fuga”というパッケージがあったとすると、それぞれ次のように書きます。

import “github.com/nulab/hoge/fuga”  // フルパス
import “./fuga” // 相対パス

ワークスペース内でも同じプログラム内であれば相対パスで指定できていいんじゃないかと思うんですが、go buildでコンパイルを実行するとエラーになります。そうなっている理由として、goツールのドキュメントに次のように書いてあります(参照)。

To avoid ambiguity, Go programs cannot use relative import paths within a work space.

ambiguityというのが何についてのことなのか不明ですが、コンパイラにとってはパッケージを一意に特定できないことはなさそうに思えるので、おそらく人間にとってのambiguityなのかと思いました。

相対パスを使わずに済むところでそれを許すと、

import “../../../../../../../../../../../../../../fuga”

とか書く人が出てきて、ぱっと見でどのパッケージかわからん、なんてことが起きるの嫌だよね、といったところなのかなと。

ビルド済みのファイルを利用できるかどうかが違う

ワークスペースにはpkgディレクトリの下にコンパイル済みのファイルを置く場所があります。開発中の自分のプログラムでgo installを実行すると、mainではないパッケージはコンパイル済みのものがそこに配置されます。以後、ソースコードが修正されない限りgoツールはそのパッケージを再コンパイルする必要がなくなります。一方、ワークスペース外で開発をする場合は毎回すべてのファイルをコンパイルし直すことになります。

公開する予定のライブラリはワークスペースで開発するのが都合がよい

プロジェクトのディレクトリを(ワークスペース)/src/github.com/nulab/hogeなどにするのは、go getで取得したライブラリが同様の場所にダウンロードされるからです。例えば

$ go get github.com/nulab/go-typetalk

を実行すると、(ワークスペース)/src/github.com/nulab/go-typetalkにソースコードがダウンロードされ、コンパイルされたものが(ワークスペース)/pkg/以下に配置されます。すでにソースコードがダウンロード済みであれば、再度ダウンロードは実行しません。

つまり、ワークスペース下で開発をするのは、ダウンロード済みのライブラリを修正するのと同じことになります。実際にライブラリがGitHubなどに公開されてなくても、公開済みのものを手元に取得しているのと同じ状態なので、他のプロジェクトからもパブリックなリポジトリで公開されているサードパーティのライブラリと同様にそれを利用できます。

 インターフェイスをコピーするコストが気になる

Goで関数に引数を渡す際、値渡しされます。言い換えるとデータがコピーされます。関数の引数の型がインターフェイスの場合、そのインターフェイスに適合するデータを渡すことができます。

Goと同じく引数が値渡しで、呼び名は違えどインターフェイスもあるC++だと、データがコピーされるのを避けるために明示的に参照かポインタにするところなので、ポインタ型ではない型の引数だと無駄にコピーが発生しないのかと気になりました。標準ライブラリにある関数やメソッドで素のインターフェイス型が引数や戻り値で普通に使われているので問題ないのだろうと思いつつもなんかモヤモヤしていました。

結論からいうと、インターフェイスはデータそのものではなくデータへの参照情報を持った軽量なデータなので、それを値渡ししてもパフォーマンス的に問題になることはないはず、です。

より詳細には、インターフェイスは、参照する値の型と値自身へのポインタを含んだ2ワードの軽量なデータです(参照)。ただし、値をインターフェイスに代入する際に値がコピーされるので、それがサイズの大きな構造体だと好ましくないでしょう。

ただ、これが問題になるかと言えばそんなことはなさそうです。コピーすることがコストとなる構造体をインターフェイスに準拠させたいときは、メソッドのレシーバの型を構造体そのものではなくその構造体のポインタ型にするからです。レシーバがポインタ型であれば値をインターフェイスにコピーする際にコピーされるのもポインタだけなので低コストです。そんなわけで、インターフェイスの受け渡しは値渡しでやることにして、問題があるならレシーバの型をポインタ型に変更するのがよいでしょう。

また、インターフェイス型の他にも、string、スライス、map、チャンネルあたりも、それ自体は実体となるデータへの参照のようなもので、値渡しして問題ないようです。一方配列は実体そのものなので、値渡しするとがっつり全体がコピーされます。

余談ですが、C++を思い出したせいか、ポインタ型のレシーバのメソッドでデータが更新されるのを防ぐために、C++のメンバ関数のconst指定みたいな保護手段はないのかと考えてしまいました。残念ながらそういうものはないようです。Javaにもそういうのないし、まあいいか、と自分を納得させました。 

標準のエラーが不便

Goのエラーはerrorインターフェイスでやり取りすることになっています。ただしコンパイラがそれを強制してくるわけではありません。開発者間の約束事のようなものです。このerrorインターフェイスですが、Error() stringという1つのメソッドがあるだけです。このメソッドの戻り値はエラー内容を表す文字列です。

自分の場合、開発をしていてエラーに対して次のような要望が湧いてきました。

  • スタックトレース情報が欲しい
  • エラーの種類によって処理を分岐したい

何かやりようがあるだろうと標準ライブラリのerrorsパッケージのドキュメントを見ても、errors.New()という、新しいエラーデータを作る関数しかありません。errors.New()が返すデータにはもっと情報が入ってるだろうと実装を見ても、文字列を保持するフィールド1つしかありません。わりと戸惑いました。

スタックトレースを見る

ちょっと調べると、どうもgithub.com/pkg/errorsというライブラリがよく使われているようです。これを使うと、次のようにしてエラー発生箇所までの呼び出し経路がわかります。

package main
import (
    "fmt"
    "github.com/pkg/errors"
)
 
func g() error {
    return errors.New("error in g")
}
 
func f() error {
    err := g()
    return errors.Wrap(err, "error in f")
}
 
func main() {
    err := f()
    fmt.Printf("%+v\n", errors.Cause(err))
}

手元の環境ではmain関数のfmt.Printfは以下の内容を出力しました。

error in g
main.g
       /Users/shimokawa/Documents/blogs/201708/go/error/main.go:10
main.f
       /Users/shimokawa/Documents/blogs/201708/go/error/main.go:14
main.main
       /Users/shimokawa/Documents/blogs/201708/go/error/main.go:19
runtime.main
       /usr/local/go/src/runtime/proc.go:185
runtime.goexit
       /usr/local/go/src/runtime/asm_amd64.s:2197

このライブラリを使えば、エラーの原因の特定がだいぶ楽になりそうです。

ライブラリのコードを見ると、runtime.Caller()を使えばソースファイルや行数の情報が取れるようです。ソースコードと行番号の表示する処理は、fmt.Formatterインターフェイスで定義されているFormatメソッドを実装することで実現されています。

errorインターフェイスさえ満たしていればなんでもいいのだから、自分に都合のよい実装を利用するか作るか好きにしなさい、という考えなんでしょうね。

エラーを分類する

JSON形式のデータを返すHTTPサーバーを作成していて、エラーの内容に応じてレスポンスのステータスコードを出し分けることになりました。Go言語のエラーは他のデータと同じただの値で、try-catchなどの構文上の特別扱いはありません。そこでGo言語で関数の戻り値としてエラーを受け取ったあと、エラーの種類によって処理を変えたい場合にどういうやり方があるか調べてみると、https://dave.cheney.net/paste/gocon-spring-2016.pdf にまとまっていました。上記のpkg/errorsを開発されているのと同じ方でした。

このスライドでは単純なものから、

  • エラー値による判定 (Sentinel errors)
  • エラー型による判定 (Error types)
  • インターフェイスを使った判定 (Opaque errors)

の3つのアプローチが挙げられています。それぞれコード例を挙げると、以下のような感じになるかと思います。

エラー値による判定

// package b
var (
   ErrA = errors.New("this is error a")
   ErrB = errors.New("this is error b")
)

func Func() error {
   // ...
}
// package a
err := b.Func()
switch err {
case b.ErrA:
   // ...
case b.ErrB:
   // ...
}

C言語でint型の戻り値でエラーを区別するのとほとんど同じノリです。この方法は手軽でわかりやすいですが、エラーメッセージが固定の文言になるため、エラーの状況の詳細を伝えることができません。

エラー型による判定

// package b
type myError struct {
    s string
}

func (e *myError) Error() string {
    return e.s
}

type ErrA struct {
    *myError
}
type ErrB struct {
    *myError
}

func Func() error {
    //...
}
// package a
err := b.Func()
switch e := err.(type) {
case b.ErrA:
    fmt.Printf("A: %v\n", e)
case b.ErrB:
    fmt.Printf("B: %v\n", e)
}

Javaのcatch節のようなテイストです。ただ、Goの型は親子関係を持たせられないので、Javaの例外クラス群のようにエラーを階層的に分類することはできません。

この方法だとエラー値は動的に作れるので、エラーメッセージにエラーの状況を伝える値を埋め込むことができます。

インターフェイスを使った判定

// package b
type myError struct {
    temporary bool
}

func (e *myError) Error() string {
    return fmt.Sprintf("temporary error %v", e.temporary)
}

func (e *myError) Temporary() bool {
    return e.temporary
}

func Func() error {
    return &myError{temporary: true}
}
// package a
type temporary interface {
    Temporary() bool
}

func isTemporary(err error) bool {
    e, ok := err.(temporary)
    return ok && e.Temporary()
}

func f() error {
    err := b.Func()
    if err != nil {
        if isTemporary(err) {
            // do something
        } else {
            return err
        }
    }
    return nil
}

エラーが特定のメソッド(ここではTemporaryメソッド)を備えているかどうかで処理を分岐しています。トリッキーな感じになってきましたが、この方法のミソはパッケージaが、パッケージbの関数呼び出しで発生するエラーについて、errorインターフェイスで得られる以上の情報を何も参照していない(参照できない)点です。

先のスライドの著者はパッケージ間の依存関係が少ないことをよしとしていて、その観点からすると、はじめの二つのアプローチは別のパッケージで定義されている特定のエラー値やエラー型を参照しているため理想的とは言い難く、エラー値の実装に立ち入らないこちらのアプローチの方が優れていると考えているようです。

エラー値がどんなメソッドを備えているかを判定するには返されるエラーについての事前知識が必要となるので、それはそれで依存しているじゃないかとか、むしろ明示的に依存関係があった方が、何か変更があった時にコンパイルエラーとしてわかるので好都合じゃないかとか、異論は出そうです。今後、エラーの性質を表す手段として特定のメソッドを持たせることが開発者コミュニティの間で広く共有されるようになれば、この方法も便利になりそう気がします。

本当に必要だったもの

自分がやりたかったのは、自分たちが開発しているアプリ内でのエラーの分類なので、正直さほど神経質になる必要はありませんでした。サーバー側のエラーなのか、ユーザーの入力値の問題なのかが区別できれば十分だったので、とりあえずこんなやり方をしました。

inputErr, err := b.Func(params)
if err != nil {
    // ステータスコード500を返す
}
if inputErr != nil {
    // ステータスコード400を返す
}
この方式だと区別したいエラーの種類だけ戻り値が増えることになるのでスマートさは微塵もありませんが、単純な実装で目的は果たせました。
 
ちなみに、先のスライドにはGoにおけるエラー処理のベストな方法を長いこと考えた結論として、
However, I have concluded that there is no single way to handle errors.
と書いてありました。

またfor文か

Go言語で配列・スライス・マップを走査するときにfor文を使います。今時のプログラミング言語にはこうしたデータの集まりを変換したり集計したりするのに使える便利な操作が標準ライブラリで備わっているものが多く、明示的にループ処理を書くケースが減っています。Scalaを例に挙げると、Arrayクラスのmap, filter, foldLeftメソッドなどです。

Scalaで

val a = Array(1,2,3)
val b = a.map(_ * 2)

と書くのと同じことをするには、Goだと次のように書くことになります。

a := []int{1, 2, 3}
b := make([]int, len(a))
for i, n := range a {
    b[i] = n * 2
}

そんなわけで、Go言語でコードを書くとfor文を書くことが、今時にしては多くなると思います。同様の操作をGoで実装できなくもないのですが、使い勝手に不満が残るものしかできなさそうです(参考)。だからと言って延々とforループを書き続けるのも嫌気がさしてきます。

現実的な対処法として、エディタやIDEのコードスニペット機能を使ってforループを手書きするのはどうでしょうか。

これぐらいの長さだとあまりありがたみを感じないかもしれませんが、並列処理版のmapだと割と省力化できた気がします。

使用頻度を考えるとやはりありがたみは感じないかもしれません。

さいごに

Go言語 でプログラムを開発してみて、個人的に引っかかったところや気になったことを挙げ、現時点での自分の理解や対処法を書いてみました。同じような疑問やストレスを覚えた方のヒントにでもなれば幸いです。

開発メンバー募集中

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

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

製品をみる