POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Fatih Arslan

本記事は、原著者の許諾のもとに翻訳・掲載しております。

ここでは、私がたどりついた最善のやり方を紹介しましょう。個人的に過去数年にわたって大量のGoコードと付き合ってきた経験から集めたものです。これらは全て非常にスケーラビリティがあると思っています。私が、スケールする、と言うときは次のような意味があります。

  1. アプリケーションが求める環境は、アジャイル環境の中で変化していきます。開発の3、4か月後に、全てをリファクタリングする必要が出てくるなど、考えたくもないはずです。新しい機能は簡単に追加できなくては意味がありません。

  2. あなたのアプリケーションは多くの人々によって開発されます。可読性が高く、維持しやすいものでなくてはなりません。

  3. あなたのアプリケーションは大勢の人々に使われます。バグは容易に特定でき、修正できなくてはなりません。

長期的にみるとこれらのことが重要になる、ということを私は今までに学んできました。小さなことであっても、多数に影響します。これらの助言を作業において試し、役に立ったかどうかぜひ聞かせてほしいと思います。気軽にコメントをください :)

1. GOPATHを1つにする。

GOPATHを複数にすると、スケーラビリティに限りが出てきます。GOPATH自体が、もともと非常に(インポートパスを経由した)自己完結的な性質を備えています。重要な複数のパスをインポートすることで、複数のGOPATHを持つと、既存パッケージ間のバージョンの不整合など、副作用が出てくる可能性があります。ある場所で更新したものの、別の場所では行っていないこともあるでしょう。とはいえ、私は複数のGOPATHが必要なケースには一度も出会ったことはありません。単純にGOPATHを1つにすれば、Goの開発プロセスに弾みがつきます。

この提案については多くの反対意見があったので、ひとつはっきりさせておきたいことがあります。 etcdcamlistore といった大きなプロジェクトでは、 godep のようなツールで、ベンダリングし、依存するレポジトリを1つのフォルダに凍結します。つまり、これらのプロジェクトの中で、GOPATHは1つということなのです。彼らはベンダーフォルダの中にあるバージョンのみを参照します。各プロジェクトごとにGOPATHを設定するのは、よほど大きくて重要なプロジェクトでない限りやり過ぎでしょう。もしプロジェクト独自のGOPATHフォルダが必要になり作成するとしても、そのときまでは複数GOPATHを使わないことです。それは仕事のペースを下げてしまいます。

2.for-selectイディオムを1つの関数にラッピングする

あるfor-selectイディオムから抜け出したい状況にあるなら、ラベルを使う必要があります。例えば下記のようになります。

func main() {

L:
    for {
        select {
        case <-time.After(time.Second):
            fmt.Println("hello")
        default:
            break L
        }
    }

    fmt.Println("ending")
}

ご覧のように、 break をラベルと結合させて使うでしょう。これは定位置ではありますが、私は好きではありません。forループはこの例では小さく見えますが、たいていはもっと広範囲にわたり、 break の状態を追うのは退屈です。

私の場合は、for-selectイディオムを1つの関数にラッピングします。

func main() {
    foo()
    fmt.Println("ending")
}

func foo() {
    for {
        select {
        case <-time.After(time.Second):
            fmt.Println("hello")
        default:
            return
        }
    }
}

この方法の良いところは、エラー(またはその他の値)を返せて、それが次のように簡単なことです。

// blocking
if err := foo(); err != nil {
    // do something with the err
}

3. 構造体を初期化するときはタグ付きリテラルを使う

これはタグのないリテラルの例です。

type T struct {
    Foo string
    Bar int
}

func main() {
    t := T{"example", 123} // untagged literal
    fmt.Printf("t %+v\n", t)
}

T 構造体に新規フィールドを追加すると、そのコードはコンパイルできなくなります。

type T struct {
    Foo string
    Bar int
    Qux string
}

func main() {
    t := T{"example", 123} // doesn't compile
    fmt.Printf("t %+v\n", t)
}

