Spatial な Redis のお試シは Windows でも C/S 双方微可能

SpatiaLite のネタと動機は同じ。

「GIS の色んなこと」。要するに個人的に 一連の cartopy ネタからちょっと火が着いちゃって、「そういえば」という類のネタを、思いつく限り思い出してみてる真っ最中、なの。

ワタシが仕事としていわゆる「NoSQL、XMLDB」の検証をしたのが 2011年、なので、もう10年前のこと。その際にピックアップしたのは、Redis、Memcached、MongoDB、BaseX、とかだったと思うんだけれど、今の今までずっと存在を忘れなかったのは Redis だけ。Memcached はほんとにさっき名前をみるまですっかり失念してた。

そして、ずっと頭の片隅にあって、だけれども後回しにしてたのが、そうした NoSQL の「spatial 拡張は、ありや、なしや」の件。たとえば「個人的なナレッジベース」を自 PC に構築するためのインフラとして NoSQL を考えるとして、それが spatial を扱えたら、果たして嬉しいであろうか、ということをね、まぁたまには考えてたわけだ。目的が「non-relational な kv-store の spatial 拡張」であっても、実際 Redis しか覚えてなかったわけだから、検索はまさしく「Redis+spatial」。結局 memcached には spatial 拡張はないようなので、「ひとまず Redis の spatial 拡張を目的のものと考えてみる」ことに。

まぁ迷走したよ。

一般論としてだけど、C/S アーキテクチャを採っているシステムについて、「サーバサイド」のほうを Windows に構築するのは非常に困難であることが多いわけね。Redis も現在進行形ではまさにその状態になっていて、最新の 6.x は、もうね、これ、Windows でのビルドは絶望的とみた。少なくとも Redis 本家は「Windows でのビルドを考えてる形跡はない」。ので、さすがに vcpkg にも Redis は含まれておらず、msys2 のパッケージもない。そして、「Redis for Windos」で見つかるものが「非常に古い」ということに愕然とするわけだ。

なので、「これはもう、virtual box 使ってでも本物の Unix に頼るしか正解はなさそうだなぁ」と結論付けようとして、やっと気付いた:


このキャプチャで右側に見えている「Related commands」に関して、3.2 と 6.x との差異は、ざっくり言えば、「GEORADIUS、GEORADIUSBYMEMBER は(3.2 にはない)GEOSEARCH、GEOSEARCHSTORE によって deprecated となった」てこと。どちらも目的は位置による検索。

というわけでめでたく「2016年版、というかなり古いものだが一応目的の評価は部分的に出来るもの」として 3.2.100 をインストール出来た。

あまりに久しぶりで、動かし方から何からすっかり忘れてたが、サーバはコマンドラインからなら例えば:

いつものように MSYS bash なコマンドライン。
1 [me@host: ~]$ "/c/Program Files/Redis/redis-server.exe" "/c/Program Files/Redis/redis.windows-service.conf"

クライアントは単純なテキストをサーバにぶん投げて結果をテキストで受け取るだけなので、あらゆるバインディングを簡単に作れる、てことなわけで、すなわち、「Redis クライアントを用立てるのに Windows では苦労する」なんてことはないはずだ。そしてワタシの本日のゴールは無論「python がクライアント」のパターンのお試し。けれども、ひとまず出来合いの、Redis をインストールするだけで同梱されるクライアントが今手に入ったのだから:

ドキュメントに書かれてるまんまの例
 1 [me@host: ~]$ "/c/Program Files/Redis/redis-client.exe"
 2 127.0.0.1:6379> GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"
 3 (integer) 2
 4 127.0.0.1:6379> GEOADD Sicily 12.758489 38.788135 "edge1" 17.241510 38.788135 "edge2"
 5 (integer) 2
 6 127.0.0.1:6379> GEOSEARCH Sicily FROMLONLAT 15 37 BYRADIUS 200 km ASC
 7 (error) ERR unknown command 'GEOSEARCH'
 8 127.0.0.1:6379> GEODIST Sicily Palermo Catania
 9 "166274.1516"
10 127.0.0.1:6379> GEORADIUS Sicily 15 37 200 km WITHDIST
11 1) 1) "Palermo"
12    2) "190.4424"
13 2) 1) "Catania"
14    2) "56.4413"
15 127.0.0.1:6379>

うん、良いね。

