Привет, меня зовут Игорь и я работаю в команде Tarantool. При разработке мне часто требуется быстрое прототипирование приложений с базой данных, например, для тестирования кода или для создания MVP. Конечно же хочется, чтобы такой прототип требовал минимальных усилий по доработке, если вдруг будет решено пустить его в работу.


Мне не нравится тратить время на настройку SQL базы данных, думать, как управлять шардированием данных, тратить много времени на изучение интерфейсов коннекторов. Хочется просто написать несколько строчек кода и запустить его, чтобы все работало из коробки. В быстрой разработке распределенных приложений мне помогает Cartridge — фреймворк для управления кластерными приложениями на основе NoSQL базы данных Tarantool.


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


Содержание


В статье вы узнаете о том, что такое Cartridge и познакомитесь с принципами написания кластерной бизнес-логики в нем.


Мы напишем кластерное приложение для хранения данных о сотрудниках некоторой компании, нас ждет:


  • Создание приложения из шаблона с cartridge-cli
  • Описание своей бизнес-логики на Lua в кластерных ролях Cartridge
    • Хранилище данных
    • Пользовательское HTTP API
  • Написание тестов
  • Локальный запуск и настройка небольшого кластера
    • Загрузка конфигурации
    • Настройка фейловера

Cartridge


Cartridge — это фреймворк для разработки кластерных приложений, он управляет несколькими инстансами NoSQL БД Tarantool и шардирует данные с помощью модуля vshard. Tarantool — это персистентая in-memory база данных, он очень быстрый за счет хранения данных в оперативной памяти, но при этом надежный — Tarantool сбрасывает все данные на жесткий диск и позволяет настроить репликацию, а Cartridge сам заботится о настройке узлов Tarantool и шардировании узлов кластера, так что все, что нужно разработчику — написать бизнес-логику приложений и произвести настройку фейловера.


Преимущества Сartridge


  • Шардирование и репликация из коробки
  • Встроенный failover
  • NoSQL язык кластерных запросов — CRUD
  • Интеграционное тестирование всего кластера
  • Управление кластером с помощью ansible
  • Утилита для администрирования кластера
  • Инструменты для мониторинга

Создаем первое приложение


Для этого нам понадобится cartridge-cli — это утилита для работы с картриджными приложениями. Она позволяет создавать приложение из шаблона, управлять локально запущенным кластером и подключаться к инстансам тарантула.


Установим Tarantool и cartridge-cli


Установка на Debian или Ubuntu:


curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash

sudo apt install cartridge-cli

Установка на CentOS, Fedora или ALT Linux:


curl -L https://tarantool.io/fJPRtan/release/2.8/installer.sh | bash

sudo yum install cartridge-cli

Установка на MacOS:


brew install tarantool

brew install cartridge-cli

Создадим шаблонное приложение с именем myapp:


cartridge create --name myapp

cd myapp

tree .

Получим структуру проекта примерно такого содержания:


myapp
├── app
│ └── roles
│ └── custom.lua
├── test
├── init.lua
├── myapp-scm-1.rockspec

  • init.lua — входная точка приложение на картридже, здесь прописывается конфигурация кластера и вызываются функции, которые должны будут выполнены на старте каждого узла приложения.
  • в директории app/roles/ хранятся "роли", в которых описывается бизнес-логика приложения.
  • myapp-scm-1.rockspec — файл для указания зависимостей

На этом шаге вы уже получите готовое к работе hello world-приложение. Его можно запустить с помощью команд


cartridge build

cartridge start -d

cartridge replicasets setup --bootstrap-vshard

После этого по адресу localhost:8081/hello мы увидим hello-world.


Давайте теперь на основе шаблона напишем свое небольшое приложение — шардированное хранилище с HTTP API для наполнения и получения данных. Для этого нам потребуется понимание того, как пишется кластерная бизнес-логика в Cartridge.


Пишем бизнес-логику в Cartridge


