Версия:

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

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

Далее мы пошагово разберем ключевые методики программирования, что послужит хорошим началом для написания Lua-приложений для Tarantool’а. Для интереса возьмем историю имплементирования … настоящего микросервиса на основе Tarantool’а! Мы имплементируем бэкенд для упрощенной версии Pokémon Go, игры на основе определения местоположения дополненной реальности, выпущенной в середине 2016 года. В этой игре игроки используют GPS-возможности мобильных устройств, чтобы находить, захватывать, сражаться и тренировать виртуальных существ, или покемонов, которые появляются на экране, как если бы они находились в том же реальном месте, как и игрок.

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

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

../../../_images/aster.svg

Для начала как лучше всего предоставить микросервис?

Модули и приложения

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

Модуль (который называется «rock» в Lua) - это дополнительная библиотека, которая расширяет функции Tarantool’а. Поэтому можно установить нашу логическую схему в виде модуля в Tarantool и использовать ее из любого Tarantool-приложения или модуля. Как и приложения, модули в Tarantool’е могут быть написаны на Lua (rocks), C или C++.

Модули хороши для двух целей:

  • облегченное управление кодом (переиспользование, подготовка к развертыванию, версионирование) и
  • горячая перезагрузка кода без перезапуска экземпляра Tarantool’а.

В техническом смысле, модуль - это файл с исходным кодом, который экспортирует свои функции в API. Например, вот Lua-модуль под названием mymodule.lua, который экспортирует одну функцию под названием myfun:

local exports = {}
        exports.myfun = function(input_string)
           print('Hello', input_string)
        end
        return exports

Чтобы запустить функцию myfun() — из другого модуля, из Lua-приложения или из самого Tarantool’а — необходимо сохранить этот модуль в виде файла, а затем загрузить этот модуль с директивой require() и вызвать экспортированную функцию.

Например, вот Lua-приложение, которое использует функцию myfun() из модуля mymodule.lua:

-- загрузка модуля
        local mymodule = require('mymodule')

        -- вызов myfun() из функции test()
        local test = function()
          mymodule.myfun()
        end

Здесь важно запомнить, что директива require() берет пути загрузки к Lua-модулям из переменной package.path. Она представляет собой строку с разделителями в виде точки с запятой, где знак вопроса используется для вставки имени модуля. По умолчанию, эта переменная содержит пути в системе и рабочую директорию. Но если мы поместим наши модули в особую папку (например, scripts/), необходимо будет добавить эту папку в package.path до вызова require():

package.path = 'scripts/?.lua;' .. package.path

Для нашего микросервиса простым и удобным решением будет разместить все методы в Lua-модуле (скажем, pokemon.lua) и написать Lua-приложение (скажем, game.lua), которое запустит игровое окружение и цикл игры.

../../../_images/aster.svg

Теперь приступим к деталям реализации. В игре нам необходимы три сущности:

  • карта, которая представляет собой массив покемонов с координатами мест респауна; в данной версии игры пусть местом будет прямоугольник, установленный по двум точкам, верхней левой и нижней правой;
  • игрок, у которого есть ID, имя и координаты местонахождения игрока;
  • покемон, у которого такие же поля, как и у игрока, плюс статус (активный/неактивный, то есть находится ли на карте) и возможность поимки (давайте уж дадим нашим покемонам шанс сбежать :-) )

Эти данные будем хранить как кортежи в спейсах Tarantool’а. Но Но чтобы бэкенд-приложение работало как микросервис, правильно будет отправлять/получать данные в универсальном формате JSON, используя Tarantool в качестве системы хранения документов.

Avro-схемы

