shikhalev.org

Оригинал этой статьи опубликован в журнале «Системный администратор» №5 (150) за май 2015. Прошу обратить внимание на год — какие-то моменты могут расходиться с современными версиями языка и библиотек…


Библиотека Rack — простой объектный интерфейс для написания веб-приложений.

Слово «rack» в английском языке имеет множество значений, включая такие, как «пытка» и «разрушение»… Однако, надо полагать, название рассматриваемой библиотеки произошло от другой группы смыслов: «стойка», «штатив», «каркас» и т.д. Rack обеспечивает простой и в то же время удобный интерфейс, обеспечивающий взаимодействие между веб-сервером и приложением, позволяя программисту сосредоточиться исключительно на логике последнего.

Этот интерфейс достаточно низкоуровневый и не ограничивает разработчика каким-либо заранее заданным способом организации приложения и высокоуровневыми абстракциями. Соответственно, он и не предоставляет таких абстракций — это уже дело фреймворков, которые работают поверх него: Rails, Sinatra и других.

Зачем знать Rack?

Практически вся веб-разработка на Ruby использует Rack, как правило — посредством того или иного более высокоуровневого фреймворка. Но это не обязательно, задачи бывают разные: для каких-то те же Rails слишком тяжеловесны, для каких-то — слишком «заточены» под определенное использование и структуру программы.

Можно выделить три цели изучения именно Rack как такового:

  • Понимание того, что находится у популярных фреймворков «под капотом», чтобы ориентироваться в более-менее сложных случаях, не предусмотренных их создателями.
  • Написание небольших веб-приложений для простых задач, когда использование чего-то более тяжелого будет напрасной тратой ресурсов.
  • Разработка сложных и необычных веб-приложений, которые не вписываются в идеологию и структуру существующих фреймворков. MVC1 — хорошая и проверенная временем концепция, но все же не панацея и не «серебряная пуля».

Как это работает

Для начала установим соответствующий гем:

$ sudo gem install rack
Fetching: rack-1.6.0.gem (100%)
Successfully installed rack-1.6.0
1 gem installed

Установить можно было бы и без sudo, т.е. только для локального пользователя. Но в этом случае мы не сможем использовать предоставляемые гемом исполняемые файлы, без которых обойтись можно, но не хочется.

Простейшее веб-приложение может выглядеть, например, так2:

require 'pp'
require 'rack'

app = proc do |env|
  [
    200,
    { 'Content-Type' => 'text/plain' },
    [ env.pretty_inspect ]
  ]
end

Rack::Handler::WEBrick.run app

Запускаем:

$ ruby demo01.rb
[2015-03-24 14:07:11] INFO  WEBrick 1.3.1
[2015-03-24 14:07:11] INFO  ruby 2.1.5 (2014-11-13) [x86_64-linux]
[2015-03-24 14:07:11] INFO  WEBrick::HTTPServer♯start: pid=6883 port=8080

И теперь мы можем, зайдя браузером по адресу http://localhost:8080/, видеть список HTTP-заголовков и переменных сервера в виде объекта класса Hash. 8080 здесь — порт по умолчанию, он используется если при вызове run не указан какой-либо другой.

Как можно видеть из кода выше, основная рабочая часть у нас — это Proc-объект, который получает на вход хэш с переменными, и возвращает специальным образом определенный массив. Поскольку в Ruby принято использовать «утиную» типизацию, класс объекта на самом деле не важен, важен метод call, принимающий хэш и возвращающий соответствующий массив.

Массив должен содержать три элемента: код ответа (в нашем случае — «200 OK»3), HTTP-заголовки ответа в виде хэша и тело ответа в виде массива (точнее опять же произвольного объекта, позволяющего последовательный перебор элементов), содержащего строки.

Итак, наше приложение, т.е. объект с методом call, запускается неким обработчиком, в данном случае — WEBrick, который определяет используемый сервер. Серверы могут быть разными, каждый имеет свои преимущества и недостатки, причем для серьезной работы под реальной нагрузкой WEBrick, вероятно, хуже всех, однако только он входит в стандартную библиотеку Ruby, соответственно его не придется отдельно устанавливать в большинстве случаев.