В основе каждого кластерного приложения лежат роли — Lua-модули, в которых описывается бизнес-логика приложения. Например, это могут быть модули, которые занимаются хранением данных, предоставляют HTTP API или кэширует данные из Oracle. Роль назначается на набор инстансов, объединенных репликацией (репликасет) и включается на каждом из них. У разных репликасетов может быть разный набор ролей.


В cartridge есть кластерная конфигурация, которая хранится на каждом из узлов кластера. Там описывается топология, а также туда можно добавить конфигурацию, которой будет пользоваться ваша роль. Такую конфигурацию можно изменять в рантайме и влиять на поведение роли.


Каждая роль имеет структуру следующего вида:


return {
    role_name = 'your_role_name',

    init = init,
    validate_config = validate_config,
    apply_config = apply_config,
    stop = stop,

    rpc_function = rpc_function,
    dependencies = {
        'another_role_name',
    },
}

Жизненный цикл роли


  1. Инстанс запускается.
  2. Роль с именем role_name ждет запуска всех зависимых ролей, указанных в dependencies.
  3. Вызывается функция validate_config, которая проверяет валидность конфига роли.
  4. Вызывается функция инициализации роли init, в которой производятся действия, которые должны запускаться один раз на старте роли.
  5. Вызывается apply_config, которая применяет конфиг (если таковой имеется). validate_config и apply_config вызываются также при каждом изменении конфигурации роли.
  6. Роль попадает в registry, откуда будет доступна для других ролей на этом же узле с помощью
    cartridge.service_get('your_role_name').
  7. Объявленные в роли функции будут доступны для вызова с других узлов с помощью
    cartridge.rpc_call('your_role_name', 'rpc_function').
  8. Перед выключением или перезапуском роли запускется функция stop, которая завершает работу роли, например, удаляют созданные ролью файберы.

Кластерные NoSQL-запросы


В Cartridge есть несколько вариантов написания кластерных запросов:


  • Вызовы функций через API vshard (это сложный способ, но зато очень гибкий):
    vshard.router.callrw(bucket_id, 'app.roles.myrole.my_rpc_func', {...})


  • Tarantool CRUD


    • Простые вызовы функций crud.insert / get / replace / ...
    • Ограниченная поддержка вычисления bucket_id
    • Роли должны иметь зависимость от crud-router / crud-storage


Схема приложения


Представим, что мы хотим кластер с одним роутером и с двумя группами стораджей по два инстанса. Такая топология характерна и для Redis Cluster, и для Mongodb кластера. Также в нашем кластере будет один инстанс — stateboard (в котором stateful-failover будет сохранять состояние текущих мастеров). Когда требуется повышенная надёжность, вместо stateboard лучше использовать кластер etcd.


Роутер будет распределять запросы по кластеру, а также управлять фейловером.



Пишем свои роли


Нам потребуется написать две своих роли, одну для хранения данных, вторую для HTTP API.


В директории app/roles создаем два новых файла: app/roles/storage.lua и app/roles/api.lua


Хранилище данных


Опишем роль для хранения данных. В функции init мы создадим таблицу и индексы для нее, а в зависимости добавим crud-storage.


Если вы привыкли к SQL, то Lua-код в init-функции будет эквивалентен следующему псевдо-SQL коду:


CREATE TABLE employee(
    bucket_id unsigned,
    employee_id string,
    name string,
    department string,
    position string,
    salary unsigned
);
CREATE UNIQUE INDEX primary ON employee(employee_id);
CREATE INDEX bucket_id ON employee(bucket_id);

Добавим следующий код в файл app/roles/storage.lua :