ぶっちゃけ、redis の C/S のプロトコルは HTTP の数億倍かは単純で、なんなら剥き身の socket 通信で自力でクライアントを短時間で書けてしまうほどなんだけれど、もちろん「ちゃんとしたのが既に書かれてる」。ドキュメントがやや荒れているようだが、pip install redis でインストール出来る redis に、geoadd メソッドなどが明示的に追加されているみたいだ。「荒れている」ちうかさ、ワタシが一番嫌いなパターンになってる。「API リファレンスだけ保守」してる模様。トップレベルから読めるドキュメントからは、geoadd 等は見えない。やめてほしいよなぁこういうの…。ともあれ:

 1 >>> import redis
 2 >>> r = redis.Redis(host='127.0.0.1', port=6379, db=0)
 3 >>> r.delete("ToFuTbl")
 4 1
 5 >>> # 「究極的には key-value だけが世界である」ということを忘れてはいけない。
 6 >>> # geometry を扱える、といっても結局は「ある key に対応付く value 内が
 7 >>> # spatial を解する」というだけのことなのだ。key をまたいだ串刺しは
 8 >>> # 出来ない。ほんとはそれこそがやりたいことなんだけどね、Redis の
 9 >>> # 基本原則には馴染まない、てことだわな。
10 >>> r.geoadd(
11 ...     "ToFuTbl",
12 ...     141.35437400, 43.06197200, "Tokyo",
13 ...     135.50204600, 34.69389100, "Osaka",
14 ...     135.76818100, 35.01157400, "Kyoto")
15 3
16 >>> r.geodist("ToFuTbl", "Osaka", "Kyoto", "km")
17 42.8791
18 >>> r.geodist("ToFuTbl", "Tokyo", "Kyoto", "km")
19 1016.5984
20 >>> r.georadius("ToFuTbl", 135.7, 34.5, 80, "km", "WITHCOORD")
21 [[b'Osaka', 28.1702], [b'Kyoto', 57.2407]]
22 >>> # ↑この結果はオカシイよなぁ。WITHCOORD 言うておるに、これ、
23 >>> # WITHDIST の結果じゃない?
24 >>>
25 >>> r.georadius("ToFuTbl", 135.7, 34.5, 80, "km")
26 [b'Osaka', b'Kyoto']
27 >>> r.georadius("ToFuTbl", 135.7, 34.5, 80, "km", "WITHDIST")
28 [[b'Osaka', 28.1702], [b'Kyoto', 57.2407]]
29 >>> r.georadius("ToFuTbl", 135.7, 34.5, 30, "km", "WITHDIST", "WITHCOORD")
30 [[b'Osaka', 28.1702, (135.50204783678055, 34.693890677523775)]]
31 >>> # ↑あぁ…WITHCOORD のみ、はダメなのか…。
32 >>>

コメントに書いた通りなのだろうなぁと思う。たとえば PostGIS や SpatiaLite に求めるようなものを期待すると、これは全然違う、まるで似て非なるもの、てこと。そうなのかぁ…。まぁ理解できてしまうとそりゃそーだ、とは思うんだけどね。(あと思ったんだけど、もう少し SQL/MM に寄せるとか考えなかったんだろうか? なんでこんなに発想からして違うんだか…。)

一応上でやってる「ToFuTbl」に相当するものをデータベースで言うところの「テーブル」とみなし、そのレコードをそれなりの量ブチ込むつもりならば、georadius や geosearch による検索、というものに意味があるわけだが、感覚的にはその「レコード」として大量レコードを放り込んで使う、という使い方は想像しにくくて、であるならば、つまり例にしたような「少量」相手なのならば、いったいなんの役に立つんだ、という気分になる、と。なにかいいあんばいの用途はありそうな気はするけど、これを上手に活用するには結構頭使いそうだね。

あと、ドキュメントを読めばちゃんと書いてあるけれど、PROJ や GeographicLib を用いて求めることが出来るような正確さを求めてはいけない。完全なインチキをしているわけではないけれど、回転楕円体としてではなく真球として計算するので、正確な距離を求めたいなら結構誤差が出る。


2021-06-10追記:
「大量レコードを放り込んで使う、という使い方は想像しにくく」と思ったけれど、実際やってみないとなんとも言えんなぁと思って、geoshape-city のデータを Redis にブチ込み、そこから引っ掛けた位置を中心として overpass API で検索というストーリーを考えてみた。