Goの互換性ルール( http://golang.org/doc/go1compat )は、タグ付きリテラルを使用した場合に適用されます。 Zone と呼ばれる新規フィールドがいくつかの net パッケージ型に導入されてからは特にその原則に忠実になりました。 http://golang.org/doc/go1.1#library を参照してください。さて、例に戻り、タグ付きリテラルを使うようにします。

type T struct {
    Foo string
    Bar int
    Qux string
}

func main() {
    t := T{Foo: "example", Bar: 123}
    fmt.Printf("t %+v\n", t)
}

きちんとコンパイルされ、スケーラビリティもあります。 T 構造体に別のフィールドを追加しても問題ありません。あなたのコードは常にコンパイル可能で、Goの将来のバージョンでもコンパイルされる保証がなくてはなりません。 go vet はタグのない構造体リテラルもキャッチするので、コードベースで走らせてみてください。

4. 構造体の初期化を複数行に分ける

2つ以上のフィールドがある場合は、複数行を使いましょう。コードがずっと読みやすくなります。つまり、次のようにする代わりに、

T{Foo: "example", Bar:someLongVariable, Qux:anotherLongVariable, B: forgetToAddThisToo}

複数の行にします。

T{
    Foo: "example",
    Bar: someLongVariable,
    Qux: anotherLongVariable,
    B: forgetToAddThisToo,
}

このやり方にはいくつか利点があります。まず読みやすい。2つ目にフィールドの初期化の無効/有効の変更を容易にする(コメントアウトか、削除するだけ)、3つ目に別のフィールドの追加が非常に簡単になることです(1行追加するだけ)。

5. 整数型定数値にString()メソッドを追加する

カスタム列挙型に、iotaを使って定義した整数型を使う場合は、常にString()メソッドを加えましょう。例えば次のように。

type State int

const (
    Running State = iota 
    Stopped
    Rebooting
    Terminated
)

この型から新規変数を作成しプリントすると、整数を取得できます( http://play.golang.org/p/V5VVFB05HB )。下記を見てください。

func main() {
    state := Running

    // print: "state 0"
    fmt.Println("state ", state)
}

ここでは、再度、変数をルックアップしない限り、 0 に大した意味はありません。ただ、 String() メソッドを State 型に追加するだけで調整可能です( http://play.golang.org/p/ewMKl6K302 )。

func (s State) String() string {
    switch s {
    case Running:
        return "Running"
    case Stopped:
        return "Stopped"
    case Rebooting:
        return "Rebooting"
    case Terminated:
        return "Terminated"
    default:
        return "Unknown"
    }
}

新たなアウトプットは、 state: Running です。ご覧のとおり、かなり可読性が高くなりました。ご自身のアプリをデバッグする際に、随分ラクになるでしょう。MarshalJSON()メソッド、 UnmarshalJSON() メソッドなどを実装しても同様の結果が得られます。

これは多くの人が既に説明していますが、上記のことは全てStringerツールで自動化できます。
https://godoc.org/golang.org/x/tools/cmd/stringer

このツールはgo generateコマンドを使い、定数 type をベースにして非常に効率的なStringメソッドを自動的に作り出します。

6. iota は+1でインクリメントしてから使う

上記の例の中にも、バグを引き起こすものはあります。実際、私も何度かバグを目にしてきました。ではここで、 State フィールドを格納する新しい構造体型について見てみましょう。

type T struct {
    Name  string
    Port  int
    State State
}

T を基に新しい変数を作成して、それを出力すると、驚くべき結果が得られます( http://play.golang.org/p/LPG2RF3y39 )。

func main() {
    t := T{Name: "example", Port: 6666}

    // prints: "t {Name:example Port:6666 State:Running}"
    fmt.Printf("t %+v\n", t)
}

バグに気づきましたか?この State フィールドは初期化されておらず、Goはデフォルトで、型ごとに決まったゼロ値を使用します。 State は整数なので、ゼロ値は 0 です。そしてこの例では、ゼロは基本的に Running を意味します。では、このStateが本当に初期化されているのか、そして実際に Running モードなのかは、どうすれば分かるのでしょうか。残念ながらこれを判別する手段はありません。そのため、未知の予期せぬバグが発生してしまうのです。しかし、単純にiotaを +1 してから使うことで、この問題は簡単に解決できます( http://play.golang.org/p/VyAq-3OItv )。

    Running State = iota + 1
    Stopped
    Rebooting
    Terminated
)

これで、 t はデフォルトで Unknown と表示されるはずです。簡単でしょう?

func main() {
    t := T{Name: "example", Port: 6666}

    // prints: "t {Name:example Port:6666 State:Unknown}"
    fmt.Printf("t %+v\n", t)
}

別の解決法として、あらかじめiotaを妥当なゼロ値にしておく手もあります。例えば Unknown という新たな状態を導入して、単純に以下のように変更するのです。

const (
    Unknown State = iota 
    Running
    Stopped
    Rebooting
    Terminated
)

7. 関数呼び出しで値を返す

私は、このようなコードをたくさん見てきました( http://play.golang.org/p/8Rz1EJwFTZ )。

func bar() (string, error) {
    v, err := foo()
    if err != nil {
        return "", err
    }

    return v, nil
}

これは、次のように書けば済むことです。

func bar() (string, error) {
    return foo()
}

より簡潔で読みやすいですよね(もちろん、ログや中間値が不要な場合に限りますが)。

8. Sliceやmapなどをカスタムな型に変換する

sliceやmapなどを再びカスタム型に変換すると、コードのメンテナンスが格段に楽になります。では Server 型と、サーバのリストを返す関数について見てみましょう。

type Server struct {
    Name string
}

func ListServers() []Server {
    return []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }
}

ここでは、特定の名前を持つサーバのみ取得する場合を考えます。ListServers()関数を少しだけ変えて、単純なフィルタ機能を追加しましょう。

// ListServers returns a list of servers. If name is given only servers that
// contains the name is returned. An empty name returns all servers.
func ListServers(name string) []Server {
    servers := []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }

    // return all servers
    if name == "" {
        return servers
    }

    // return only filtered servers
    filtered := make([]Server, 0)

    for _, server := range servers {
        if strings.Contains(server.Name, name) {
            filtered = append(filtered, server)
        }
    }

    return filtered
}

これを用いると、 Foo という文字列を含むサーバをフィルタすることができます。

func main() {
    servers := ListServers("Foo")

    // prints: "servers [{Name:Foo1} {Name:Foo2}]"
    fmt.Printf("servers %+v\n", servers)
}

ご覧のとおり、サーバはフィルタされました。しかし、これではスケーラビリティが良くありません。サーバのセットに別のロジックを導入したい場合、どうしたらいいのでしょうか?例えば、全てのサーバの調子を確認したり、各サーバのデータベースの記録を作成したり、新しいフィールドを別の要素でフィルタリングするというような場合です。

ここで、もう1つ Servers という新しい型を導入して、この型を返すように、最初のListServers()関数を変更してみましょう。

type Servers []Serve

// ListServers returns a list of servers.
func ListServers() Servers {
    return []Server{
        {Name: "Server1"},
        {Name: "Server2"},
        {Name: "Foo1"},
        {Name: "Foo2"},
    }
}

ここでは、新しい Filter() メソッドを Servers 型に追加するだけです。

// Filter returns a list of servers that contains the given name. An
// empty name returns all servers.
func (s Servers) Filter(name string) Servers {
    filtered := make(Servers, 0)

    for _, server := range s {
        if strings.Contains(server.Name, name) {
            filtered = append(filtered, server)
        }

    }

    return filtered
}

そして、 Foo という文字列でサーバをフィルタします。

func main() {
    servers := ListServers()
    servers = servers.Filter("Foo")
    fmt.Printf("servers %+v\n", servers)
}

完成です!コードがどう簡略化されたのか、お分かりいただけたでしょうか。サーバの調子を確認したい時や各サーバのデータベースの記録を追加したい時は、以下の新しいメソッドを追加するだけです。

func (s Servers) Check() 
func (s Servers) AddRecord() 
func (s Servers) Len()
...

9. withContextのラッパー関数

時々、各関数で同じ処理を繰り返してしまうことがあるかと思います。例えば、新しいローカルコンテキストのロック・アンロックや初期化、変数の初期化などです。以下の例を見てみましょう

func foo() {
    mu.Lock()
    defer mu.Unlock()

    // foo related stuff
}

func bar() {
    mu.Lock()
    defer mu.Unlock()

    // bar related stuff
}

func qux() {
    mu.Lock()
    defer mu.Unlock()

    // qux related stuff
}

これでは、どこか1箇所だけ変更したい場合、他の箇所も全て変えなくてはいけません。もし、これが共通タスクであれば、 withContext 関数を作成するのが最良の方法です。この関数は引数に関数をとり、それを与えられたコンテキストに応じて呼び出します。

func withLockContext(fn func()) {
    mu.Lock
    defer mu.Unlock()

    fn()
}

あとは、このコンテキストのラッパーを使うために、初期関数をリファクタリングするだけです。

func foo() {
    withLockContext(func() {
        // foo related stuff
    })
}

func bar() {
    withLockContext(func() {
        // bar related stuff
    })
}

func qux() {
    withLockContext(func() {
        // qux related stuff
    })
}

単にコンテキストをロックしようと考えてはいけません。なお、この最良のユースケースは、データベース接続やデータベースコンテキストです。では、withContext関数を少しだけ変更しましょう。

func withDBContext(fn func(db DB)) error {
    // get a db connection from the connection pool
    dbConn := NewDB()

    return fn(dbConn)
}

ご覧のとおり上記の関数では接続を確立し、それを既定の関数に渡し、関数呼び出しのエラーを返しています。あとは以下のようにするだけです。

func foo() {
    withDBContext(func(db *DB) error {
        // foo related stuff
    })
}

func bar() {
    withDBContext(func(db *DB) error {
        // bar related stuff
    })
}

func qux() {
    withDBContext(func(db *DB) error {
        // qux related stuff
    })
}

事前初期化を行うなど、方針を変えて別のアプローチを取る場合も問題はありません。その処理を withDBContext に追加するだけで、準備完了です。テストの際も、これで完璧に動作します。

ただ、このアプローチを取るとインデントが深くなるため、読みにくくなるという難点があります。繰り返しますが、常に最もシンプルな解決策を探るようにしましょう。

10. Mapへアクセスするためにsetterとgetterを追加する

検索や追加処理にmapを多用している場合は、必ずmapの近くでgetterとsetterを使いましょう。getterとsetterを使うと、ロジックを各関数にカプセル化することができます。この際、最も多く発生するエラーが同時アクセスです。あるgoroutineの中に以下の処理があるとしましょう。

m["foo"] = bar

そして別のgoroutineには以下の処理があるとします。

delete(m, "foo")

この場合、何が起きるでしょうか?大半の方は、このような競合状態になじみがあると思います。基本的にmapはデフォルトでスレッドセーフではないので、これは単純な競合状態です。こういう場合には、mutexを使えばmapを簡単に保護することができます。

mu.Lock()
m["foo"] = "bar"
mu.Unlock()

さらに以下のようにします。

mu.Lock()
delete(m, "foo")
mu.Unlock()

このmapを別の場所でも使っている場合、全ての箇所でmutexを使わなくてはいけません。しかしgetterとsetterを使えば、そういった面倒な作業は一切不要になります。

func Put(key, value string) {
    mu.Lock()
    m[key] = value
    mu.Unlock()
}
func Delete(key string) {
    mu.Lock()
    delete(m, key)
    mu.Unlock()
}

このプロシージャを改善するには、インタフェースを使用します。そうすることで、実装を完全に隠すことも可能です。シンプルかつ明確に定義されたインタフェースを使い、それらをパッケージで使うようにするだけです。

type Storage interface {
    Delete(key string)
    Get(key string) string
    Put(key, value string)
}

これは一例に過ぎませんが、概要はお分かりいただけたと思います。基本実装に何を使用しているかは問題ではありません。重要なのは使用方法そのものと、内部データ構造を公開することで生じる多数のバグを簡略化して解決するインタフェースなのです。

とはいえ、複数の変数を同時にロックする必要があるかもしれないので、インタフェースは、ただのやり過ぎになってしまう場合があります。よって、アプリケーションを熟知し、本当に必要な時だけこの改善策を適用するようにしてください。

結論

抽象化が必ずしも良いことだとは限りません。すでに現状が、最もシンプルな状態になっている場合もあります。つまり、より洗練されたコードにしようとすべきではないのです。Goは元来シンプルな言語で、何をするにも大抵1通りの方法しかありません。Goの強みはこの簡潔さです。それこそが、手動で制御するのと同等にうまくスケーリングできる理由の1つでもあります。

今回紹介したテクニックを使うのは、本当に必要な時だけにしましょう。例えば []Server から Servers へ変換するのは、さらに抽象化していることになるので、このような処理は正当な理由がある時のみ行うべきです。ただ、iotaを1にしてから使うテクニックなど、いつでも使える内容もあります。最後にもう一度繰り返しますが、常に重視すべきなのはシンプルさです。

価値あるフィードバックと提案をしてくれたCihangir Savas、Andrew Gerrand、Ben Johnson、Damian Gryskiに心から感謝します。

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。