local function init(opts)
    -- в opts хранится признак, вызывается функция на мастере или на реплике
    -- мы создаем таблицы только на мастере, на реплике они появятся автоматически
    if opts.is_master then
        -- Создаем таблицу с сотрудниками
        local employee = box.schema.space.create('employee', {if_not_exists = true})

        -- задаем формат
        employee:format({
            {name = 'bucket_id', type = 'unsigned'},
            {name = 'employee_id', type = 'string', comment = 'ID сотрудника'},
            {name = 'name', type = 'string', comment = 'ФИО сотрудника'},
            {name = 'department', type = 'string', comment = 'Отдел'},
            {name = 'position', type = 'string', comment = 'Должность'},
            {name = 'salary', type = 'unsigned', comment = 'Зарплата'}
        })

        -- Создаем первичный индекс
        employee:create_index('primary', {parts = {{field = 'employee_id'}},
            if_not_exists = true })

        -- Индекс по bucket_id, он необходим для шардирования
        employee:create_index('bucket_id', {parts = {{field = 'bucket_id'}},
            unique = false,
            if_not_exists = true })
    end

    return true
end

return {
    init = init,
    -- <<< не забываем про зависимость от crud-storage
    dependencies = {'cartridge.roles.crud-storage'},
}

Остальные функции из API роли нам не понадобятся — у нашей роли нет конфигурации и она не выделяет ресурсы, которые нужно очищать после завершения работы.


HTTP API


Нам понадобится вторая роль для наполнения таблиц данными и получения этих данных по запросу. Она будет обращаться к встроенному в Cartridge HTTP-серверу и иметь зависимость от crud-router.


Определим функцию для POST-запроса. В теле запроса будет приходить объект, который мы хотим записать в базу.


local function post_employee(request)
    -- достаем объект из тела запроса
    local employee = request:json()

    -- записываем в БД
    local _, err = crud.insert_object('employee', employee)

    -- В случае ошибки пишем ее в лог и возвращаем 500
    if err ~= nil then
        log.error(err)
        return {status = 500}
    end
    return {status = 200}
end

В GET-метод будет передаваться уровень зарплаты сотрудников и в качестве ответа мы будем возвращать JSON со списком сотрудников, которые имеют зарплату выше заданной.


SELECT employee_id, name, department, position, salary
FROM employee
WHERE salary >= @salary

local function get_employees_by_salary(request)
    -- достаем query-параметр salary
    local salary = tonumber(request:query_param('salary') or 0)

    -- отбираем данные о сотрудниках
    local employees, err = crud.select('employee', {{'>=', 'salary', salary}})

    -- В случае ошибки пишем ее в лог и возвращаем 500
    if err ~= nil then
        log.error(err)
        return { status = 500 }
    end

    -- в employees хранится список строк, удовлетворяющих условию и формат спейса
    -- unflatten_rows нужна, чтобы преобразовать строку таблицы в таблицу вида ключ-значение
    employees = crud.unflatten_rows(employees.rows, employees.metadata)

    employees = fun.iter(employees):map(function(x) 
        return {
            employee_id = x.employee_id, 
            name = x.name, 
            department = x.department, 
            position = x.position, 
            salary = x.salary,
        }
    end):totable()
    return request:render({json = employees})
end

Теперь напишем init-функцию роли. Здесь мы обратимся к registry Cartridge для получения HTTP-сервера и используем его для назначения HTTP-эндпоинтов нашего приложения.


local function init()
    -- получаем HTTP-сервер из registry Cartridge
    local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd serivce")

    -- прописываем роуты
    httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary)
    httpd:route({method = 'POST', path = '/employee'}, post_employee)

    return true
end

Соберем все это вместе:


app/roles/api.lua
local cartridge = require('cartridge')
local crud = require('crud')
local log = require('log')
local fun = require('fun')

-- метод GET /employees будет возвращать список сотрудников с зарплатой больше заданной
local function get_employees_by_salary(request)
    -- достаем query-параметр salary
    local salary = tonumber(request:query_param('salary') or 0)

    -- отбираем данные о сотрудниках
    local employees, err = crud.select('employee', {{'>=', 'salary', salary}})

    -- В случае ошибки пишем ее в лог и возвращаем 500
    if err ~= nil then
        log.error(err)
        return { status = 500 }
    end

    -- в employees хранится список строк, удовлетворяющих условию и формат спейса
    -- unflatten_rows нужна, чтобы преобразовать строку таблицы в таблицу вида ключ-значение
    employees = crud.unflatten_rows(employees.rows, employees.metadata)
    employees = fun.iter(employees):map(function(x) 
        return {
            employee_id = x.employee_id, 
            name = x.name, 
            department = x.department, 
            position = x.position, 
            salary = x.salary,
        }
    end):totable()
    return request:render({json = employees})
