Advertisement
  1. Code
  2. Node.js

Создание поисковика магазинов на Node.js и Redis

Scroll to top
Read Time: 15 min

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 - посмотрите на эти ссылки и посмотрите, как координаты и карта удаляются от исходного местоположения:

Есть еще одна функция, которую нам нужно будет покрыть, и если вы уже знакомы с сортированными наборами 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.

Basic form screenshotBasic form screenshotBasic form screenshot

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

Form with values after click screenshotForm with values after click screenshotForm with values after click screenshot

Интеграция 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, чтобы обеспечить встроенные карты и направления.

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.