Как работать с Tarantool на Golang вместо Lua

от автора

Ядро Tarantool-а написано на C, а вся бизнес-логика создаётся на Lua. Это не самый сложный язык, но и не самый популярный. Поэтому сегодня я расскажу, как начать работать с Tarantool, написав всего три строчки кода на Lua. А всё остальное приложение написано на Golang. Чтобы было еще интереснее, я даю альтернативный вариант на Python. Что за проект? Делаем приложение, которое позволяет ставить метки на карте: дом, работа, первое свидание, первый Hello World, первый «too long wal write» Tarantool.

Поехали!

Общая архитектура выглядит следующим образом. На фронтенде мы воспользуемся восхитительным фреймворком Leaflet и не менее замечательной картографической базой OpenStreetMap.

Golang выставит три апишки для работы с картой:

  • создание метки;
  • загрузка меток при навигации по карте;
  • удаление меток.

Tarantool будет хранить метки в таблице и с помощью geo-индекса давать нужные метки за 4 миллисекунды (при навигации по карте).

Содержание

  1. Введение в Tarantool и Lua
  2. Как приложение взаимодействует с БД
  3. Как будем строить приложение
  4. Конфигурация БД
  5. Golang приложение
    1. Работа с БД
    2. HTTP-сервер
    3. HTTP API
  6. Фронтенд
  7. Golang-приложение целиком
  8. Запуск приложения
  9. Тот же пример на Python
  10. Докрутка перед запуском в прод
  11. Заключение

Введение в Tarantool и Lua

Освежим в памяти, что такое Tarantool. Здесь я сделаю это в два абзаца, а подробнее читайте в статье: Архитектура in-memory СУБД: 10 лет опыта в одной статье.

  • Tarantool — персистентная масштабируемая NoSQL база данных.
  • Tarantool хранит данные в оперативной памяти.
  • Tarantool — надежное хранилище, каждую транзакцию пишет сразу в журнал на диск.
  • Tarantool по умолчанию раз в час делает снапшот всех данных на диск.
  • Tarantool написан на C.
  • Lua встроен в Tarantool.
  • Lua компилирует скрипты трассирующим Just-In-Time компилятором.
  • Lua позволяет выполнять логику работы с данными прямо в базе за наносекунды.
  • Lua простой скриптовый язык для программирования всего: от игр до сетевых фильтров.
  • Lua позиционируется для людей, для которых программирование не является основной деятельностью.

Какие сложности чаще всего возникают при работе с Lua и LuaJIT в Tarantool:

  • Кооперативная многозадачность.
    • В момент времени работает только одна задача.
    • Задача должна передать управление вызвав асинхронную операцию или явно.
      • Для этого механизма нет ключевых слов async/await, которые помогают глазу зацепиться за передачу управления.
  • Непрерываемые файберы (корутины).
  • Ограничение по рантайм памяти в 2 Гб, при этом персистентное хранилище Tarantool не ограничено.
  • Один интерфейс к массивам и таблицам.
  • Не самый современный Incremental Mark&Sweep Garbage Collector.
  • Спорно, но динамическая типизация (потому что на этапе прототипирования это плюс).

Мало кто может назвать какой-то язык программирования серебрянной пулей. (\<шёпотом>но Common Lisp всё-таки лучший из лучших\<\/шёпотом>). Поэтому сегодня мы будет работать с Golang и Python.

Как приложение взаимодействует с Tarantool

Я хочу показать в простой схеме как работает Tarantool в связке с Golang. Запросы выстраиваются в очередь, Tarantool сохраняет транзакции на диск.

Начинаем строить приложение

Вот что нам нужно сделать:

  1. Конфигурация базы данных (3 строки Lua).
  2. Создание Golang приложения (150 строк Golang).
    • Подключение к базе
    • Схема данных
    • Индексирование
    • Запросы к базе
    • HTTP-сервер
    • HTTP API
  3. Фронтенд на HTML/JS с Leaflet и OpenStreetMap (150 строк HTML/JS).
    • Виджет
    • События пользователя
    • Запросы к Golang-бекенду

Конфигурация базы данных (всё-таки на Lua)

  • Redis говорит: «Сконфигурируй меня с помощью простых команд в файле»
  • Mongo говорит: «Сконфигурируй меня с помощью YAML файла»
  • Tarantool говорит: «Используй Lua скрипт»

