Russian (Pусский) translation by Masha Kolesnikova (you can also view the original English article)
Посетите веб-сайт любого сетевого ресторана или магазина, и вы, скорее всего, найдете «поиск магазина»: казалось бы, простая небольшая страница, на которой вы вводите свой адрес или почтовый индекс, и он предоставляет расположенные поблизости магазины. Для клиента это здорово, потому что вы можете найти магазин, который находится к вам близко.
Построение «искателя магазинов» на самом деле является сложной задачей. В этом уроке мы расскажем об основах работы с геопространственными данными в Node.js и Redis и создадим рудиментарный поиск магазина.
Мы будем использовать команды «geo» Redis. Эти команды были добавлены в версии 3.2, поэтому вам нужно будет установить ее на вашей рабочей машине. Давайте сначала проверим с помощью redis-cli
и напечатаем GEOADD
. Вы должны увидеть сообщение об ошибке, которое выглядит так:
1 |
(error) ERR wrong number of arguments for 'GEOADD' command |
Несмотря на сообщение об ошибке, это хороший знак - он показывает, что у вас есть команда GEOADD
. Если вы запустите команду, и вы получите следующую ошибку:
1 |
(error) ERR unknown command 'GEOADD' |
Вам нужно будет загрузить, установить и установить версию Redis, которая поддерживает команды geo, прежде чем двигаться дальше.
Теперь, когда у вас есть поддерживаемый сервер Redis, давайте проведем экскурсию по командам geo. Redis имеет шесть команд, которые непосредственно связаны с геопространственным индексированием: GEOADD
, GEOHASH
, GEOPOS
, GEODIST
, GEORADIUS
и GEORADIUSBYMEMBER
.
Начнем с GEOADD
. Эта команда, как вы можете себе представить, добавляет геопространственный предмет. Он содержит четыре аргумента: ключ, долгота, широта и элемент. Ключ подобен группировке и представляет одно значение в пространстве ключей. Долгота и широта, очевидно, являются координатами как поплавками; обратите внимание на порядок этих значений, поскольку они, скорее всего, будут в обратном порядке из того, что вы привыкли видеть. Наконец, «member» - это то, как вы собираетесь идентифицировать местоположение. В redis-cli
давайте запустим следующие команды:
1 |
geoadd va-universities -76.493 37.063 christopher-newport-university |
2 |
geoadd va-universities -76.706944 37.270833 college-of-william-and-mary |
3 |
geoadd va-universities -78.868889 38.449444 james-madison-university |
4 |
geoadd va-universities -78.395833 37.297778 longwood-university |
5 |
geoadd va-universities -76.2625 36.8487 norfolk-state-university |
6 |
geoadd va-universities -76.30522 36.88654 old-dominion-university |
7 |
geoadd va-universities -80.569444 37.1275 radford-university |
8 |
geoadd va-universities -77.475 38.301944 university-of-mary-washington |
9 |
geoadd va-universities -78.478889 38.03 university-of-virginia |
10 |
geoadd va-universities -82.576944 36.978056 uva-wise |
11 |
geoadd va-universities -77.453255 37.546615 virginia-commonwealth-university |
12 |
geoadd va-universities -79.44 37.79 virginia-military-institute |
13 |
geoadd va-universities -77.425556 37.242778 virginia-state-university |
14 |
geoadd va-universities -80.425 37.225 virginia-tech |
Это длинный способ добавления нескольких записей, но хорошо видеть шаблон. Если вы хотите сократить этот процесс, вы можете сделать то же самое, повторив долготу, широту и member для каждого дополнительного места в качестве большего количества аргументов. Это пример короткого представления двух последних элементов:
1 |
geoadd va-universities -77.425556 37.242778 virginia-state-university -80.425 37.225 virginia-tech |
Внутри эти гео-элементы на самом деле не являются чем-то особенным - они хранятся в Redis как zset или сортированный набор. Чтобы показать это, давайте запустим еще несколько команд над ключом va-universities
:
1 |
TYPE va-universities |
Это возвращает zset
, как и любой другой отсортированный набор. Теперь, что произойдет, если мы попытаемся вернуть все значения и включить оценки?
1 |
ZRANGE va-universities 0 -1 WITHSCORES |
Это возвращает массовый ответ элементов, введенных выше, с очень большим числом - 52-битным целым числом. Целое число на самом деле представляет собой представление geohash, умную небольшую структуру, которая может представлять любое место на земном шаре. Мы погрузимся немного глубже позже и на самом деле не будем взаимодействовать с геопространственными данными таким образом, но всегда хорошо знать, как хранятся ваши данные.
Теперь, когда у нас есть некоторые данные для игры, давайте посмотрим на команду GEODIST
. С помощью этой команды вы можете определить расстояние между двумя точками, которые вы ранее ввели под одним и тем же ключом. Итак, давайте найдем расстояние между элементами virginia-tech
и christopher-newport-university
:
1 |
GEODIST va-universities virginia-tech christopher-newport-university |
Это должно выводить 349054.2554687438 или расстояние между двумя местами в метрах. Вы также можете указать третий аргумент как единица mi
(мили), km
(километров), ft
(футы) или m
(метры, значение по умолчанию). Давайте пройдем расстояние в милях:
1 |
GEODIST va-universities virginia-tech christopher-newport-university mi |
Команда должна ответить «216.89279795987412».
Прежде чем идти дальше, давайте поговорим о том, почему вычисление расстояния между двумя пространственными точками - это не просто простой геометрический расчет. Земля круглая (или почти), так как вы уходите от экватора, расстояние между линиями долготы начинает сходиться, и они «встречаются» на полюсах. Итак, чтобы рассчитать расстояние, вам нужно учесть земной шар.
К счастью, Redis защищает нас от этой математики (если вам интересно, есть пример чистой реализации JavaScript). Одно замечание, Redis делает предположение, что земля - идеальная сфера (формула Хаверсина), и она может ввести ошибку до 0,5%, что достаточно для большинства приложений, особенно для чего-то вроде магазина.
Большую часть времени нам понадобятся все точки в определенном радиусе местоположения, а не только расстояние между двумя точками. Мы можем сделать это с помощью команды GEORADIUS
. Команда GEORADIUS
ожидает, по крайней мере, ключа, долготы, широты, расстояния и единицы. Итак, давайте найдем все университеты в наборе данных в пределах 100 миль от этого пункта.
1 |
GEORADIUS va-universities -78.245278 37.496111 100 mi |
Что возвращает:
1 |
1) "longwood-university" |
2 |
2) "virginia-state-university" |
3 |
3) "virginia-commonwealth-university" |
4 |
4) "university-of-virginia" |
5 |
5) "university-of-mary-washington" |
6 |
6) "college-of-william-and-mary" |
7 |
7) "virginia-military-institute" |
8 |
8) "james-madison-university” |
У GEORADIUS
есть несколько опций. Скажем, мы хотели получить расстояние между нашей указанной точкой и всеми местоположениями. Мы можем сделать это, добавив аргумент WITHDIST
в конце:
1 |
GEORADIUS va-universities -78.245278 37.496111 100 mi WITHDIST |
Это возвращает массовый ответ с указанием местоположения и расстояния (в указанном модуле):
1 |
1) 1) "longwood-university" |
2 |
2) "16.0072" |
3 |
2) 1) "virginia-state-university" |
4 |
2) "48.3090" |
5 |
3) 1) "virginia-commonwealth-university" |
6 |
2) "43.5549" |
7 |
4) 1) "university-of-virginia" |
8 |
2) "39.0439" |
9 |
5) 1) "university-of-mary-washington" |
10 |
2) "69.7595" |
11 |
6) 1) "college-of-william-and-mary" |
12 |
2) "85.9017" |
13 |
7) 1) "virginia-military-institute" |
14 |
2) "68.4639" |
15 |
8) 1) "james-madison-university" |
16 |
2) “74.1314" |
Другим необязательным аргументом является WITHCOORD
, который, как вы могли догадаться, возвращает вам координаты долготы и широты. Вы можете соединить это с аргументом WITHDIST
. Попробуем это:
1 |
GEORADIUS va-universities -78.245278 37.496111 100 mi WITHCOORD WITHDIST |
Результат получается немного сложнее:
1 |
1) 1) "longwood-university" |
2 |
2) "16.0072" |
3 |
3) 1) "-78.395833075046539" |
4 |
2) "37.297776773137613" |
5 |
2) 1) "virginia-state-university" |
6 |
2) "48.3090" |
7 |
3) 1) "-77.425554692745209" |
8 |
2) "37.242778393422277" |
9 |
3) 1) "virginia-commonwealth-university" |
10 |
2) "43.5549" |
11 |
3) 1) "-77.453256547451019" |
12 |
2) "37.546615418792236" |
13 |
4) 1) "university-of-virginia" |
14 |
2) "39.0439" |
15 |
3) 1) "-78.478890359401703" |
16 |
2) "38.029999417483971" |
17 |
5) 1) "university-of-mary-washington" |
18 |
2) "69.7595" |
19 |
3) 1) "-77.474998533725739" |
20 |
2) "38.301944581227126" |
21 |
6) 1) "college-of-william-and-mary" |
22 |
2) "85.9017" |
23 |
3) 1) "-76.706942617893219" |
24 |
2) "37.27083268721384" |
25 |
7) 1) "virginia-military-institute" |
26 |
2) "68.4639" |
27 |
3) 1) "-79.440000951290131" |
28 |
2) "37.789999344511962" |
29 |
8) 1) "james-madison-university" |
30 |
2) "74.1314" |
31 |
3) 1) "-78.868888914585114" |
32 |
2) "38.449445074931383" |
Обратите внимание, что расстояние приближается к координатам, несмотря на обратный порядок в наших аргументах. Redis не заботится о том, в каком порядке вы указываете аргумент WITH *
, но он вернет расстояние до координат. Существует еще один аргумент (WITHHASH
), но мы расскажем об этом в следующем разделе - просто знайте, что он будет последним в вашем ответе.
Небольшое отступление от вычислений, происходящих здесь - если вы думаете о математике, которую мы ранее рассматривали в работе GEODIST
, давайте подумаем о радиусе. Поскольку радиус является кругом, мы должны думать о том, что круг лежит над сферой, которая совершенно отличается от простого круга, наложенного на плоскую плоскость. Опять же, Redis делает все эти расчеты для нас (к счастью).
Теперь давайте рассмотрим команду GEORADIUS
, связанную с GEORADIUSBYMYBER
. GEORADIUSBYMEMBER
работает точно так же, как GEORADIUS
, но вместо указания долготы и широты в аргументах вы можете указать элемент уже в своем ключе. Таким образом, это, например, вернет все элементы в пределах 100 миль от university-of-virginia
.
1 |
GEORADIUSBYMEMBER va-universities university-of-virginia 100 mi |
Вы можете использовать те же единицы и аргументы и единицы WITH *
на GEORADIUSBYMEMBER
, как вы могли бы на GEORADIUS
.
Раньше, когда мы запускали ZRANGE
по нашему ключу, вы, возможно, задавались вопросом, как вернуть координаты из позиции, которую вы добавили с помощью GEOADD
, - мы можем выполнить это с помощью команды GEOPOS
. Предоставляя ключ и элемент, мы можем получить обратно координаты:
1 |
GEOPOS va-universities university-of-virginia |
Что должно дать результат:
1 |
1) 1) "-78.478890359401703" |
2 |
2) “38.029999417483971" |
Если вспомнить, когда мы добавили значение для university-of-virginia
, цифры немного отличаются, хотя они округляются до той же суммы. Это связано с тем, как Redis сохраняет координаты в формате geohash. Опять же, это очень близко и достаточно хорошо для большинства приложений. В приведенном выше примере фактическая разница расстояний между входом и выходом GEOPOS
составляет 5,5 дюйма / 14 см.
Это приводит нас к нашей окончательной команде Redis GEO: GEOHASH
. Это вернет значение geohash, используемое для хранения координат. Ранее упоминалось, что это умная система, основанная на сетке и может быть представлена различными способами. Redis использует 52-битное целое число, но более часто встречающееся представление представляет собой строку base-32. Используя команду GEOHASH
с ключом и элементом, Redis вернет строку base-32, которая представляет это местоположение. Если мы запустим команду:
1 |
GEOHASH va-universities university-of-virginia |
Вы получите:
1 |
1) "dqb0q5jkv30" |
Это строковое представление geohash base-32. Строки Geohash имеют аккуратное свойство: если вы удаляете символы справа от строки, вы постепенно уменьшаете точность координат. Это можно проиллюстрировать на веб-сайте geohash - посмотрите на эти ссылки и посмотрите, как координаты и карта удаляются от исходного местоположения:
- http://geohash.org/dqb0q5jkv30 (очень точно)
- http://geohash.org/dqb0q5jkv3
- http://geohash.org/dqb0q5jkv
- http://geohash.org/dqb0q5jk
- http://geohash.org/dqb0q5j
- http://geohash.org/dqb0q5
- http://geohash.org/dqb0q
- http://geohash.org/dqb0
- http://geohash.org/dqb
- http://geohash.org/dq
- http://geohash.org/d (очень неточно)
Есть еще одна функция, которую нам нужно будет покрыть, и если вы уже знакомы с сортированными наборами Redis, вы ее уже знаете. Поскольку ваши геопространственные данные действительно просто хранятся в zset, мы можем удалить элемент с помощью ZREM
:
1 |
ZREM va-universities university-of-virginia |
Server искателя магазинов
Теперь, когда у нас есть основы для использования команд Redis GEO, давайте построим Node.js-based сервер поиска в качестве примера. Мы собираемся использовать данные выше, поэтому я предполагаю, что технически это искатель университетов, а не поисковик магазина, но концепция идентична. Прежде чем начать, убедитесь, что у вас установлены Node.js и npm. Создайте каталог для своего проекта и перейдите в этот каталог в командной строке. В командной строке введите:
1 |
npm init |
Это создаст ваш файл package.json
, задав вам несколько вопросов. После инициализации вашего проекта мы будем устанавливать четыре модуля. Опять же, из командной строки выполните следующие четыре команды:
1 |
npm install express --save |
2 |
npm install pug --save |
3 |
npm install redis --save |
4 |
npm install body-parser --save |
Первый модуль - Express.js, модуль веб-сервера. Чтобы пользоваться сервером, нам также потребуется установить систему шаблонов. Для этого проекта будет использоваться pug (ранее известный как Jade). Pug прекрасно интегрируется с Express и позволит нам создать основной шаблон страницы всего в нескольких строках. Мы также установили node_redis, который управляет соединением между Node.js и сервером Redis. Наконец, нам понадобится еще один модуль для обработки значений HTTP POST: body-parser.
Для нашего первого шага мы просто собираемся запустить сервер так, что он сможет принимать HTTP-запросы и заполнять шаблон со значениями.
1 |
var
|
2 |
bodyParser = require('body-parser'), |
3 |
express = require('express'), |
4 |
|
5 |
app = express(); |
6 |
|
7 |
app.set('view engine', 'pug'); //this associates the pug module with the res.render function |
8 |
|
9 |
app.get( // method "get" |
10 |
'/', // the route, aka "Home" |
11 |
function(req, res) { |
12 |
res.render('index', { //you can pass any value to the template here |
13 |
pageTitle: 'University Finder' |
14 |
});
|
15 |
}
|
16 |
);
|
17 |
|
18 |
app.post( // method "post" |
19 |
'/', |
20 |
bodyParser.urlencoded({ extended : false }), // this allows us to parse the values POST'ed from the form |
21 |
function(req,res) { |
22 |
var
|
23 |
latitude = req.body.latitude, // req.body contains the post values |
24 |
longitude = req.body.longitude; |
25 |
|
26 |
res.render('index', { |
27 |
pageTitle : 'University Finder Results', |
28 |
latitude : latitude, |
29 |
longitude : longitude, |
30 |
results : [] // we'll populate it later |
31 |
});
|
32 |
}
|
33 |
);
|
34 |
|
35 |
app.listen(3000, function () { |
36 |
console.log('Sample store finder running on port 3000.'); |
37 |
});
|
Этот сервер будет успешно обслуживать только страницу верхнего уровня ('/'), и только если HTTP-клиент (a.k.a. браузер) с помощью метода GET
или POST
.
Нам понадобится шаблон bare-bones - достаточно, чтобы показать заголовок, форму и (позже) показать результаты. Pug - очень сложный язык шаблонов с соответствующими пробелами. Таким образом, при вложенности тегов вложений первое слово строки после отступа является тегом (и закрывающие теги выводятся парсером), и мы интерполируем значения с помощью #{}
. К такому не сразу привыкаешь, но вы можете быстро создать много HTML с минимальными усилями - взгляните на сайт pug, чтобы узнать больше. Обратите внимание, что на момент публикации этой статьи официальный сайт Pug не обновлялся. Вот официальный тикет GitHub относительно этой проблемы.
1 |
//- Anything that starts with "//-" is a non-rendered comment |
2 |
//- add the doctype for HTML 5 |
3 |
doctype html |
4 |
//- the HTML tag with the attribute "lang" equal to "en" |
5 |
html(lang="en") |
6 |
head |
7 |
//- this produces a title tag and the "=" means to assign the entire value of pageTitle (passed from our server) between the opening and closing tag |
8 |
title= pageTitle |
9 |
body |
10 |
h1 University Finder |
11 |
form(action="/" method="post") |
12 |
div |
13 |
label(for="#latitude") Latitude |
14 |
//- "value=" will pull in the 'latitude' variable in from the server, ignoring it if the variable doesn't exist |
15 |
input#latitude(type="text" name="latitude" value= latitude) |
16 |
div |
17 |
label(for="#longitude") Longitude |
18 |
input#longitude(type="text" name="longitude" value= longitude) |
19 |
button(type="submit") Find |
20 |
//- "if" is a reserved word in Pug - anything that follows and is indented one more level will only be rendered if the 'results' variable is present |
21 |
if results |
22 |
h2 Showing Results for #{latitude}, #{longitude} |
Мы можем попробовать наш магазин, запустив сервер в командной строке:
1 |
node app.js |
Затем открыть на ваш браузер по адресу http://localhost:3000/
.
Вы должны увидеть простую, неэкранированную страницу с большим заголовком, в котором говорится «Университетский поиск» и форма с несколькими текстовыми полями. Поскольку обычный запрос страницы браузером - это запрос GET, эта страница генерируется функцией в аргументе для app.get
.