Чтобы хранить JSON-данные в виде кортежей, используем продвинутую методику, которая уменьшит отпечаток данных и обеспечит пригодность всех сохраняемых документов. Будем использовать Tarantool-модуль avro-schema, который проверяет схему JSON-документа и конвертирует его в кортеж Tarantool’а. Кортеж будет содержать только значения полей, таким образом, занимая меньше места, чем оригинальный документ. С точки зрения avro-схемы, конвертация JSON-документов в кортежи - «flattening» (конвертация в плоские файлы), а восстановление оригинальных документов - «unflattening» (конвертация из плоских файлов). Использовать модуль достаточно просто:

  1. Для каждой сущности необходимо определить схему в синтаксисе схемы Apache Avro, где мы перечисляем поля сущности с их наименованиями и типами данных по Avro.
  2. При инициализации мы вызываем функцию avro-schema.create(), которая создает объекты в памяти для всех сущностей схемы, а также функцию compile(), которая создает методы flatten/unflatten (конвертация в плоские файлы и обратно) для каждой сущности.
  3. Далее мы просто вызываем методы flatten/unflatten для соответствующей сущности при получении/отправке данных об этой сущности.

Вот как будут выглядеть определения схемы для сущностей игрока и покемона:

local schema = {
            player = {
                type="record",
                name="player_schema",
                fields={
                    {name="id", type="long"},
                    {name="name", type="string"},
                    {
                        name="location",
                        type= {
                            type="record",
                            name="player_location",
                            fields={
                                {name="x", type="double"},
                                {name="y", type="double"}
                            }
                        }
                    }
                }
            },
            pokemon = {
                type="record",
                name="pokemon_schema",
                fields={
                    {name="id", type="long"},
                    {name="status", type="string"},
                    {name="name", type="string"},
                    {name="chance", type="double"},
                    {
                        name="location",
                        type= {
                            type="record",
                            name="pokemon_location",
                            fields={
                                {name="x", type="double"},
                                {name="y", type="double"}
                            }
                        }
                    }
                }
            }
        }

А вот как мы создадим и скомпилируем наши сущности при инициализации:

-- загрузить модуль avro-schema с директивой require()
        local avro = require('avro_schema')

        -- создать модели
        local ok_m, pokemon = avro.create(schema.pokemon)
        local ok_p, player = avro.create(schema.player)
        if ok_m and ok_p then
            -- скомпилировать модели
            local ok_cm, compiled_pokemon = avro.compile(pokemon)
            local ok_cp, compiled_player = avro.compile(player)
            if ok_cm and ok_cp then
                -- начать игру
                <...>
            else
                log.error('Schema compilation failed')
            end
        else
            log.info('Schema creation failed')
        end
        return false

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

../../../_images/aster.svg

Далее нам нужны методы для имплементации игровой логики. Чтобы смоделировать объектно-ориентированное программирование в нашем Lua-коде, будем хранить все Lua-функции и общие переменные в одной внутренней переменной (назовем ее game). Это позволит нам обращаться к функциям или переменным из нашего модуля с помощью self.func_name или self.var_name следующим образом:

local game = {
            -- локальная переменная
            num_players = 0,

            -- метод, который выводит локальную переменную
            hello = function(self)
              print('Hello! Your player number is ' .. self.num_players .. '.')
            end,

            -- метод, который вызывает другой метод и возвращает локальную переменную
            sign_in = function(self)
              self.num_players = self.num_players + 1
              self:hello()
              return self.num_players
            end
        }

В терминах ООП сейчас мы можем рассматривать внутренние переменные внутри переменной game как поля объекта, а внутренние функции — как методы объекта.

Примечание

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

Чтобы включить/отключить использование необъявленных глобальных переменных в вашем коде на языке Lua, используйте модуль Tarantool’а strict.

Таким образом, в модуле игры будут следующие методы:

  • catch() (поймать) для расчета, когда был пойман покемон (помимо координат как игрока, так и покемона, этот метод будет использовать коэффициент вероятности, чтобы в пределах досягаемости игрока можно было поймать не каждого покемона);
  • respawn() (респаун) для добавления отсутствующих покемонов на карту, скажем, каждые 60 секунд (предположим, что испуганный покемон убегает, поэтому мы убираем покемона с карты при любой попытке поймать его и через некоторое время добавляем обратно на карту);
  • notify() (уведомить) для записи информации о пойманных покемонах (например, «Игрок 1 поймал покемона A»);
  • start() (начать) для инициализации игры (метод создаст спейсы в базе данных, создаст и скомпилирует avro-схемы, а также запустит метод respawn()).