Чтобы запустить Tarantool и подключиться к нему, нам понадобится всего три Lua-функции:

  • box.cfg
  • box.schema.user.create
  • box.schema.user.grant

box.cfg

Функция настраивает весь Tarantool. Часть параметров можно задать только один раз при старте. Другую часть можно менять в любой момент времени.

box.schema.user.create

Функция создаёт пользователя для удаленной работы.

box.schema.user.grant

Функция перечисляет, что можно и что нельзя будет делать пользователю.

Конфигурация Tarantool-а — единственное место, где будет Lua.

-- Открываем порт для доступа по iproto box.cfg({listen="127.0.0.1:3301"}) -- Создаём пользователя для подключения box.schema.user.create('storage', {password='passw0rd', if_not_exists=true}) -- Даём все-все права box.schema.user.grant('storage', 'super', nil, nil, {if_not_exists=true}) -- Чуть настраиваем сериализатор в iproto, чтобы не ругался на несериализуемые типы require('msgpack').cfg{encode_invalid_as_nil = true}

На этом Lua закачивается.

Golang-приложение

В Golang-приложении будет работа с Tarantool и HTTP-сервер. Это две большие задачи, рассмотрим их по очереди.

Работа с Tarantool

Подключение к Tarantool

В Tarantool используется бинарный протокол iproto. Он реализован на базе msgpack. А msgpack — это аналог бинарного JSON.

К Tarantool в одном подключении можно одновременно отправлять несколько запросов. В своем приложении мы можем создать одно подключение и пользоваться им из нескольких горутин.

Выполним подключение:

package main  import (     "fmt"     "github.com/tarantool/go-tarantool" )  func main() {     opts := tarantool.Opts{User: "storage", Pass: "passw0rd"}     conn, err := tarantool.Connect("127.0.0.1:3301", opts)     if err != nil {         panic(err)     }     defer conn.Close() }

Схема данных

На одном узле Tarantool находится только одна база данных. Данные складываются в спейсы == таблицы в мире SQL. К данным обязательно строится первичный индекс, а количество вторичных произвольно.

Для хранения маркеров сделаем таблицу:

id coordinates comment
string [double, double] string

В поле id хранится уникальный идентификатор, который мы сами сгенерируем.
В поле coordinates — координаты маркера (массив из двух double).
В поле comment — строка с комментарием.

Создадим спейс geo из Golang. Для этого вызовем удалённую функцию создания box.schema.space.create. В первом параметре имя спейса, во втором опции. В опциях мы укажем флаг if_not_exist = true. Это нужно, чтобы при перезагрузке Golang-приложения, при уже существующем спейсе не бросалась ошибка.

_, err = conn.Call("box.schema.space.create", []interface{}{         "geo",         map[string]bool{"if_not_exists": true}})

Зададим схему хранения. Для этого функция используем функцию box.space.geo:format.

_, err = conn.Call("box.space.geo:format", [][]map[string]string{         {             {"name": "id", "type": "string"},             {"name": "coordinates", "type": "array"},             {"name": "comment", "type": "string"},         }})

Создадим аналогичную структуру в Golang для дальнейшей десериализации данных.

type GeoObject struct {     Id          string     `json:"id"`     Coordinates [2]float64 `json:"coordinates"`     Comment     string     `json:"comment"` }

Индексация

Для работы со спейсом необходимо создать первичный ключ. Иначе любое действие с данными в спейсе будет создавать ошибку.

Функция box.space.geo:create_index. Двоеточие означает, что мы вызываем эту функцию для объекта-спейса box.space.geo.

Параметры:

  • имя;
  • поле для индекса;
  • флаг для игнорирования ошибки при существующем индексе.

_, err = conn.Call("box.space.geo:create_index", []interface{}{     "primary",     map[string]interface{} {         "parts":         []string{"id"},         "if_not_exists": true}})

Геоиндекс

Для поиска объектов понадобится геоиндекс, который сможет быстро возвращать данные, которые расположены в некотором регионе.

Параметры:

  • имя;
  • поле для индекса;
  • тип индекса RTREE;
  • индекс может содержать неуникальные координаты;
  • флаг для игнорирования ошибки при существующем индексе.

