Pull to refresh

LUA в nginx: лапшакод в стиле inline php

Reading time 5 min
Views 15K

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

Думаю, что все разработчики на PHP (включая меня) так или иначе проходили через период, когда код представляет из себя жуткую смесь html и php, напиханных в одном файле. И речь не о шаблонах, а вообще о всей логике в лапше/спагетти-коде.
И в качестве концепта я решил к первому апреля набросать реализацию чего-то подобного, но на lua под nginx. Прямо как на картинке.

Скрипты можно писать примерно такие (ссылка, по которой отзывается данный код):
<?lml tmpl:include('sugar') ?>
<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Сейчас <?lml print(ngx.utctime()) ?></title>
</head>
<body>
<?lml local alc = require('lib.alc') ?>
Привет, <?lml print(esc(req:get('name', 'traveler')), '/', ngx.var.remote_addr) ?>.
Это уже <?lml print(alc:inc('cnt')) ?> запрос с последнего перезапуска сервера.

<?lml
    local hdrs = {}
    for k,v in pairs(ngx.req.get_headers()) do
        table.insert(hdrs, '<tr><td style="font-weight:bold;">'..esc(k)..'</td><td>'..esc(v)..'</td></tr>')
    end
?>

<h3>Заголовки <?lml print(ngx.req.get_method()) ?> запроса к <?lml print(esc(ngx.var.request_uri)) ?></h3>
<table><?lml print(hdrs) ?></table>

<?lml include('footer') ?>

Т.е. полноценный lua в лапшастиле. Для проверки работы были реализованы:
  • непосредственно сам «шаблонизатор»;
  • близкий аналог APC: всякие store/fetch/cas и т.п. + compile_string/compile_file для кеширования байткода скомпилированных шаблонов;
  • ob_* функции без поддержки вложенности (нет необходимости);
  • всякая мелочь для замены htmlspecialchars, $_GET[name] и т.п.


Возможно, кому-то будет интересно почитать о реализации. Кому же интересен только код — выложил на github, хоть там кода и кот наплакал.

Вся работа основана на следующем:
  • LUA позволяет в runtime скомпилировать исходный код, представленный строкой, в функцию (на вход строка, на выходе function (callable в терминах php/java)). За это отвечает функция loadstring;
  • Для имеющейся function можно в runtime получить ее байткод через вызов string.dump;
  • Получить function обратно из байткода можно через все ту же loadstring;
  • Для кеширования в оперативке используется ngx.shared.DICT, работу с которым я уже описывал ранее;
  • Немного кручу-верчу для соединения этого всего воедино.


Для начала конфигурируем сам nginx:
http {
    lua_shared_dict lml_shared 10m;
    lua_package_path '/path/to/lml/?.lua;;';
}

# имя location и пути могут быть, само собой, произвольными
location /lml {
    # грузим шаблонизатор и выводим шаблон index (по умолчанию, это файл /path/to/lml/tmpl/index.lml)
    content_by_lua '
        local tmpl = require "lib.tmpl"
        tmpl:set_root("/path/to/lml/tmpl/")
        tmpl:include("index")
    ';
}


Обработка шаблонов простейшая: весь текст вне тегов <?lml ?> заворачивается в stdout:print(ТЕКСТ), а содержимое тегов оставляется как есть, выкидывая только сами границы тегов. HTML текст в print заворачивается в многострочные литералы, чтобы не пришлось экранировать символы внутри:
stdout:print([[Hello
world
]])

Но, т.к. возможна ситуация использования границ литерала внутри шаблона(Hello [[<?lml ?>]] World), то шаблонизатор ищет «свободный» вариант границ многострочного литерала, итерационно наращивая его длину:
print([[...]])
print([=[...]=])
print([==[...]==])
...


Компиляция в байткод по аналогии с php вынесена из шаблонизатора в опкод кешер, бесхитростно названный ALC (Alternative Lua Cache).
В самом минимальном исполнении кеширование байткода выглядит так (это крайне урезанная версия! не стоит рассматривать ее как минимальный, но рабочий пример):
function M:compile_string(str, filename)
    local cache_key = 'tmpl_bytecode:' .. filename
    local bytecode, created_at = cache:get(cache_key)

    local lua_func = nil

    if not bytecode then
        locked = cache:add(key_lock, 1, key_lock_ttl)
        bytecode, created_at = cache:get(cache_key)

        if not bytecode then
            if type(str) == 'function' then
                str = str(filename)
            end
            lua_func = assert(loadstring(str, filename))
            bytecode = assert(string.dump(lua_func))
        end

        if locked then
            if lua_func and bytecode then
                cache:set(cache_key, bytecode, 0, ngx.now())
            end
            cache:delete(key_lock)
        end
    end

    if (not lua_func) and bytecode then
        lua_func = loadstring(bytecode, filename)
    end

    return lua_func
