📍

【go-redis】GoとRedisで位置情報を管理しよう【GEOADD/ZREM/GEORADIUS編】

2021/03/31に公開

Redisには位置情報を保存する機能がある

Redisは緯度と経度をSorted Set(ソート済みセット)として保存しておく機能があります。

またGEORADIUSコマンドを使って指定範囲内のユーザーを取得するなど単にCreate/Deleteを提供するだけではないので位置情報を管理する手段として大変便利です。(LINEに昔あったふるふる機能は位置情報とジャイロセンサの合わせ技らしいのでああいうのが作れます。)

go-redisを使ってGoからRedisの位置情報機能を操作してみます。

今回は以下のコマンドをGoのプログラムから実行してみます。

  • GEOADD
  • ZREM
  • GEORADIUS

バージョン

  • Go 1.15.6
  • Redis 6.1.242
  • go-redis v8.5.0
  • godotenv v1.3.0 (Redisへの接続情報をenvファイルで管理します。envファイルを使わないのであれば必要ありません。)

envファイルの準備

※envファイルを使う方のみお読みください

.env
REDIS_ADDR: {redis-serverへのアドレス}
REDIS_PORT: {redis-serverのポート番号} # デフォルトは6379
REDIS_DB: 0
REDIS_PASSWORD: ""

GoでRedisに接続

本題ではないのでコードを置いておきます

connect.go
package redisconn

import (
  "fmt"
  "os"
  "strconv"
  "time"
  
  rds "github.com/go-redis/redis/v8"
)

var (
  client *rds.Client

  addr     = os.Getenv("REDIS_ADDR")
  port     = os.Getenv("REDIS_PORT")
  password = os.Getenv("REDIS_PASSWORD")
  dbStr    = os.Getenv("REDIS_DB")
)

func init() {
  db, err := strconv.Atoi(dbStr)
  if err != nil {
    panic(err)
  }

  addr := fmt.Sprintf("%s:%s", addr, port)
  c := rds.NewClient(&rds.Options{
    Addr:     addr,
    Password: password,
    DB:       db,
  })

  client = c
}

※package名は適宜変えてください

位置情報の追加:GEOADD

RedisではGEOADDコマンドで位置情報を保存できます。

ではgo-redisの力を借りてGeoAdd関数を作ってみましょう。

geoadd.go
package redisconn

func GeoAdd(ctx context.Context, key string, geoLocation ...*rds.GeoLocation) error {
  if err := client.GeoAdd(ctx, key, geoLocation...).Err(); err != nil {
    return err
  }

  return nil
}

第2引数にあるkeyは位置情報を保存しているセットに対するキーです。Redisでは位置情報はソート済みセットとして扱うので、key-valueの形ではなく、key-{value1, value2, ...}といった形で保存されています。

特に気にしない実装であればkeyは位置情報の管理用に1つ定数として宣言しておくと楽だと思います。

第3引数にあるrds.GeoLocation型はこんな形になってます。

type GeoLocation struct {
  Name                      string
  Longitude, Latitude, Dist float64
  GeoHash                   int64
}

Nameフィールドは場所の名前です。僕が開発したときはユーザーの位置情報を扱っていたのでユーザーIDを入れておきました。Set内で一意になるようにします。
LatitudeLongitudeは名前の通り、緯度(latitude)と経度(longitude)を保持しておくフィールドです。
GeoHashDistはGEOADDでは使いません。空のままで大丈夫です。

GEOADDしたら削除処理を忘れずに!

ご注意いただきたいのがRedisはGEOADDの際にExpiredAt(有効期限)を設定しないのでGEOADDと同時に削除処理を書かないとデータが溜まり続けます。

削除処理は後述しますので続けてお読みください。

位置情報の削除:ZREM (GEOREMじゃないよ)

GEOADD(追加)コマンドがあるならGEOREM(削除)コマンドもあるだろと思う気持ちよくわかります。

GEOREMはありません。

とはいえ先ほど申し上げた通りGEOADDに有効期限はないので削除されず溜まり続けるのも困ったものです。

ご安心ください。ここまで何度も書いた通り位置情報はソート済みセットとして保存されるのでソート済みセットの削除操作を行えば良いだけなのです。

RedisではZREMコマンドです。

というわけでZRem関数を書いてみましょう。

zrem.go
package redisconn

func ZRem(ctx context.Context, key string, members ...interface{}) error {
  if err := client.ZRem(ctx, key, members...).Err(); err != nil {
    return err
  }

  return nil
}

第2引数のkeyは先ほどのGeoAdd関数の第2引数と同じものを指定します。membersはinterface型でわかりにくいですがrds.GeoLocation型のNameフィールドに指定した値を与えます。

この関数を実行することでmembersで指定した値をNameに持つ位置情報がセットから削除されます。

半径○m以内のユーザーを取得する:GEORADIUS

※単位はメートル(m)、キロメートル(km)、マイル(mi)、フィート(ft)が使えます。

僕はRedisで位置情報を扱う上で圧倒的な即応性とこのGEORADIUSコマンドが便利だなと思っています。GEORADIUSコマンドは指定した緯度経度から指定した半径以内に含まれているメンバのリストを返すコマンドです。

かいつまんで言えばユーザーの位置情報を扱っている場合、このコマンドによって指定した場所から半径○m以内にいるユーザーたちをさがすことができるというわけです。

というわけでGeoRadius関数を書いてみましょう

georadius.go
package redisconn

func GeoRadius(ctx context.Context, key string, lati, longi float64, query *rds.GeoRadiusQuery) (*[]rds.GeoLocation, error) {
  results, err := client.GeoRadius(ctx, key, longi, lati, query).Result()
  if err != nil {
    return err
  }

  return &results, nil
}

第2引数のkeyにはGeoAdd関数やZRem関数で与えたkeyを入れます。

第3、第4引数のlatilongiはそれぞれ中心となる緯度、経度を入れます。

第5引数のqueryにはrds.GeoRadiusQuery型のポインタを入れます。
rds.GeoRadiusQuery型をみてみましょう

// GeoRadiusQuery is used with GeoRadius to query geospatial index.
type GeoRadiusQuery struct {
  Radius float64
  // Can be m, km, ft, or mi. Default is km.
  Unit        string
  WithCoord   bool
  WithDist    bool
  WithGeoHash bool
  Count       int
  // Can be ASC or DESC. Default is no sort order.
  Sort      string
  Store     string
  StoreDist string
}

最低限必要なのはRadius(半径)とUnit(単位)です。例えば「半径15m」をGeoRadius関数で検索する場合、第5引数のqueryには以下のような値を入れます。

&rds.GeoRadiusQuery{
  Radius: 5,
  Unit:   "m",
}

戻り値は*[]rds.GeoLocationなのでNameフィールドからユーザーIDを取り出すことができます。

おまけ

GeoAdd関数とZRem関数を組み合わせて指定時間後に自動で削除するGeoAddAndRem関数を書いてみました。

geoaddandrem.go
package redisconn

import (
  "context"
  "time"

  rds "github.com/go-redis/redis/v8"
)

func GeoAddAndRem(key string, exp time.Duration, geoLocation ...*rds.GeoLocation) error {
  ctx := context.Background()
  err := GeoAdd(ctx, key, geoLocation...)
  if err != nil {
    return err
  }
  
  go func() {
    members := []string{}
    for _, geoLoc := range geoLocation {
      members = append(members, geoLoc.Name)
    }
    time.Sleep(exp)
    _ = ZRem(ctx, key, members)
  }()

  return nil
}

実際削除せずに放置なんてことはないと思うのでこちらの関数が便利だと思います。

自己紹介

Discussion