Типичные ошибки в Rack middleware
В последнее время пришлось вплотную поработать с Rack и стандартными middleware. И хотя сама спецификация Rack весьма ясна и конкретна в ней все равно есть нюансы которые легко можно упустить и понаделать ошибок работая над своим middleware.
Просматривая исходники rack и rack-contrib раз за разом я обнаруживал одни и те же ошибки стабильно кочующие из одного middleware в другой. И здесь я перечислю вот такие стандартные ошибки.
Итак, как же не надо писать middleware.
Thread safety
Не используйте request-scoped instance-переменные, которые живут только при обработке запроса.
Объект каждого middleware создается в приложении только один раз и
обрабатывает все входящие запросы. Если веб-сервер использует
многопоточную модель (например, Puma), то метод middleware call
(который и обрабатывает запрос) может вызваться одновременно в двух
потоках. Объект middleware не должен иметь состояние (в примере ниже
instance-переменную @host
). Если в одном потоке присвоить
instance-переменной одно значение, то второй поток может присвоить
другое. Первый поток еще не завершился и может увидеть второе значение
выставленное другим потоком.
Вот пример такой ошибки в middleware Rack::CommonCookies:
module Rack
# Rack _middleware_ to use common cookies across domain and subdomains.
class CommonCookies
DOMAIN_REGEXP = /([^.]*)\.([^.]*|..\...|...\...|..\....)$/
LOCALHOST_OR_IP_REGEXP = /^([\d.]+|localhost)$/
PORT = /:\d+$/
def initialize(app)
@app = app
end
def call(env)
@app.call(env).tap do |(status, headers, response)|
@host = env['HTTP_HOST'].sub PORT, ''
share_cookie headers
end
end
private
def domain
@host =~ DOMAIN_REGEXP
".#{$1}.#{$2}"
end
def share_cookie(headers)
headers['Set-Cookie'] &&= common_cookie(headers) if @host !~ LOCALHOST_OR_IP_REGEXP
end
def cookie(headers)
cookies = headers['Set-Cookie']
cookies.is_a?(Array) ? cookies.join("\n") : cookies
end
def common_cookie(headers)
cookie(headers).gsub(/; domain=[^;]*/, '').gsub(/$/, "; domain=#{domain}")
end
end
end
(source)
В переменную @host
сохраняют данные для текущего запроса. Далее ее
значение используют в обработке запроса. В корректной реализации
значение host должно передаваться везде как параметр:
def call(env)
host = env['HTTP_HOST'].sub PORT, ''
share_cookie(headers, host)
end
Еще одна рекомендация. Если используете “глобальные” instance-переменные, то надо обеспечить синхронизацию доступа к ним. Это предотвратит race condition. Рассмотрим пример из Rack::LazyConditionalGet:
def initialize app, cache={}
# ...
@cache = cache
end
def call env
# ...
update_cache
# ...
end
def update_cache
# ...
@cache[KEY] = stamp
# ...
end
(source)
Здесь используется переменную @cache
, куда сохраняются ответы
приложения. Эта переменная, назовем ее “глобальной”, разделяется всеми
потоками и если два потока одновременно попытаются обновить кеш, то
может возникнуть race condition и данные могут потеряться. По-умолчанию
в качестве кеша используется объект Hash
, а метод []=
для него не
атомарный - поэтому операции с кешом нужно синхронизировать. Несмотря на
то, что кеш может обеспечивать thread-safety сам, здесь, думаю, это
ответственность именно middleware. Например, можно использовать классы
Mutex
или Monitor
для пессимистичной блокировки:
def initialize app, options = {}
@mutex = Mutex.new
# ...
end
def call
# ...
@mutex.synchronize do
@cache[KEY] = stamp
end
# ...
end
Заголовки запроса с префиксом HTTP_
По спецификации все заголовки (на самом деле почти все) приходят в
верхнем регистре и с приставкой HTTP_, например HTTP_ACCEPT_ENCODING.
Надо помнить об этом и не использовать канонические имена заголовков
(такие как Accept-Encoding). Такую ошибку, например, сделали в
Rack::ExpectationCascade - в env
никогда не придет ключ Expect, а
только HTTP_EXPECT:
Expect = "Expect".freeze
def call(env)
env[Expect] != ContinueExpectation
# ...
env.delete(Expect)
end
(source)
Забытый rewind для rack.input
В объекте запроса env
в параметре rack.input
приходит тело запроса.
Это IO-like объект, который можно “прочитать” вызывая методы each
,
read
или gets
и перемотать в начало методом rewind
. Так вот, если
вы прочитали что-то из тела запроса, то всегда надо отматывать его
назад, ведь и приложение и другие middleware могут тоже работать с
телом запроса. А если прочитать тело запроса и не вызвать метод rewind
то все последующие попытки прочитать данные вернут только пустую строку.
В этом примере в Rack::JSONBodyParser читают тело запроса и отматывают его назад:
body = env[Rack::RACK_INPUT]
body_content = body.read # ...
body.rewind # somebody might try to read this stream
(source)
Статус ответа - это не Integer
Не используйте статус ответа будто это целое число. Спецификация
требует, чтобы статус имел только метод to_i
. Приложение может вернуть
строку или какой-то совсем кастомный класс, который при вызове метода
to_i
вернет уже числовое значение.
def call(env)
# ...
status, headers, body = @app.call(env)
if status == 200
# ...
end
(source)
Согласно спецификации нужно всегда приводить статус к целому числу:
status.to_i == 200
Заголовки ответа - это не Hash
Не используйте объект с заголовками ответа как Hash
. Не вызывайте
методы []
, []=
, has_key
или merge
ведь спецификация требует от
объекта заголовков только реализовать метод each
.
Вот несколько примеров такой ошибки в Rack::CSSHTTPRequest и Rack::Cors.
Здесь с headers
работают как с Hash
‘ом, выставляя заголовки
Content-Length и Content-Type.
def call(env)
status, headers, response = @app.call(env)
# ...
modify_headers!(headers, response)
# ...
end
def modify_headers!(headers, encoded_response)
headers['Content-Length'] = encoded_response.bytesize.to_s
headers['Content-Type'] = 'text/css'
nil
end
(source)
А вот здесь заголовки headers
мержат с другим Hash
‘ом add_headers
,
что не корректно и вызовет исключение, если headers
окажется не
объектом класса Hash
:
def call(env)
# ...
status, headers, body = @app.call env
# ...
if add_headers
headers = add_headers.merge(headers)
# ...
end
(source)
Регистр заголовков ответа
Не полагайтесь на формат имени заголовка в ответе, например Content-Type, Vary итд.
Спецификация протокола HTTP определяет, что имя заголовка - case-insensitive, т.е. и заголовок content-type и CONTENT-TYPE совершенно корректны. А спецификация Rack не накладывают никаких дополнительных ограничений на имя заголовка ответа.
def call env
# ..
status, headers, body = @app.call env
# ..
headers['Last-Modified'] = cached_value
# ..
end
(source)
Если приложение в примере выше вернет заголовок 'last-modified'
, то
headers['Last-Modified'] = cached_value
добавить дублирующее значение:
{
'last-modified' => '...',
'Last-Modified' => '...'
}
И что именно вернется приложению уже не известно, так как это зависит от веб-сервера. Сервер может вернуть как оба заголовка, так и удалить один из них.
Корректный способ работы с заголовками ответа - это использовать класс
Rack::Response
. Он решает обе проблема - и позволяет работать с
заголовками как с Hash
‘ом и игнорирует регистр имен заголовков:
response = Rack::Response.new([], 204, {'Content-Type' => 0})
response['Content-Type'] # => 0
response.headers['Content-Type'] # => 0
response.finish => [status, headers, body]
Можно также использовать приватный класс Rack::Utils::HeaderHash
. Под
капотом Rack::Response
тоже использует Rack::Utils::HeaderHash
для
работы с заголовками.
status, headers, body = @app.call(env)
headers = Utils::HeaderHash[headers]
Headers['Content-Type'] # => 0
Вызывайте close для оригинального тела ответа
Если middleware игнорирует тело ответа приложения и отдает свой ответ (например при conditional get), всегда закрывайте body из ответа приложения. Это может быть файл или другой IO-объект, который нужно закрыть и его дескриптор не утечет.
Спецификация Rack требует, чтобы вызвали метод close
, если объект тела
ответа его поддерживает. Обычно его вызывает уже сам веб-сервер после
того, как отошлет тело ответа клиенту. Но если body из ответа
приложения игнорируется и возвращается совсем другой ответ, то
middleware должно вызвать метод close
самостоятельно.
Обычно для этого используют вспомогательный класс Rack::BodyProxy
,
который вызывает блок кода когда веб-сервер вызывает close
для него
самого:
def call(env)
# ...
status, headers, Rack::BodyProxy.new([]) do
body.close if body.respond_to? :close
end
end
(source)
Тестируйте с Rack::Lint
Всегда тестируйте ваш middleware. Не только простыми unit-тестами,
но и используя middleware Rack::Lint. Rack::Lint проверяет ваше
middleware на совместимость со спецификацией Rack и может поймать
целый класс ошибки. Приведу пример из тестов в gem‘е rack
:
def conditional_get(app)
Rack::Lint.new Rack::ConditionalGet.new(app)
end
it "set a 304 status and truncate body when If-Modified-Since hits" do
timestamp = Time.now.httpdate
app = conditional_get(lambda { |env|
[200, { 'Last-Modified' => timestamp }, ['TEST']] })
response = Rack::MockRequest.new(app).
get("/", 'HTTP_IF_MODIFIED_SINCE' => timestamp)
response.status.must_equal 304
response.body.must_be :empty?
end
(source)
В тесте строиться цепочка middleware, которая состоит из Rack::Lint, Rack::ConditionalGet и самого приложения:
Rack::Lint.new(
Rack::ConditionalGet.new(
lambda { |env| [200, { 'Last-Modified' => timestamp }, ['TEST']] }
)
)
Используйте тестовые helper’ы
В дикой природе встречаются несколько подходов к тестированию middleware:
- голые unit-тесты
- тесты с использованием
Rack::MockRequest.env_for
- тесты с использованием
Rack::MockRequest.new
Итак, голый unit-тест выглядит примерно так:
specify "exists and sets X-Runtime header" do
app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello!"] }
status, headers, body = Rack::Runtime.new(app).call({})
_(headers['X-Runtime']).must_match /[\d\.]+/
end
Передается параметром env
пустой Hash
, затем вызывается метод call
на middleware и в результате получаем массива из трех элементов -
статус, заголовки и тело ответа.
Это абсолютно корректный подход, но в нем есть один недостаток - он не
работает с Rack::Lint, так как там проверяется в том числе и сам запрос
(объект env). Запрос должен содержать несколько обязательных параметров,
таких как REQUEST_METHOD
, SERVER_NAME
, QUERY_STRING
или
rack.input
.
Чтобы получить полноценный объект env
, который удовлетворит
Rack::Lint
, можно использовать класс Rack::MockRequest
из состава
gem‘а rack
. И приведенный выше тест будет выглядеть вот так:
specify "exists and sets X-Runtime header" do
app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello!"] }
env = Rack::MockRequest.env_for("/")
status, headers, body = Rack::Runtime.new(app).call(env)
_(headers['X-Runtime']).must_match /[\d\.]+/
end
Опять таки, здесь уже все хорошо, но остаются неудобства с проверкой результата. Тело возвращается в виде списка строк. А если используется Rack::Lint, то тело вернется в виде объекта класса Rack::Lint и, чтобы получить тело в виде строки, надо немного поизворачиваться:
_(body.join).must_equal "" # если body это Array
_(body.to_enum.to_a.join).must_equal "" # если body это Rack::Lint
В любом случае намного удобней работать с ответом, если обернуть его в
Rack::MockResponse
:
r = Rack::MockResponse.new(200, { 'Content-Type' => 'application/json'}, ['Hi'])
r.status # => 200
r.body # => "Hi"
r.headers['Content-Type'] # => "application/json"
r['Content-Type'] # => "application/json"
Теперь тест выглядит уже так:
specify "exists and sets X-Runtime header" do
app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello!"] }
env = Rack::MockRequest.env_for("/")
status, headers, body = Rack::Runtime.new(app).call(env)
response = Rack::MockResponse.new(status, headers, body)
_(response['X-Runtime']).must_match /[\d\.]+/
end
Чтобы избавиться от рутинного повторяющегося кода можно вынести его в
отдельный helper. Но такой helper уже есть и реализован в
Rack::MockRequest
:
specify "exists and sets X-Runtime header" do
app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello!"] }
response = Rack::MockRequest.new(Rack::Runtime.new(app)).get('/')
_(response['X-Runtime']).must_match /[\d\.]+/
end
За методом get('/')
стоит и создание полноценного объекта env
вызовом Rack::MockRequest.env_for
и создание объекта класса
Rack::MockResponse
. Так что это самый предпочтительный способ
протестировать middleware.
Не будем забывать о Rack::Lint. Вот так должен выглядеть наш тест в итоге:
specify "exists and sets X-Runtime header" do
app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello!"] }
response = Rack::MockRequest.new(Rack::Lint.new(Rack::Runtime.new(app))).get('/')
_(response['X-Runtime']).must_match /[\d\.]+/
end
Заключение
В рамках ревью gem‘а rack-contrib
все (надеюсь, что все)
перечисленные типы ошибок были исправлены
(фиксы).
Конечно же, еще осталась проблема с тестами - надо дописать недостающие
и порефакторить те что уже есть. Тесты писались разными людьми в разное
время и как следствие в разном стиле.
В отличии от rack-contrib
основной gem rack
находится в очень
хорошем состоянии. Хотя и там я нашел несколько проблемных мест
(фиксы).
Сейчас обсуждают изменения в самой спецификации Rack - по большей части
мелкие упрощения. Например, предлагают, чтобы статус ответа был целым
числом и не надо было его конвертировать вызовом метода to_i
. Чтобы
заголовки ответа возвращались в виде Hash
‘а, а имена заголовков должны
быть только в нижнем регистре - это решает проблему с case-insensitive
именами. Посмотрим чем это закончится.