Кроме того, было бы удобно завести методы для работы с хранилищем Tarantool’а. Например:

  • add_pokemon() (добавить покемона) для добавления покемона в базу данных и
  • map() (карта) для заполнения карты всеми покемонами, которые хранятся в Tarantool’е.

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

Настройка базы данных

Обсудим инициализацию игры. В методе start() нам нужно заполнить спейсы Tarantool’а данными о покемонах. Почему бы не хранить все игровые данные в памяти? Зачем нужна база данных? Ответ на это: персистентность. Без базы данных мы рискуем потерять данные при отключении электроэнергии, например. Но если мы храним данные в in-memory базе данных, Tarantool позаботится о том, чтобы обеспечить постоянное хранение данных при их изменении. Это дает дополнительное преимущество: быстрая загрузка в случае отказа. Умный алгоритм Tarantool’а быстро загружает все данные с диска в память при начале работы, так что подготовка к работе не займет много времени.

Мы будем использовать функции из встроенного модуля Tarantool’а box:

  • box.schema.create_space('pokemons') для создания спейса под названием pokemon (покемон), чтобы хранить информацию о покемонах (мы не создаем аналогичный спейс по игрокам, потому что планируем только отправлять и получать информацию об игроках с помощью вызовов API, так что нет необходимости хранить ее);
  • box.space.pokemons:create_index('primary', {type = 'hash', parts = {1, 'unsigned'}}) для создания первичного HASH-индекса по ID покемона;
  • box.space.pokemons:create_index('status', {type = 'tree', parts = {2, 'str'}}) для создания вторичного TREE-индекса по статусу покемона.

Обратите внимание на аргумент parts = в спецификации индекса. ID покемона — это первое поле в кортеже Tarantool’а, потому что это первый элемент соответствующего типа Avro. То же относится к статусу покемона. В самом JSON-файле поля ID или статуса могут быть в любом положении на JSON-карте.

Имплементация метода start() выглядит следующим образом:

-- создать игровой объект
        start = function(self)
            -- создать спейсы и индексы
            box.once('init', function()
                box.schema.create_space('pokemons')
                box.space.pokemons:create_index(
                    "primary", {type = 'hash', parts = {1, 'unsigned'}}
                )
                box.space.pokemons:create_index(
                    "status", {type = "tree", parts = {2, 'str'}}
                )
            end)

            -- создать модели
            local ok_m, pokemon = avro.create(schema.pokemon)
            local ok_p, player = avro.create(schema.player)
            if ok_m and ok_p then
                -- скомпилировать модели
                local ok_cm, compiled_pokemon = avro.compile(pokemon)
                local ok_cp, compiled_player = avro.compile(player)
                if ok_cm and ok_cp then
                    -- начать игру
                    <...>
                else
                    log.error('Schema compilation failed')
                end
            else
                log.info('Schema creation failed')
            end
            return false
        end

ГИС

Теперь обсудим метод catch(), который является основным в логике нашей игры.

Здесь мы получаем координаты игрока и номер ID искомого покемона, а нужен нам ответ на вопрос, поймали ли игрок покемона (помните, что у каждого покемона есть шанс убежать).

Для начала проверим полученные данные об игроке по Avro-схеме. Также проверим, есть ли такой покемон в базе данных, и отображается ли он на карте (у покемона должен быть активный статус):

catch = function(self, pokemon_id, player)
            -- проверить данные игрока
            local ok, tuple = self.player_model.flatten(player)
            if not ok then
                return false
            end
            -- получить данные покемона
            local p_tuple = box.space.pokemons:get(pokemon_id)
            if p_tuple == nil then
                return false
            end
            local ok, pokemon = self.pokemon_model.unflatten(p_tuple)
            if not ok then
                return false
            end
            if pokemon.status ~= self.state.ACTIVE then
                return false
            end
            -- логика поимки будет дополняться
            <...>
        end