_, err = conn.Call("box.space.geo:create_index", []interface{}{     "geoidx",     map[string]interface{}{         "parts":         []string{"coordinates"},         "type":          "RTREE",         "unique":        false,         "if_not_exists": true}})

Важно

После создание схемы хранения, перезапускаем подключение, чтобы новая схема загрузилась в коннектор.

conn, err = tarantool.Connect("127.0.0.1:3301", opts) if err != nil {     panic(err) } defer conn.Close()

Запись данных

Для вставки данных я воспользуюсь функцией Golang-коннектора InsertTyped.

InsertTyped позволяет вставлять только новые данные и возвращает ошибку, если данные уже существовали.

Параметры:

  • имя спейса;
  • данные;
  • переменная для вставленных таплов.

Например, здесь я вставляю тапл {"Indisko", {299.073, 148.857}, "Indian Food" } в спейс geo.

var tuples []GeoObject err = conn.InsertTyped("geo", []interface{}{     "Indisko",     []float64{299.073, 148.857},     "Indian Food",},      &tuples)

Удаление данных

Для удаления данных пользуемся Golang-функцией DeleteTyped.
Параметры:

  • спейс;
  • индекс;
  • значение индекса для удаления;
  • переменная для удалённых таплов.

Например, здесь я удаляю тапл, у которого первичный ключ "Indisko".

var tuples []GeoObject err = conn.DeleteTyped("geo", "primary", []interface{}{"Indisko"}, &tuples)

Запрос данных

NoSQL-функция для запроса данных: select. Она более простая, чем SELECT из SQL-мира. Функция проходит по индексу относительно одного искомого значения.

Чтобы понять, как работают обращения к данным, я покажу это на примере табличных данных. Этот пример простой и наглядный. После чего мы вернёмся к geo-индексу и посмотрим select там.

Табличные данные для примера

На секунду представим, что у нас не геоданные, а обычная табличная модель с каталогом игр и с полями:

  • идентификатор;
  • имя игры;
  • категория игры;
  • цена.

Для такой таблицы построен вторичный индекс по двум столбцам: Категория и Цена. В этом случае NoSQL-запросы выглядели бы так:

Зелёной штриховкой обозначен индексируемый массив.
Зелёными стрелками направление сортировки в индексе. Это не значит, что данные будут возвращаться только в этом порядке.

Красной обводкой обозначен массив возвращаемых данных.
Красной стрелкой внутри красной обводки обозначена сортировка, в которой данные будут возвращены.

Геоданные

У нас модель хранения геоданных и select в этом случае будет работать так.

Система координат двумерная. Точки — это некоторые объекты с координатами x,y. Прямоугольники — это объекты с координатами левого нижнего и правого верхнего углов.

Карсным обозначен искомый объект: точка или прямоугольник. Зеленым обозначены те объекты, которые вернуться в результате.

Сигнатура select

Для запроса данных используем функцию SelectTyped.

Параметры:

  • cпейс;
  • индекс;
  • смещение, лучше указывать 0;
  • максимум сколько можно отдавать объектов;
  • направление поиска по индексу;
  • значение индекса для поиска. Для индексов, состоящих из нескольких полей, можно указывать часть значения, начиная с самой старшей позиции;
  • параметр для возврата сериализованных данных.

В этим примере я выполняю поиск данных в спейсе geo по индексу geoidx. И ищу только те данные, которые входят (tarantool.IterLe) в заданный регион поиска {0, 0, 300, 400}. Tarantool вернет мне данные, координаты которых лежат в квадрате от вершины 0,0 до вершины 300,400.

var tuples []GeoObject err = conn.SelectTyped("geo", "geoidx", 0, 10, tarantool.IterLe,      []interface{}{0, 0, 300, 400},      &tuples)

Важно

Строить запрос таким образом, чтобы за раз возвращалось приемлемое количество объектов, рекомендую до 1000. Смещение лучше всего указывать 0, потому что любое другое смещение все равно приведёт на сервере к дополнительной итерации по индексу.

HTTP-сервер

Переходим ко второй большой задаче. Возмём стандартный web-сервер Golang. Приложение становится большим, но это не страшно. Эти две строчки еще найдут свое место. После этого раздела я приведу полный код приложения, чтобы вам было понятно расположение всех сниппетов.

err = http.ListenAndServe("127.0.0.1:8080", nil) if err != nil {     panic(err) }

HTTP API

