Do you want to pick up from where you left of?
Take me there

Lua

Библиотека Lua предоставляет эргономичный интерфейс к Luerl, позволяющий безопасное выполнение изолированных Lua-скриптов прямо в виртуальной машине BEAM. В этом уроке мы исследуем встраивание возможностей Lua-скриптинга в наши Elixir-приложения для пользовательской логики, конфигурации и расширяемости.

Библиотека Lua для Elixir — это эргономичная обёртка над Luerl, чистой Erlang-реализацией Lua 5.3 от Роберта Вирдинга. В отличие от подходов, опирающихся на NIF или другие механизмы, эта реализация работает целиком на виртуальной машине BEAM и при этом обеспечивает отличную изоляцию и возможности интеграции.

Зачем использовать Lua в Elixir?

Зачем нам вообще обращаться к Lua, когда Elixir сам по себе такой мощный язык? Дело в том, что при выполнении пользовательского кода Elixir несёт в себе определённые риски. Функция горячей замены кода позволяет заменять модули во время работы приложения, а значит, выполнение ненадёжного Elixir-кода способно перезаписать существующие модули прямо в работающем приложении или внедрить вредоносный код, который переживёт исходный контекст выполнения! Всё это делает прямое выполнение пользовательского Elixir-кода крайне опасным в production-среде и категорически не рекомендуется.

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

Установка

Добавим библиотеку Lua в зависимости mix.exs:

defp deps do
  [
    {:lua, "~> 0.1.0"}
  ]
end

Затем выполним:

mix deps.get

Основы использования

Начнём с простейшего примера выполнения Lua-кода:

iex> {result, _state} = Lua.eval!("return 2 + 3")
{[5], #PID<0.123.0>}
iex> result
[5]

Функция Lua.eval!/2 возвращает кортеж, содержащий результаты (в виде списка) и состояние Lua. Важно отметить, что даже простые выражения возвращают результаты в виде списков — это связано с тем, что Lua-функции могут возвращать несколько значений.

Сигил ~LUA

Библиотека Lua предоставляет сигил ~LUA, который проверяет синтаксис Lua на этапе компиляции:

iex> import Lua, only: [sigil_LUA: 2]
iex> {[7], _state} = Lua.eval!(~LUA[return 3 + 4])
{[7], #PID<0.124.0>}

Если попробовать использовать некорректный синтаксис Lua, получим ошибку компиляции:

iex> {result, _state} = Lua.eval!(~LUA[return 2 +])
** (Lua.CompilerException) Failed to compile Lua!

Оптимизация на этапе компиляции

Модификатор c в сочетании с сигилом компилирует наш Lua-код в Lua.Chunk.t() во время компиляции, что улучшает производительность во время выполнения:

iex> import Lua, only: [sigil_LUA: 2]
iex> {[42], _state} = Lua.eval!(~LUA[return 6 * 7]c)
{[42], #PID<0.125.0>}

Работа с состоянием Lua

Каждая среда выполнения Lua поддерживает собственное состояние, включая переменные, функции и данные. Мы можем создавать это состояние и управлять им:

iex> lua = Lua.new()
#PID<0.126.0>

# Устанавливаем переменную
iex> lua = Lua.set!(lua, [:my_var], 42)
#PID<0.126.0>

# Читаем её обратно
iex> {[42], _state} = Lua.eval!(lua, "return my_var")
{[42], #PID<0.126.0>}

Также можно работать с вложенными структурами данных:

iex> lua = Lua.new()
iex> lua = Lua.set!(lua, [:config, :database, :port], 5432)
iex> {[5432], _state} = Lua.eval!(lua, "return config.database.port")
{[5432], #PID<0.127.0>}

Предоставление функций Elixir в Lua

Простое предоставление функций

Самый прямолинейный способ предоставить функцию Elixir в Lua — использовать Lua.set!/3:

iex> import Lua, only: [sigil_LUA: 2]
iex> 
iex> lua = 
...>   Lua.new()
...>   |> Lua.set!([:sum], fn args -> [Enum.sum(args)] end)
#PID<0.128.0>

iex> {[10], _state} = Lua.eval!(lua, ~LUA[return sum(1, 2, 3, 4)]c)
{[10], #PID<0.128.0>}

Обратите внимание, что функции Elixir, предоставляемые в Lua, должны:

Использование макроса deflua

Для более сложных API можно использовать макрос deflua:

defmodule MathAPI do
  use Lua.API

  deflua add(a, b), do: a + b
  deflua multiply(a, b), do: a * b
  deflua power(base, exponent), do: :math.pow(base, exponent)
end

# Загружаем API в состояние Lua
iex> lua = Lua.new() |> Lua.load_api(MathAPI)
iex> {[16.0], _state} = Lua.eval!(lua, ~LUA[return power(2, 4)])
{[16.0], #PID<0.129.0>}

API с пространствами имён

Опция :scope предоставляет нам механизм для организации функций в пространствах имён:

defmodule StringAPI do
  use Lua.API, scope: "str"

  deflua upper(text), do: String.upcase(text)
  deflua lower(text), do: String.downcase(text)
  deflua length(text), do: String.length(text)
end

iex> lua = Lua.new() |> Lua.load_api(StringAPI)
iex> {["HELLO"], _state} = Lua.eval!(lua, ~LUA[return str.upper("hello")])
{["HELLO"], #PID<0.130.0>}

API с состоянием

Иногда может потребоваться обращаться к состоянию Lua или изменять его непосредственно из наших API-функций — для этого состояние передаётся вторым аргументом deflua:

defmodule CounterAPI do
  use Lua.API, scope: "counter"

  deflua increment(), state do
    current = Lua.get(state, [:count], 0)
    new_count = current + 1
    state = Lua.set!(state, [:count], new_count)
    {[new_count], state}
  end

  deflua get_count(), state do
    count = Lua.get(state, [:count], 0)
    {[count], state}
  end
end

iex> lua = Lua.new() |> Lua.load_api(CounterAPI)
iex> {[1], lua} = Lua.eval!(lua, ~LUA[return counter.increment()])
iex> {[2], lua} = Lua.eval!(lua, ~LUA[return counter.increment()])
iex> {[2], _state} = Lua.eval!(lua, ~LUA[return counter.get_count()])

Вызов Lua-функций из Elixir

Мы также можем вызывать Lua-функции из нашего Elixir-кода с помощью Lua.call_function!/3:

defmodule StringProcessorAPI do
  use Lua.API, scope: "processor"

  deflua process_with_lua(text), state do
    # Вызываем Lua-функцию для обработки текста
    Lua.call_function!(state, [:string, :upper], [text])
  end
end

iex> lua = Lua.new() |> Lua.load_api(StringProcessorAPI)
iex> {["PROCESSED"], _state} = Lua.eval!(lua, ~LUA[return processor.process_with_lua("processed")])

Типы данных и кодирование

При работе с Lua важно понимать, как типы данных Elixir преобразуются в типы Lua:

Тип Elixir Тип Lua Требует кодирования?
nil nil Нет
boolean() boolean Нет
number() number Нет
binary() string Нет
atom() string Да
map() table Да
list() table Возможно*
{:userdata, any()} userdata Да

*Списки требуют кодирования только в том случае, если содержат элементы, нуждающиеся в кодировании.

Работа с ассоциативными массивами и таблицами

При кодировании ассоциативные массивы Elixir превращаются в таблицы Lua:

iex> config = %{database: %{host: "localhost", port: 5432}, debug: true}
iex> {encoded_config, lua} = Lua.encode!(Lua.new(), config)
iex> lua = Lua.set!(lua, [:config], encoded_config)
iex> {[5432], _state} = Lua.eval!(lua, "return config.database.port")
{[5432], #PID<0.131.0>}

Пользовательские данные для сложных структур

Для передачи сложных структур данных Elixir, которые мы не хотим отдавать на изменение Lua:

defmodule User do
  defstruct [:id, :name, :email]
end

iex> user = %User{id: 1, name: "Alice", email: "alice@example.com"}
iex> {encoded_user, lua} = Lua.encode!(Lua.new(), {:userdata, user})
iex> lua = Lua.set!(lua, [:current_user], encoded_user)
iex> {[{:userdata, %User{id: 1, name: "Alice", email: "alice@example.com"}}], _state} = 
...>   Lua.eval!(lua, "return current_user")
{[{:userdata, %User{id: 1, name: "Alice", email: "alice@example.com"}}], #PID<0.132.0>}

Приватный контекст и безопасность

Одна из самых мощных возможностей — поддержка приватного контекста, доступного нашему Elixir-коду, но скрытого от Lua-скриптов:

defmodule UserAPI do
  use Lua.API, scope: "user"

  deflua get_name(), state do
    user = Lua.get_private!(state, :current_user)
    {[user.name], state}
  end

  deflua get_permission(resource), state do
    user = Lua.get_private!(state, :current_user)
    permissions = Lua.get_private!(state, :permissions)
    
    has_permission = resource in Map.get(permissions, user.id, [])
    {[has_permission], state}
  end
end

# Настраиваем контекст выполнения
user = %{id: 1, name: "Alice"}
permissions = %{1 => ["read_posts", "write_comments"]}

lua = 
  Lua.new()
  |> Lua.put_private(:current_user, user)
  |> Lua.put_private(:permissions, permissions)
  |> Lua.load_api(UserAPI)

# Пользователь может только получить своё имя и проверить права
{["Alice"], _state} = Lua.eval!(lua, ~LUA[return user.get_name()])
{[true], _state} = Lua.eval!(lua, ~LUA[return user.get_permission("read_posts")])
{[false], _state} = Lua.eval!(lua, ~LUA[return user.get_permission("admin_panel")])

Практический пример: движок конфигурации

Давайте создадим практический пример, который позволит пользователям определять сложные бизнес-правила:

defmodule PricingAPI do
  use Lua.API, scope: "pricing"

  deflua get_base_price(product_type), state do
    prices = Lua.get_private!(state, :base_prices)
    price = Map.get(prices, product_type, 0)
    {[price], state}
  end

  deflua calculate_discount(user_tier, order_amount), _state do
    discount = case user_tier do
      "premium" when order_amount >= 100 -> 0.2
      "premium" -> 0.1
      "standard" when order_amount >= 50 -> 0.05
      _ -> 0.0
    end
    {[discount], state}
  end

  deflua apply_seasonal_modifier(month), _state do
    modifier = case month do
      12 -> 0.9  # Скидка в декабре
      1 -> 0.95  # Скидка в январе
      _ -> 1.0
    end
    {[modifier], state}
  end
end

defmodule ConfigEngine do
  def calculate_price(product_type, quantity, user_tier, lua_script) do
    base_prices = %{
      "widget" => 10.0,
      "gadget" => 25.0,
      "premium_item" => 100.0
    }

    lua = 
      Lua.new()
      |> Lua.put_private(:base_prices, base_prices)
      |> Lua.load_api(PricingAPI)
      |> Lua.set!([:product_type], product_type)
      |> Lua.set!([:quantity], quantity)
      |> Lua.set!([:user_tier], user_tier)
      |> Lua.set!([:current_month], Date.utc_today().month)

    {[final_price], _state} = Lua.eval!(lua, lua_script)
    final_price
  end
end

Теперь пользователи смогут определять сложную логику ценообразования под свои нужды, не требуя от нас реализации множества различных сценариев в коде приложения:

pricing_script = ~LUA"""
base_price = pricing.get_base_price(product_type)
subtotal = base_price * quantity

discount = pricing.calculate_discount(user_tier, subtotal)
seasonal_modifier = pricing.apply_seasonal_modifier(current_month)

final_price = subtotal * (1 - discount) * seasonal_modifier
return final_price
"""c

# Считаем цену для премиум-пользователя, покупающего 5 виджетов в декабре
price = ConfigEngine.calculate_price("widget", 5, "premium", pricing_script)
# Результат: 50 * 0.8 * 0.9 = 36.0

Обработка ошибок и отладка

Библиотека Lua предоставляет более информативные сообщения об ошибках по сравнению с «сырым» Luerl:

iex> try do
...>   Lua.eval!("return undefined_function()")
...> rescue
...>   e -> IO.puts("Lua error: #{inspect(e)}")
...> end

Для ошибок валидации на этапе компиляции:

iex> import Lua, only: [sigil_LUA: 2]
iex> try do
...>   ~LUA[return 2 +]
...> rescue
...>   e in Lua.CompilerException -> IO.puts("Compile error: #{e.message}")
...> end
Compile error: Failed to compile Lua!

Для отладки можно инспектировать состояние Lua:

iex> lua = Lua.new() |> Lua.set!([:debug_var], "debugging")
iex> variables = Lua.get_globals(lua)
iex> IO.inspect(variables)

Тестирование интеграции с Lua

При тестировании кода, использующего Lua, мы можем передавать управляемые Lua-скрипты:

defmodule MyAppTest do
  use ExUnit.Case
  import Lua, only: [sigil_LUA: 2]

  test "pricing calculation with lua script" do
    script = ~LUA[return base_price * quantity * 0.9]c
    
    lua = 
      Lua.new()
      |> Lua.set!([:base_price], 10.0)
      |> Lua.set!([:quantity], 3)

    {[result], _state} = Lua.eval!(lua, script)
    assert result == 27.0
  end

  test "error handling for invalid lua" do
    assert_raise Lua.CompilerException, fn ->
      Lua.eval!(~LUA[return invalid syntax])
    end
  end
end

О чём стоит подумать

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

Производительность

С точки зрения производительности наибольший эффект даёт использование компилируемых чанков с модификатором c в сигилах ~LUA — это устраняет накладные расходы на разбор кода при каждом выполнении. Также рекомендуется по возможности повторно использовать состояние Lua, поскольку создание нового состояния требует дорогостоящей инициализации всей среды Lua.

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

Безопасность

При выполнении пользовательского кода никогда не предоставляйте опасные функции, обращающиеся к файловой системе, сети или процессам, — вредоносные скрипты могут скомпрометировать всё приложение. Если необходимо хранить конфиденциальные данные в состоянии Lua, используйте приватный контекст, а не Lua-переменные: приватный контекст изолирован от среды выполнения Lua.

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

Заключение

Библиотека Lua для Elixir предоставляет мощный и безопасный способ добавить в наши приложения возможность пользовательского скриптинга. Опираясь на сильные стороны виртуальной машины BEAM и возможности изоляции Luerl, мы можем строить гибкие и расширяемые системы, позволяющие пользователям настраивать поведение приложения без ущерба для безопасности.

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

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!