Далее вычисляем ответ: пойман или нет.

Чтобы работать с географическими координатами, используем модуль Tarantool’а gis.

Чтобы не усложнять, не будем загружать какую-то особую карту, допуская, что рассматриваем карту мира. Также не будет проверять поступающие координаты, снова допуская, что все места находятся на планете Земля.

Используем две географические переменные:

  • wgs84, что означает последнюю редакцию стандарта Мировой геодезической системы координат, WGS84. В целом, она представляет собой стандартную систему координат Земли и изображает Землю как эллипсоид.
  • nationalmap, что означает Государственный атлас США в равновеликой проекции (US National Atlas Equal Area). Это система спроецированных координат на основании WGS84. Она дает основу для проецирования мест и позволяет определить местоположение наших игроков и покемонов в метрах.

Обе системы указаны в Реестре геодезических параметров EPSG, где каждой системе присвоен уникальный номер. Мы назначим эти числа соответствующим переменным в нашем коде:

wgs84 = 4326,
        nationalmap = 2163,

Для игровой логики необходима еще одна переменная catch_distance, которая определяет, насколько близко игрок должен подойти к покемону, чтобы попытаться поймать его. Определим это расстояние в 100 метров.

catch_distance = 100,

Теперь можно рассчитать ответ. Необходимо спроецировать текущее местоположение как игрока (p_pos), так и покемона (m_pos) на карте, проверить, достаточно ли близко к покемону находится игрок (с помощью catch_distance), и рассчитать, поймал ли игрок покемона (здесь мы генерируем случайное значение, и покемон убегает, если случайное значение оказывается меньше, чем 100 минус случайная величина покемона):

-- спроецировать местоположение
        local m_pos = gis.Point(
            {pokemon.location.x, pokemon.location.y}, self.wgs84
        ):transform(self.nationalmap)
        local p_pos = gis.Point(
            {player.location.x, player.location.y}, self.wgs84
        ):transform(self.nationalmap)

        -- проверить условие близости игрока
        if p_pos:distance(m_pos) > self.catch_distance then
            return false
        end
        -- попытаться поймать покемона
        local caught = math.random(100) >= 100 - pokemon.chance
        if caught then
            -- обновить и сообщить об успехе
            box.space.pokemons:update(
                pokemon_id, {{'=', self.STATUS, self.state.CAUGHT}}
            )
            self:notify(player, pokemon)
        end
        return caught

Итератор с индексом

По сюжету игры все пойманные покемоны возвращаются на карту. Метод respawn() обеспечивает это для всех покемонов на карте каждые 60 секунд. Мы выполняем перебор покемонов по статусу с помощью функции Tarantool’а итератора с индексом index:pairs и сбрасываем статусы всех «пойманных» покемонов обратно на «активный» с помощью box.space.pokemons:update().

respawn = function(self)
            fiber.name('Respawn fiber')
            for _, tuple in box.space.pokemons.index.status:pairs(
                   self.state.CAUGHT) do
                box.space.pokemons:update(
                    tuple[self.ID],
                    {{'=', self.STATUS, self.state.ACTIVE}}
                )
            end
         end

Для удобства введем именованные поля:

ID = 1, STATUS = 2,

Имплементация метода start() полностью теперь выглядит так:

