JSON返すAPIをスクレイピングするPrometheusのexporterをGoで作ってみた

# TL;DR
- golangのお勉強がてらprometheusのcustom exporter作った
- お天気APIをスクレイピングして結果をmetricsとして返すやつ
- 初golangで動くものつくった(のでコードはとてもとても雑

# はじめに
プチ開発合宿にお呼ばれしたので､やってみた｡
最近prometheusさわってて､pythonだとぼちぼちcustom exporter作ってたけど､まぁgolangで書けるようにもなりたいなと｡

# 何を作ったか
[Weather Hacks - livedoor 天気情報](http://weather.livedoor.com/weather_hacks/)から東京の天気がとれるので､明日の天気の最高/最低気温を摂氏華/氏それぞれ教えてくれる｡

```bash:curl_example
$ curl http://weather.livedoor.com/forecast/webservice/json/v1?city=130010 |jq ".forecasts[1].temperature"
{
  "min": {
    "celsius": "14",
    "fahrenheit": "57.2"
  },
  "max": {
    "celsius": "21",
    "fahrenheit": "69.8"
  }
}
```
.forecasts[0]が今日の天気で.forecasts[1]が明日の天気な感じ｡

そしてそれをそのままスクレイピングして以下なメトリクスを返すexporterを作った｡

| 項目 | 中身 |
|:----|:----|:----|
| メトリクス名 | temprature_tokyo_tomorrow |
| ラベルその1 | category : 最高 or 最低 |
| ラベルその2 | degree: 摂氏 or 華氏 |
| 値 | 気温それぞれ |

# 書いたコード
必要なパッケージはいれておいた｡

```bash:install_some_go_packages
$ go get github.com/koron/go-dproxy
$ go get github.com/prometheus/client_golang/prometheus/promhttp

```

```go:weather_exporter.go
package main

import (
        "flag"
        "log"
        "net/http"
        "github.com/prometheus/client_golang/prometheus"
        "github.com/prometheus/client_golang/prometheus/promhttp"
        "io/ioutil"
        "encoding/json"
        "github.com/koron/go-dproxy"
        "strconv"
        "time"
)

var (
        addr = flag.String("listen-address", ":8080", "The address to listen on for HTTP requests.")
)

var (
        // Gauge metrics define
        tempTokyoTomorrow = prometheus.NewGaugeVec(
                prometheus.GaugeOpts{
                        Name:       "temperature_tokyo_tomorrow",
                        Help:       "Tomorrow weather forecast for Tokyo.",
                },
                []string{"category", "degree"},
        )
)

func init() {
        prometheus.MustRegister(tempTokyoTomorrow)
}

// scrape wearch api & parse result json
func getWeatherForecast() (float64, float64, float64, float64) {
        url := "http://weather.livedoor.com/forecast/webservice/json/v1?city=130010"

        res, err_http := http.Get(url)
        if err_http != nil {
                panic(err_http.Error())
        }

        body, _ := ioutil.ReadAll(res.Body)

        var tempData interface{}
        err_json := json.Unmarshal(body, &tempData)
        if err_json != nil {
                panic(err_json.Error())
        }

        minCelsius, _ := dproxy.New(tempData).M("forecasts").A(1).M("temperature").M("min").M("celsius").String()
        maxCelsius, _ := dproxy.New(tempData).M("forecasts").A(1).M("temperature").M("max").M("celsius").String()
        minFahrenheit, _ := dproxy.New(tempData).M("forecasts").A(1).M("temperature").M("min").M("fahrenheit").String()
        maxFahrenheit, _ := dproxy.New(tempData).M("forecasts").A(1).M("temperature").M("max").M("fahrenheit").String()
        var _fmic, _ = strconv.ParseFloat(minCelsius, 64)
        var _fmac, _ = strconv.ParseFloat(maxCelsius, 64)
        var _fmif, _ = strconv.ParseFloat(minFahrenheit, 64)
        var _fmaf, _ = strconv.ParseFloat(maxFahrenheit, 64)

        return _fmic, _fmac, _fmif, _fmaf
}

func main() {
        flag.Parse()

        // repeat scraping for each 60s
        go func() {
                for {
                        fmic, fmac, fmif, fmaf := getWeatherForecast()
                        tempTokyoTomorrow.WithLabelValues("min", "celsius").Set(fmic)
                        tempTokyoTomorrow.WithLabelValues("max", "celsius").Set(fmac)
                        tempTokyoTomorrow.WithLabelValues("min", "fahrenheit").Set(fmif)
                        tempTokyoTomorrow.WithLabelValues("max", "fahrenheit").Set(fmaf)
                        time.Sleep(60 * time.Second)
                }
        }()

        // Expose the registered metrics via HTTP.
        http.Handle("/metrics", promhttp.Handler())
        log.Fatal(http.ListenAndServe(*addr, nil))
}

```
# 動いたもの
curlで値とれた｡

```bash:run_weather_exporter
$ go build weather_exporter.go
$ ./weather_exporter &

$ curl localhost:8080/metrics
...
# HELP temperature_tokyo_tomorrow Tomorrow weather forecast for Tokyo.
# TYPE temperature_tokyo_tomorrow gauge
temperature_tokyo_tomorrow{category="max",degree="celsius"} 21
temperature_tokyo_tomorrow{category="max",degree="fahrenheit"} 69.8
temperature_tokyo_tomorrow{category="min",degree="celsius"} 14
temperature_tokyo_tomorrow{category="min",degree="fahrenheit"} 57.2
...

```

prometheus.ymlに適当にjob登録したらブラウザからも見れた｡

targetsとれてる｡
![target.png](https://qiita-image-store.s3.amazonaws.com/0/26677/3e286d77-9478-55e3-2774-e3e74daeede2.png)

Consoleでも出てる｡
![console2.png](https://qiita-image-store.s3.amazonaws.com/0/26677/444fa171-7f92-58ed-c7d4-6b22a26df1f4.png)

# 開発で困ったこと
- getしたJSONをパースする際にそのstructをどう定義したものか?
    - 取得するjsonの構造体を事前に定義がいるのはわかる｡ 
        - [Go言語でJSONを扱う - Qiita](http://qiita.com/nayuneko/items/2ec20ba69804e8bf7ca3)
    - しかし､スクレイピング時だとJSONのごく一部の情報しかいらないので全部定義はやだなぁ
    - goqueryなるものはあるらしいがjsonパース関係ないし...
        - [goqueryでお手軽スクレイピング！ - Qiita](http://qiita.com/yosuke_furukawa/items/5fd41f5bcf53d0a69ca6)
    - 結局dproxyなるものがあったのでそれを利用したけどこれでいいのか感が残った
        - [golang は ゆるふわに JSON を扱えまぁす! — KaoriYa](https://www.kaoriya.net/blog/2016/06/25/)
- GOPATHというか､golangの開発時ファイルツリーどうするといいかとかお勉強しないとなと
- 繰り返しっぽい処理をどう構造化するといいのかのありがちな書き方みたいなのがわからないのでつらい
    - 温度それぞれmetricsにいれたり､文字列からfloat64にキャストしたりするとこが特に

# 所感
- 動くものできたの大事｡とても大事
    - やっぱ開発合宿は詰まった時に逃げられないのはいい
    - 時間配分は､全体(3時間) = お題&仕様確定(30分)+JSONのパースできない(120分)+残りのコード(30分)という時間配分
    - つまり､JSONパースのとこどうするか問題､どう構造体定義するかに悩んだ｡とても悩んだ
    - とりま多少はgolangのコードが書けたので今度はtour of goを完走できるはず!
    - 動くようになって多少refactorして､関数外に出したりしたらちょっと理解が進んだ
- prometheusのgolang clientはREADME説明すくない...
    - sampleコードx2あるからわかるやろ?感
    - てか説明ないからgithub上のコード検索してGaugeの定義どうするの?とか探したし
    - prometheusがらみ､全体的に準備された変数あるけど解説ないのでコードを読む､が多い気がする
    - python clientのREADMEはとても充実してるのに(それでも足りないくらいだけど)
    - ま､もう慣れたので特に困りはしなかったけど
- single binaryで動くのやっぱいいなぁ
    - というかPrometheus環境に変なexporterのためだけに､じゃらじゃら言語環境増やすのやだなと思ってた
    - Prometheusはまんま動くのにjavaだpythonだと3rd partyなexporter増えると...
    - なので今回の一番のモチベーションはここ
- pythonで同じことやれと言われたら5分くらいでできる気がするけど気にしない

# (おまけ) 開発方針
- APIはどれでもよかった
    - 認証ない方が簡単かな位
- prometheusのexporterは何度か作ってるし､公式のサンプルコードいじる程度でいい
- golangは初心者なのでまず動くのが大事
    - tour of goを何度か途中挫折した程度しか分かってないし､かっこよくは書けない
    - コピペ動かしていじっていく､を繰り返す
    - やってから体系的にお勉強しよう
- 以下､やった開発段階
    0. [hello world](http://qiita.com/techys/items/44a712a76859fe0a9dea)､コピペして動かす
    0. [net/httpでgetしてresponse textを標準出力](http://qiita.com/taizo/items/c397dbfed7215969b0a5)､コピペして天気APIにURLだけ変えて動かす
    0. そのget responseのjsonを[dproxy](https://www.kaoriya.net/blog/2016/06/25/)でパースして温度だけとる
    0. [prometheus/client_golangのsampleのsimple](https://github.com/prometheus/client_golang/blob/master/examples/simple/main.go)コピペして動かす
    0. [prometheus/client_golangのsampleのrandom](https://github.com/prometheus/client_golang/blob/master/examples/random/main.go)コピペして動かす
    0. randomのmetricsを1つに減らして動かす
    0. ↑のmetricsがSummaryなので､Gaugeで固定値返すようにして動かす
    0. 天気用のmetrics名やlabelを返すように名前変えて動かす
    0. 3.の気温スクレイピング処理を足して､固定値をその値に置きかえて動かす
    0. mainに全部突っ込んでたのでfuncで処理をちょっと外へ
    0. 軽く発表

以上｡