Останавливается сервер нажатием Ctrl+C в консоли, где он запущен (в некоторых случаях, в зависимости от версий ПО и системного окружения, может потребоваться Ctrl+Alt+C). Это корректный способ прерывания работы, высвобождающий системные ресурсы (такие, например, как занятый порт), хотя, конечно, в реальных проектах следует предусмотреть альтернативный способ останова/перезапуска, чтобы выполнить какие-то дополнительные действия — сохранить данные, например.

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

require 'pp'

app = proc do |env|
  [
    200,
    { 'Content-Type' => 'text/plain' },
    [ env.pretty_inspect ]
  ]
end

run app

Воспользуемся утилитой rackup:

$ rackup demo01.ru
[2015-03-24 15:04:08] INFO  WEBrick 1.3.1
[2015-03-24 15:04:08] INFO  ruby 2.1.5 (2014-11-13) [x86_64-linux]
[2015-03-24 15:04:08] INFO  WEBrick::HTTPServer♯start: pid=12753 port=9292

Изменился порт по умолчанию, не требуется явное указание «require 'rack'», и, что гораздо интереснее, наш исходный код абстрагировался от выбора сервера. Его при использовании rackup тоже можно указать в явном виде, но уже в параметрах командной строки (как и номер порта).

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

Rack::Request и разбор параметров

Данные запроса приходят в параметре env в виде текстовых строк, как они собственно и определены протоколом HTTP. Однако некоторые из них на самом деле имеют свою внутреннюю структуру и для нормальной работы их еще предстоит распарсить. Можно, конечно, это каждый раз делать вручную, но Rack предоставляет более удобный вариант — класс Rack::Request. Так, мы можем получить параметры в виде хэша — заменим нашу процедуру (в новом .ru-файле) на такую:

app = proc do |env|
  req = Rack::Request.new env
  [
    200,
    { 'Content-Type' => 'text/plain' },
    [ req.params.pretty_inspect ]
  ]
end

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

$ rackup demo02.ru

И обратимся, например, по адресу http://localhost:9292/?alpha=beta&gamma=delta. В браузере мы увидим:

{"alpha"=>"beta", "gamma"=>"delta"}

Вместо строковой переменной из demo01.ru:

{. . .,
 "QUERY_STRING"=>"alpha=beta&gamma=delta",
 . . .}

Аналогично в удобном структурированном виде представлены и куки.

Кроме того, состав переменных в хэше env зависит от выбранного сервера, отличия небольшие, но учитывать их придется, и лучше пусть это сделает за нас Rack::Request, методы которого всегда выдают одну и ту же информацию4.

Для формирования ответа тоже есть специальный класс — Rack::Response5.

С его помощью пример можно переписать так:

app = proc do |env|
  req = Rack::Request.new env
  res = Rack::Response.new
  res['Content-Type'] = 'text/plain'
  res.write req.params.pretty_inspect
  res.finish
end

Важно не забыть про метод finish, который собственно и формирует необходимый ответ.

Декомпозиция

Очевидно, более-менее сложное приложение в одной процедуре или методе не поместится. Поэтому, как правило, приложение все-таки представляет собой отдельный объект (модуль/класс), в котором метод call обращается к более специфическим методам. Но это — процедурная декомпозиция, подходящая для разных действий одного объекта. Если наше приложение содержит логически различные части, имеет смысл вынести эту логику в разные объекты. И здесь Rack предоставляет два механизма декомпозиции — в разных, если можно так выразиться, измерениях.

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

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

Для этих целей используются механизмы роутинга и «прослоек» (middleware в терминологии Rack), предоставляемые классом Rack::Builder. Как это выглядит? Примерно так (создадим очередной .ru-файл):