-- создать игровой объект
        start = function(self)
            -- создать спейсы и индексы
            box.once('init', function()
               box.schema.create_space('pokemons')
               box.space.pokemons:create_index(
                   "primary", {type = 'hash', parts = {1, 'unsigned'}}}n        )
               box.space.pokemons:create_index(
                   "status", {type = "tree", parts = {2, 'str'}}}n        )
            end)

            -- создать модели
            local ok_m, pokemon = avro.create(schema.pokemon)
            local ok_p, player = avro.create(schema.player)
            if ok_m and ok_p then
                -- скомпилировать модели
                local ok_cm, compiled_pokemon = avro.compile(pokemon)
                local ok_cp, compiled_player = avro.compile(player)
                if ok_cm and ok_cp then
                    -- начать игру
                    self.pokemon_model = compiled_pokemon
                    self.player_model = compiled_player
                    self.respawn()
                    log.info('Started')
                    return true
                 else
                    log.error('Schema compilation failed')
                 end
            else
                log.info('Schema creation failed')
            end
            return false
        end

Файберы

Но подождите! Если мы запустим функцию self.respawn(), как показано выше, то она запустится только один раз, как и остальные методы. А нам необходимо запускать respawn() каждые 60 секунд. Tarantool заставляет логику приложения непрерывно работать в фоновом режиме с помощью файбера.

Файбер предназначен для выполнения последовательностей команд, но это не поток. Ключевое отличие в том, что потоки используют многозадачность с реализацией приоритетов, тогда как файберы используют кооперативную многозадачность. Это дает файберам два преимущества над потоками:

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

Однако у файберов есть определенные ограничения, по сравнению с потоками, основное из которых — отсутствие режима работы с многоядерной системой. Все файберы в приложении относятся к одному потоку, поэтому они используют то же ядро процессора, что и родительский поток. В то же время, это ограничение незначительно для приложений Tarantool’а, поскольку узкое место Tarantool’а — жесткий диск, а не ЦП.

У файбера есть все возможности сопрограммы на языке Lua, и все принципы программирования, которые применяются к сопрограммам на Lua, применимы и к файберам. Однако Tarantool расширил возможности файберов для внутреннего использования. Поэтому, несмотря на возможность и поддержку использования сопрограмм, рекомендуется использовать файберы.

Производительность или управляемость не слишком важны в нашем случае. Запустим respawn() в файбере для непрерывной работы в фоновом режиме. Для этого необходимо изменить respawn():

respawn = function(self)
            -- назовем наш файбер;
            -- это выполнит чистый вывод в fiber.info()
            fiber.name('Respawn fiber')
            while true do
                for _, tuple in box.space.pokemons.index.status:pairs(
                        self.state.CAUGHT) do
                    box.space.pokemons:update(
                        tuple[self.ID],
                        {{'=', self.STATUS, self.state.ACTIVE}}
                    )
                end
                fiber.sleep(self.respawn_time)
            end
        end

и назвать его файбером в start():

start = function(self)
            -- создать спейсы и индексы
                <...>
            -- создать модели
                <...>
            -- скомпилировать модели
                <...>
            -- начать игру
               self.pokemon_model = compiled_pokemon
               self.player_model = compiled_player
               fiber.create(self.respawn, self)
               log.info('Started')
            -- ошибки, если создание схемы или компиляция не работает
               <...>
        end

Запись в журнал

В start() мы использовали еще одну полезную функцию — log.infо() из модуля log Tarantool’а . Эта функция также понадобится в notify() для добавления записи в файл журнала при каждой успешной поимке:

-- уведомление о событии
        notify = function(self, player, pokemon)
            log.info("Player '%s' caught '%s'", player.name, pokemon.name)
        end

Мы используем стандартные настройки журнала Tarantool’а, поэтому увидим вывод записей журнала в консоли, когда запустим приложение в режиме скрипта.

../../../_images/aster.svg

Great! We’ve discussed all programming practices used in our Lua module (see pokemon.lua).

Now let’s prepare the test environment. As planned, we write a Lua application (see game.lua) to initialize Tarantool’s database module, initialize our game, call the game loop and simulate a couple of player requests.

Чтобы запустить микросервис, поместим модуль pokemon.lua и приложение game.lua в текущую директорию, установим все внешние модули и запустим экземпляр Tarantool’а с работают приложением game.lua (это пример для Ubuntu):