Создадим корневой эндпоинт, в котором просто отдаём index.html. Сам index.html напишем чуть позже.

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {     http.ServeFile(w, r, "index.html") })

Создадим три эндпоинта:

  • /new для создания новых маркеров;
  • /remove для удаления;
  • /list для запроса маркеров, входящих в регион.

/new

В начале функции декодируем JSON, пришедший к нам с фронтенда. Затем генерируем уникальный идентификатор и вставляем объект в Tarantool.

http.HandleFunc("/new", func(w http.ResponseWriter, r *http.Request) {     dec := json.NewDecoder(r.Body)     obj := &GeoObject{}     err := dec.Decode(obj)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     obj.Id = sid.IdHex()     var tuples []GeoObject     err = conn.InsertTyped("geo", []interface{}{obj.Id, obj.Coordinates, obj.Comment}, &tuples)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     enc := json.NewEncoder(w)     enc.Encode(tuples) })

/remove

В начале функции декодируем JSON, пришедший к нам с фронтенда. Вызываем функцию удаления объекта в Tarantool по полю с первичным ключем.

http.HandleFunc("/remove", func(w http.ResponseWriter, r *http.Request) {     dec := json.NewDecoder(r.Body)     obj := &GeoObject{}     err := dec.Decode(obj)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     var tuples []GeoObject     err = conn.DeleteTyped("geo", "primary", []interface{}{obj.Id}, &tuples)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     enc := json.NewEncoder(w)     enc.Encode(tuples) })

/list

В эндпоинте /list получаем get-параметр запроса — rect. Декодируем из этого параметра JSON-массив с координатами карты: левого нижнего угла и правого верхнего. Выполняем запрос в Tarantool с поиском тех объектов, которые входят в rect-регион. Ограничиваем количество объектов 1000.

http.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) {     rect, ok := r.URL.Query()["rect"]     if !ok || len(rect) < 1 {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }      var arr []float64     err := json.Unmarshal([]byte(rect[0]), &arr)     if err != nil {         panic(err)     }      var tuples []GeoObject     err = conn.SelectTyped("geo", "geoidx", 0, 1000, tarantool.IterLe,         arr,         &tuples)     if err != nil {         http.Error(w, err.Error(), http.StatusInternalServerError)         return     }     enc := json.NewEncoder(w)     enc.Encode(tuples) })

На этом мы закончили как работу с Tarantool, так и работу с HTTP-сервером. Golang тоже закончился, дальше JS.

Фронтенд

Для фронтенда возьмем фреймворк Leaflet от Владимира Агафонцева, который поможет нам перемещаться по карте с маркерами.

Подключим нужные библиотеки и стили.

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" crossorigin="" /> <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" crossorigin=""></script> <script src="https://unpkg.com/leaflet-providers@1.0.13/leaflet-providers.js" crossorigin=""></script>

Создадим объект с картой

var mymap = L.map('mapid',     { 'tap': false })     .setView([59.95184617254149, 30.30683755874634], 13)

Загрузим туда базу OpenStreetMap.

var osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {     maxZoom: 19,     attribution: '&copy <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', }).addTo(mymap)

Пайплайн создания маркеров учитывает, какие маркеры уже были загружены, и отображает на карте только новые.

var alreadyloaded = {} var popups = {} function addObject(data) {     if (!(data.id in alreadyloaded)) {         var l = mymap.unproject(L.point(data['coordinates'][0], data['coordinates'][1]), 1)          var description = data['comment']         description += `<br /><a href="#" onclick="removeObject('${data.id}')">Remove</a>`         popups[data.id] = L.marker(l).addTo(mymap).bindPopup(description)         alreadyloaded[data.id] = data     } } function parse(array) {     array.forEach(addObject) } function errorResponse(error) {     alert('Error: ' + error) } function handleListResponse(res) {     res.json().then(parse).catch(errorResponse) }

Обработаем событие для создания маркера на карте. В обработчике отправим запрос на сохранение на сервер на эндпоинт /new. Результат с сервера отправим в пайплайн создания маркера.

function onMapClick(e) {     var response = window.prompt('Что здесь?')     if (response != null) {         var p = mymap.project(e.latlng, 1)          var data = {             "coordinates": [p.x, p.y],             "comment": response,         }          fetch("/new", {             method: "POST",             body: JSON.stringify(data)         })         .then(handleListResponse)         .catch(errorResponse)     } } mymap.on('click', onMapClick)

