Привет, Хабр! Сегодня я хочу поделиться с вами опытом написания приложений для Tarantool 1.7. Этот цикл статей будет полезен тем, кто уже собирается использовать Tarantool в своих проектах, либо тем, кто ищет новое решение для оптимизации проектов.


Весь цикл посвящен рассмотрению уже существующего приложения на Tarantool. В этой части будут описаны вопросы установки Tarantool, хранения данных и обращения к ним, а также некоторые хитрости написания хранимых процедур.


Tarantool — это NoSQL база данных, которая хранит данные в памяти либо на диске (в зависимости от подсистемы хранения). Хранилище персистентно за счет продуманного механизма write ahead log. В Tarantool встроен LuaJIT (Just-In-Time Compiler), позволяющий исполнять код на Lua. Также можно писать хранимые процедуры на C.


image


Содержание цикла «Приложения для Tarantool 1.7»


  • Часть 1. Хранимые процедуры
  • Часть 2. Сторонние модули
  • Часть 3. Тестирование и запуск

Зачем создавать свои приложения для Tarantool


Есть две причины:


  1. Это ускорит работу сервиса. Обработка данных на стороне хранилища сокращает объем передаваемых данных, а объединение нескольких запросов в одну хранимую процедуру позволит сэкономить на сетевых задержках.
  2. Готовые приложения можно переиспользовать. Сейчас экосистема Tarantool активно развивается, появляются новые opensource-приложения на Tarantool, часть которых со временем переносится в сам Tarantool. Такие модули позволяют создавать новые сервисы быстрее.

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


Рассмотрим, как было создано одно из приложений для Tarantool. Оно реализует API для регистрации и аутентификации пользователей. Функционал приложения:


  • регистрация и аутентификация по email в два этапа: создание аккаунта, подтверждение аккаунта с установкой пароля;
  • регистрация через социальные сети (FB, VK, Google+);
  • возможность восстановить пароль.

В качестве примера написания хранимой процедуры Tarantool мы разберем первый этап регистрации по email — получение кода подтверждения. Чтобы оживить примеры, можно воспользоваться исходным кодом, который доступен на github.


Поехали!


Установка Tarantool


О том, как установить Tarantool, прочитайте в документации. Например, для Ubuntu нужно выполнить в терминале:


curl http://download.tarantool.org/tarantool/1.7/gpgkey | sudo apt-key add -
release=`lsb_release -c -s`

sudo apt-get -y install apt-transport-https