やってみると悪くないかなって気がする:

  1 # -*- coding: utf-8 -*-
  2 # redis を R-Tree インデクスみたいな気分で使ってみるとするならば。
  3 import io
  4 import csv
  5 from datetime import date
  6 import json
  7 import shapely.geometry as sgeom
  8 import redis
  9 # ↓redis georadius でヒットしたものを overpass で OSM にお問い合わせ、的なことを。
 10 import overpy
 11 
 12 
 13 def _load_from_geoshape_city_csv():
 14     # 『歴史的行政区域データセットβ版』(CODH作成)
 15     # https://geonlp.ex.nii.ac.jp/dictionary/geoshape-city/
 16     def _map(rec, fields):
 17         import re
 18         temp = []
 19         for v in rec:
 20             if re.match(r"\d+\.\d+", v):
 21                 temp.append(float(v))
 22             elif re.match(r"\d+\-\d+\-\d+", v):
 23                 temp.append(date(*map(int, v.split("-"))))
 24             else:
 25                 temp.append(v)
 26         return {k: v for k, v in zip(fields, temp)}
 27     reader = csv.reader(
 28         io.open("geoshape-city.csv", encoding="utf-8"))
 29     fields = next(reader)
 30     return (_map(rec, fields) for rec in reader)
 31 
 32 
 33 def _build_geoshape_city_index(redis_cli):
 34     # マインドとしては R-Tree の insert をしているような気分で。
 35     # 実用で考えるなら geoadd に与える「lon lat name」の name としては
 36     # 何かキー項目、geoshape-city なら entry_id を使い、これに紐づく
 37     # 情報も引けるようにするのがベストだが、今回の例では単にわかりやすさの
 38     # ために「address」を使う。(geoshape-city のミッションの性質上、
 39     # これにより「すでに存在していない都市」がそうであるとわからないまま
 40     # 平気でヒットしまくるが、今回の例ではそこは問わない。
 41     allcol = []  # lon, lat, dat, lon, lat, dat, ...
 42     for rec in _load_from_geoshape_city_csv():
 43         # R-Tree とは違って MBR を考える必要はない。(というか geoadd
 44         # 内部がまさに R-Tree を使ってたりするんじゃないかしらね?)
 45         allcol.extend(
 46             [rec["longitude"], rec["latitude"], rec["address"]])
 47     #
 48     redis_cli.geoadd(*(["geoshape-city"] + allcol))
 49 
 50 
 51 def _overpass_query(
 52         redis_cli, center_pt, radius_km_redis, buffer_deg_overpass):
 53     # たとえば「仙台市の lon, lat はざっくりとしか知らない」状態
 54     # の際に、そのおよそ知ってる lon, lat から「本当の仙台市代表地点」
 55     # を探り当て、そしてその中心から改めて overpass API で「何かを
 56     # 検索」ということ。あんまり複雑な例から試そうとするとハマるので、
 57     # ここでの overpass 検索は「"religion"="shinto"」でのみ。
 58     # (神社検索てこと。)
 59 
 60     # GEORADIUS 検索結果は先頭のものだけ使うことにする。
 61     founds = redis_cli.georadius(
 62         "geoshape-city", center_pt[0], center_pt[1], radius_km_redis, "km",
 63         "WITHDIST", "WITHCOORD")
 64     if not founds:
 65         return
 66     # たとえば「北海道函館市, (140.72910800, 41.76871200)」
 67     address, _, (rptlon, rptlat) = founds[0]
 68 
 69     ex = buffer_deg_overpass / 2
 70     # bb: lat-min,lon-min,lat-max,lon-max
 71     bb = [rptlat - ex, rptlon - ex, rptlat + ex, rptlon + ex]
 72     #
 73     api = overpy.Overpass()
 74     opresult = api.query("""
 75 [timeout:60][bbox:{}, {}, {}, {}];
 76 way["religion"="shinto"];
 77 out;
 78 >; /* recurse down */
 79 out;""".format(*bb))
 80     result = []
 81     for way in opresult.ways:
 82         pts = []
 83         for node in way.nodes:
 84             pts.append((node.lon, node.lat))
 85         if len(pts) == 1:
 86             poly = sgeom.Point(pts)
 87         elif len(pts) == 2:
 88             poly = sgeom.LineString(pts)
 89         else:
 90             poly = sgeom.Polygon(pts)
 91         result.append({
 92             "tags": way.tags,
 93             "centroid": poly.centroid.wkt,
 94             "areapolygon": poly.wkt})
 95     # ほんとはシンプルにコンソールにダンプしたいんだけどね、どうせ
 96     # 「日本語なんかきらいだ」問題が起こるでしょ、windows では。
 97     json.dump(
 98         result,
 99         io.open("exam_redis_result.json", "w", encoding="utf-8"),
100         ensure_ascii=False, indent=4)
101 
102     
103 def main(args):
104     redis_cli = redis.Redis(host='127.0.0.1', port=6379, db=0)
105     if not redis_cli.exists("geoshape-city"):
106         _build_geoshape_city_index(redis_cli)
107     _overpass_query(
108         redis_cli,
109         (args.rough_lon, args.rough_lat),
110         args.radius_km_redis,
111         args.buffer_deg_overpass)
112 
113 
114 if __name__ == '__main__':
115     import argparse
116     ap = argparse.ArgumentParser()
117     ap.add_argument("rough_lon", type=float)
118     ap.add_argument("rough_lat", type=float)
119     ap.add_argument("radius_km_redis", type=float)
120     ap.add_argument("buffer_deg_overpass", type=float)
121     args = ap.parse_args()
122     main(args)
1 [me@host: ~]$ ls -l geoshape-city.csv
2 -rw-r--r-- 3 hhsprings Administrators 4485002 Dec  3  2020 geoshape-city.csv
3 [me@host: ~]$ py -3 exam_redis.py 141.3 43 50 0.5
4 [me@host: ~]$ ls -l exam_redis_result.json
5 -rw-r--r-- 1 hhsprings Administrators 2333 Jun 10 04:14 exam_redis_result.json
exam_redis_result.json
 1 [
 2     {
 3         "tags": {
 4             "amenity": "place_of_worship",
 5             "name": "俱知安神社",
 6             "religion": "shinto"
 7         },
 8         "centroid": "POINT (140.7560756022154 42.90038809006553)",
 9         "areapolygon": "POLYGON ((140.7557014 42.9005077, 140.7564933 42.9004441, 140.7564825 42.9002851, 140.7556797 42.9003129, 140.7557014 42.9005077))"
10     },
11     {
12         "tags": {
13             "man_made": "torii",
14             "name": "鳥居",
15             "religion": "shinto"
16         },
17         "centroid": "POINT (140.78534555 42.6328931)",
18         "areapolygon": "LINESTRING (140.7853232 42.6328839, 140.7853679 42.6329023)"
19     },
20     {
21         "tags": {
22             "amenity": "place_of_worship",
23             "building": "yes",
24             "religion": "shinto"
25         },
26         "centroid": "POINT (140.7870706206552 42.51482001004054)",
27         "areapolygon": "POLYGON ((140.7869773 42.5147707, 140.7871639 42.5147696, 140.7871703 42.5148671, 140.7869725 42.5148707, 140.7869773 42.5147707))"
28     },
29     {
30         "tags": {
31             "amenity": "place_of_worship",
32             "building": "yes",
33             "name": "大原神社",
34             "religion": "shinto"
35         },
36         "centroid": "POINT (140.8491003921191 42.67959363085933)",
37         "areapolygon": "POLYGON ((140.8490419 42.679623, 140.8490453 42.6795687, 140.8490458 42.6795605, 140.8491588 42.6795642, 140.849155 42.6796268, 140.8490419 42.679623))"
38     },
39     {
40         "tags": {
41             "amenity": "place_of_worship",
42             "building": "yes",
43             "name": "伏見神社",
44             "religion": "shinto"
45         },
46         "centroid": "POINT (140.9577684391768 42.79930241473576)",
47         "areapolygon": "POLYGON ((140.9577061 42.7993299, 140.9577034 42.7992709, 140.9578294 42.7992729, 140.9578321 42.7993359, 140.9577061 42.7993299))"
48     },
49     {
50         "tags": {
51             "amenity": "place_of_worship",
52             "building": "yes",
53             "name": "尻別神社",
54             "religion": "shinto"
55         },
56         "centroid": "POINT (140.9433636332977 42.77955644969908)",
57         "areapolygon": "POLYGON ((140.9433672 42.7795868, 140.9434051 42.7795557, 140.94336 42.7795261, 140.9433222 42.7795572, 140.9433672 42.7795868))"
58     }
59 ]

