POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

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

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

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

FeedlyRSSTwitterFacebook
Peter Bourgon

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

Goは、 信頼できる賢い人たち によって作られた愛すべきささやかなプログラミング言語で、 現在も成長中の大規模なオープンソースコミュニティ によって、継続的に改善が続けられています。

Goの基本原則はシンプルであることですが、時折、約束事が分かりにくいこともありますね。以下では、私がどのようにしてGoのプロジェクトを始め、どのようにGoのイディオムを使っているかを紹介したいと思います。一緒に、Webアプリケーション用のバックエンドサービスを構築しましょう。

  1. 環境の構築
  2. 新規プロジェクト
  3. Webサーバの作成
  4. ルートの追加
  5. 複数APIへのクエリ
  6. 並列化
  7. シンプルさ
  8. 追加演習

環境の構築

最初のステップは、もちろんGoをインストールすることです。オフィシャルサイトに用意されている、 お使いのオペレーティングシステム用のバイナリディストリビューション を使ってください。MacでHomebrewを使っている場合は、 brew install go でうまくいくと思います。インストールが終わったら、次のように動作するはずです。

$ go version
go version go 1.3.1 darwin/amd64

インストール完了後にGOPATHの設定をしてください。GOPATHは、Goのコードや中間生成物を収めるためのルートディレクトリです。この設定をすると、GOPATH内には3つのサブディレクトリ(bin、pkg、src)が生成されます。人によっては、これに $HOME/go のような設定をする方もいますが、私はシンプルに $HOME としています。お使いの環境で、これがきちんとエクスポートされるかどうかを確認してください。bashを使っている場合は、次のようにしてもいいと思います。

$ echo 'export GOPATH=$HOME' >> $HOME/.profile
$ source $HOME/.profile
$ go env | grep GOPATH
GOPATH="/Users/peter"

Goには様々なエディタやプラグインが使用できますが、個人的にはSublime Textと優秀な GoSublime プラグインがお気に入りです。ただ、言語そのものが分かりやすいため、特に小規模のプロジェクトにおいては、シンプルなテキストエディタでも十分に事足りるかと思います。私と一緒に作業をしているフルタイムのGo開発者のプロは、いまだに通常のVimをシンタックスハイライトもせずに使っているほどです。だから、始めるのに大層なものは必要ありません。シンプル・イズ・ベストですよね。

新規プロジェクト

機能的な環境が整ったら、プロジェクトのために新しいディレクトリを作りましょう。Goのツールチェーンでは、全てのソースコードは$GOPATH/srcにあることが前提のため、そこがワークスペースとなります。また、ツールチェーンは、GitHubやBitbucketといったサイトにホストされているプロジェクトを、それらが正しい場所にありさえすれば、直接インポートしたり、やり取りをしたりすることも可能です。

この例として、GitHubで空のレポジトリを新規に作成してみます。仮に名前を「hello」として、$GOPATH内にそのレポジトリを設定してください。

$ mkdir -p $GOPATH/src/github.com/your-username
$ cd $GOPATH/src/github.com/your-username
$ git clone git@github.com:your-username/hello
$ cd hello

いいですね。 main.go を作成します。これがGoの最小限のプログラムです。

package main

func main() {
    println("hello!")
}

go build を呼び出して、カレントディレクトリ内の全てをコンパイルします。これにより、ディレクトリと同じ名前のバイナリが生成されます。

$ go build
$ ./hello
hello!

簡単ですね。Goを書き始めて数年が経ちますが、私はいまだにこうやって新規プロジェクトを始めています。手始めに空のGitレポジトリ、続いて main.go を作成し、あとはわずかなタイピング操作のみです。

共通の規則に従っているため、このアプリケーションは自動的に go get が可能な状態になります。このファイルをコミットし、GitHubにプッシュした場合、Goがインストールされた作業環境では、次のことができるはずです。

$ go get github.com/your-username/hello
$ $GOPATH/bin/hello
hello!

Webサーバの作成

このhello, worldをWebサーバに転化してみましょう。以下がその全プログラムです。

package main

import "net/http"

func main() {
    http.HandleFunc("/", hello)
    http.ListenAndServe(":8080", nil)
}

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello!"))
}

手順は次の通りです。まずは標準ライブラリから net/http パッケージをインポートする必要があります。

import "net/http"

次に、main関数の中で、Webサーバのルートパスにハンドラ関数を設定します。 http.HandleFunc は、正式には ServeMux と呼ばれており、デフォルトのHTTPルータ上で動作します。

http.HandleFunc("/", hello)