end

Передав строку с lua кодом, на выходе получаем function, готовую для выполнения, а в оперативке у нас теперь лежит байткод.

Соотвественно, в шаблонизаторе достаточно вызвать соответствуйщий метод, подсунув ему нужные данные:
local function _include_string(str, filename)
    local lua_func = alc:compile_string(str, filename)
    if lua_func then
        lua_func()
    end
end

function M:include_string(str, filename)
    local succ, err = pcall(_include_string, str, filename)
    if not succ then
        ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR

        local errstr = 'Error (' .. filename .. '): ' .. err
        ngx.log(ngx.ERR, errstr)
        ngx.say(errstr)
        return ngx.exit(ngx.HTTP_OK)
    end
    return succ
end

-- Для загрузки из файла на диске (как раз тот случай, который используется в самих шаблонах и location nginx'а):
function M:include(name)
    local path = root_path .. name .. file_ext

    M:include_string(
        function(filename)
            local str = assert(file:read_all(filename))
            return assert(parse_tmpl(str, filename))
        end,
        path
    )
end

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

Вся функциональность распределена по небольшим модулям: шаблонизатор в lib.tmpl, кешер в lib.alc, вывод и буферизация вывода в lib.stdout и т.д. В шаблонах для работы с модулями в общем случае требуется явная их загрузка и обращение к функциям по полным именам:
-- некий шаблон example.lml
<?lml
local stdout = require('lib.stdout')
local html = require('lib.html')
local tmpl = require('lib.tmpl')

tmpl:include('header')

stdout:print(html:escape(ngx.var.request_uri))
?>


Это явно и понятно, но в качестве «сахара» часть модулей сделаны обязательными и подключаются автоматически через генерацию в коде префикса с подгрузкой этих модулей:
local required_libs = {'stdout', 'html', 'req', 'tmpl'}

-- tmpl_chunks содержит куски lua кода, полученного из lml шаблона

-- добавляем в начало кода подгрузку всех обязательных модулей
for _,l in ipairs(required_libs) do
    table.insert(tmpl_chunks, 1, 'local '..l..' = require("lib.'..l..'");')
end


Теперь эти модули можно сразу использовать в шаблоне:
-- некий шаблон example.lml
<?lml
tmpl:include('header')

stdout:print(html:escape(ngx.var.request_uri))
?>


В дополнение к этому были подслащены еще и наиболее часто используемые функции, такие как stdout:print, tmpl:include, html:escape. Сделано это было для примера уже на уровне lml шаблонов:
-- sugar.lml
<?lml
function include(...)
    tmpl:include(...)
end

function print(...)
    stdout:print(...)
end

function esc(...)
    return html:escape(...)
end
?>

-- некий шаблон example.lml
<?lml
tmpl:include('sugar')
include('header')

print(esc(ngx.var.request_uri))
?>

Данное решение является палкой о двух концах и сделано для приведения кода шаблонов ближе к стилистике php.

В заключение сферический тест производительности данного велосипеда в сравнении с php-fpm+apc на простейшем «домашнем сервачке» с Athlon II, ссылка на который приведена в начале поста.
Сравнение происходило со столь же примитивным php кодом из 3х файлов с максимальной адаптацией.
Пока что тестировал через siege по 100Мбит локалке, так что кое где производительность упиралась в сетку.
Запуск через siege -cX -t300S -b URL показал следующие trans/sec:
  -c10 -c100 -c200 -c500
php-fpm 3350 3150 уперся в cpu http 502 * http 502 *
lml без опкешера не тестил 6950 не тестил не тестил
lml с опкешером 7000 8100 уперся в сеть 8200 уперся в сеть 7500 уперся в сеть

* массовые connect() to unix:/var/run/php-fpm-*.sock failed (11: Resource temporarily unavailable)

Вроде не так и ужасно.

Еще раз ссылка на github, если кто упустил или начал с конца, но хочет грянуть подробности.

Всем желаю не поддаваться на провокации :)
Tags:
Hubs:
+26
Comments 21
Comments Comments 21

Articles