Иногда нужен сервис на двадцать маршрутов: приём вебхука, витрина документации, внутренняя панель, проверка идеи. Тянуть для этого полноценный Rails всё равно что заказывать грузовик за хлебом. Sinatra, набравший более 360 миллионов загрузок с момента появления, за один файл с десятком строк поднимает работающий сервис на Ruby, а по мере роста спокойно дорастает до модульного приложения с десятками файлов. Актуальная версия 4.2.1 работает поверх Rack 3, а под капотом у неё mustermann для шаблонов путей, tilt для шаблонов страниц и rack-protection от типовых атак, каждая шестерёнка заменяема.

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

Стиль Sinatra выражает маршрут строкой и блоком кода

Весь фреймворк держится на одной идее: HTTP-запрос описывается методом и путём, а ответ пишет руби-блок. Классическое приложение умещается в четыре строки и запускается командой ruby app.rb прямо из терминала.

require 'sinatra'

get '/hello' do
  'Привет из Sinatra'
end

Запуск поднимает локальный сервер на порту 4567, страница отвечает мгновенно, а возврат строки из блока это уже тело ответа, статус 200 прилагается по умолчанию. Ничего объявлять, конфигурировать и наследовать не требуется, в этом и есть соль микрофреймворка. Локальный подъём подойдёт любому установленному серверу с поддержкой Rack, чаще всего это Puma, и одна командная строка ruby app.rb по-прежнему стартует сервис целиком.

У больших фреймворков за простоту hello world прячется раскладка на десятки каталогов и соглашения на сотню страниц. У Sinatra наоборот: заявленная простота не растёт в скрытую сложность, каждый слой появляется, когда его осознанно включили. Остановить запрос досрочно помогает halt со статусом, телом и заголовками: короткий отказ без вложенных условий читается лучше любой лестницы if.

Модульный стиль наследует Sinatra Base для крупных проектов

Классический стиль держит состояние глобально и хорош для скриптов и прототипов. Как только файл переваливает за сотню строк или нужен второй сервис в одном процессе, переходят к модульному стилю: класс наследует Sinatra::Base, маршруты живут внутри, а каждое приложение становится обычным руби-объектом, который можно комбинировать.

require 'sinatra/base'

class Billing < Sinatra::Base
  set :root, File.dirname(__FILE__)

  get '/invoices/:id' do
    invoice = Invoice.find(params[:id])
    halt 404 unless invoice
    json invoice
  end

  post '/invoices' do
    invoice = Invoice.create(payload)
    status 201
    json invoice
  end
end

Запускается такой сервис из config.ru, стандартного файла Rack-протокола, и любой сервер, понимающий Rack, поднимет его. Файл из двух строк говорит сам за себя:

require './billing'
run Billing

Пара run в одном config.ru с map для префиксов собирает из нескольких классов единую точку входа, где биллинг висит на одном пути, админка на другом:

map '/billing' do
  run Billing
end

map '/panel' do
  run Panel
end

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

Маршруты принимают параметры фильтры и условия соответствия

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

get '/search/:category/*' do
  terms = params['splat'].first.split('/')
  "раздел: #{params[:category]}, поиск по: #{terms.join(', ')}"
end

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

get '/legacy', agent: /MSIE/ do
  redirect '/modern'
end

Для работы с JSON-телом запроса читают request.body и разбирают его явно, а общий формат ошибок API собирает блок error по классу исключения:

post '/invoices' do
  data = JSON.parse(request.body.read)
  invoice = Invoice.create(data)
  status 201
  json invoice
end

error Invoice::NotFound do
  status 404
  json error: 'счёт не найден'
end

Так единый стиль ответов распространяется на весь сервис без повторения в каждом маршруте. Фильтры before и after выполняются вокруг каждого запроса, в них ставят проверку авторизации, загрузку текущего пользователя и записи в журнал. Когда логика нужна в нескольких маршрутах, её выносят в блок helpers, и методы становятся доступны представлениям.

before '/admin/*' do
  halt 401 unless authorised?
end

helpers do
  def authorised?
    env['HTTP_AUTH_TOKEN'] == ENV['ADMIN_TOKEN']
  end
end

Порядок объявления маршрутов имеет значение: проверяется первый подошедший, поэтому специфичные пути пишут выше общих, и это единственное место, где Sinatra требует дисциплины.

Представления и разметка подключаются через Tilt и каталог views

Ответ в виде строки хорош для API, а для HTML приложение опирается на Tilt, общий интерфейс к шаблонным движкам. ERB идёт из стандартной библиотеки, Haml, Slim и Markdown подключаются гемами, а рендер выбором имени файла с расширением: erb :invoice подхватит views/invoice.erb и обернёт его в layout.erb, если тот найдётся.

get '/invoices/:id' do
  @invoice = Invoice.find(params[:id])
  erb :invoice
end

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

Заголовки кэширования ставятся помощниками etag и last_modified: совпавший отпечаток завершает запрос статусом 304 без передачи тела, и витрина с тяжёлыми страницами худеет на глазах.