end

local function post_employee(request)
    -- достаем объект из тела запроса
    local employee = request:json()

    -- записываем в БД
    local _, err = crud.insert_object('employee', employee)

    -- В случае ошибки пишем ее в лог и возвращаем 500
    if err ~= nil then
        log.error(err)
        return {status = 500}
    end
    return {status = 200}
end

local function init()
    -- получаем HTTP-сервер из registry Cartridge
    local httpd = assert(cartridge.service_get('httpd'), "Failed to get httpd service")

    -- прописываем роуты
    httpd:route({method = 'GET', path = '/employees'}, get_employees_by_salary)
    httpd:route({method = 'POST', path = '/employee'}, post_employee)

    return true
end

return {
    init = init,
    -- добавляем зависимость от crud-router
    dependencies = {'cartridge.roles.crud-router'},
}

init.lua


Опишем файл init.lua, который будет являться входной точкой приложения на Cartridge. В init-файле картриджа необходимо вызвать функцию cartridge.cfg() для настройки инстанса кластера.


cartridge.cfg(<opts>, <box_opts>)


  • <opts> — параметры кластера по умолчанию
    • список доступных ролей (нужно указать все роли, даже перманентные, иначе они не появятся в кластере)
    • параметры шардирования
    • конфигурация WebUI
    • и другое
  • <box_opts> — параметры Tarantool по умолчанию (которые передаются в box.cfg{} инстанса)

#!/usr/bin/env tarantool

require('strict').on()

-- указываем путь для поиска модулей
if package.setsearchroot ~= nil then
    package.setsearchroot()
end

-- конфигурируем Cartridge
local cartridge = require('cartridge')

local ok, err = cartridge.cfg({
    roles = {
        'cartridge.roles.vshard-storage',
        'cartridge.roles.vshard-router',
        'cartridge.roles.metrics',
        -- <<< Добавляем crud-роли
        'cartridge.roles.crud-storage',
        'cartridge.roles.crud-router',
        -- <<< Добавляем кастомные роли
        'app.roles.storage',
        'app.roles.api',
    },
    cluster_cookie = 'myapp-cluster-cookie',
})

assert(ok, tostring(err))

Последним шагом будет описание зависимостей нашего приложения в файле myapp-scm-1.rockspec.


package = 'myapp'
version = 'scm-1'
source  = {
    url = '/dev/null',
}
-- Добавляем зависимости
dependencies = {
    'tarantool',
    'lua >= 5.1',
    'checks == 3.1.0-1',
    'cartridge == 2.7.3-1',
    'metrics == 0.11.0-1',
    'crud == 0.8.0-1',
}
build = {
    type = 'none';
}

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


Пишем тесты


Любое приложение нуждается в тестировании. Для unit-тестов хватит обычного luatest, но если вы хотите написать хороший интеграционный тест, вам поможет модуль cartridge.test-helpers. Он поставляется вместе с Cartridge и позволяет поднять в тестах кластер любого состава, который вам нужен.