関数helloは、 http.HandleFunc で、特定の型シグネチャを持ち、HandleFuncに引数として渡すことができることを意味します。ルートパスにマッチした新しいリクエストがHTTPサーバに来る度に、サーバはhello関数を実行する新しいgoルーチンを生成するというわけです。hello関数は、シンプルに http.ResponseWriter を使い、クライアントにレスポンスを返します。http.ResponseWriter.Writeは、パラメータとして、より一般的な []byte (byteスライス)を取るので、文字列を単純に型変換すれば大丈夫です。

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello!"))
}

最後に、 http.ListenAndServe 経由で、デフォルトServeMuxのHTTPサーバをポート8080で開始させます。これは同期、またはブロッキングやコールで、割り込みがあるまではプログラムを起動し続けます。先ほどのようにコンパイルして実行してみましょう。

$ go build
./hello

別のターミナルかブラウザで、HTTPリクエストを出してみてください。

$ curl http://localhost:8080
hello!

簡単ですね。インストールすべきフレームワークもなく、ダウンロードすべき外部依存関係もなく、作成すべきプロジェクトスケルトンもありません。バイナリ自体でさえ、静的にリンクされたネイティブコードで、ランタイムの依存関係もないのです。さらに、標準ライブラリのHTTPサーバは、一般的な攻撃に対する耐性を持つ本番用の製品グレードで、何の仲介もなしにインターネットからのリクエストを直接、処理することができます。

ルートの追加

もちろん、単にhelloと言う以外にも、もっと面白いことができますよ。都市を入力して気象データAPIを呼び出し、気温と共にレスポンスを送ってみましょう。 OpenWeatherMap は、 現在の天気予報 用の シンプルなフリーAPI を提供しており、 都市名によってクエリ ができるようになっています。そして、次のようなレスポンスを返します(一部編集済みです)。

{
    "name": "Tokyo",
    "coord": {
        "lon": 139.69,
        "lat": 35.69
    },
    "weather": [
        {
            "id": 803,
            "main": "Clouds",
            "description": "broken clouds",
            "icon": "04n"
        }
    ],
    "main": {
        "temp": 296.69,
        "pressure": 1014,
        "humidity": 83,
        "temp_min": 295.37,
        "temp_max": 298.15
    }
}

Goは静的型付き言語であるため、このレスポンス形式を反映した構造を作成する必要があります。情報は全てではなく、必要な分だけ取得できれば大丈夫です。現状、都市名と(面白いことに)ケルビンで返される気温を取得してみましょう。ここで、気象データAPIから戻された必要データを表現するための構造体を定義します。

type weatherData struct {
    Name string `json:"name"`
    Main struct {
        Kelvin float64 `json:"temp"`
    } `json:"main"`
}

type は、新しい型を定義するキーワードです。名前を weatherData とし、構造体として宣言します。構造体の各フィールドには名前(例: NameMain )、型( string 、別の無名 struct )、そしてタグと呼ばれるものがあります。タグはメタデータのようなもので、構造体へのAPIのレスポンスを直接、展開するために encoding/json パッケージを使えるようにするものです。PythonやRubyといった動的プログラミング言語に比べると、入力が多くなるものの、非常に理想的な形で型の安全性の特性が得られます。JSONとGoについてのより詳しい情報は、 ブログのこの投稿こちらのコード例 をご覧ください。

これで構造体は定義ができました。次は、そこにデータを投入する方法を定義する必要があります。そのための関数を書いてみましょう。

func query(city string) (weatherData, error) {
    resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?q=" + city)
    if err != nil {
        return weatherData{}, err
    }

    defer resp.Body.Close()

    var d weatherData

    if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
        return weatherData{}, err
    }

    return d, nil
}

この関数は都市を表す文字列を受け取り、weatherData構造体とエラーを返します。これはGoの基本的なエラー処理のイディオムです。関数は振る舞いをエンコードしますが、振る舞いは概して失敗する可能性があります。この例で言うと、OpenWeatherMapに対するGETリクエストが失敗する理由はいくらでもあり、返されたデータが期待したものではない可能性もあります。いずれの場合も、クライアントにはnil以外のエラーを返します。そうすればクライアントは、呼び出し元のコンテキストに従ってそれを処理するはずです。

http.Get が成功すると、レスポンスボディをクローズするための呼び出しを defer して保留します。この呼び出しは関数スコープを離れる時(クエリ関数から戻る時)に実行されます。これはリソース管理の洗練された形です。その一方で、weatherData構造体をアロケートし、 json.Decoder を使用してレスポンスボディから直接この構造体に展開します。

余談ですが、 json.NewDecoder はGoの洗練された機能である インターフェース を利用します。DecoderはHTTPレスポンスボディの実体を受け取らず、 http.Response.Body の要件を満たす io.Reader インターフェースを受け取ります。Decoderは、型に対して他の振る舞い(Read)の要件を満たすメソッドを呼び出すことで機能する振る舞い(Decode)を提供します。Goでは、インターフェースで動作する関数単位で振る舞いを実装することが多いです。これにより、データ転送と経路制御を明確に切り離し、モックを使ったテストを容易にし、論理的で分かりやすいコードを書くことができます。