Обработчик навигации по карте будет срабатывать по таймеру, чтобы не заспамить запросами сервер.

function getObjects() {     var bounds = mymap.getBounds()     var northeast = bounds.getNorthEast()     var southwest = bounds.getSouthWest()     var ne = mymap.project(northeast, 1)     var sw = mymap.project(southwest, 1)     var options = {         "rect": JSON.stringify([ne.x, ne.y, sw.x, sw.y]),     }      fetch("/list?" + new URLSearchParams(options))         .then(handleListResponse)         .catch(errorResponse) } var timerId = null function onMapMove(e) {     if (timerId == null) {         timerId = setTimeout(function () {             getObjects()             timerId = null         }, 1000)     } }  mymap.on('move', onMapMove)  timerId = setTimeout(function () {     getObjects()     timerId = null }, 1000)

Удаление объекта по клику на кнопке на маркере.

function removeObject(id) {     if (!(id in alreadyloaded)) {         alert(`Sorry point with ${id} not found`)         return     }     var data = alreadyloaded[id]      popups[id].remove()     delete alreadyloaded[id]     delete popups[id]      fetch("/remove", {         method: "POST",         body: JSON.stringify(data)     })     .catch(errorResponse) }

Приложение целиком

init.lua

-- Открываем порт для доступа по iproto box.cfg({listen="127.0.0.1:3301"}) -- Создаём пользователя для подключения box.schema.user.create('storage', {password='passw0rd', if_not_exists=true}) -- Даём все-все права box.schema.user.grant('storage', 'super', nil, nil, {if_not_exists=true}) -- Чуть настраиваем сериализатор в iproto, чтобы не ругался на несериализуемые типы require('msgpack').cfg{encode_invalid_as_nil = true}

index.html

<html>  <head>     <title>The Map</title>     <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" crossorigin="" />     <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" crossorigin=""></script>     <script src="https://unpkg.com/leaflet-providers@1.0.13/leaflet-providers.js" crossorigin=""></script> </head>  <body>     <!-- div для карты -->     <div id="mapid" style="height:100%"></div>     <script>         // Карта         var mymap = L.map('mapid',             { 'tap': false })             .setView([59.95184617254149, 30.30683755874634], 13)          // Слой карты с домами, улицами и т.п.         var osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {             maxZoom: 19,             attribution: '&copy <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',         }).addTo(mymap)          // Здесь хранятся те маркеры, что уже отобразили на карте         var alreadyloaded = {}         var popups = {}         // Функция для создания маркера на карте         function addObject(data) {             if (!(data.id in alreadyloaded)) {                 /*                 * Карта использует систему координат на шаре                 * Tarantool хранит координаты на плоскости                 * Конвертируем из одной системы в другую                 */                 var l = mymap.unproject(L.point(data['coordinates'][0], data['coordinates'][1]), 1)                  var description = data['comment']                 // Добавляем кнопку удаления маркера                 description += `<br /><a href="#" onclick="removeObject('${data.id}')">Remove</a>`                 // Создаем маркер                 popups[data.id] = L.marker(l).addTo(mymap).bindPopup(description)                 alreadyloaded[data.id] = data             }         }         // Обрабатываем json пришедший с сервера         function parse(array) {             array.forEach(addObject)         }         function errorResponse(error) {             alert('Error: ' + error)         }         function handleListResponse(res) {             res.json().then(parse).catch(errorResponse)         }          // Обрабатываем нажатие на карту         function onMapClick(e) {             var response = window.prompt('Что здесь?')             if (response != null) {                 /*                 * Карта использует систему координат на шаре                 * Tarantool хранит координаты на плоскости                 * Конвертируем из одной системы в другую                 */                 var p = mymap.project(e.latlng, 1)                  var data = {                     "coordinates": [p.x, p.y],                     "comment": response,                 }                  // Отправляем запрос на сервер для создания маркера                 fetch("/new", {                     method: "POST",                     body: JSON.stringify(data)                 })                 .then(handleListResponse)                 .catch(errorResponse)             }         }         mymap.on('click', onMapClick)          function getObjects() {              var bounds = mymap.getBounds()             var northeast = bounds.getNorthEast()             var southwest = bounds.getSouthWest()             var ne = mymap.project(northeast, 1)             var sw = mymap.project(southwest, 1)             var options = {                 "rect": JSON.stringify([ne.x, ne.y, sw.x, sw.y]),             }              // Отправляем запрос на сервер с получением маркеров             fetch("/list?" + new URLSearchParams(options))                 .then(handleListResponse)                 .catch(errorResponse)         }          // Удаление маркера          function removeObject(id) {             if (!(id in alreadyloaded)) {                 alert(`Sorry point with ${id} not found`)                 return             }             var data = alreadyloaded[id]              popups[id].remove()             delete alreadyloaded[id]             delete popups[id]              fetch("/remove", {                 method: "POST",                 body: JSON.stringify(data)             })             .catch(errorResponse)         }          // Загружаем комментарии при навигации по карте         var timerId = null         function onMapMove(e) {             if (timerId == null) {                 timerId = setTimeout(function () {                     getObjects()                     timerId = null                 }, 1000)             }         }         mymap.on('move', onMapMove)          timerId = setTimeout(function () {             getObjects()             timerId = null         }, 1000)     </script> </body>  </html>