local cartridge_helpers = require('cartridge.test-helpers')
-- создаем тестовый кластер
local cluster = cartridge_helpers.Cluster:new({
    server_command = './init.lua', -- entrypoint тестового приложения
    datadir = './tmp', -- директория для xlog, snap и других файлов
    use_vshard = true, -- включение шардирования кластера
    -- список репликасетов:
    replicasets = {
        {
            alias = 'api',
            uuid = cartridge_helpers.uuid('a'),
            roles = {'app.roles.custom'}, -- список ролей, назначенных на репликасет
            -- список инстансов в репликасете:
            servers = {
                { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' },
                ...
            },
        },
        ...
    }
})

Напишем вспомогательный модуль, который будем использовать в интеграционных тестах. В нем создается тестовый кластер из двух репликасетов, в каждом из которых будет по одному инстансу:


Код вспомогательного модуля:


test/helper.lua
local fio = require('fio')
local t = require('luatest')
local cartridge_helpers = require('cartridge.test-helpers')

local helper = {}

helper.root = fio.dirname(fio.abspath(package.search('init')))
helper.datadir = fio.pathjoin(helper.root, 'tmp', 'db_test')
helper.server_command = fio.pathjoin(helper.root, 'init.lua')

helper.cluster = cartridge_helpers.Cluster:new({
    server_command = helper.server_command,
    datadir = helper.datadir,
    use_vshard = true,
    replicasets = {
        {
            alias = 'api',
            uuid = cartridge_helpers.uuid('a'),
            roles = {'app.roles.api'},
            servers = {
                { instance_uuid = cartridge_helpers.uuid('a', 1), alias = 'api' },
            },
        },
        {
            alias = 'storage',
            uuid = cartridge_helpers.uuid('b'),
            roles = {'app.roles.storage'},
            servers = {
                { instance_uuid = cartridge_helpers.uuid('b', 1), alias = 'storage' },
            },
        },
    }
})

function helper.truncate_space_on_cluster(cluster, space_name)
    assert(cluster ~= nil)
    for _, server in ipairs(cluster.servers) do
        server.net_box:eval([[
            local space_name = ...
            local space = box.space[space_name]
            if space ~= nil and not box.cfg.read_only then
                space:truncate()
            end
        ]], {space_name})
    end
end

function helper.stop_cluster(cluster)
    assert(cluster ~= nil)
    cluster:stop()
    fio.rmtree(cluster.datadir)
end

t.before_suite(function()
    fio.rmtree(helper.datadir)
    fio.mktree(helper.datadir)
    box.cfg({work_dir = helper.datadir})
end)

return helper

Код интеграционного теста:


test/integration/api_test.lua
local t = require('luatest')
local g = t.group('integration_api')

local helper = require('test.helper')
local cluster = helper.cluster

g.before_all = function()
    g.cluster = helper.cluster
    g.cluster:start()
end

g.after_all = function()
    helper.stop_cluster(g.cluster)
end

g.before_each = function()
    helper.truncate_space_on_cluster(g.cluster, 'employee')
end

g.test_get_employee = function()
    local server = cluster.main_server

    -- наполним хранилище данными через HTTP API:
    local response = server:http_request('post', '/employee',
        {json = {name = 'John Doe', department = 'Delivery', position = 'Developer',
        salary = 10000, employee_id = 'john_doe'}})
    t.assert_equals(response.status, 200)

    response = server:http_request('post', '/employee',
        {json = {name = 'Jane Doe', department = 'Delivery', position = 'Developer',
        salary = 20000, employee_id = 'jane_doe'}})
    t.assert_equals(response.status, 200)

    -- Делаем GET запрос и проверяем правильность выдаваемых данных
    response = server:http_request('get', '/employees?salary=15000.0')
    t.assert_equals(response.status, 200)
    t.assert_equals(response.json[1], {name = 'Jane Doe', department = 'Delivery', employee_id = 'jane_doe',
        position = 'Developer', salary = 20000
    })
end

Запускаем тесты


Если вы уже запускали приложение


Остановите его:


cartridge stop

Удалите папку с данными:


rm -rf tmp/

Соберем приложение и установим зависимости:


cartridge build

./deps.sh

Запустим линтер:


.rocks/bin/luacheck .


Запустим тесты с записью покрытия:


.rocks/bin/luatest --coverage


Сгенерируем отчеты по покрытию тестов и посмотрим на результат:


.rocks/bin/luacov .
grep -A999 '^Summary' tmp/luacov.report.out


Локальный запуск


Для локального запуска приложений можно воспользоваться cartridge-cli, но сначала нужно добавить написанные нами роли в replicasets.yml:


router:
  instances:
  - router
  roles:
  - failover-coordinator
  - app.roles.api
  all_rw: false
s-1:
  instances:
  - s1-master
  - s1-replica
  roles:
  - app.roles.storage
  weight: 1
  all_rw: false
  vshard_group: default
s-2:
  instances:
  - s2-master
  - s2-replica
  roles:
  - app.roles.storage
  weight: 1
  all_rw: false
  vshard_group: default

С параметрами запускаемых инстансов можно ознакомиться в instances.yml.


Запускаем кластер локально:


cartridge build
cartridge start -d
cartridge replicasets setup --bootstrap-vshard


Теперь мы можем зайти в webui и загрузить конфигурацию ролей, а также настроить фейловер. Чтобы настроить stateful failover, необходимо:


  • нажать на кнопку Failover
  • выбрать stateful
  • прописать адрес и пароль:
    • localhost:4401
    • passwd


Давайте посмотрим на его работу. Сейчас в репликасете s-1 лидером является s1-master.



Остановим его:


cartridge stop s1-master

Лидер переключится на s1-replica:



Восстановим s1-master:


cartridge start -d s1-master

s1-master поднялся, но благодаря stateful-фейловеру лидером остается s1-replica:



Загрузим конфигурацию для роли cartridge.roles.metrics, для этого необходимо перейти на вкладку Code и создать файл metrics.yml следующего содержания:


export:
  - path: '/metrics'
    format: prometheus
  - path: '/health'
    format: health


После того, как мы нажмем на кнопку Apply, метрики будут доступны на каждом узле приложения по эндпоинту localhost:8081/metrics и появится health-check по адресу localhost:8081/health.


На этом базовая настройка небольшого приложения завершена: кластер готов к работе и теперь мы можем написать приложение, которое будет общаться с кластером с помощью HTTP API или через коннектор, а также можем расширять функциональность кластера.


Заключение


Многим разработчикам не нравится тратить время на настройку базы данных. Нам хочется, чтобы все обязанности по управлению кластером взял на себя какой-нибудь фреймворк, а нам приходилось только писать код. Для решения этой проблемы я использую Cartridge — фреймворк, который управляет кластером из нескольких инстансов Tarantool.


В статье я рассказал:


  • как построить надежное кластерное приложение с помощью Cartridge и Tarantool,
  • как написать код небольшого приложения для хранения информации о сотрудниках,
  • как добавить тесты,
  • как настроить кластер.

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


Что дальше?


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


  1. LaRN
    11.11.2021 08:59

    Слишком простой пример.

    В примере в одной таблице и отделы и сотрудники и ЗП и ФИО в одном поле слитно хранится.

    В реальной жизни хранить в одной таблице разные сущности в общем случае не "православно" - есть же нормализации.

    Интересно насколько быстрее и проще все это будет для примера, где есть несколько связанных сущностей?


    1. yngvar_antonsson Автор
      11.11.2021 14:44

      Да, действительно, хранить разные сущности в одной таблице - плохой подход.

      Но на самом деле код не сильно усложнится, если добавить несколько сущностей и делать "join" этих данных.

      Один из вариантов примерной реализации:

      -- в роли storage:
      local employee = box.schema.space.create('employee', {if_not_exists = true}
      employee:format(...)
      -- добавляем новые спейсы, форматы и индексы
      local department = box.schema.space.create('department', {if_not_exists = true}
      department:format(...)
      
      ---------------------------------
      -- в роле api:
      local employees, err = crud.select('employee', {{'>=', 'salary', salary}}) 
      
      -- получаем список отделов по названиям
      local department = {}
      for _, x in ipairs(employees) do
          if department[x.department] ~= nil then 
          	local dep, err = crud.get('department', department)
          	department[dep.name] = dep
          end
      end
      
      -- возвращаем employee join department 
      employees = fun.iter(employees):map(function(x) 
          return {
              employee_id = x.employee_id, 
              ...
              salary = x.salary,
            	department_data = department[x.department],
          }
      end):totable()