最後に、デコードが成功したら、成功を意味するnilエラーを付けてweatherDataを呼び出し元に返します。それでは、この関数をリクエストハンドラまでつないでみましょう。

http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) {
    city := strings.SplitN(r.URL.Path, "/", 3)[2]

    data, err := query(city)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    json.NewEncoder(w).Encode(data)
})

ここでは、ハンドラを個別の関数ではなくインラインで定義しています。 strings.SplitN を使用して/weather/以降のパス全てを受け取り、それを都市として扱います。クエリを行いエラーがあればクライアントに http.Error ヘルパー関数を使って伝えます。HTTPリクエストを完了するため、その時点で戻る必要があります。エラーがない場合は、JSONデータを送信することをクライアントに伝え、 json.NewEncoder を使って直接weatherData をJSON形式にエンコードします。

ここまで、コードはいい感じです。手順に沿い、理解しやすいものになっています。誤った解釈の心配はありませんし、一般的なエラーを見逃す恐れもありません。「hello, world」ハンドラを /hello に移動し、必要なインポートを行うと、プログラムが完成します:

package main

import (
    "encoding/json"
    "net/http"
    "strings"
)

func main() {
    http.HandleFunc("/hello", hello)

    http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) {
        city := strings.SplitN(r.URL.Path, "/", 3)[2]

        data, err := query(city)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        json.NewEncoder(w).Encode(data)
    })

    http.ListenAndServe(":8080", nil)
}

func hello(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello!"))
}

func query(city string) (weatherData, error) {
    resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?q=" + city)
    if err != nil {
        return weatherData{}, err
    }

    defer resp.Body.Close()

    var d weatherData

    if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
        return weatherData{}, err
    }

    return d, nil
}

type weatherData struct {
    Name string `json:"name"`
    Main struct {
        Kelvin float64 `json:"temp"`
    } `json:"main"`
}

ビルドして実行しましょう。前回と同じです。

$ go build
$ ./hello
$ curl http://localhost:8080/weather/tokyo
{"name":"Tokyo","main":{"temp":295.9}}

コミットしてプッシュしましょう!

複数APIへのクエリ

複数の気象データAPIにクエリして平均値を出せば、その都市のより正確な気温を出せるかもしれません。残念ながらほとんどの気象データAPIは認証が必要です。ここでは自分で Weather Underground のAPIキーを取得して下さい。

すべての気象データAPIで同じ振る舞いをしてほしいので、その振る舞いをインターフェースとしてエンコードします。

type weatherProvider interface {
    temperature(city string) (float64, error) // in Kelvin, naturally
}

これで、先ほどのOpenWeatherMapクエリ関数を、weatherProviderインターフェースの要件を満たす型に変換できるようになりました。HTTP GETを行うために状態を保存する必要がないので、空の構造体を使用します。そして動作を確認するために、ロギングを行うためのシンプルな行を追加しましょう。

type openWeatherMap struct{}