sudo rm -f /etc/apt/sources.list.d/*tarantool*.list
sudo tee /etc/apt/sources.list.d/tarantool_1_7.list <<- EOF
deb http://download.tarantool.org/tarantool/1.7/ubuntu/ $release main
deb-src http://download.tarantool.org/tarantool/1.7/ubuntu/ $release main
EOF

sudo apt-get update
sudo apt-get -y install tarantool

Проверим, что установка прошла успешно, вызвав в консоли tarantool и запустив интерактивный режим работы.


$ tarantool
version 1.7.3-202-gfe0a67c
type 'help' for interactive help
tarantool>

Здесь можно попробовать свои силы в программировании на Lua.
Если сил нет, то наберитесь их в этом небольшом tutorial.


Регистрация по email


Идем дальше. Напишем первый скрипт, позволяющий создать пространство (space) с пользователями. Space — это аналог таблиц для хранения данных. Cами данные хранятся в виде кортежей (tuple). Space должен содержать один первичный (primary) индекс, в нем также может быть несколько вторичных (secondary) индексов. Индекс бывает и по одному ключу, и сразу по нескольким. Tuple представляет собой массив, в котором хранятся записи. Рассмотрим схему space’ов сервиса аутентификации:


image


Как видно из схемы, мы используем индексы двух типов: hash и tree. Hash-индекс позволяет находить кортежи по полному совпадению первичного ключа и обязан быть уникальным. Tree-индекс поддерживает неуникальные ключи, поиск по первой части составного индекса и позволяет оптимизировать операции сортировки по ключу, так как значения в индексе хранятся упорядоченно.


В space session хранится ключ (session_secret), которым подписывается сессионная кука. Хранение ключей сессий позволяет разлогинивать пользователей на стороне сервиса, если нужно. Сессия имеет опциональную ссылку на space social. Это необходимо для валидации сессий пользователей, входящих через социальные сети (проверки валидности хранимого OAuth2-токена).


Перейдем к написанию приложения. Для начала рассмотрим структуру будущего проекта:


tarantool-authman
+-- authman
¦   +-- model
¦   ¦   +-- password.lua
¦   ¦   +-- password_token.lua
¦   ¦   +-- session.lua
¦   ¦   +-- social.lua
¦   ¦   L-- user.lua
¦   +-- utils
¦   ¦   +-- http.lua
¦   ¦   L-- utils.lua
¦   +-- db.lua
¦   +-- error.lua
¦   +-- init.lua
¦   +-- response.lua
¦   L-- validator.lua
L-- test
    +-- case
    ¦   +-- auth.lua
    ¦   L-- registration.lua
    +-- authman.test.lua
    L-- config.lua

Модули в Lua импортируются из путей, указанных в package.path переменной.
В нашем случае модули импортируются относительно текущей директории, т. е. tarantool-authman. Однако при необходимости пути импорта можно дополнить:


lua
-- Добавляем новый путь с самым высоким приоритетом (в начало строки)
package.path = "/some/other/path/?.lua;" .. package.path

Прежде чем мы создадим первый space, вынесем необходимые константы в модели. Каждый space и каждый индекс должен определить свое название. Также необходимо определить порядок хранения полей в кортеже. Так выглядит модель пользователя authman/model/user.lua:


-- Наш модуль — это Lua-таблица
local user = {}

-- Модуль содержит единственную функцию — model, которая возвращает таблицу с полями и методами модели
-- На входе функция принимает конфигурацию в виде опять же lua-таблицы
function user.model(config)
    local model = {}

    -- Название спейса и индексов
    model.SPACE_NAME = 'auth_user'
    model.PRIMARY_INDEX = 'primary'
    model.EMAIL_INDEX = 'email_index'

    -- Номера полей в хранимом кортеже (tuple)
    -- Индексация массивов в Lua начинается с 1 (!)
    model.ID = 1
    model.EMAIL = 2
    model.TYPE = 3
    model.IS_ACTIVE = 4

    -- Типы пользователя: email-регистрация или через соцсеть
    model.COMMON_TYPE = 1
    model.SOCIAL_TYPE = 2

    return model
end

-- Возвращаем модуль
return user

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


Модуль authman/db.lua содержит метод для создания space’ов:


local db = {}

-- Импортируем модуль и вызываем функцию model
-- При этом в параметр config попадает nil — пустое значение
local user = require('authman.model.user').model()

-- Метод модуля db, создающий пространства (space) и индексы
function db.create_database()

    local user_space = box.schema.space.create(user.SPACE_NAME, {
        if_not_exists = true
    })
    user_space:create_index(user.PRIMARY_INDEX, {
        type = 'hash',
        parts = {user.ID, 'string'},
        if_not_exists = true
    })
    user_space:create_index(user.EMAIL_INDEX, {
        type = 'tree',
        unique = false,
        parts = {user.EMAIL, 'string', user.TYPE, 'unsigned'},
        if_not_exists = true
    })
end

return db

В качестве id пользователя берем uuid, тип индекса hash, ищем по полному совпадению. Индекс для поиска по email состоит из двух частей: (user.EMAIL, 'string') — email, (user.TYPE, 'unsigned') — тип пользователя. Типы были определены ранее в модели. Составной индекс позволяет искать не только по всем полям, но и по первой части индекса, поэтому доступен поиск только по email (без типа пользователя).


Теперь запустим интерактивную консоль Tarantool в директории с проектом и попробуем воспользоваться модулем authman/db.lua.


$ tarantool
version 1.7.3-202-gfe0a67c
type 'help' for interactive help
tarantool> db = require('authman.db')
tarantool> box.cfg({listen=3331})
tarantool> db.create_database()

Отлично, первый space создан! Внимание: перед обращением к box.schema.space.create необходимо сконфигурировать и запустить сервер методом box.cfg. Теперь рассмотрим несколько простых действий внутри созданного space:


-- Создание пользователей
tarantool> box.space.auth_user:insert({'user_id_1', 'exaple_1@mail.ru', 1})
---
- ['user_id_1', 'exaple_1@mail.ru', 1]
...
tarantool> box.space.auth_user:insert({'user_id_2', 'exaple_2@mail.ru', 1})
---
- ['user_id_2', 'exaple_2@mail.ru', 1]
...
-- Получие Lua-таблицы (массива) всех пользователей
tarantool> box.space.auth_user:select()
---
- - ['user_id_2', 'exaple_2@mail.ru', 1]
  - ['user_id_1', 'exaple_1@mail.ru', 1]
...

-- Получение пользователя по первичному ключу
tarantool> box.space.auth_user:get({'user_id_1'})
---
- ['user_id_1', 'exaple_1@mail.ru', 1]
...

-- Получение пользователя по составному ключу
tarantool> box.space.auth_user.index.email_index:select({'exaple_2@mail.ru', 1})
---
- - ['user_id_2', 'exaple_2@mail.ru', 1]
...

-- Обновление данных с заменой второго поля
tarantool> box.space.auth_user:update('user_id_1', {{'=', 2, 'new_email@mail.ru'}, })
---
- ['user_id_1', 'new_email@mail.ru', 1]
...

Уникальные индексы ограничивают вставку неуникальных значений. Если необходимо создавать записи, которые уже могут находиться в space, воспользуйтесь операцией upsert (update/insert). Полный список доступных методов можно найти в документации.


Обновим модель пользователя, добавив функционал, позволяющий нам зарегистрировать его:


    function model.get_space()
        return box.space[model.SPACE_NAME]
    end

    function model.get_by_email(email, type)
        if validator.not_empty_string(email) then
            return model.get_space().index[model.EMAIL_INDEX]:select({email, type})[1]
        end
    end

    -- Создание пользователя
    -- Поля, не являющиеся частями уникального индекса, необязательны
    function model.create(user_tuple)
        local user_id = uuid.str()
        local email = validator.string(user_tuple[model.EMAIL]) and user_tuple[model.EMAIL] or ''
        return model.get_space():insert{
            user_id,
            email,
            user_tuple[model.TYPE],
            user_tuple[model.IS_ACTIVE],
            user_tuple[model.PROFILE]
        }
    end

    -- Генерация кода, который отправляется в письме, с просьбой активировать аккаунт
    -- Как правило, такой код подставляется GET-параметром в ссылку
    -- activation_secret — один из настраиваемых параметров при инициализации приложения
    function model.generate_activation_code(user_id)
        return digest.md5_hex(string.format('%s.%s', config.activation_secret, user_id))
    end

В приведенном фрагменте кода применены два стандартных модуля Tarantool — uuid и digest, а также один пользовательский — validator. Перед использованием их необходимо импортировать:


-- Стандартные модули Tarantool
local digest = require('digest')
local uuid = require('uuid')
-- Модуль нашего приложения (отвечает за валидацию данных)
local validator =  require('authman.validator')

Переменные объявляются с оператором local, ограничивающим область видимости переменной текущим блоком. В противном случае переменная будет глобальной, чего следует избегать из-за возможного конфликта имен.


А теперь создадим основной модуль authman/init.lua. В этом модуле будут собраны все методы api приложения.


local auth = {}

local response = require('authman.response')
local error = require('authman.error')
local validator = require('authman.validator')
local db = require('authman.db')
local utils = require('authman.utils.utils')

-- Модуль возвращает единственную функцию — api, которая конфигурирует приложение и возвращает его
function auth.api(config)
    local api = {}
    -- Модуль validator содержит проверки различных типов значений
    -- Здесь же выставляются значения по умолчанию
    config = validator.config(config)

    -- Импортируем модели для работы с данными
    local user = require('authman.model.user').model(config)

    -- Создаем space
    db.create_database()

    -- Метод api создает неактивного пользователя с указанным адресом электронной почты
    function api.registration(email)
        -- Перед работой с email — приводим его к нижнему регистру
        email = utils.lower(email)

        if not validator.email(email) then
            return response.error(error.INVALID_PARAMS)
        end

        -- Проверяем, нет ли существующего пользователя с таким email
        local user_tuple = user.get_by_email(email, user.COMMON_TYPE)
        if user_tuple ~= nil then
            if user_tuple[user.IS_ACTIVE] then
                return response.error(error.USER_ALREADY_EXISTS)
            else
                local code = user.generate_activation_code(user_tuple[user.ID])
                return response.ok(code)
            end
        end

        -- Записываем данные в space
        user_tuple = user.create({
            [user.EMAIL] = email,
            [user.TYPE] = user.COMMON_TYPE,
            [user.IS_ACTIVE] = false,
        })

        local code = user.generate_activation_code(user_tuple[user.ID])
        return response.ok(code)
    end

    return api
end

return auth

Отлично! Теперь пользователи смогут создавать аккаунты.


tarantool> auth = require('authman').api(config)
-- Воспользуемся api для получения кода регистрации
tarantool> ok, code = auth.registration('example@mail.ru')
-- Этот код необходимо передать пользователю на email для активации аккаунта
tarantool> code
022c1ff1f0b171e51cb6c6e32aefd6ab

На этом все. В следующей части рассмотрим использование готовых модулей, сетевое взаимодействие и реализацию OAuth2 в tarantool-authman.

Поделиться с друзьями
-->

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


  1. bugdebug
    01.08.2017 13:56

    Добротно ражеван вопрос хранимок.
    Чуть не хватает примера интеграции с приложением.
    Как в приложении обращаться и правильно взаимодействовать с бд/хранимкой?


    1. relevance_17
      01.08.2017 14:11
      +2

      Взаимодействие стоит реализовывать используя один из коннеторов, например tarantool-python:


      import tarantool
      
      server = tarantool.connect('127.0.0.1', 3331)
      
      response = server.call('auth.registration', ('example@mail.ru', ))
      # response содержит результат вызова метода registration
      # [[True], ['email': 'example@mail.ru', 'code':'af045da638fd3a8e3b5f8352866c3a13']]

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


      1. bugdebug
        01.08.2017 14:45
        +1

        На проде всё часто выглядит несколько иначе. Обвешено таймаутами, контролем ошибок коннекта/выполнения.
        Нашел задание таймаута для коннектора на питоне. Не нашел на пыхе.
        Будет очень кстати статья про эксплуатацию на проде с приведением всех этих моментов.


  1. hrLexyc
    01.08.2017 15:33

    Возможно не в тему статьи, но тут говорилось про сессии и разлогинивание на стороне сервера :)

    Уже в гуглгруппе вопрос задавал с год назад, повторю здесь. Сессии на тарантуле это хорошо, но для удаления по произвольному ключу надо писать хранимую процедуру в которой будет сначала space:select() и затем в цикле удаление.

    Удаление не по первичному ключу может понадобится как минимум для удаления истекших сессий и возможности удаления всех сессий конкретного пользователя (что бы разлогинить на всех устройствах).

    Собственно вопрос: Нет ли в планах сделать удаление записей по запросу, а не только по первичному ключу?
    Что-то вроде селекта, но для удаления — params = {iterator = 'LT'}
    space.index[index_name_or_id]:delete(index_key, params)


    1. relevance_17
      01.08.2017 15:39
      +1

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


      В 1.8 для подобных целей можно использовать SQL. На сколько мне известно в 1.7, к сожалению, не планируется удаление по неуникальному ключу.


  1. babylon
    01.08.2017 18:51
    -4

    Зачем NoSQL базе SQL запросы? И там и там множества ведь. JSONPath работает? Если нет, то какой это NoSQL?!!!


  1. andreylartsev
    02.08.2017 06:43
    -2

    NoSQL чаще означает NoACID


  1. andreylartsev
    02.08.2017 06:52
    -2

    Может это не обосновано, но вообще объедение бизнес логики и БД в одном флаконе что-то мне напоминает… была в своё такая популярная коммерческая система MUMPS кажется называлась… Тоже быстро работала, только когда проекты разрастались люди начинали плакать кровавыми слезами… Но скорее всего в этом проекте уже сделаны соотвествующие вывода из прошлого?


    1. blackhearted
      02.08.2017 11:35
      +2

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

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

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


  1. antonksa
    02.08.2017 12:18
    +2

    Красиво. Я немного начал писать на луа только из-за тарантула.
    Собственно весь синтаксис я выковырял из татантутовского туториала.


    user_tuple = user.create({
                [user.EMAIL] = email,
                [user.TYPE] = user.COMMON_TYPE,
                [user.IS_ACTIVE] = false,
            })

    Это ваще круть, у меня по всему коду user[1], user[121] и в отдельных файлах спеки какой номер у какого поля.
    Так и хочется спросить — где вы были раньше )))


  1. babylon
    03.08.2017 08:18

    Ориентация на Lua обозначило печальный тренд, который будет сохранятся в tnt и дальше. Скорее всего мы не увидим jsonnet подобных систем когда код является частью данных, а не наоборот. Именно в этом истинный смысл хранимости и реактивности. Точка, точка, два крючочка. Это уже проходили. Что мы еще не увидим? Мы не увидим систем с гибким расширением ядра плагинами и работы с помощью перепрограммируемых запросов, а не жёстких методов. Это важно для проектирования, адаптирования и тестирования систем без переписывания значительной части кода. Но программистов считающих, что tnt "круть" достаточно. Поэтому фронт работ для них предопределён… В конце концов какая разница за, что платится бабло. Главное, что оно платится.
    Стоит отметить и сильные стороны tnt. Это безусловно мультизадачность, использование MsgPack. и механизма копирования при записи COW. Остальным смело можно пренебречь.


  1. chudinov
    08.08.2017 18:56

    >сконфигурировать и запустить сервер методом box.cfg
    вот тут бы отдельный материал, с указанием оптимальных вариантов под 1,7 и 1,8