Если вы вводите значения в поля широты и долготы и нажимаете «найти», вы увидите, что эти результаты отображаются и отображаются в строке, которая читает «Отображение результатов для ...». На данный момент у вас не будет никаких результатов, поскольку мы еще не интегрировали Redis.



Интеграция Redis
Чтобы интегрировать Redis, сначала нам нужно немного настроить. В объявлении переменной укажите как модуль, так и переменную (пока еще не определенную) для клиента.
1 |
...
|
2 |
redis = require('redis'), |
3 |
client, |
4 |
...
|
После объявления переменной нам нужно будет создать соединение с Redis. В нашем примере мы предположим подключение localhost на порту по умолчанию и без аутентификации (в производственной среде обязательно защитите свой сервер Redis).
1 |
client = redis.createClient(); |
Уточненная особенность node_redis заключается в том, что клиент будет устанавливать очереди в очереди при установлении соединения, поэтому нет необходимости беспокоиться о ожидании установления соединения с сервером Redis.
Теперь, когда наш экземпляр node имеет клиента Redis, который может принимать соединения, давайте поработаем над сердцем нашего поисковика. Мы будем использовать широту и долготу пользователя и применять их к команде GEORADIUS
. Наш пример использует 100-мильный радиус. Мы также захотим получить расстояние и координаты этих результатов.
В обратном вызове мы обрабатываем любые ошибки, если они возникнут. Если ошибок не обнаружено, сопоставьте полученные результаты, чтобы сделать их более полезными и легче интегрировать в шаблон. Эти результаты затем подаются в шаблон.
1 |
app.post( // method "post" |
2 |
'/', |
3 |
bodyParser.urlencoded({ extended : false }), // this allows us to parse the values POST'ed from the form |
4 |
function(req,res,next) { |
5 |
var
|
6 |
latitude = req.body.latitude, // req.body contains the post values |
7 |
longitude = req.body.longitude; |
8 |
|
9 |
client.georadius( |
10 |
'va-universities', //va-universities is the key where our geo data is stored |
11 |
longitude, //the longitude from the user |
12 |
latitude, //the latitude from the user |
13 |
'100', //radius value |
14 |
'mi', //radius unit (in this case, Miles) |
15 |
'WITHCOORD', //include the coordinates in the result |
16 |
'WITHDIST', //include the distance from the supplied latitude & longitude |
17 |
'ASC', //sort with closest first |
18 |
function(err, results) { |
19 |
if (err) { next(err); } else { //if there is an error, we'll give it back to the user |
20 |
//the results are in a funny nested array. Example:
|
21 |
//1) "longwood-university" [0]
|
22 |
//2) "16.0072" [1]
|
23 |
//3) 1) "-78.395833075046539" [2][0]
|
24 |
// 2) "37.297776773137613" [2][1]
|
25 |
//by using the `map` function we'll turn it into a collection (array of objects)
|
26 |
results = results.map(function(aResult) { |
27 |
var
|
28 |
resultObject = { |
29 |
key : aResult[0], |
30 |
distance : aResult[1], |
31 |
longitude : aResult[2][0], |
32 |
latitude : aResult[2][1] |
33 |
};
|
34 |
|
35 |
return resultObject; |
36 |
})
|
37 |
res.render('index', { |
38 |
pageTitle : 'University Finder Results', |
39 |
latitude : latitude, |
40 |
longitude : longitude, |
41 |
results : results |
42 |
});
|
43 |
}
|
44 |
}
|
45 |
);
|
46 |
|
47 |
}
|
48 |
);
|
В шаблоне нам нужно обработать набор результатов. Pug имеет бесшовную итерацию по массивам (с почти словесным синтаксисом). Речь идет о том, чтобы потянуть эти значения за один результат; шаблон будет обрабатывать все остальное.
1 |
each result in results |
2 |
div |
3 |
h3 #{result.key} |
4 |
div |
5 |
strong Distance: |
6 |
| #{result.distance} |
7 |
| miles |
8 |
div |
9 |
strong Coordinates: |
10 |
| #{result.latitude} |
11 |
| , |
12 |
| #{result.longitude} |
13 |
| ( |
14 |
a(href="https://www.openstreetmap.org/#map=18/"+result.latitude+"/"+result.longitude) Map |
15 |
| ) |
После того, как вы получите окончательный код шаблона и node, снова запустите свой сервер app.js и откройте свой браузер снова по адресу http://localhost:3000/.
Если вы введете широту 38.904722 и долготу -77.016389 (координаты для Вашингтона, округ Колумбия, на северной границе Вирджинии) в поле и нажмите «Найти», вы получите три результата. Если вы измените значения на широту 37.533333 и долготу -77.466667 (Ричмонд, штат Вирджиния, столица штата и в центральной / восточной части штата), вы увидите десять результатов.



На данный момент у вас есть основные части поисковика, но вам нужно настроить его в соответствии с вашим собственным проектом.
-
Большинство пользователей не думают с точки зрения координат, поэтому вам нужно будет рассмотреть более удобный подход, например:
1. Использование клиентского JavaScript для определения местоположения с использованием API геолокации
2. Использование службы геолокатора на основе IP
3. Запросите у пользователя почтовый индекс или адрес и используйте службу геокодирования, которая преобразует его в координаты. На рынке существует множество различных услуг по геокодированию, поэтому выберите ту, которая хорошо работает для вашей целевой области. -
Этот скрипт не выполняет валидации формы. Если вы оставите поля ввода широты и долготы, вы должны убедиться, что вы проверяете свои данные и избегаете сообщения об ошибке.
-
Расширьте ключ местоположения до более полезных данных. Если вы используете Redis для хранения дополнительной информации о каждом местоположении, подумайте о сохранении этой информации в хэшах с помощью ключа, который соответствует вашим возвращенным элементам из
GEORADIUS
. Вам нужно будет сделать дополнительный вызов для Redis. -
Более тесная интеграция с сервисами карт, такими как Карты Google, OpenStreetMap или Bing Maps, чтобы обеспечить встроенные карты и направления.