理系学生日記

おまえはいつまで学生気分なのか

Golangでジオロケーションを行ってみる

大量の住所を Google Maps 上にマッピングする必要が生じ、住所を緯度と経度に変換することになったので、いわゆるジオコーディングをプログラムすることにしました。 ジオコーディングに関しては、Google Maps API が無料で使用できて、かつ、扱いやすいので、これを使うことにします。

Google Maps API では、緯度や経度が 10 進数で返却されるのですが、今回は諸事情で 60 進数 (度分秒) で表現する必要があったので、そのあたりの処理も入れています。

仕様

  1. 入力は住所やランドマークを記載した CSV ファイル
  2. 出力は、住所やランドマーク、緯度、経度を記載した CSV ファイル

具体的な例としては、以下のような入力に対して、

東京ドーム
東京駅

以下のファイルが出力されます。

東京ドーム,35°42'20.3"N,139°45'6.8"E
東京駅,35°40'52.7"N,139°45'58.5"E

緯度、経度をこういう形式で記述するのは初めてでしたが、Google Maps はこの形式でも場所を特定できるんですね。知らなんだ。

技術要素

Golang 入門者なので、今回は

  • CSV からの読み取り、書き込み
  • JSON のデコード
  • Channel
  • errgroup

あたりにチャレンジしました。

JSON から Go Struct の生成

Go は静的言語であるが故に、基本的には JSON と Go の構造体 (Struct) をマッピングする必要があります(interface を使用して回避することはできますが、逆に実装がダルいことになります…) 今回は、Google Maps API のレスポンスのうち、必要なデータは限られていたので、以下のような struct を作っています。

type Geo struct {
    Results []struct {
        FormattedAddress string `json:"formatted_address"`
        Geometry         struct {
            Location struct {
                Lat float64 `json:"lat"`
                Lng float64 `json:"lng"`
            } `json:"location"`
        } `json:"geometry"`
    } `json:"results"`
    Status string `json:"status"`
}

この Struct とのマッピングを記述するのはわりかしダルいので、ぼくは JSON-to-Go というサービスを使いました。

左側のテキストボックスに JSON をぶちこむと、右側に Go の struct が出力されます。 こういうところを手で書くほど潤沢な時間が人類に残されているわけではないので、今後も積極的に使っていきたい。

実装コード

Go、まだ慣れない。

package main

import (
    "context"
    "encoding/csv"
    "encoding/json"
    "flag"
    "fmt"
    "io"
    "math"
    "net/http"
    "net/url"
    "os"

    "golang.org/x/sync/errgroup"
)

func main() {
    var infile = flag.String("i", "", "住所が入ったCSVファイル")
    var outfile = flag.String("o", "", "出力するCSVファイル")

    flag.Parse()

    if *infile == "" {
        fmt.Fprintf(os.Stderr, "specify csv file with -i\n")
        os.Exit(1)
    }
    if *outfile == "" {
        fmt.Fprintf(os.Stderr, "specify output file with -o\n")
        os.Exit(1)
    }

    if err := newApp(*infile, *outfile).run(); err != nil {
        fmt.Fprintf(os.Stderr, "%v", err)
    }
}

type Geo struct {
    Results []struct {
        FormattedAddress string `json:"formatted_address"`
        Geometry         struct {
            Location struct {
                Lat float64 `json:"lat"`
                Lng float64 `json:"lng"`
            } `json:"location"`
        } `json:"geometry"`
    } `json:"results"`
    Status string `json:"status"`
}

type App struct {
    AddressFile   string
    GeoDecodeFile string
    Client        *http.Client
}

// NewApp creates a new application with input and output file pointer.
func newApp(infile, outfile string) *App {
    return &App{
        AddressFile:   infile,
        GeoDecodeFile: outfile,
        Client:        &http.Client{},
    }
}

func (app *App) run() error {

    infp, err := os.Open(app.AddressFile)
    if err != nil {
        return fmt.Errorf("open %s: ", app.AddressFile)
    }
    defer infp.Close()

    outfp, err := os.Create(app.GeoDecodeFile)
    if err != nil {
        return fmt.Errorf("open %s: ", app.GeoDecodeFile)
    }
    defer outfp.Close()

    eg, ctx := errgroup.WithContext(context.Background())
    q := make(chan string, 1000)

    eg.Go(func() error {
        return app.enqueue(ctx, infp, q)
    })

    eg.Go(func() error {
        return app.putGeocode(ctx, outfp, q)
    })

    if err := eg.Wait(); err != nil {
        return err
    }

    return nil
}

func (app *App) enqueue(ctx context.Context, fp *os.File, q chan<- string) error {
    reader := csv.NewReader(fp)
    reader.LazyQuotes = true

    for {
        records, err := reader.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            return fmt.Errorf("error while reading %s", fp.Name())
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case q <- records[0]:
        }
    }
    close(q)
    return nil
}

func (app *App) putGeocode(ctx context.Context, fp *os.File, q <-chan string) error {
    for address := range q {
        lat, lng, err := app.geocode(ctx, address)
        if err != nil {
            return fmt.Errorf("decode %s", err)
        }
        fmt.Fprintf(fp, "%s,%sN,%sE\n", address, convert(lat), convert(lng))
    }
    fp.Sync()
    return nil

}

// 10 進数による座標を 60 進数(度分秒)に変換する
// ref. http://www.benricho.org/map_latlng_10-60conv/
func convert(n float64) string {
    degree := math.Trunc(n)
    leftover := (n - degree) * 60

    minute := (int)(math.Trunc(leftover))
    leftover -= (float64)(minute)

    second := leftover * 60
    return fmt.Sprintf("%d°%d'%3.1f\"", int(degree), minute, second)
}

func (app *App) geocode(ctx context.Context, address string) (lat, lng float64, err error) {
    values := url.Values{}
    values.Add("address", address)

    req, err := http.NewRequest("GET", "https://maps.googleapis.com/maps/api/geocode/json", nil)
    if err != nil {
        return -1, -1, err
    }
    req = req.WithContext(ctx)

    req.URL.RawQuery = values.Encode()
    resp, err := app.Client.Do(req)
    if err != nil {
        return -1, -1, err
    }
    defer resp.Body.Close()

    var geo Geo
    decoder := json.NewDecoder(resp.Body)
    err = decoder.Decode(&geo)
    if err != nil {
        return -1, -1, err
    }

    fmt.Printf("[%d] %s\n%s\n\n", resp.StatusCode, address, geo)
    l := geo.Results[0].Geometry.Location
    return l.Lat, l.Lng, nil
}