map.go

package main  import (     "encoding/json"     "net/http"      "github.com/chilts/sid"     "github.com/tarantool/go-tarantool" )  // Структура для сериализации гео объектов в/из Tarantool type GeoObject struct {     Id          string     `json:"id"`     Coordinates [2]float64 `json:"coordinates"`     Comment     string     `json:"comment"` }  func main() {     opts := tarantool.Opts{User: "storage", Pass: "passw0rd"}     conn, err := tarantool.Connect("127.0.0.1:3301", opts)     if err != nil {         panic(err)     }     defer conn.Close()      // Создадим таблицу     _, err = conn.Call("box.schema.space.create", []interface{}{         "geo",         map[string]bool{"if_not_exists": true}})     if err != nil {         panic(err)     }     // Зададим типы полей     _, err = conn.Call("box.space.geo:format", [][]map[string]string{         {             {"name": "id", "type": "string"},             {"name": "coordinates", "type": "array"},             {"name": "comment", "type": "string"},         }})     if err != nil {         panic(err)     }      // Создадим первичный индекс     _, err = conn.Call("box.space.geo:create_index", []interface{}{         "primary",         map[string]interface{}{             "parts":         []string{"id"},             "if_not_exists": true}})     if err != nil {         panic(err)     }      // Создадим вторичный geo-индекс по полю с координатами     _, err = conn.Call("box.space.geo:create_index", []interface{}{         "geoidx",         map[string]interface{}{             "parts":         []string{"coordinates"},             "type":          "RTREE",             "unique":        false,             "if_not_exists": true}})     if err != nil {         panic(err)     }      // Перезагружаем схему данных     conn, err = tarantool.Connect("127.0.0.1:3301", opts)     if err != nil {         panic(err)     }     defer conn.Close()      // В корневом эндпоинте отдаём пользователю фронтенд     http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {         http.ServeFile(w, r, "index.html")     })      // Отдаём маркеры для указанного в url региона     http.HandleFunc("/list", func(w http.ResponseWriter, r *http.Request) {         rect, ok := r.URL.Query()["rect"]         if !ok || len(rect) < 1 {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }          var arr []float64         err := json.Unmarshal([]byte(rect[0]), &arr)         if err != nil {             panic(err)         }          // Запрашивает 1000 маркеров, которые находятся в регионе rect         var tuples []GeoObject         err = conn.SelectTyped("geo", "geoidx", 0, 1000, tarantool.IterLe,             arr,             &tuples)         if err != nil {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }         enc := json.NewEncoder(w)         enc.Encode(tuples)     })      // Эндпоинт для сохранения маркера     http.HandleFunc("/new", func(w http.ResponseWriter, r *http.Request) {         dec := json.NewDecoder(r.Body)         obj := &GeoObject{}         err := dec.Decode(obj)         if err != nil {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }         // Генерируем уникальный идентификатор маркера         obj.Id = sid.IdHex()         var tuples []GeoObject         // Вставляем новый маркер         err = conn.InsertTyped("geo", []interface{}{obj.Id, obj.Coordinates, obj.Comment}, &tuples)         if err != nil {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }         enc := json.NewEncoder(w)         enc.Encode(tuples)     })      // Эндпоинт для удаления маркера     http.HandleFunc("/remove", func(w http.ResponseWriter, r *http.Request) {         dec := json.NewDecoder(r.Body)         obj := &GeoObject{}         err := dec.Decode(obj)         if err != nil {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }         // Удаляем переданный маркер по его первичному ключу         var tuples []GeoObject         err = conn.DeleteTyped("geo", "primary", []interface{}{obj.Id}, &tuples)         if err != nil {             http.Error(w, err.Error(), http.StatusInternalServerError)             return         }         enc := json.NewEncoder(w)         enc.Encode(tuples)     })      // Запускаем http сервер на локальном адресе     err = http.ListenAndServe("127.0.0.1:8080", nil)     if err != nil {         panic(err)     } }

