pco2699’s blog

学んだものについて、メモしておく場所

Go言語で今日傘が必要か教えてくれる傘APIをつくってみた ~Mockテストもしっかりやるよ~

こんにちは、この記事はgo advent calendar 2018 9日目の記事です

Go言語初心者といっても過言ではなく他のGoアドベントカレンダーの方々の記事を見てると。とても恐れ多いですが 初心者なりにアウトプットしたいと思っています!!

ちなみに、↓のネタもあったんですが、全く受けなかったのでやめておきましたw

モチベーション

  • Go言語でちょっとしたCLIツール(tailコマンドコピーみたいなやつ)は作ったことあるけど、Web系のAPI作ったこと無い
  • せっかくだから、なんか欲しいものつくりたい
  • 最近、手ぶらで出かけるのにハマってるんだけど、雨によく打たれて困るから、朝に傘もっていくか知りたい -> それ作ろう

というわけで今回のネタに決まりましたー!!!

APIの方針

  • GoのAPIフレームワーク(Goaとか)はいろいろあるらしいけど、超簡単なAPIなのでとりあえず標準パッケージのみでつくる
  • API自体の設計は超シンプルにする
  • せっかくなので、モックかましAPIのテストも書いてみる

せっかくなので、GraphQLとかgRPC とかでやってみる説もあったんですが
今後LINE BOTとかに組み込みやすくしたいので
ノウハウが溜まっているJSON/REST APIにしておきます。(ほんとは時間なかっただけ

APIをつくっていくよ編

API自体は非常にシンプルでして、ほぼほぼOpenWeatherMap APIのラップに近いです。 /v1を投げると以下のようなシンプルなjsonが返ってきます。

{
    "is_umbrella_required": false, // 傘いるかフラグ
    "rain_volume": 0 // 向こう24時間での予測降水量
}

OpenWeatherMap APIにリクエストを投げる

天気を取得するAPIとしてOpenWeatherMapAPIを使います。
このAPIは天気関連のプロダクトを作る際には非常によく使われているAPIなので
基本的な登録の仕方やAPIの内容詳細については、説明を割愛します。

今回はこちらの「5 day / 3 hour forecast」を使います。 f:id:pco2699:20181209122117p:plain

構造体でjsonマッピングを設定

まずはAPIの仕様書に基づいて Goで構造体を定義します。

type WeatherData struct {
    Cod string `json:"cod"`
    Message float64 `json:"message"`
    Count int `json:"count"`
    List []WeatherList `json:"list"`
}

// 以下、WeatherListの内容など...(長いので省略)

これって全部マッピングする必要無いよな...!?とか思ったんですが
よくわからなかったんで、とりあえず全部jsonを構造体にマッピングしました。

たぶん、必要なデータだけjson:"hogehoge"的な感じでやればマッピングされると思います。

ApiClient interfaceの定義

今回はMockテストをしたいので、interfaceでAPIコールを行う機能を定義します。
以下の通りです。

package WeatherApiClient

import "umbrellaApi/WeatherData"

type ApiClient interface {
    Fetch() WeatherData.WeatherData
}

ちなみに、自分は普段JavaでSpringを書いているのですが
このコードを書いたことでデフォでinterfaceに依存させているのか よく理解できました。

ApiClientの実装を行う

上記のinterfaceの実装を行っていきます。 まず構造体で接続情報を定義していきます。

type WeatherApiClient struct {
    RequestUrl string //リクエスト先のURL
    Place      string //OpenWeatherMapのリクエストパラメータ①
    Format     string //リクエストパラメータ②
    Count      string //③
    Lang       string //④
}

コンストラクタを定義します。 ここで具体的な接続情報を入れていきます。

// WeatherApiClient.go
func NewWeatherApiClient() ApiClient {
    return &WeatherApiClient{
        "https://api.openweathermap.org/data/2.5/forecast",
        "Tokyo,jp",
        "json",
        "8", // 3時間毎×8 -> 24時間後までの予測を取得する
        "ja",
    }
}

Fetchの処理の中身を書いていきます。 ここで、OpenWeatherMapAPIをコールしてjsonをFetchした上で、WeatherDataの構造体につめて返してます。

// WeatherApiClient.go
func (w *WeatherApiClient) Fetch() WeatherData.WeatherData {
    values := url.Values{}
    apiKey := os.Getenv("API_KEY")
    values.Add("APPID", apiKey)
    values.Add("q", w.Place)
    values.Add("mode", w.Format)
    values.Add("cnt", w.Count)
    values.Add("lang", w.Lang)
    res, err := http.Get(w.RequestUrl + "?" + values.Encode())
    if err != nil {
        log.Fatal("Failed to get request url")
    }

    defer res.Body.Close()
    body, err := ioutil.ReadAll(res.Body)
    var wd WeatherData.WeatherData
    err = json.Unmarshal(body, &wd)
    if err != nil {
        log.Fatal("Failed to fetched unmarshal json")
    }

    return wd
}

APIサーバーを立てる

OpenWeatherMap APIにコールする部分は完成したので
今度は、APIサーバー部を書きます。

Serverの定義

テストしやすいようにAPIサーバーの処理は
構造体で定義しておき、その中にinterfaceであるAPIClientを入れておきます。

APIサーバーのルーティングはシンプルなのでhttp.handleFuncで定義します。

handleApiでは、OpenWeatherMap APIで取得した情報をもとに
今後、24時間の予測降水量を積算して、少しでも今後雨が降るようだったら
傘を持っていくフラグをONにしています。 それをmapに詰めてjson形式にして返しています。

// Server.go
func (s *Server) Start() {
    http.HandleFunc("/v1", s.handleApi)
    log.Fatal(http.ListenAndServe(":3000", nil))
}

func (s *Server) handleApi(w http.ResponseWriter, r *http.Request) {
    wd := s.ApiClient.Fetch()
    rainVolume := 0.0
    for _, values := range wd.List {
        rainVolume += values.Rain.Volume
    }

    isUmbrellaRequired := false
    if rainVolume > 0 {
        isUmbrellaRequired = true
    }
    if err := json.NewEncoder(w).Encode(map[string]interface{}{"isUmbrellaRequired": isUmbrellaRequired, "rainVolume": rainVolume}); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

mainを書く

あとは、mainで書いたServerを呼び出せば OKです。 .envファイルを読み込めるようにjoho/godotenv/autoloadを読み込んでます。 これでルートディレクトリにある.envを勝手に読み込んでくれます。(便利)

// main.go
import (
    "umbrellaApi/Server"
    "umbrellaApi/WeatherApiClient"

    _ "github.com/joho/godotenv/autoload"
)

func main() {
    wac := WeatherApiClient.NewWeatherApiClient()
    server := &Server.Server{
        wac,
    }
    server.Start()
}

Mockかましてテストするよ編

さて、すごい簡単なAPIにもかかわらずinterfaceなどが登場して、いろいろめんどくさい感じになってますが あれもこれも、テストをしやすくするための工夫です。

それでは、以下の重要な部分について、それぞれテストを書いていきましょう。

  • OpenWeatherMap APIにリクエスト投げる部分
  • APIの傘持っていく判定を行う部分

今回のコードですと、それぞれ依存することなくテストを書くことができます。

OpenWeatherMap APIにリクエスト投げる部分

WeatherMapApiClientに対して、テストを書いていきます。
今回は、利用する部分のチェックしか行ってません。

// WeatherMapApiClient_test.go
package WeatherApiClient

import (
    "testing"
)

func TestWeatherApiClient_Fetch(t *testing.T) {
    wac := NewWeatherApiClient("{API_KEYの情報を入れる}")
    wd := wac.Fetch()

    if wd.Cod.String() != "200" {
        t.Errorf("got: %v\nwant: %v", wd.Cod, "200")
    }
    for _, values := range wd.List {
        if &values.Rain.Volume == nil {
            t.Errorf("got: %v\nwant: %v", values.Rain.Volume, "Number")
        }
    }
}

APIの傘を持っていく判定を行う部分

まずは、mockのApiClientを差し込むことで、毎回OpenWeatherMap APIにアクセスせずとも
テストを行えるようにします。

type testApiClient struct {}

func (t testApiClient) Fetch() WeatherData.WeatherData {
    wd := WeatherData.WeatherData{
        // テストデータを詰める
    }
    return wd
}

こいつをServerに押し込んでやることで、Serverでテストが行えるようになります。
Serverのテストはhttptest.NewServerで行います。

func TestServer(t *testing.T) {
    server := &Server{
        testApiClient{},
    }
    ts := httptest.NewServer(http.HandlerFunc(server.handleApi))
    defer ts.Close()

    res, err := http.Get(ts.URL)
    if err != nil {
        t.Fatalf("%v", err)
    }

    data, err := ioutil.ReadAll(res.Body)
    if err != nil {
        t.Fatalf("%v", err)
    }
    var apiData ApiData
    err = json.Unmarshal(data, &apiData)
    if err != nil {
        t.Fatalf("%v", err)
    }
    if apiData.IsUmbrellaRequired != true {
        t.Errorf("got: %v\nwant: %v", apiData.IsUmbrellaRequired, true)
    }

    if apiData.RainVolume != 0 {
        t.Errorf("got: %v\nwant: %v", apiData.RainVolume, true)
    }

}

今回のコード

今回はコードは以下からご覧いただけます。
指摘やコメントなど大歓迎です m( )m

github.com