---
tags: Tarantool, OTUS, trainings, practice
title: OTUS — Практика «Сокращатель ссылок»
---
План https://hackmd.io/@Mons/OTUS-Practice-Plan
# OTUS — Сервис сокращения ссылок
[TOC]
## ТЗ
- Пользователь хотел бы сократить ссылку:
- Чтобы, например, вместить её в смс или в твиттер
- Или даже расположить в печати, из которой переходы могут происходить только «вручную»
## Общий план
- Настроить хранилище `Tarantool`
- Смасштабировать его
- Запустить простейший `http` сервер
- *Соединить их вместе и все готово:)*
## Инструмент
- Установка в Centos 8
```bash=
sudo dnf install tarantool tarantool-devel
```
- Установка в других ОС
https://www.tarantool.io/en/download/os-installation/
## База данных и сервер приложений `Tarantool`
- Создание приложений на языке `Lua` с компиляцией «на лету» (`LuaJIT`)
- `ACID` движок базы данных c возможностью репликации между двумя и более инстансами
- Обе части в одном процессе
# День 1 — Архитектура
- Сегодня вы скачаете и запустите `Tarantool`
- Создадите спейс
- Создадите тапл
- Создадите индексы
- Вставите и достанете таплы из спейса
## API
- Настройка базы данных происходит на языке `Lua`
- `box.cfg` — первоначальная настройка базы данных
- Перенастройка базы тоже происходит в этой функции, но не все параметры при этом можно менять на лету
- `listen` — параметр, на каком порту слушать подключения
- Например
```lua
box.cfg{}
```
- `box.schema.space.create(<name>)` — создание спейсов, так в `Tarantool` называются таблицы
- Например, создать спейс `experience`, если она ещё не была создана
```lua
box.schema.space.create('experience',
{if_not_exists=true})
```
- Таблицы в `Tarantool` называются спейсами
- После создания доступ к таблице происходит через функции `box.space.<table_name>` или `box.space['<table_name>']`
- `box.space.<table_name>:format({fields, ....})` — функция для указания какие колонки будут в таблице
- Например
```lua
box.space.experience:format({
{name='date', type='unsigned'},
{name='event', type='string'},
{name='effect', type='string'}})
```
- `box.space.redirector:create_index` — создание индексов функцией самой таблицы
- Первый созданный индекс является первичным
- И он должен быть уникальным
- Все остальные являются вторичными
- Любой индекс может состоять из нескольких колонок
- Например, первичный
```lua
box.space.experience:create_index('primary',
{parts={
{field='date', type='unsigned'}
}})
```
- Например, вторичный неуникальный
```lua
....
box.space.experience:create_index('samsara',
{parts={
{field='event', type='string'},
{field='effect', type='string'}
}})
```
### Схема данных для сокращателя ссылок
- Таблица `redirector`
| source | short |
|:------:|:------:|
| string | string |
- Для перенаправления пользователя нам понадобятся два поля:
- «Короткая ссылка» (`short`)
- «И исходная (длинная)» (`source`)
#### Индексация
- В `Tarantool` таблица не может существовать без первичного индекса — одного или нескольких полей однозначно идентифицирующих запись в таблице
- Самый первый созданный индекс на таблице и будет первичным
- В нашем случае исходная ссылка уникальна и однозначно идентифицирует строку
- Первичный индекс `source`
- Кроме этого мы будем искать исходную ссылку для короткой для перенаправления браузера пользователя
- Вторичный индекс `short`
### Создание таблицы для сокращателя ссылок
- Создадим директорию проекта
```bash=
mkdir makeshort && cd makeshort
```
- :::spoiler `storage.lua`
```lua=
local fio = require('fio')
local wrkdir = './db1'
fio.mktree(wrkdir)
box.cfg{
memtx_dir=wrkdir,
wal_dir=wrkdir,}
local function init()
--[[
Создание схемы данных, индексов
]]
box.schema.user.grant('guest', 'super', nil, nil, {if_not_exists=true})
--[[
Таблица `redirector` — хранилище коротких ссылок
if_not_exists — флаг, о том чтобы не генерировать исключение,
если таблица уже существует
]]
box.schema.space.create('redirector', {if_not_exists=true})
box.space.redirector:format({
{name='source', type='string'},
{name='short', type='string'},
})
--[[
Первичный ключ, первый создаваемый индекс таблицы
]]
box.space.redirector:create_index(
'primary',
{
parts={
{field='source', type='string'}},
if_not_exists=true}
)
--[[
Вторичный индекс, для быстрого поиска строки по короткой ссылке
]]
box.space.redirector:create_index(
'short',
{
parts={
{field='short', type='string'}},
unique=true,
if_not_exists=true,}
)
end
return {
init=init,
}
```
:::
- Запуск
```bash
tarantool
```
- `tarantool>`
```lua
storage = require('storage')
storage.init()
```
- Добавить данные
- `tarantool>`
```lua
box.space.redirector:insert(
{'https://google.com', 'abcdef'})
```
- Запросить данные
- По первичному ключу (уникальному)
- `tarantool>`
```lua
box.space.redirector:get('https://google.com')
```
- По вторичному
- `tarantool>`
```lua
box.space.redirector.index.short:get('abcdef')
```
# День 2 — Репликация
- Сегодня вы запустите два узла
- Настроите репликацию между ними
- Научитесь анализировать состояние репликации
## Intro
- Tarantool содержит механизм копирования (репликации) данных между узлами
- Множество узлов объединённых репликацией называется репликасет (`replicaset`)
- Например, простейшая схема
- На узел-лидер записываются данные
- Узел-реплика эти данные с лидера забирает и применяет у себя
```graphviz
digraph {
node [shape = circle style = filled];
node [fillcolor=white] leader;
node [fillcolor=grey] replica;
leader -> replica
}
```
- Схема посложнее
- Оба узла могут принимать операции записи
- Оба узла применяют изменения соседа у себя
- Пользователь может разрешать конфликты в конфликт-триггере
```graphviz
digraph {
node [shape = circle style = filled];
node [fillcolor=white] leader1 leader2;
leader1 -> leader2
leader1 -> leader2[dir=back]
}
```
- И сложные схемы
- Мультимастер звезда
```graphviz
digraph cluster_star {
layout=circo;
node [shape = circle style = filled];
node [label=leader fillcolor=white] leader1;
node [label=leader fillcolor=white] leader2 leader3;
node [label=replica fillcolor=grey] replica1 replica2 replica3 replica12 replica22 replica32;
leader1 -> {leader2, leader3}[style=bold]
leader2 -> {leader1, leader3}[style=bold]
leader3 -> {leader1, leader2}[style=bold]
leader1 -> {replica1, replica12}[color=grey]
leader2 -> {replica2, replica22}[color=grey]
leader3 -> {replica3, replica32}[color=grey]
}
```
- Или даже `fullmesh`
```graphviz
digraph cluster_star {
layout=circo;
overlap = false;
ranksep=2.5;
node [shape = circle, style = filled]
node [label=leader fillcolor=white] leader1
node [label=leader fillcolor=white] leader2 leader3
leader1 -> {leader2, leader3}[style=bold]
leader2 -> {leader1, leader3}[style=bold]
leader3 -> {leader1, leader2}[style=bold]
node [label=replica fillcolor=grey] replica1 replica2 replica3 replica12 replica22 replica32
leader1 -> {replica1, replica12, replica2, replica22, replica3, replica32}[color=grey]
leader2 -> {replica1, replica12, replica2, replica22, replica3, replica32}[color=grey]
leader3 -> {replica1, replica12, replica2, replica22, replica3, replica32}[color=grey]
}
```
### Настройка репликации
- `box.cfg` содержит параметры для настройки репликации
- `replication` — адреса узлов, от которых копировать данные
- `instance_uuid` — идентификатор текущего узла
- `replicaset_uuid` — идентификатор репликасета, у всех принадлежащих узлов он одинаковый
- `read_only` — режим «только чтение» для узла, в целом может использоваться и без репликации
- Репликация работает на том порту, что был указан в `box.cfg{listen}`, таким образом `replication` это список `listen` других узлов
- В случае репликации, настройка схемы происходит **только на одном** на лидере
- `box.info.ro` позволяет понять, текущий узел лидер или реплика
### Репликация для сокращателя ссылок
```graphviz
digraph {
node [shape = circle style = filled];
node [fillcolor=white label="moscow"] leader;
node [fillcolor=grey label="moscow2"] replica;
leader -> replica
}
```
## Конфигурация узлов
- Оба узла мы будем запускать на одной машине
- Исходный код будет использоваться один и тот же, но с разной конфигурацией
- Вынесем конфигурацию узла в переменные среды (`environment variable`)
- Конфигурация узла будет содержать:
- Бинарный (`iproto`) адрес и порт для репликации
- Источник репликации и режим реплики «только для чтения»
- Переменные среды
- `tarantool_instance_uuid`
- `tarantool_replicaset_uuid`
- `tarantool_listen`
- `tarantool_replication`
- `tarantool_workdir`
- Добавим конфигурацию в исходный код
- Отредактируем модуль `storage.lua`, чтобы указать параметры репликации
- :::spoiler `storage.lua`
```lua=
local fio = require('fio')
local instance_uuid = os.getenv('tarantool_instance_uuid')
local replicaset_uuid = os.getenv('tarantool_replicaset_uuid')
local listen = os.getenv('tarantool_listen') or '127.0.0.1:3301'
local replication = os.getenv('tarantool_replication')
local wrkdir = os.getenv('tarantool_workdir') or './db1'
fio.mktree(wrkdir)
box.cfg{
instance_uuid = instance_uuid,
replicaset_uuid = replicaset_uuid,
listen = listen,
replication = {replication},
memtx_dir=wrkdir,
wal_dir=wrkdir,}
local function init()
if box.info.ro then
return
end
--[[
Создание схемы данных, индексов, и пользователя репликации
]]
box.schema.user.grant('guest', 'super', nil, nil, {if_not_exists=true})
--[[
Таблица `redirector` — хранилище коротких ссылок
if_not_exists — флаг, о том чтобы не генерировать исключение,
если таблица уже существует
]]
box.schema.space.create('redirector', {if_not_exists=true})
box.space.redirector:format({
{name='source', type='string'},
{name='short', type='string'},
})
--[[
Первичный ключ, первый создаваемый индекс таблицы
]]
box.space.redirector:create_index(
'primary',
{
parts={
{field='source', type='string'}},
if_not_exists=true}
)
--[[
Вторичный индекс, для быстрого поиска строки по короткой ссылке
]]
box.space.redirector:create_index(
'short',
{
parts={
{field='short', type='string'}},
unique=true,
if_not_exists=true,}
)
end
return {
init=init,
}
```
- Создадим входные точки
- :::spoiler `start_moscow.lua`
```lua=
os.setenv('tarantool_instance_uuid', '00000000-0000-4000-a000-0000000000a1')
os.setenv('tarantool_replicaset_uuid', '00000000-0000-4000-a000-000000000001') --<< одинаковые для всех узлов репликасета
os.setenv('tarantool_workdir', 'moscow')
os.setenv('tarantool_listen', '127.0.0.1:3301')
local storage = require('storage')
storage.init()
```
- :::spoiler `start_moscow2.lua`
```lua=
os.setenv('tarantool_instance_uuid', '00000000-0000-4000-a000-0000000000a2')
os.setenv('tarantool_replicaset_uuid', '00000000-0000-4000-a000-000000000001') --<< одинаковые для всех узлов репликасета
os.setenv('tarantool_workdir', 'moscow2')
os.setenv('tarantool_listen', '127.0.0.1:3302')
os.setenv('tarantool_replication', '127.0.0.1:3301')
local storage = require('storage')
storage.init()
```
#### Запуск
```bash
tarantool start_moscow.lua
```
```bash
tarantool start_moscow2.lua
```
#### Проверка
- Добавить данные на `moscow`
```bash
tarantoolctl connect 127.0.0.1:3301
```
- `tarantool>`
```lua
box.space.redirector:insert(
{'https://google.com', 'abcdef'})
```
- Запросить данные с реплики `moscow2`
```bash
tarantoolctl connect 127.0.0.1:3302
```
- `tarantool>`
```lua
box.space.redirector:get('https://google.com')
```
```lua
box.space.redirector.index.short:get('abcdef')
```
### Мониторинг репликации
- Проверим репликацию на реплике `moscow2`
```bash
tarantoolctl connect 127.0.0.1:3302
```
- `tarantool>`
```lua
box.info().replication
```
- Примерный вывод
```yaml
---
- 1:
id: 1
uuid: 00000000-0000-4000-a000-0000000000a1
lsn: 8
upstream:
status: follow
idle: 0.73673253925517
peer: 127.0.0.1:3301
lag: 0.00011086463928223
2:
id: 2
uuid: 00000000-0000-4000-a000-0000000000a2
lsn: 1
...
```
- `upstream.status`
- `follow` — репликация работает
- `upstream.lag`
- репликация отстаёт на `lag` секунд
- Если `status` не `follow` — репликация не идёт, необходим анализ и вмешательство
- `upstream.idle` — укажет как давно сломалась репликация
# День 3 — Шардирование
- Сегодня вы запустите шардированный кластер из четырех узлов
- Адаптируете хранилище под шардинг
## Intro
- Шардирование позволяет распределить данные по нескольким репликасетам `Tarantool`
- Данные распределяются по корзинам
- Корзины распределяются по репликасетам
- Внутри репликасетов данные реплицируются между узлами
- Обработка данных происходит на узлах-роутерах
- Хранение данных происходит на узлах-стораджах
- В случае добавления новых репликасетов с узлами-стораджами, происходит автоматическая перебалансировка корзин с данными на новый репликасет
- Общее количество корзин `bucket_count` задаётся только один раз при первом старте приложения
- Распределение данных по стораджам происходит на узлах-роутерах
- Но в целом на одном узле может быть запущено обе роли: роутер и сторадж
- Каждая таблица, что хочет шардирования, должна содержать неуникальный индекс `bucket_id`
### Топология, для сокращателя ссылок
- Для простоты расположим роутеры на каждом из шардов
```graphviz
digraph onnode {
subgraph cluster_1 {
label="Московский шард"
subgraph cluster_2 {
label="Москва";
moscow_router[label="Роутер"]
moscow_storage[label="Хранилище" shape=cylinder]
}
style=dashed
}
subgraph cluster_3 {
label="Питерский шард"
subgraph cluster_4 {
label="Питер"
spb_router[label="Роутер"]
spb_storage[label="Хранилище" shape=cylinder]
}
style=dashed
}
moscow_router -> moscow_storage
moscow_router -> spb_storage
spb_router -> spb_storage
spb_router -> moscow_storage
}
```
### Шардирование сокращателя ссылок
- Удалим устаревшие рабочие директории
```bash
rm -rf moscow*
```
- Установим фреймворк шардирования `vshard`
```bash
tarantoolctl rocks install vshard
```
- `topology.lua`
```lua=
local cfg = {
BUCKET_COUNT = 16,
sharding = {
['00000000-0000-4000-a000-000000000001'] = { -- replicaset #1
replicas = {
['00000000-0000-4000-a000-0000000000a1'] = {
uri = 'rep:rep@127.0.0.1:3301',
name = 'storage_moscow',
master = true
},
},
}, -- replicaset #1
['00000000-0000-4000-a000-000000000002'] = { -- replicaset #2
replicas = {
['00000000-0000-4000-a000-0000000000b1'] = {
uri = 'rep:rep@127.0.0.1:3303',
name = 'storage_spb',
master = true
},
},
}, -- replicaset #2
}, -- sharding
}
return cfg
```
- `storage.lua`
```lua=
local function init()
if box.info.ro then
return
end
--[[
Создание схемы данных, индексов, и пользователя репликации
]]
box.schema.user.grant('guest', 'super', nil, nil, {if_not_exists=true})
--[[
Таблица `redirector` — хранилище коротких ссылок
if_not_exists — флаг, о том чтобы не генерировать исключение,
если таблица уже существует
]]
box.schema.space.create('redirector', {if_not_exists=true})
box.space.redirector:format({
{name='source', type='string'},
{name='short', type='string'},
{name='bucket_id', type='unsigned'},})
--[[
Первичный ключ, первый создаваемый индекс таблицы
]]
box.space.redirector:create_index(
'primary',
{
parts={
{field='source', type='string'}},
if_not_exists=true})
--[[
Вторичный индекс, для быстрого поиска строки по короткой ссылке
]]
box.space.redirector:create_index(
'short',
{
parts={
{field='short', type='string'}},
unique=true,
if_not_exists=true,})
--[[
Индекс для шардирования данных
]]
box.space.redirector:create_index(
'bucket_id',
{
parts={
{field='bucket_id', type='unsigned'}},
unique=false,
if_not_exists=true,})
box.schema.user.grant(
'rep', 'read,write,execute', 'universe',
nil, {if_not_exists=true}
)
end
return {
init=init,
}
```
- `start_moscow.lua`
```lua=
vshard = require('vshard')
local topology = require('topology')
local storage = require('storage')
local fio = require('fio')
fio.mktree('moscow')
vshard.storage.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
memtx_dir = 'moscow',
wal_dir = 'moscow',
replication_connect_quorum = 1,
},
'00000000-0000-4000-a000-0000000000a1')
storage.init()
vshard.router.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,})
```
- `start_spb.lua`
```lua=
vshard = require('vshard')
local topology = require('topology')
local storage = require('storage')
local fio = require('fio')
fio.mktree('spb')
vshard.storage.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
memtx_dir = 'spb',
wal_dir = 'spb',
replication_connect_quorum = 1,
},
'00000000-0000-4000-a000-0000000000b1')
storage.init()
vshard.router.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,})
```
### Запуск
```bash
tarantool start_moscow.lua
```
```bash
tarantool start_spb.lua
```
### Первоначальная настройка
- На данный момент в логах
```
Some buckets are not active, retry rebalancing later
```
- Требуется Бутстрап Хранилища
```
tarantoolctl connect 127.0.0.1:3301
```
- И в запущенном подключении выполнить бутстрап
- `tarantool>`
```lua
vshard.router.bootstrap()
```
### Вставка данных
- Доступ к данным теперь происходит через роутер
- Подключимся к роутеру
```
tarantoolctl connect 127.0.0.1:3301
```
- Подготовим данные
- Для каждого объекты мы теперь должны вычислять `bucket_id` — прямо сейчас захардкодим эти значения
- В будущем мы выберем по какому полю шардировать данные, что сценарий работы с сокращателем ссылок был удобен для пользователя
- Для отправки данных на шард используем функцию `vshard.router.callrw`
- `tarantool>`
```lua=
bucket_id=1
google={'https://google.com', 'abcdef', bucket_id}
vshard.router.callrw(bucket_id,
'box.space.redirector:put', {google})
bucket_id=2
ya={'https://ya.ru', 'qwerty', bucket_id}
vshard.router.callrw(bucket_id,
'box.space.redirector:put', {ya})
bucket_id=3
apple={'https://apple.com', 'aaa', bucket_id}
vshard.router.callrw(bucket_id,
'box.space.redirector:put', {apple})
```
- Для запроса данных по ключу шардирования `vshard.router.callro`
```lua=
vshard.router.callro(3,
'box.space.redirector:get',
{'https://apple.com'})
```
## Запрос данных `map/reduce`
- `vshard.router.routeall` возвращает все репликасеты
- Функции роутера работают только на роутере
- `replica:call*` вызывает функцию на подходящей реплике
```lua
do
local resultset = {}
shards, err = vshard.router.routeall()
if err ~= nil then
print(err)
return
end
for uid, replica in pairs(shards) do
local set, err = replica:callro('box.space.redirector:select', {})
if err ~= nil then
print(err)
return
end
for _, link in ipairs(set) do
table.insert(resultset, link)
end
end
table.sort(resultset, function(a, b) return a[1] < b[1] end)
return resultset
end
```
### Отказоустойчивый шардинг
- Доработаем исходники
- `topology.lua`
```lua=
local cfg = {
BUCKET_COUNT = 16,
sharding = {
['00000000-0000-4000-a000-000000000001'] = { -- replicaset #1
replicas = {
['00000000-0000-4000-a000-0000000000a1'] = {
uri = 'rep:rep@127.0.0.1:3301',
name = 'storage_moscow',
master = true
},
['00000000-0000-4000-a000-0000000000a2'] = {
uri = 'rep:rep@127.0.0.1:3302',
name = 'storage_moscow2',
},
},
}, -- replicaset #1
['00000000-0000-4000-a000-000000000002'] = { -- replicaset #2
replicas = {
['00000000-0000-4000-a000-0000000000b1'] = {
uri = 'rep:rep@127.0.0.1:3303',
name = 'storage_spb',
master = true
},
['00000000-0000-4000-a000-0000000000b2'] = {
uri = 'rep:rep@127.0.0.1:3304',
name = 'storage_spb2',
},
},
}, -- replicaset #2
}, -- sharding
}
return cfg
```
- `start_moscow2.lua`
```lua=
vshard = require('vshard')
local topology = require('topology')
local storage = require('storage')
local fio = require('fio')
fio.mktree('moscow2')
vshard.storage.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
memtx_dir = 'moscow2',
wal_dir = 'moscow2',
replication_connect_quorum = 1,
},
'00000000-0000-4000-a000-0000000000a2')
storage.init()
vshard.router.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,})
```
- `start_spb2.lua`
```lua=
vshard = require('vshard')
local topology = require('topology')
local storage = require('storage')
local fio = require('fio')
fio.mktree('spb2s')
vshard.storage.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
memtx_dir = 'spb2',
wal_dir = 'spb2',
replication_connect_quorum = 1,
},
'00000000-0000-4000-a000-0000000000b2')
storage.init()
vshard.router.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,})
```
- Запуск
```bash
tarantool start_moscow.lua
```
```bash
tarantool start_spb.lua
```
```bash
tarantool start_moscow2.lua
```
```bash
tarantool start_spb2.lua
```
### Запросы
```
tarantoolctl connect 127.0.0.1
```
- `tarantool>`
- Запись
```lua
vshard.router.callrw(1, 'print', {'<<CALL>>'})
```
- Чтение предпочитая лидера
```lua
vshard.router.callro(1, 'print', {'<<CALL>>'})
```
- Чтение с балансировкой
```lua
vshard.router.callbro(1, 'print', {'<<CALL>>'})
```
- Чтение с балансировкой предпочитая реплику
```lua
vshard.router.callbro(1, 'print', {'<<CALL>>'})
```
# День 4 — Апп сервер
- Сегодня вы добавите логику приложения к своему хранилищу
- Напишете легковесный `http` сервер
- Реализуете `http` `api` в своем приложении
## Intro
### Lua за 15 минут
- https://learnxinyminutes.com/docs/ru-ru/lua-ru/
### Код
- Код на языке `Lua` выполняется в файберах (их также называют корутины, горутины, зеленые треды, легковесные сопрограммы)
- Файберы работают по принципу кооперативной многозадачности
- В текущий момент времени выполняется только один файбер, и этот файбер, если операция ввода-вывода или явно, передаёт управление другому файберу
- `Tarantool` содержит встроенные модули, некоторыми из которых мы воспользуемся:
- `log` — журналирование событий приложения и базы данных
- `console` — предлагает возможность выполнения и отладки кода в уже запущенном инстансе тарантула
- `clock` — функции для работы с системным временем
- `digest` — вычисление хеша от строки
- `fio` — работа с файлами
- `socket` — работа с сетью
- `box` — настройка и хранение данных
- Подробнее обо всех модулях: https://www.tarantool.io/en/doc/latest/reference/
## Сервер приложений
- Сделаем легковесный `http` сервер
- `socket.tcp_server(server, port, client_handler)` создаёт файбер, который
- слушает tcp/ip `server:port`
- для каждого нового клиента запускает `client_handler` в отдельном файбере
- `client_handler` принимает первым аргументом соединение с клиентом
- Функция обработки `http` запросов:
- для `GET /` вернет html форму для ввода ссылки
- для `GET /?url=*` сгенерирует и вернет короткую ссылку
- для `GET /*` перенаправит на исходную ссылку
- Таким rest api я слегка хитрю, на этом этапе мы быстренько напишем `http` парсер воспользовавшись только первой строкой протокола
- Модуль `digest` помогает сгенерировать хеш от строки
- Прежде чем вычислить хеш, к исходной строке добавляется рандомная «соль»
- Доступ к данным будет выполняться всё также через `vshard.router.callrw`(`-ro`)
### Топология
- Возьмём топологию попроще для удобства запуска и отладки
```graphviz
digraph onnode {
subgraph cluster_shard1 {
label="Московский шард"
subgraph cluster_1 {
label="Москва"
appserver[label="Апп сервер" shape=box]
moscow_router[label="Роутер"]
moscow_storage[label="Хранилище" shape=cylinder]
moscow_storage[label="Хранилище" shape=cylinder]
}
style=dashed
}
subgraph cluster_shard2 {
label="Питерский шард"
subgraph cluster_3 {
label="Питер"
spb_storage[label="Хранилище" shape=cylinder]
}
style=dashed
}
appserver -> moscow_router
moscow_router -> moscow_storage
moscow_router -> spb_storage
}
```
### Сервер обработки http запросов
- `appserver.lua`
```lua=
local log = require('log')
local socket = require('socket')
local clock = require('clock')
local digest = require('digest')
local vshard = require('vshard')
local serveraddr = '0.0.0.0'
local port = 8080
local hostname = 'localhost' ---<<<!!!!! Вставить dns имя или ip своей виртуалки
--[[
Сердце сервиса — функция генерации короткой ссылки
Генерируем хеш с рандомной солью и вставляем в хранилище
Шардируем по значению короткой ссылки
]]
local function make_it_short(source)
local short = nil
short = digest.base64_encode(
digest.urandom(10),
{nopad=true, nowrap=true, urlsafe=true})
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local now = math.floor(clock.time())
local rc, err = vshard.router.callrw(bucket_id, 'box.space.redirector:insert',
{{source, short, now, bucket_id}})
if err ~= nil then
return nil, err
end
return short
end
--[[
Тоже сердце — функция поиска исходной ссылки по короткой
]]
local function take_me_source(short)
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local tuple, err = vshard.router.callro(bucket_id, 'box.space.redirector.index.short:get',
{short})
if err ~= nil then
log.warn(err)
return nil, err
end
if tuple == nil then
return nil
end
return tuple[1]
end
--[[
HTTP сервер обработки запросов
]]
--[[
Утилиты для декодирования значений GET параметров url строки
]]
local hex_to_char = function(x)
return string.char(tonumber(x, 16))
end
local unescape = function(url)
return url:gsub("%%(%x%x)", hex_to_char)
end
--[[
HTML форма для создания короткой ссылки
]]
local form = [[
<html>
<head></head>
<body>
<form>
<input name=url type=text />
<input type=submit />
</form>
</body>
</html>
]]
--[[
Функция возвращающая успешный (200) http пакет
с данными `data`
]]
local function response200(data)
return ("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Length: %u\r\nConnection: close\r\n\r\n%s")
:format(#data, data)
end
--[[
Возврат ошибки 404
]]
local function response404()
return "HTTP/1.1 404 Not Found\r\nContent-Type: text; charset=UTF-8\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found"
end
--[[
Редирект на страницу target
]]
local function redirect(target)
return ("HTTP/1.1 301 Moved Permanently\r\nLocation: %s\r\nContent-Type: text/html\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
:format(target)
end
--[[
TCP/IP сервер с оооочень простой (прямо таки простейшей)
логикой http протокола
Для каждого соединения в отдельном файбере запускается
функция `function(client)`, которая обрабатывает http запросы
и отвечает http ответы
]]
--[[
(ну почти REST) API
GET / — html форма для создания короткой cсылки
GET /?url=* — создание короткой ссылки по исходной в url параметре
GET /* - переход по короткой ссылке
]]
local server
local function init(opts)
server = socket.tcp_server(
serveraddr, port,
function(client)
local request_line = client:read('\r\n', 10)
if request_line == nil then
log.info('Timeout')
return
end
--[[
Если запрос корня — предлагаем форму ввода
]]
if request_line:startswith('GET / HTTP') then
client:write(response200(form))
--[[
Запрос на создание короткой ссылки
]]
elseif request_line:startswith('GET /?url=') then
local url = request_line:sub(#'GET /?url=')
url = url:sub(2, url:find(' ') - 1)
url = unescape(url)
local short = make_it_short(url)
if short == nil then
client:write(response404())
return
end
local pasteable_short = 'http://' .. hostname .. ':' .. tostring(port) .. '/' .. short
client:write(response200(pasteable_short))
--[[
Запрос на переход по короткой ссылке
Редиректим на исходную ссылку, если она была в базе
]]
elseif request_line:startswith('GET /') then
local short = request_line:sub(#'GET /')
short = short:sub(2, short:find(' ') - 1)
local source = take_me_source(short)
if source == nil then
client:write(response404())
end
client:write(redirect(source))
end
end)
server:listen()
end
local function stop()
server:close()
end
--[[
Возвращаем
- имя роли для использования в GUI и rpc API
- колбеки жизненного цикла
- зависимости от других ролей
- vshard-router - роль, которая поможет запрашивать шардированные данные
]]
return {
init=init,
stop=stop,
}
```
- `start_moscow.lua`
```lua=
vshard = require('vshard')
local topology = require('topology')
local storage = require('storage')
local fio = require('fio')
fio.mktree('moscow')
vshard.storage.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
memtx_dir = 'moscow',
wal_dir = 'moscow',
replication_connect_quorum = 1,
},
'00000000-0000-4000-a000-0000000000a1')
storage.init()
vshard.router.cfg({
bucket_count = topology.BUCKET_COUNT,
sharding = topology.sharding,
})
local appserver = require('appserver')
appserver.init()
```
## Запуск
```bash
tarantool start_moscow.lua
```
```bash
tarantool start_spb.lua
```
## Оно заработало?
- Откроем браузер на `http://<ip_address>:8080/`
- Должна появится форма для ввода `url`
- Введем любой `url`
- Получим короткую ссылку
- Перейдем по короткой ссылке
- Или можно воспользоваться готовым скриптом, который пытается укоротить `https://google.com/?q=<random-string>`
- `test.lua`
```lua=
local log = require('log')
local client = require('http.client')
--[[
Простой тест для проверки укорачивания ссылок
]]
local host = '127.0.0.1'
local port = 8080
local source = 'https://google.com/?q=' .. tostring(os.time())
local makeshort = 'http://' .. host .. ':' .. port .. '/?url='
local response = client.get(makeshort .. source)
local short = response.body
print(short, '->', source)
local redirect = client.get(short, {follow_location=true})
assert(redirect.headers['location'] == source,
"Source url and redirect not equal")
```
- Запуск теста
```bash
tarantool test.lua
```
- Примерный вывод
```bash
http://localhost:8080/XniDtG2jrBa23Q
-> https://google.com/?q=1607896232
```
# День 5 — Cartridge приложение
- Соберете приложение в пакет `Cartridge`
- Поуправляете кластером через веб интерфейс
- Создадите кластерный тест к своему приложению
## Intro
- `Cartidge` — фреймворк, который предлагает простой способ разработки и управления кластерным приложением
- Приложение разделяется на модули специальным образом — такие модули называются «роли»
- После чего вы запускаете одно или несколько экземпляров приложения и конфигурируете из них кластер с помощью `web ui`
- `Cartridge` содержит модуль для тестирования приложений в кластерном виде
- Для деплоя `Cartridge` предоставляет интерфейс для упаковки приложения в `rpm`/`deb`/`targz` пакет
### Роль
- Роль — модуль реализующий API
- `role_name`
- `init`
- `validate_config`
- `apply_config`
- `stop`
- `dependencies`
- Роли могут взаимодействовать друг с другом:
- Локально, если одна роль зависит от другой
- Тогда `Cartridge` их запускает на одном узле
- Удаленно, тогда между ролями используется вызов `cartridge.rpc_call`
- `init`/`stop` вызываются при запуске и останове роли соответственно
- `validate_config`/`apply_config` вызываются при изменении конфигурации кластера:
- Топологии
- Переключении мастера
- Настроек failover-а
- Пользовательских настроек
## Подготовка
- Удаление устаревших рабочих директорий
```bash
rm -rf moscow* spb*
```
- Удаление устаревших файлов
```bash
rm -rf start_* topology.lua
```
- Установка фреймворка
```bash
tarantoolctl rocks install cartridge 2.3.0
```
- У всех узлов теперь входная точка с инициализацией `cartridge`
- `init.lua`
```lua=
#!/usr/bin/env tarantool
--[[
Устанавливаем текущую директорию,
как начальный путь загрузки всех модулей
]]
package.setsearchroot()
local cartridge = require('cartridge')
local log = require('log')
--[[
Конфигурируем и запускаем cartridge на узле
Задаем небольшое количество корзин шардирования
для удобства разработки и отладки
Указываем какие роли мы будем использовать в кластере
- vshard storage для хранения шардированных данных
- vshard router для доступа к ним
Указываем рабочую директорию для хранения снапов, икслогов
и конфигурации приложения `one`
]]
local _, err = cartridge.cfg({
workdir = 'moscow',
bucket_count = 32,
roles = {
'appserver',
'storage',
'cartridge.roles.vshard-storage',
'cartridge.roles.vshard-router'
},
})
if err ~= nil then
log.info(err)
os.exit(1)
end
```
- Для модуля апсервера добавим API для кластеризации
- :::spoiler `appserver.lua`
```lua=
local log = require('log')
local socket = require('socket')
local clock = require('clock')
local digest = require('digest')
local vshard = require('vshard')
local serveraddr = '0.0.0.0'
local port = 8080
local hostname = 'localhost' ---<<<!!!!! Вставить dns имя или ip своей виртуалки
--[[
Сердце сервиса — функция генерации короткой ссылки
Генерируем хеш с рандомной солью и вставляем в хранилище
Шардируем по значению короткой ссылки
]]
local function make_it_short(source)
local short = nil
short = digest.base64_encode(
digest.urandom(10),
{nopad=true, nowrap=true, urlsafe=true})
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local now = math.floor(clock.time())
local rc, err = vshard.router.callrw(bucket_id, 'box.space.redirector:insert',
{{source, short, now, bucket_id}})
if err ~= nil then
return nil, err
end
return short
end
--[[
Тоже сердце — функция поиска исходной ссылки по короткой
]]
local function take_me_source(short)
local bucket_id = vshard.router.bucket_id_mpcrc32(short)
local tuple, err = vshard.router.callro(bucket_id, 'box.space.redirector.index.short:get',
{short})
if err ~= nil then
log.warn(err)
return nil, err
end
if tuple == nil then
return nil
end
return tuple[1]
end
--[[
HTTP сервер обработки запросов
]]
--[[
Утилиты для декодирования значений GET параметров url строки
]]
local hex_to_char = function(x)
return string.char(tonumber(x, 16))
end
local unescape = function(url)
return url:gsub("%%(%x%x)", hex_to_char)
end
--[[
HTML форма для создания короткой ссылки
]]
local form = [[
<html>
<head></head>
<body>
<form>
<input name=url type=text />
<input type=submit />
</form>
</body>
</html>
]]
--[[
Функция возвращающая успешный (200) http пакет
с данными `data`
]]
local function response200(data)
return ("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Length: %u\r\nConnection: close\r\n\r\n%s")
:format(#data, data)
end
--[[
Возврат ошибки 404
]]
local function response404()
return "HTTP/1.1 404 Not Found\r\nContent-Type: text; charset=UTF-8\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found"
end
--[[
Редирект на страницу target
]]
local function redirect(target)
return ("HTTP/1.1 301 Moved Permanently\r\nLocation: %s\r\nContent-Type: text/html\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
:format(target)
end
--[[
TCP/IP сервер с оооочень простой (прямо таки простейшей)
логикой http протокола
Для каждого соединения в отдельном файбере запускается
функция `function(client)`, которая обрабатывает http запросы
и отвечает http ответы
]]
--[[
(ну почти REST) API
GET / — html форма для создания короткой cсылки
GET /?url=* — создание короткой ссылки по исходной в url параметре
GET /* - переход по короткой ссылке
]]
local server
local function init(opts)
server = socket.tcp_server(
serveraddr, port,
function(client)
local request_line = client:read('\r\n', 10)
if request_line == nil then
log.info('Timeout')
return
end
--[[
Если запрос корня — предлагаем форму ввода
]]
if request_line:startswith('GET / HTTP') then
client:write(response200(form))
--[[
Запрос на создание короткой ссылки
]]
elseif request_line:startswith('GET /?url=') then
local url = request_line:sub(#'GET /?url=')
url = url:sub(2, url:find(' ') - 1)
url = unescape(url)
local short = make_it_short(url)
if short == nil then
client:write(response404())
return
end
local pasteable_short = 'http://' .. hostname .. ':' .. tostring(port) .. '/' .. short
client:write(response200(pasteable_short))
--[[
Запрос на переход по короткой ссылке
Редиректим на исходную ссылку, если она была в базе
]]
elseif request_line:startswith('GET /') then
local short = request_line:sub(#'GET /')
short = short:sub(2, short:find(' ') - 1)
local source = take_me_source(short)
if source == nil then
client:write(response404())
end
client:write(redirect(source))
end
end)
server:listen()
end
local function stop()
server:close()
end
--[[
Возвращаем
- имя роли для использования в GUI и rpc API
- колбеки жизненного цикла
- зависимости от других ролей
- vshard-router - роль, которая поможет запрашивать шардированные данные
]]
return {
role_name="appserver",
init=init,
stop=stop,
dependencies={'cartridge.roles.vshard-router'}
}
```
:::
- :::spoiler `storage.lua`
```lua=
--[[
Запуск роли
В случае запуска на лидере репликасета создаём таблицы
И для неё создаем первичный индекс и индекс для шардирования данных
В случае, когда роль перезапускается, используем флаг `if_not_exists=true`
для игнорирования в случае уже созданных объектов
]]
local function init(opts)
if not opts.is_master then
return
end
--[[
Таблица `redirector` — хранилище коротких ссылок
if_not_exists — флаг, о том чтобы не генерировать исключение,
если таблица уже существует
]]
box.schema.space.create('redirector', {if_not_exists=true})
box.space.redirector:format({
{name='source', type='string'},
{name='short', type='string'},
{name='bucket_id', type='unsigned'},})
--[[
Первичный ключ, первый создаваемый индекс таблицы
]]
box.space.redirector:create_index(
'primary',
{
parts={
{field='source', type='string'}},
if_not_exists=true})
--[[
Вторичный индекс, для быстрого поиска строки по короткой ссылке
]]
box.space.redirector:create_index(
'short',
{
parts={
{field='short', type='string'}},
unique=true,
if_not_exists=true,})
--[[
Индекс для шардирования данных
]]
box.space.redirector:create_index(
'bucket_id',
{
parts={
{field='bucket_id', type='unsigned'}},
unique=false,
if_not_exists=true,})
end
--[[
Роль не пользуется кластерным конфигом
]]
local function validate_config(new_conf, old_conf)
return true
end
local function apply_config(conf, opts)
return true
end
--[[
Удаление таблиц предпочтительно не автоматизировать
]]
local function stop()
end
--[[
Возвращаем
- имя роли для использования в GUI и rpc API
- колбеки жизненного цикла
- зависимости от других ролей
- vshard-storage, роль которая будет заботиться о шардировании данных
всех спейсов, у которых есть индекс `bucket_id`
]]
return {
role_name="storage",
init=init,
validate_config=validate_config,
apply_config=apply_config,
stop=stop,
dependencies={'cartridge.roles.vshard-storage'}
}
```
:::
## Однонодовый запуск
- Запустим приложение
```bash
tarantool init.lua
```
- Откроем страницу администрирования http://127.0.0.1:8081
- На вкладке `Cluster` на единственном репликасете выберем `Edit` и включим роли `appserver`, `storage`
- Автоматически включатся зависимые роли
## Bootstrap vshard
- Если сейчас вставлять данные, будет ошибка о том, что шардированное хранилище не инициализировано
- Откроем страницу администрирования http://127.0.0.1:8081
- На вкладке `Cluster` нажмем `Bootstrap vshard`
## Кластер
- Запустим ещё один узел приложения
- В другую директорию
- С другим портом
- С отключенным `http` сервером
```bash
tarantool init.lua --advertise-uri 127.0.0.1:3302 \
--http-enabled false --workdir spb
```
- На `web gui` на вкладке `Cluster` должен отобразиться несконфигурированный узел
- Если не появился, нужно нажать `Probe uri` и ввести `advertise-uri` нового запущенного сервера `127.0.0.1:3302`
- Нажмем `Configure`, укажем роль `storage`, создадим новый репликасет с этим узлом `Create replica set`
- Таким образом получится два шарда
- Запустим тест для проверки, что всё получилось
```bash
tarantool test.lua
```
## Интеграционное тестирование
- Особенностью тестирования является возможность собирать произвольную топологию кластера, и проводить сценарии используя один или даже все узлы
- Установить фреймворк для тестирования
```bash
tarantoolctl rocks install luatest
```
- В директории `test`
- Имя файла заканчивается на `_test`
- <details> <summary><code>test/basic_test.lua</code></summary>
```lua=
local fio = require('fio')
local log = require('log')
local client = require('http.client')
local uri = require('uri')
local t = require('luatest')
local g = t.group('starwars')
local helpers = require('cartridge.test-helpers')
g.before_all(function()
local tempdir = fio.tempdir()
local cluster = helpers.Cluster:new({
datadir = tempdir,
server_command = 'init.lua',
use_vshard = true,
replicasets = {{
roles = {'appserver'},
servers = {{
alias = 'appserver',
}}},
{
roles = {'storage'},
servers = {{
alias = 'storage',
}}},
{
roles = {'storage'},
servers = {{
alias = 'storage2',
}}},
},
})
cluster:start()
g.cluster = cluster
end)
g.after_all(function()
g.cluster:stop()
end)
function g.test_starwars_storage()
local cluster = g.cluster
local server = cluster:server('appserver')
--[[
Простой тест для проверки укорачивания ссылок
]]
local url = uri.parse(server.advertise_uri)
local host = url.host
local port = 8080
local source = 'https://google.com/?q=' .. tostring(os.time())
local makeshort = 'http://' .. host .. ':' .. port .. '/?url='
local response = client.get(makeshort .. source)
local short = response.body
log.info(short, '->', source)
local redirect = client.get(short, {follow_location=true})
t.assert_equals(redirect.headers['location'], source,
"Source url and redirect not equal")
end
```
</details>
- `chmod +x init.lua`
- Завершить запущенный кластер
```bash
pkill -9 tarantool
```
- Запуск тестов
``` bash
.rocks/bin/luatest test/basic_test.lua
```
```
Ran 1 tests in 2.233 seconds, 1 success, 0 failures
```
# TODO
- github.com/tarantool/metrics
- curl | grep `box.space.redirector` запрос
- prometheus, grafana (подчеркнуть опциональность)