func (w openWeatherMap) temperature(city string) (float64, error) {
    resp, err := http.Get("http://api.openweathermap.org/data/2.5/weather?q=" + city)
    if err != nil {
        return 0, err
    }

    defer resp.Body.Close()

    var d struct {
        Main struct {
            Kelvin float64 `json:"temp"`
        } `json:"main"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
        return 0, err
    }

    log.Printf("openWeatherMap: %s: %.2f", city, d.Main.Kelvin)
    return d.Main.Kelvin, nil
}

レスポンスからはケルビン温度を取り出したいだけなので、レスポンス構造体はインラインで定義できます。それ以外は、openWeatherMap構造体でメソッドとして定義したクエリ関数とほとんど同じです。このようにして、openWeatherMapのインスタンスをweatherProviderとして使用できます。

Weather Undergroundでも同じことをしましょう。唯一の違いは、APIキーを提供する必要があることです。キーを構造体に保存して、メソッド内で使用します。非常に似通った関数になります。

(Weather UndergroundはOpenWeatherMapほどあいまいさを排除しないことに注意してください。ここでは例を示すことを優先して、あいまいな都市名を扱うための重要なロジックをいくつか飛ばします。)

type weatherUnderground struct {
    apiKey string
}

func (w weatherUnderground) temperature(city string) (float64, error) {
    resp, err := http.Get("http://api.wunderground.com/api/" + w.apiKey + "/conditions/q/" + city + ".json")
    if err != nil {
        return 0, err
    }

    defer resp.Body.Close()

    var d struct {
        Observation struct {
            Celsius float64 `json:"temp_c"`
        } `json:"current_observation"`
    }

    if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
        return 0, err
    }

    kelvin := d.Observation.Celsius + 273.15
    log.Printf("weatherUnderground: %s: %.2f", city, kelvin)
    return kelvin, nil
}

これで気象データプロバイダが2つ揃いました。では両方にクエリし、平均気温を返す関数を書きましょう。ここでは複雑さをさけるために、エラーが発生したらあきらめることにします。

func temperature(city string, providers ...weatherProvider) (float64, error) {
    sum := 0.0

    for _, provider := range providers {
        k, err := provider.temperature(city)
        if err != nil {
            return 0, err
        }

        sum += k
    }

    return sum / float64(len(providers)), nil
}

この関数の定義はweatherProvider temperatureメソッドとほとんど同じであることにお気付きですか? 個々のweatherProvidersを型に集めて、その型にtemperatureメソッドを定義すれば、他のweatherProvidersから構成される包括的なweatherProviderを実装できます。

type multiWeatherProvider []weatherProvider

func (w multiWeatherProvider) temperature(city string) (float64, error) {
    sum := 0.0

    for _, provider := range w {
        k, err := provider.temperature(city)
        if err != nil {
            return 0, err
        }

        sum += k
    }

    return sum / float64(len(w)), nil
}

完璧です。これで、weatherProviderを受け入れるところならどこへでもmultiWeatherProviderを渡すことができます。

これで、HTTPサーバにつなぐことができるようになりました。前回とほとんど同じです。

func main() {
    mw := multiWeatherProvider{
        openWeatherMap{},
        weatherUnderground{apiKey: "your-key-here"},
    }

    http.HandleFunc("/weather/", func(w http.ResponseWriter, r *http.Request) {
        begin := time.Now()
        city := strings.SplitN(r.URL.Path, "/", 3)[2]

        temp, err := mw.temperature(city)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json; charset=utf-8")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "city": city,
            "temp": temp,
            "took": time.Since(begin).String(),
        })
    })

    http.ListenAndServe(":8080", nil)
}

前回と同様に、コンパイルして、実行し、GETします。JSONレスポンスに加えて、サーバログに何か出力されるはずです。

$ ./hello
2015/01/01 13:14:15 openWeatherMap: tokyo: 295.46
2015/01/01 13:14:16 weatherUnderground: tokyo: 273.15
$ curl http://localhost/weather/tokyo
{"city":"tokyo","temp":284.30499999999995,"took":"821.665230ms"}

コミットしてプッシュしましょう!

並列化

現時点では、APIを1つずつ同期的にクエリしているだけです。しかし、同時にクエリしてはいけない理由はありません。そうすればレスポンス時間を削減できるはずです。

これを行うために、Goの並列処理命令であるgoルーチンとチャネルを使います。各APIへのクエリをそれぞれのgoルーチン内で生成すれば、並列で実行されます。レスポンスを1つのチャネルに集めて、全てが完了したら平均値を算出します。

func (w multiWeatherProvider) temperature(city string) (float64, error) {
    // Make a channel for temperatures, and a channel for errors.
    // Each provider will push a value into only one.
    temps := make(chan float64, len(w))
    errs := make(chan error, len(w))

    // For each provider, spawn a goroutine with an anonymous function.
    // That function will invoke the temperature method, and forward the response.
    for _, provider := range w {
        go func(p weatherProvider) {
            k, err := p.temperature(city)
            if err != nil {
                errs <- err
                return
            }
            temps <- k
        }(provider)
    }

    sum := 0.0

    // Collect a temperature or an error from each provider.
    for i := 0; i < len(w); i++ {
        select {
        case temp := <-temps:
            sum += temp
        case err := <-errs:
            return 0, err
        }
    }

    // Return the average, same as before.
    return sum / float64(len(w)), nil
}

これで、リクエストは最も遅いweatherProviderにかかる時間で処理できるようになりました。multiWeatherProviderの振る舞いを変更するだけでできたのです。そして特筆すべきは、いまだにシンプルで同期的なweatherProvider インターフェースとしての要件を満たしていることです。

コミットしてプッシュしましょう!

シンプルさ

「hello world」から始めて、並行処理のREST風バックエンドサーバまで、わずかなステップとGoの標準ライブラリだけを使ってやってきました。このコードはフェッチして、 ほとんどすべてのサーバアーキテクチャ にデプロイできます。結果として生成されるバイナリは自己完結型で高速です。そして最も重要なのは、このコードは読みやすく解釈が容易なことです。必要に応じて保守や拡張を簡単に行うこともできます。これらの特性は、シンプルさをあくまで最重要視したGoの哲学のおかげだと私は思います。Goの開発者の1人であるRob Pikeが記したように、 少ないほど飛躍的に増加する のです。

追加演習

最終コードをgithubに フォーク しましょう。

別のweatherProviderを追加してみましょう(ヒント: forecast.io はお勧めです)。

multiWeatherProviderにタイムアウトを実装しましょう(ヒント: time.After を参照してください)。

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