Запуск приложения

Запуск базы

Запустить базу данных можно просто руками указав скрипт инициализации.

tarantool init.lua

В директории запуска появятся файлы *.snap, *.xlog. Это файлы обеспечивающие персистентность данных.

  • *.snap — снапшот данных
  • *.xlog — журнал применённых транзакций

Запуск Golang

Инициализируем golang проект.

go mod init map

Установим коннектор из Golang в Tarantool.

go get github.com/tarantool/go-tarantool

Установим библиотеку для генерации уникальных идентификаторов.

go get github.com/chilts/sid

Запускаем бэкенд.

go run ./map.go

Если перейти по адресу http://127.0.0.1:8080, нам предстанет карта, на которой можно ставит маркеры. Например, как проходил бы путь Фродо из Властелина Колец, если бы действия происходили в Ленинградской области.

Python приложение

Давайте рассмотрим такое же приложение на Python3. Оно будет состоять из тех же частей, что и Golang-приложение. Напомню:

  • подключение к базе;
  • схема данных;
  • индексирование;
  • запросы к базе;
  • HTTP-сервер;
  • HTTP API.

Для подключения к базе я использую асинхронный коннектор asynctnt. В качестве асинхронного вебсервера: aiohttp.

map.py

import asyncio import json import uuid  import asynctnt from aiohttp import web  conn = None  # Отдаём фронтенд async def index(request):     return web.FileResponse('./index.html', headers={"Content-type": "text/html; charset=utf-8"})  # Обработчик для создания новой геоточки async def new(request):     data = await request.json()     # Генерируем уникальный идентификатор     data['id'] = str(uuid.uuid4())     # Вставляем в Tarantool     await conn.insert("geo", [data['id'], data['coordinates'], data['comment']])     return web.json_response([data])  # Удаление геоточки async def remove(request):     data = await request.json()     await conn.delete("geo", [data['id']])     return web.json_response([data])  # Возврат всех геоточек в запрашиваемом регионе async def lst(request):     rect = request.rel_url.query['rect']     rect = json.loads(rect)     # Запрашиваем 1000 точек из базы     res = await conn.select('geo', rect, index="geoidx", iterator="LE", limit=1000)     result = []     for tuple in res.body:         result.append({             "id": tuple['id'],             "coordinates": tuple['coordinates'],             "comment": tuple['comment'],         })     return web.json_response(result)  async def main():     global conn     # Подключаемся в базе     conn = asynctnt.Connection(host='127.0.0.1', port=3301, username="storage", password="passw0rd")     await conn.connect()      # Создаём таблицу для геоточек     await conn.call("box.schema.space.create", ["geo", {"if_not_exists": True}])     await conn.call("box.space.geo:format", [[         {"name": "id", "type": "string"},         {"name": "coordinates", "type": "array"},         {"name": "comment", "type": "string"},     ]])      # Создаём первичный ключ на таблицу     await conn.call("box.space.geo:create_index", ["primary", {"parts":["id"], "if_not_exists": True}])     # Создаём геоиндекс     await conn.call("box.space.geo:create_index", ["geoidx", {"parts":["coordinates"], "type":"RTREE", "unique":False,          "if_not_exists": True}])      app = web.Application()     app.add_routes([web.get('/', index),                 web.get('/list', lst),                 web.post('/new', new),                 web.post('/remove', remove)])      await asyncio.gather(web._run_app(app))  asyncio.run(main())

Запуск Python приложения

Установим коннектор asynctnt.

pip3 install asynctnt

Установим aiohttp.

pip3 install aiohttp

Погасим Golang-приложение.

