Ядро 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()

Комментарии (8)


  1. NikS42
    30.08.2021 17:14
    -1

    Зачем, а главное - за что?


  1. Hes
    31.08.2021 00:21

    Создание спейсов/индексов лучше оставить на стороне Lua.


    1. michael-filonenko Автор
      01.09.2021 17:07

      Это так, да. В статье решил минимизировать Lua, чтобы у бекендеров не складывалось впечатление что Tarantool == Lua.


      1. LordDarklight
        02.09.2021 11:34

        СУБД Tarantool и есть Lua - так же как СУБД Oracle Database есть Java, а MS SQL Server как .NET (там вот реально можно на любом языке писать, который компилируется в CLR .NET; ещё есть поддержка языка R). И, например, если не ошибаюсь, СУБД SAP HANA есть поддержка JavaScript подпрограмм (наверянка можно привести и другие примеры СУБД - но я просто про них не знаю). То есть на этих языковых платформах внутри этих СУБД можно создавать то, что некоторые называют костюмными микросервисами - выполняющими гибкую обработку данных на алгоритмах внутри контекста кластера серверов данных СУБД, близко к данным. Тем самым могут брать часть задач сервера приложений. Вот только гибкость прямого взаимодействия через HTTP-сервисы может быть разной.

        Это можно использовать, можно не использовать. Можно, как и в данной статье, просто подключаться к этим СУБД через коннектор (например native-клиент), и работать через него, не задействую внутренние микросервисы в СУБД на костюмных "скриптах", и не знаю о том как оно там вообще выполняется, и на чём там оно пишется.

        Альтернативой native-микросервисам во многих СУБД всегда были серверные процедуры, обычно создаваемые на расширенном SQL - например T-SQL, PL/SQL. Там есть свои плюсы, минусы и ограничения использования. Но в Tarantool таких процедур нет (если не ошибаюсь).

        Активно использовать большие подпрограммы на Lua в Tarantool я бы не стал советовать - т.к. данная СУБД однопоточная - и этот подход будет её замедлять очень сильно. В отличии от названных мной выше других СУБД (и вообще других, не только реляционных, но многопоточных). То есть как сервер приложений Tarantool - это плохая идея - поэтому особо заворачиваться на подпрограммах Lua внутри Tarantool смысла нет - нужна эффективная сложная бакэнд-обработка данных - пишите свой сервер приложений, и работайте из него с Tarantool по принципу, показанному в данной статье. Но какой бы многозадачный сервер приложений Вы не сделали в однопоточность сервера Tarantool всё-равно рано или поздно упрётесь


  1. LordDarklight
    31.08.2021 13:31
    +1

    Хм.... всё не то - я то думал в статье будет написано как создавать высокопроизводительный код, выполняемый на стороне сервера Tarantool в основном потоке сервера - т.е. рядом с данными. А тут... просто примеры использования готовых сторонних клиентов на двух языках, использующие готовые черные ящики - коннекторы из библиотек, специально созданных для этих языков. Причём такие - что промежуточный сервер им вообще не особо то и нужен - можно было бы прямо из фронт-енда к Taratool'у обращаться (оставим в стороне - что такая работа не совсем кошерно - ведь Tarantool позиционируется и как сервер приложений).

    Да и в самом примере работы с Tarantool не показано ничего особенного - с чем бы не менее эффективно не справится любая другая конкурирующая СУБД (а Tarantool конкурирует с разными типами СУБД).

    Код на Golang (мне - не сторонниre Golang) кажется уродским чрезмерно перегруженным. Нет никакого синтаксического сахара - поэтому в XXI веке это выглядит печально!

    Код на Paython (как по мне - более симпатичном ЯП, чем Golang, но не идеальном языке) выглядит несколько лучше, но в нём потеряна вся примитивная обработка ошибок.

    Жаль автор не привёл и native-вариант для Tarantool - созданный на языке Lua - он бы смотрелся куда лучше (мнение матёрого программиста, почти не создававшего скриптов на Lua) - а код на Lua что-то среднее между Golang, Python и JS

    Заодно можно было бы показать производительность разных решений, и преимущество размещение таких функций прямо на сервере Tararantool.

    Формат обращения к API Tarantool в исполосованных коннекторах мне тоже показался перегруженным и корявым - текстовые строки в ЯП это одновременно и мощь универсальности и головная боль, и снижение читабельности/реиспользования/рефакторинга в больших проектах, не говоря уже о проблемах в наборе - опечатки, отсутствие контроля, проблемы применения всплывающих подсказок! А обилие кавычек всё только усугубляет!

    В общем не впечатлило.

    И да ++++++ чтобы Чёрт не спутал моё виденье!


    1. michael-filonenko Автор
      01.09.2021 17:05

      в статье будет написано как создавать высокопроизводительный код, выполняемый на стороне сервера Tarantool в основном потоке сервера - т.е. рядом с данными.

      Создаем высоконагруженное приложение на Tarantool

      native-вариант для Tarantool - созданный на языке Lua - он бы смотрелся куда лучше

      Поапдейтил статью

      В общем не впечатлило.

      okay(


  1. shakasu
    01.09.2021 23:30

    Классный getting started, спасибо Михаил!


    1. michael-filonenko Автор
      03.09.2021 18:12

      Спасибо)