$ ls
        game.lua  pokemon.lua
        $ sudo apt-get install tarantool-gis
        $ sudo apt-get install tarantool-avro-schema
        $ tarantool game.lua

Tarantool запускает и инициализирует базу данных. Затем Tarantool выполняет демо-логику из game.lua: добавляет покемона под названием Пикачу (Pikachu) (шанс его поимки очень высок - 99,1), отображает текущую карту (на ней расположен один активный покемон, Пикачу) и обрабатывает запросы поимки от двух игроков. Player1 (Игрок 1) находится очень близко к одинокому покемону Пикачу, а Player2 (Игрок 2) находится очень далеко от него. Как предполагается, результаты поимки в таком выводе будут «true» для Player1 и «false» для Player2. Наконец, Tarantool отображает текущую карту, которая пуста, потому что Пикачу пойман и временно неактивен:

$ tarantool game.lua
        2017-01-09 20:19:24.605 [6282] main/101/game.lua C> version 1.7.3-43-gf5fa1e1
        2017-01-09 20:19:24.605 [6282] main/101/game.lua C> log level 5
        2017-01-09 20:19:24.605 [6282] main/101/game.lua I> mapping 1073741824 bytes for tuple arena...
        2017-01-09 20:19:24.609 [6282] main/101/game.lua I> initializing an empty  data directory
        2017-01-09 20:19:24.634 [6282] snapshot/101/main I> saving snapshot `./00000000000000000000.snap.inprogress'
        2017-01-09 20:19:24.635 [6282] snapshot/101/main I> done
        2017-01-09 20:19:24.641 [6282] main/101/game.lua I> ready to accept requests
        2017-01-09 20:19:24.786 [6282] main/101/game.lua I> Started
        ---
        - {'id': 1, 'status': 'active', 'location': {'y': 2, 'x': 1}, 'name': 'Pikachu', 'chance': 99.1}
        ...

        2017-01-09 20:19:24.789 [6282] main/101/game.lua I> Player 'Player1' caught 'Pikachu'
        true
        false
        --- []
        ...

        2017-01-09 20:19:24.789 [6282] main C> entering the event loop

nginx

In the real life, this microservice would work over HTTP. Let’s add nginx web server to our environment and make a similar demo. But how do we make Tarantool methods callable via REST API? We use nginx with Tarantool nginx upstream module and create one more Lua script (app.lua) that exports three of our game methods – add_pokemon(), map() and catch() – as REST endpoints of the nginx upstream module:

local game = require('pokemon')
        box.cfg{listen=3301}
        game:start()

        -- функции add, map и catch по REST API
        function add(request, pokemon)
            return {
                result=game:add_pokemon(pokemon)
            }
        end

        function map(request)
            return {
                map=game:map()
            }
        end

        function catch(request, pid, player)
            local id = tonumber(pid)
            if id == nil then
                return {result=false}
            end
            return {
                result=game:catch(id, player)
            }
        end

An easy way to configure and launch nginx would be to create a Docker container based on a Docker image with nginx and the upstream module already installed (see http/Dockerfile). We take a standard nginx.conf, where we define an upstream with our Tarantool backend running (this is another Docker container, see details below):

upstream tnt {
              server pserver:3301 max_fails=1 fail_timeout=60s;
              keepalive 250000;
        }

и добавляем специальные параметры для Tarantool’а (см. описание в файле README модуля upstream):

server {
          server_name tnt_test;

          listen 80 default deferred reuseport so_keepalive=on backlog=65535;

          location = / {
              root /usr/local/nginx/html;
          }

          location /api {
            # ответы проверяют бесконечное время ожидания
            tnt_read_timeout 60m;
            if ( $request_method = GET ) {
               tnt_method "map";
            }
            tnt_http_rest_methods get;
            tnt_http_methods all;
            tnt_multireturn_skip_count 2;
            tnt_pure_result on;
            tnt_pass_http_request on parse_args;
            tnt_pass tnt;
          }
        }

Likewise, we put Tarantool server and all our game logic in a second Docker container based on the official Tarantool 1.9 image (see src/Dockerfile) and set the container’s default command to tarantool app.lua. This is the backend.

Неблокирующий ввод-вывод

To test the REST API, we create a new script (client.lua), which is similar to our game.lua application, but makes HTTP POST and GET requests rather than calling Lua functions:

local http = require('curl').http()
        local json = require('json')
        local URI = os.getenv('SERVER_URI')
        local fiber = require('fiber')

        local player1 = {
            name="Player1",
            id=1,
            location = {
                x=1.0001,
                y=2.0003
            }
        }
        local player2 = {
            name="Player2",
            id=2,
            location = {
                x=30.123,
                y=40.456
            }
        }

        local pokemon = {
            name="Pikachu",
            chance=99.1,
            id=1,
            status="active",
            location = {
                x=1,
                y=2
            }
        }

        function request(method, body, id)
            local resp = http:request(
                method, URI, body
            )
            if id ~= nil then
                print(string.format('Player %d result: %s',
                    id, resp.body))
            else
                print(resp.body)
            end
        end

        local players = {}
        function catch(player)
            fiber.sleep(math.random(5))
            print('Catch pokemon by player ' .. tostring(player.id))
            request(
                'POST', '{"method": "catch",
                "params": [1, '..json.encode(player)..']}',
                tostring(player.id)
            )
            table.insert(players, player.id)
        end

        print('Create pokemon')
        request('POST', '{"method": "add",
            "params": ['..json.encode(pokemon)..']}')
        request('GET', '')

        fiber.create(catch, player1)
        fiber.create(catch, player2)

        -- подождать игроков
        while #players ~= 2 do
            fiber.sleep(0.001)
        end

        request('GET', '')
        os.exit()

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

Действительно, когда Player1 посылает первый REST-вызов, скрипт не блокируется. Файбер, выполняющий функцию catch() от Player1, посылает неблокирующий вызов в операционную систему и передает управление на следующий файбер, которым оказывается файбер от Player2. Файбер от Player2 делает то же самое. Когда получен сетевой ответ, файбер от Player1 активируется с помощью кооперативного планировщика Tarantool’а и возобновляет работу. Все модули Tarantool’а используют неблокирующий ввод-вывод и интегрированы с кооперативным планировщиком Tarantool’а. Разработчикам модулей Tarantool предоставляет API.

For our HTTP test, we create a third container based on the official Tarantool 1.9 image (see client/Dockerfile) and set the container’s default command to tarantool client.lua.

../../../_images/aster.svg

Чтобы запустить тест локально, скачайте наш проект покемон из GitHub и вызовите:

$ docker-compose build
        $ docker-compose up

Docker Compose собирает и запускает все три контейнера: pserver (бэкенд Tarantool’а), phttp (nginx) и``pclient`` (демо-клиент). ВЫ можете увидеть все сообщения журнала из всех этих контейнеров в консоли. pclient выведет, что сделал HTTP-запрос на создание покемона, два запроса на поимку покемона, запросил карту (пустая, поскольку покемон пойман и временно неактивен) и завершил работу:

pclient_1  | Create pokemon
        <...>
        pclient_1  | {"result":true}
        pclient_1  | {"map":[{"id":1,"status":"active","location":{"y":2,"x":1},"name":"Pikachu","chance":99.100000}]}
        pclient_1  | Catch pokemon by player 2
        pclient_1  | Catch pokemon by player 1
        pclient_1  | Player 1 result: {"result":true}
        pclient_1  | Player 2 result: {"result":false}
        pclient_1  | {"map":[]}
        pokemon_pclient_1 exited with code 0

Поздравляем! Вот мы и закончили наш пошаговый пример. Для дальнейшего изучения рекомендуем установку и добавление модуля.

См. также справочник по модулям Tarantool’а и C API и не пропустите нашу книгу рецептов на Lua.