Ядро Tarantool-а написано на C, а вся бизнес-логика создаётся на Lua. Это не самый сложный язык, но и не самый популярный. Поэтому сегодня я расскажу, как начать работать с Tarantool, написав всего три строчки кода на Lua. А всё остальное приложение написано на Golang. Чтобы было еще интереснее, я даю альтернативный вариант на Python. Что за проект? Делаем приложение, которое позволяет ставить метки на карте: дом, работа, первое свидание, первый Hello World, первый "too long wal write" Tarantool.
Поехали!
Общая архитектура выглядит следующим образом. На фронтенде мы воспользуемся восхитительным фреймворком Leaflet и не менее замечательной картографической базой OpenStreetMap.
Golang выставит три апишки для работы с картой:
- создание метки;
- загрузка меток при навигации по карте;
- удаление меток.
Tarantool будет хранить метки в таблице и с помощью geo-индекса давать нужные метки за 4 миллисекунды (при навигации по карте).
Содержание
- Введение в Tarantool и Lua
- Как приложение взаимодействует с БД
- Как будем строить приложение
- Конфигурация БД
- Golang приложение
- Работа с БД
- HTTP-сервер
- HTTP API
- Фронтенд
- Golang-приложение целиком
- Запуск приложения
- Тот же пример на Python
- Докрутка перед запуском в прод
- Заключение
Введение в 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 сохраняет транзакции на диск.
Начинаем строить приложение
Вот что нам нужно сделать:
- Конфигурация базы данных (3 строки Lua).
- Создание Golang приложения (150 строк Golang).
- Подключение к базе
- Схема данных
- Индексирование
- Запросы к базе
- HTTP-сервер
- HTTP API
- Фронтенд на 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: '© <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)
}
Приложение целиком
-- Открываем порт для доступа по 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}
<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: '© <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>
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.
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
-- Открываем порт для доступа по 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')
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)
Hes
31.08.2021 00:21Создание спейсов/индексов лучше оставить на стороне Lua.
michael-filonenko Автор
01.09.2021 17:07Это так, да. В статье решил минимизировать Lua, чтобы у бекендеров не складывалось впечатление что Tarantool == Lua.
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 всё-равно рано или поздно упрётесь
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 в исполосованных коннекторах мне тоже показался перегруженным и корявым - текстовые строки в ЯП это одновременно и мощь универсальности и головная боль, и снижение читабельности/реиспользования/рефакторинга в больших проектах, не говоря уже о проблемах в наборе - опечатки, отсутствие контроля, проблемы применения всплывающих подсказок! А обилие кавычек всё только усугубляет!
В общем не впечатлило.
И да ++++++ чтобы Чёрт не спутал моё виденье!
michael-filonenko Автор
01.09.2021 17:05в статье будет написано как создавать высокопроизводительный код, выполняемый на стороне сервера Tarantool в основном потоке сервера - т.е. рядом с данными.
Создаем высоконагруженное приложение на Tarantool
native-вариант для Tarantool - созданный на языке Lua - он бы смотрелся куда лучше
Поапдейтил статью
В общем не впечатлило.
okay(
NikS42
Зачем, а главное - за что?