Запустим python.

python3 ./map.py

Тот же адрес http://127.0.0.1:8080, та же карта, но на этот раз Python под капотом. Выбирайте, что для вас удобнее.

В заключение

Мы в Tarantool любим Lua и много на нем пишем. Но не все разделяют наш энтузиазм. Поэтому я решил рассказать, как начать жить с Tarantool без Lua.

В моём примере показаны простые статические объекты, но таким же образом мы можем обеспечить отображение и более динамичных объектов: транспорт, погодные условия, курьеры и т.п.

Что предстояло бы сделать, если запускать приложение в прод

  • Для динамичных объектов, автомобили, погодные условия, лучше поднимать websocket-соединение.
  • Шардировать данные с помощью фреймворка Cartridge, если они не помещаются на одном сервере.
  • Подгрузка объектов при движении по карте сейчас не самая оптимальная.
    • Лучше дозагружать только новые появившееся регионы, а не весь общий регион.
  • На всех уровнях зума будут загружаться все объекты. Это избыточная визуализация.
    • Для решения задачи, нужно аггрегировать объекты для разных зумов. И при запросе на сервер указывать уровень зума.

UPD

Аналогичный код на Lua

Приложение на Lua внутри Tarantool

init.lua

-- Открываем порт для доступа по iproto box.cfg({listen="127.0.0.1:3301"}) -- Создаём пользователя для подключения box.schema.user.create('storage', {password='passw0rd', if_not_exists=true}) -- Даём все-все права box.schema.user.grant('storage', 'super', nil, nil, {if_not_exists=true}) -- Чуть настраиваем сериализатор в iproto, чтобы не ругался на несериализуемые типы require('msgpack').cfg{encode_invalid_as_nil = true}  require('app')

app.lua

local log = require('log') local fio = require('fio') local uuid = require('uuid') local json = require('json')  --[[     Отдаём фронтенд часть браузеру ]] local function index(request)     return { body=fio.open('index.html'):read() } end  --[[     Создаём новый отзыв и сохраняем в таблицу ]] local function newplace(request)     local place = {}     -- json от фронтенда     local obj = request:json()      --[[ Генерируем уникальный идентификатор для отзыва]]     place['id'] = uuid.str()     place['coordinates'] = obj['coordinates']     place['comment'] = obj['comment']      --[[         Создаём сущность для таблицы     ]]     local t, err = box.space.geo:frommap(place)     if err ~= nil then         log.error(tostring(err))         return {code=503, body=tostring(err)}     end     --[[         Вставляет объект     ]]     box.space.geo:insert(t)      return { body=json.encode({place}) } end  --[[     Функция возвращает объекты на карте     ближайшие к указанной точке ]] local function places(request)     local result = {}      local x1, y1, x2, y2 = unpack(json.decode(request:param("rect")))      local limit = 1000     --[[         Итерируемся по таблице начиная с ближайщих к указанной точке объектов     ]]     for _, place in box.space.geo.index.geoidx:pairs({x1, y1, x2, y2}, {iterator='LE'}) do         -- Создаём объект         local obj = {             ['id'] = place['id'],             coordinates = place['coordinates'],             comment = place['comment'],         }         table.insert(result, obj)         limit = limit - 1         if limit == 0 then             break         end     end     return {code=200,             body=json.encode(result)} end  --[[     Создаём таблицу для хранения отзывов на карте ]] box.schema.space.create('geo', {if_not_exists=true}) box.space.geo:format({         {name="id", type="string"},         {name="coordinates", type="array"},         {name="comment", type="string"}, }) --[[ Создаём первичный индекс ]] box.space.geo:create_index('primary', {                                 parts={{field="id", type="string"}},                                 type = 'TREE',                                 if_not_exists=true,}) --[[ Создаём индекс для координат ]] box.space.geo:create_index('geoidx', {                                 parts = {{ field="coordinates", type='array'} },                                 type = 'RTREE', unique = false,                                 if_not_exists=true,})  --[[ Настраиваем http сервис ]] local httpd = require('http.server').new('0.0.0.0', 8080) local router = require('http.router').new() httpd:set_router(router) router:route({path="/"}, index) router:route({path="/new"}, newplace) router:route({path="/list"}, places)  httpd:start()


ссылка на оригинал статьи https://habr.com/ru/articles/574542/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *