【go-redis】GoとRedisで位置情報を管理しよう【GEOADD/ZREM/GEORADIUS編】
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ファイルを使う方のみお読みください
REDIS_ADDR: {redis-serverへのアドレス}
REDIS_PORT: {redis-serverのポート番号} # デフォルトは6379
REDIS_DB: 0
REDIS_PASSWORD: ""
GoでRedisに接続
本題ではないのでコードを置いておきます
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
関数を作ってみましょう。
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内で一意になるようにします。
Latitude
とLongitude
は名前の通り、緯度(latitude)と経度(longitude)を保持しておくフィールドです。
GeoHash
とDist
はGEOADDでは使いません。空のままで大丈夫です。
GEOADDしたら削除処理を忘れずに!
ご注意いただきたいのがRedisはGEOADDの際にExpiredAt(有効期限)を設定しないのでGEOADDと同時に削除処理を書かないとデータが溜まり続けます。
削除処理は後述しますので続けてお読みください。
位置情報の削除:ZREM (GEOREMじゃないよ)
GEOADD(追加)コマンドがあるならGEOREM(削除)コマンドもあるだろと思う気持ちよくわかります。
GEOREMはありません。
とはいえ先ほど申し上げた通りGEOADDに有効期限はないので削除されず溜まり続けるのも困ったものです。
ご安心ください。ここまで何度も書いた通り位置情報はソート済みセットとして保存されるのでソート済みセットの削除操作を行えば良いだけなのです。
RedisではZREMコマンドです。
というわけでZRem
関数を書いてみましょう。
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
関数を書いてみましょう
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引数のlati
とlongi
はそれぞれ中心となる緯度、経度を入れます。
第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
関数を書いてみました。
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