これは言うほど「少量」の例ではないので、少なくとも「価値を見いだせる使い方はないではない」てことだけは言えたと思う。ゆえに、問題は「Redis でなくてもええやんけ」という葛藤とどう闘うか、だけよね。


2021-06-10 18:00追記:
cartopy ネタ(5)に追記したのだが、「__geo_interface__」をホームポジションとしておくといいのでは、と思い始めてて、なので、上のスクリプト、「どうせ json にしてるんだから」、いっそ geojson 互換の方がいいよねと:

 1 # ... (省略) ...
 2 
 3     # ... (省略) ...
 4     for way in opresult.ways:
 5         pts = []
 6         for node in way.nodes:
 7             pts.append((node.lon, node.lat))
 8         if len(pts) == 1:
 9             poly = sgeom.Point(pts)
10         elif len(pts) == 2:
11             poly = sgeom.LineString(pts)
12         else:
13             poly = sgeom.Polygon(pts)
14 
15         # geojson 互換の構造にしておく。
16         r = sgeom.mapping(poly)
17         r["properties"] = way.tags.copy()
18         result.append(r)

「FeatureCollection→Feature」という外堀構造部分が geojson としての MUST なのかどうかがワタシは今わかってなくて、上のスクリプトはそこをやらないけど、一番内側の構造としては geojson 互換であり、実際 geojson パッケージをインストールし、そちらで dump した場合に、geojson パッケージはこれに不平は言わない。のでこれを geojson と呼んでもいい…のかな? そこはよくわからんけど、とにかくこれで json dump した結果はこう:

  1 [
  2   {
  3     "type": "Polygon",
  4     "coordinates": [
  5       [
  6         [
  7           140.7557014,
  8           42.9005077
  9         ],
 10         [
 11           140.7564933,
 12           42.9004441
 13         ],
 14         [
 15           140.7564825,
 16           42.9002851
 17         ],
 18         [
 19           140.7556797,
 20           42.9003129
 21         ],
 22         [
 23           140.7557014,
 24           42.9005077
 25         ]
 26       ]
 27     ],
 28     "properties": {
 29       "amenity": "place_of_worship",
 30       "name": "俱知安神社",
 31       "religion": "shinto"
 32     }
 33   },
 34   {
 35     "type": "LineString",
 36     "coordinates": [
 37       [
 38         140.7853232,
 39         42.6328839
 40       ],
 41       [
 42         140.7853679,
 43         42.6329023
 44       ]
 45     ],
 46     "properties": {
 47       "man_made": "torii",
 48       "name": "鳥居",
 49       "religion": "shinto"
 50     }
 51   },
 52   {
 53     "type": "Polygon",
 54     "coordinates": [
 55       [
 56         [
 57           140.7869773,
 58           42.5147707
 59         ],
 60         [
 61           140.7871639,
 62           42.5147696
 63         ],
 64         [
 65           140.7871703,
 66           42.5148671
 67         ],
 68         [
 69           140.7869725,
 70           42.5148707
 71         ],
 72         [
 73           140.7869773,
 74           42.5147707
 75         ]
 76       ]
 77     ],
 78     "properties": {
 79       "amenity": "place_of_worship",
 80       "building": "yes",
 81       "religion": "shinto"
 82     }
 83   },
 84   {
 85     "type": "Polygon",
 86     "coordinates": [
 87       [
 88         [
 89           140.8490419,
 90           42.679623
 91         ],
 92         [
 93           140.8490453,
 94           42.6795687
 95         ],
 96         [
 97           140.8490458,
 98           42.6795605
 99         ],
100         [
101           140.8491588,
102           42.6795642
103         ],
104         [
105           140.849155,
106           42.6796268
107         ],
108         [
109           140.8490419,
110           42.679623
111         ]
112       ]
113     ],
114     "properties": {
115       "amenity": "place_of_worship",
116       "building": "yes",
117       "name": "大原神社",
118       "religion": "shinto"
119     }
120   },
121   {
122     "type": "Polygon",
123     "coordinates": [
124       [
125         [
126           140.9577061,
127           42.7993299
128         ],
129         [
130           140.9577034,
131           42.7992709
132         ],
133         [
134           140.9578294,
135           42.7992729
136         ],
137         [
138           140.9578321,
139           42.7993359
140         ],
141         [
142           140.9577061,
143           42.7993299
144         ]
145       ]
146     ],
147     "properties": {
148       "amenity": "place_of_worship",
149       "building": "yes",
150       "name": "伏見神社",
151       "religion": "shinto"
152     }
153   },
154   {
155     "type": "Polygon",
156     "coordinates": [
157       [
158         [
159           140.9433672,
160           42.7795868
161         ],
162         [
163           140.9434051,
164           42.7795557
165         ],
166         [
167           140.94336,
168           42.7795261
169         ],
170         [
171           140.9433222,
172           42.7795572
173         ],
174         [
175           140.9433672,
176           42.7795868
177         ]
178       ]
179     ],
180     "properties": {
181       "amenity": "place_of_worship",
182       "building": "yes",
183       "name": "尻別神社",
184       "religion": "shinto"
185     }
186   }
187 ]

うん、まぁ本題の Redis とはあんまし関係ない話をしちゃったね、すまん。



Related Posts