get '/reports/:id' do
  @report = Report.find(params[:id])
  etag @report.updated_at.to_i
  erb :report
end

Долгие ответы отдают через stream: блок получает объект-писатель и сбрасывает порции по ходу генерации, что годится для отдачи отчёта по мере готовности и для протокола событий в браузере.

Промежуточное ПО Rack расширяет приложение внешними слоями

Протокол Rack разделяет Ruby-веб на две стороны: приложения с одной, серверы и прослойки с другой. Sinatra и приложение, и потребитель прослоек одновременно. Стек описывается методом use прямо в классе, и запрос проходит по нему сверху вниз, как по конвейеру.

class Panel < Sinatra::Base
  use Rack::Deflater
  use Rack::Static, urls: ['/assets'], root: 'public'

  get '/' do
    erb :dashboard
  end
end

Сжатие ответов, раздача статики, ограничение частоты запросов и разбор кук приходят готовыми гемами, каждая прослойка универсальна и годится любому Rack-приложению, не только Sinatra. Ограничение частоты на старте собирают из простого счётчика в памяти процесса, а при росте переносят его в общее хранилище. Внутри каркаса уже стоит rack-protection, закрывающий типовые атаки с подделкой запросов, и своё подобное поведение отключается точечно, если проект берёт защиту извне. Отдельная строка стека закрывает кросс-доменные запросы: прослойка с нужными заголовками вставляется use и обслуживает браузерные клиенты со сторонних адресов. Гем sinatra-contrib добавляет поверх базовых вещей именованные пространства, согласование формата по заголовку Accept и вставки в общий лэйаут.

Настройка окружений и сессий готовит сервис к продакшену

Конфигурация в Sinatra это не файл настроек, а блоки configure, различающие окружения development, production и test. Внутри ставятся ключи через set, читаются переменные окружения, включаются нужные слои. Продакшен-конфигурацию собирают из нескольких стандартных ключей:

  1. set :sessions включает хранение сессии в подписанной куке без внешнего хранилища, а секрет задаётся ключом session_secret;
  2. set :show_exceptions отключает отладочные страницы с исходником и бэктрейсом в бою;
  3. set :static и set :public_folder управляют раздачей файлов и её каталогом;
  4. set :lock оборачивает запросы в мьютекс для потоков без опыта совместной работы;
  5. set :root задаёт корень приложения, от которого отсчитываются пути к представлениям;
  6. set :port и set :bind задают адрес и порт встроенного сервера при локальном запуске.

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

Тестирование и запуск под Puma на практике

Поскольку приложение это объект с методом call, тесты пишутся через Rack::Test: запрос отправляется к классу без сети и порта, ответ приходит в тест обычной структурой. Спека умещается в несколько строк и работает за миллисекунды, а перед выкаткой живой прогон через curl по локальному адресу подтверждает, что окружение собрано верно.

require 'rack/test'

RSpec.describe Billing do
  include Rack::Test::Methods

  def app
    Billing
  end

  it 'отдаёт счёт по идентификатору' do
    invoice = Invoice.create(total: 150)
    get "/invoices/#{invoice.id}"
    expect(last_response.status).to eq(200)
    expect(JSON.parse(last_response.body)['total']).to eq(150)
  end
end

На бою Sinatra поднимают под Puma, актуальная ветка 8.x, которая идёт рекомендованным сервером и прекрасно дружит с Rack 3. Настройки выносят в файл конфигурации сервера:

# config/puma.rb
workers 2
threads 4, 8
port 3000
environment 'production'

Два воркера с четырьмя-восемью потоками каждый покрывают типовой небольшой сервис, а конкретные числа подбирают по памяти и времени ответа. За Puma ставят обратный прокси, который терминирует соединения и раздаёт статику быстрее, а руби-процесс занимается только своей работой. Для периодических задач хватает Rake, для фоновых очередей подключают Sidekiq, и Sinatra спокойно соседствует с ними, потому что не тянет за собой обязательной экосистемы.

Характерный сценарий каркаса это приём вебхуков, где важны скорость и аккуратность с подписью:

post '/hooks/:source' do
  body = request.body.read
  signature = OpenSSL::HMAC.hexdigest('sha256', ENV['HOOK_SECRET'], body)
  halt 403 unless signature == env['HTTP_X_SIGNATURE']
  Events.enqueue(params[:source], body)
  200
end

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

Границы у микрофреймворка тоже честные. Миграций, ORM и генераторов из коробки нет, их приносят ActiveRecord или Sequel по вкусу, и оба спокойно живут рядом с маршрутом из пяти строк, получая соединение в configure-блоке. Фикстуры для окружения и базы поднимаются в before-хуках спеки, а проверка представлений сводится к содержимому last_response.body. Конвенции над конфигурацией нет вовсе, структуру каталогов команда выбирает сама. Когда приложение дорастает до сложных форм, авторизации на уровне ролей и нескольких команд разработчиков, вопрос перехода на Rails становится разумным, и миграция проходит мягко: Rack-слой, шаблоны и тесты переезжают почти без правок.

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