class Log

  def initialize app, output = $stderr
    @app = app
    @output = output
  end

  def call env
    @output.puts env.pretty_inspect
    @app.call env
  end

end

json = proc do |env|
  [
    200,
    { 'Content-Type' => 'application/json' },
    [ JSON.generate(env) ]
  ]
end

txt = proc do |env|
  [
    200,
    { 'Content-Type' => 'text/plain' },
    [ env.pretty_inspect ]
  ]
end

app = Rack::Builder.app do

  use Log

  map '/js/' do
    run json
  end

  run txt

end

run app

Здесь класс Log — та самая прослойка, которая будет вызываться при любом запросе страницы. А приложений — два: одно генерирует JSON-представление, а другое, как и раньше простой текст. Как видим, прослойка подключается методом use, а приложение для определенного пути — в блоке метода map. Кстати, внутри этого блока можно добавлять и отдельные прослойки, если они нужны только там, а не глобально.

В результате вызова Rack::Builder.app мы получаем комбинированное rack-приложение, которое и запускаем последней строчкой.

На самом деле, в .ru-файле такое описание избыточно, поскольку его содержимое запускается утилитой rackup уже внутри блока Rack::Bilder, т.е. мы могли бы написать и просто:

use Log

map '/js/' do
  run json
end

run txt

Штатные дополнения

Некоторые задачи при создании веб-приложений встречаются регулярно — практически всегда требуется вести какое-то логирование, отдавать статические файлы и так далее. Гем rack содержит ряд классов для решения таких типовых задач, я не буду на них подробно останавливаться6, лишь перечислю основные.

Приложения:

  • Rack::File отдает статические файлы по заранее заданному пути.
  • Rack::Directory выводит содержимое каталогов и отдает файлы посредством Rack::File.

Прослойки:

  • Rack::Auth::Basic и Rack::Auth::Digest предоставляют простую стандартную HTTP-аутентификацию.
  • Rack::Logger и Rack::CommonLogger обеспечивают логирование.
  • Rack::ConditionalGet и Rack::ETag позволяют уменьшить отдаваемый трафик, управляя заголовками кэширования. К сожалению, они работают с уже сформированным ответом, поэтому экономии вычислительных ресурсов сервера не получится.
  • Rack::ContentLength устанавливает автоматически соответствующий заголовок.
  • Rack::Deflate — сжимает передаваемые данные, автоматически определяя, когда это можно и нужно делать.
  • Модуль Rack::Session и определенные внутри него классы Cookie, Memcache и Pool обеспечивают управление сессиями.
  • Rack::Static отдает статические файлы по заранее определенным правилам.

Эти классы и модули решают типовые задачи типовыми и довольно таки прямолинейными методами, каких-то чудес от них ждать не стоит. Зато работают, что называется, «из коробки».

Дополнительно стоит упомянуть о модулях Rack::Mime и Rack::Utils, которые предоставляют различные вспомогательные средства для веб-разработки.

Итого

Rack представляет собой очень простой объектный интерфейс для взаимодействия с веб-сервером, с одной стороны — абстрагируя разработчика от мелких деталей, с другой — не навязывая какой-то определенной архитектуры приложения. Стоит ли его использовать вместо фрейморков — зависит от задачи и личной склонности программиста. Не надо только забывать, что это «голый каркас», и большинство даже типовых задач придется решать «с нуля», тогда как в Ruby on Rails, например, они уже решены.

  1. Model-View-Controller — широко распространенный шаблон проектирования приложений. 

  2. Полные тексты примеров размещены на GitHub — https://gist.github.com/shikhalev/8409eef4a4e66a003670

  3. См. коды ответов, например, в Википедии — https://ru.wikipedia.org/wiki/Список_кодов_состояния_HTTP

  4. Список методов см. в документации — https://www.rubydoc.info/gems/rack/Rack/Request

  5. https://www.rubydoc.info/gems/rack/Rack/Response

  6. За подробностями стоит обратиться к документации — http://www.rubydoc.info/gems/rack/