Создание собственного промежуточного программного обеспечения в приложении Rails — это не то, что делают или должны делать многие разработчики. Но когда вы хотите добавить пользовательские заголовки, аутентификацию или какое-то ограничение скорости, вам определенно следует подумать об использовании промежуточного ПО. Я хочу показать вам, как вы можете реализовать небольшой ограничитель скорости, используя собственное промежуточное ПО.
Промежуточное ПО в Rails
Промежуточное ПО в Rails — это фактически промежуточное ПО в Rack. Rack соединяет веб-фреймворк (например, Rails) с веб-сервером (например, Puma). Он отвечает за обертку данных http, чтобы представить их в виде одного вызова метода, где вы можете работать с запросами и ответами. Этот единственный вызов метода является тем местом, где вы размещаете логику промежуточного ПО. Вы можете рассматривать промежуточное ПО как вход и выход вашего Rails-приложения.
Каждая часть промежуточного ПО получает текущее окружение от предыдущего промежуточного ПО (если таковое имеется) и считывает и/или изменяет это окружение, чтобы передать его следующему промежуточному ПО (опять же, если таковое имеется).
В ванильном приложении Rails, созданном с помощью rails new
, на самом деле уже есть много промежуточного ПО по умолчанию. Вы можете перечислить это промежуточное ПО, выполнив ./bin/rails middleware
. Для нового приложения Rails 7 это выглядит следующим образом:
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActionDispatch::ServerTiming
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use Sentry::Rails::CaptureExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use Sentry::Rails::RescuedExceptionInterceptor
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
use ActionDispatch::Static
run MyApp::Application.routes
Давайте рассмотрим небольшой фрагмент стандартного middleware под названием ActionDispatch::RequestId
(источник). В комментариях к этому файлу уже есть хорошее объяснение его внутренней работы.
# https://github.com/rails/rails/blob/81c5c9971abe7a42a53ddbfede2683081a67e9d1/actionpack/lib/action_dispatch/middleware/request_id.rb
require "securerandom"
require "active_support/core_ext/string/access"
module ActionDispatch
# Makes a unique request id available to the +action_dispatch.request_id+ env variable (which is then accessible
# through ActionDispatch::Request#request_id or the alias ActionDispatch::Request#uuid) and sends
# the same id to the client via the X-Request-Id header.
#
# The unique request id is either based on the X-Request-Id header in the request, which would typically be generated
# by a firewall, load balancer, or the web server, or, if this header is not available, a random uuid. If the
# header is accepted from the outside world, we sanitize it to a max of 255 chars and alphanumeric and dashes only.
#
# The unique request id can be used to trace a request end-to-end and would typically end up being part of log files
# from multiple pieces of the stack.
class RequestId
def initialize(app, header:)
@app = app
@header = header
end
def call(env)
req = ActionDispatch::Request.new env
req.request_id = make_request_id(req.headers[@header])
@app.call(env).tap { |_status, headers, _body| headers[@header] = req.request_id }
end
private
def make_request_id(request_id)
if request_id.presence
request_id.gsub(/[^w-@]/, "").first(255)
else
internal_request_id
end
end
def internal_request_id
SecureRandom.uuid
end
end
end
Методы initialize
и call
являются ключевыми методами. Метод initialize
вызывается только один раз при загрузке приложения и может быть использован для настройки промежуточного ПО и установки данных, которые будут общими для всех запросов. Наиболее важной частью является определение метода call
. Он вызывается при каждом запросе клиента к приложению Rails. Метод call
получает текущее окружение Rack в виде хэша Ruby и должен возвращать массив из трех элементов с кодом состояния, заголовками ответа и, по желанию, телом. Этот массив генерируется при выполнении @app.call(env)
. Из этих двух методов вы, вероятно, будете работать большую часть времени с call
.
В приведенном выше промежуточном ПО RequestId
окружение (env
) изменяется путем добавления заголовка с идентификатором запроса. Этот идентификатор определяется в частных методах класса middleware. Имя заголовка берется из аргумента ключевого слова header
в initalize
. Это имеет смысл: имя заголовка не меняется в течение жизни приложения, но содержимое заголовка меняется при каждом запросе. Вот почему имя заголовка задается в initalize
(выполняется один раз при загрузке), а значение — в call
(выполняется при каждом новом запросе).
Наше собственное промежуточное ПО
Теперь, когда мы понимаем немного больше о промежуточном ПО, давайте создадим свое собственное. Мы создадим промежуточное ПО, реализующее простой ограничитель скорости с помощью Kredis. Kredis — это хорошая обертка вокруг Redis. Кроме того, я предполагаю, что у вас есть базовые знания о Rails.
Базовая настройка
Убедитесь, что у вас есть рабочий сервер Redis. Запустите redis-cli
в вашей оболочке, чтобы проверить, запущен ли Redis. Должно появиться что-то вроде 127.0.0.1:6379>
.
Начните с создания нового приложения Rails и добавьте гем Kredis.
$ rails new myratelimiter
$ cd myratelimiter
$ ./bin/bundle add kredis
$ ./bin/rails kredis:install
Проверьте config/redis/shared.yml
, чтобы убедиться, что конфигурация соответствует вашему серверу Redis. Если у вас есть стандартная настройка Redis, скорее всего, вам не придется ничего менять.
Нам нужно что-то запросить у нашего Rails-приложения, поэтому создайте контроллер и модель с помощью ./bin/rails g scaffold User name email
и запустите .bin/rails db:migrate
.
Создайте пять пользователей, выполнив 5.times {|i| User.create(name: "Name #{i}", email: "user#{i}@example.com")}
в консоли Rails.
Запустите сервер Rails и выполните команду curl http://localhost:3000/users.json
. Вы должны увидеть json, содержащий пять пользователей.
Ограничитель скорости
У нас есть базовое api Rails-приложение, которое показывает пользователей через /users.json
. Проблема в том, что потребители нашего api могут продолжать отправлять запросы, поэтому пришло время реализовать ограничение скорости.
Требования к нашему ограничителю скорости просты для простоты:
- потребитель api не может отправлять более 50 запросов в течение 5 минут
- потребитель получает http-статус
429 - Too many requests
при достижении лимита - сервер всегда отвечает с 3 заголовками:
- Rate-Limit-Reached: булево значение, которое сообщает потребителю, достигнут ли предел скорости.
- Rate-Limit-Requests-Left: число, которое сообщает, сколько запросов осталось в текущем временном окне.
- Rate-Limit-Requests-Reset: время, указывающее, когда будет сброшен ограничитель скорости.
Давайте начнем с нескольких тестов для первых двух требований:
# test/controllers/users_controller_test.rb
require 'test_helper'
class UsersControllerTest < ActionDispatch::IntegrationTest
setup do
Kredis.redis.flushall
@user = users(:one)
end
test 'should not rate limit normal use' do
49.times do
get users_url
assert_response :success
end
end
test 'should rate limit abnormal use' do
50.times do
get users_url
assert_response :success
end
get users_url
assert_response :too_many_requests
end
end
Если вы запустите набор тестов, то увидите, что второй тест не работает. Конечно, это правильно, поскольку мы еще не реализовали ограничитель скорости, так что давайте продолжим.
Создайте lib/middleware/rate_limiter.rb
с этим классом:
# lib/middleware/rate_limiter.rb - first draft
class RateLimiter
def initialize(app)
@app = app
end
def call(env)
@app.call(env)
end
end
Добавьте это промежуточное ПО в ваше приложение в config/application.rb
:
...
require_relative '../lib/middleware/rate_limiter'
...
module Myratelimiter
class Application < Rails::Application
...
config.middleware.insert_before 0, RateLimiter
end
end
Вот и все. Теперь у вас есть собственное промежуточное ПО. На данный момент оно не делает многого, и запуск набора тестов все еще дает те же самые неудачные тесты.
Пора реализовать первые два требования. Я покажу вам полный класс промежуточного ПО для этих требований и пройдусь по нему шаг за шагом.
# lib/middleware/rate_limiter.rb - second draft
class RateLimiter
MAX_PER_WINDOW = 50
WINDOW_SIZE = 1.minute
def initialize(app)
@app = app
end
def call(env)
@req = ActionDispatch::Request.new(env)
rate_limited? ? response_limit_reached : response_normal
end
private
def rate_limited?
request_counter.value >= MAX_PER_WINDOW
end
def kredis_key
"rate_limiter:#{remote_ip}"
end
def request_counter
# Only set the expires_in when the key is created
# for the first time. Otherwise expires_in is
# reset each time the key is accessed.
if key_exists?
Kredis.counter(kredis_key)
else
Kredis.counter(kredis_key, expires_in: WINDOW_SIZE)
end
end
def key_exists?
Kredis.redis.exists(kredis_key).positive?
end
def remote_ip
# No need to re-invent logic to calculate the remote IP. It's already
# available to use in ActionDispatch.
ActionDispatch::RemoteIp::GetIp.new(@req, false, []).calculate_ip
end
def response_normal
# Give back a normal response after incrementing the counter
request_counter.increment
@app.call(@req.env)
end
def response_limit_reached
# We can also just put 429 here but this is more explicit.
status_code = Rack::Utils::SYMBOL_TO_STATUS_CODE[:too_many_requests]
[status_code, {}, []]
end
end
Если вы запустите тестовый пакет сейчас, он пройдет. Если вы хотите проверить это самостоятельно, установите MAX_PER_WINDOW
на 3 и запустите curl -v http://localhost:3000/users.json
четыре раза. Первые три раза сервер отвечает как обычно. В четвертый раз он возвращает пустое тело с кодом состояния 429 — Слишком много запросов.
Что происходит в нашем недавно созданном промежуточном ПО? При каждом запросе вызывается метод call
, где инстанцируется класс ActionDispatch::Request
. С этим объектом нам легче работать с данными в env
. Затем мы проверяем, достигнут ли предел скорости, и возвращаем соответствующий ответ.
В rate_limited?
вызывается метод request_counter
, который приводит нас к части, где используется Kredis. Мы используем Kredis для инициализации счетчика в Redis. Kredis «инстанцирует» значение из Redis. Другими словами, когда вы вызываете Kredis.counter("mykey")
, у нас есть объект, который указывает на значение Redis под mykey
. На этом объекте мы можем вызвать #increment
, который увеличивает текущее значение в Redis. Как вы видите, мы проверяем, существует ли ключ Redis, чтобы мы могли решить использовать вызов с expires_in
. Каждый раз, когда вы вызываете #counter
с expires_in
, таймер истечения сбрасывается. Мы не хотим этого, потому что тогда срок действия ключа никогда не истечет. Ознакомьтесь с документацией Kredis для получения дополнительной информации о Kredis.
Нам нужно каким-то образом идентифицировать потребителя (или посетителя, если хотите). Сессия не подходит, поскольку у нас пока нет к ней доступа, и ее легко обойти, просто удалив cookies на стороне клиента. Может быть, IP-адрес? Его не так просто подделать, и вам придется приложить большие усилия, чтобы изменить IP при каждом запросе. С другой стороны, публичный IP часто разделяется между многими пользователями за каким-нибудь маршрутизатором. Пока мы используем IP-адрес, но в реальном мире вы, вероятно, будете идентифицировать пользователя с помощью токена. В методе kredis_key
ключ составляется из remote_ip
. В remote_ip
мы используем логику, которую Rails уже предоставляет из ActionDispatch.
Если лимит скорости не достигнут, вызывается response_normal
, где счетчик запросов в Redis увеличивается и управление передается обратно Rails с помощью @app.call(@req.env)
. Но если лимит скорости достигнут, вызывается response_limit_reached
. В этом методе мы возвращаем массив из трех элементов, содержащий код статуса http, дополнительные заголовки и тело. Последние два элемента пусты, так как кода состояния «слишком много запросов» достаточно, чтобы сообщить потребителю, что лимит скорости достигнут.
Теперь у вас есть рабочий ограничитель скорости в качестве промежуточного программного обеспечения. Возможно, он слишком прост для реального приложения, но он выполняет свою работу.
Дополнительные заголовки
Нам все еще нужно выполнить третье требование: три заголовка, содержащие информацию об ограничителе скорости.
Начнем с первого заголовка, который называется Rate-Limit-Reached
. Сначала напишем новый тест:
class UsersControllerTest < ActionDispatch::IntegrationTest
...
test 'should return correct Rate-Limit-Reached header' do
get users_url
# You may be tempted to use `refute` but we want to make sure it really is false
# instead of falsey like nil.
assert_equal false, response.headers['Rate-Limit-Reached']
50.times { get users_url } # Trigger rate limiter
assert_equal true, response.headers['Rate-Limit-Reached']
end
...
end
Запустите набор тестов, и он должен завершиться неудачей. Добавьте/замените следующие методы:
class RateLimiter
...
def rate_limit_headers
{
'Rate-Limit-Reached' => rate_limited?
}
end
def response_normal
# Give back a normal response after incrementing the counter
request_counter.increment
@app.call(@req.env).tap do |_status, headers, _body|
rate_limit_headers.each { |key, value| headers[key] = value }
end
end
def response_limit_reached
# We can also just put 429 here but this is more explicit.
status_code = Rack::Utils::SYMBOL_TO_STATUS_CODE[:too_many_requests]
[status_code, rate_limit_headers, []]
end
...
end
Теперь набор тестов пройден, и мы реализовали первое требование к заголовку.
Переходим к следующему заголовку под названием Rate-Limit-Requests-Left
. Добавьте следующее в набор тестов:
class UsersControllerTest < ActionDispatch::IntegrationTest
...
test 'should return correct Rate-Limit-Left header' do
get users_url
assert_equal 49, response.headers['Rate-Limit-Left']
50.times { get users_url } # Trigger rate limiter
assert_equal 0, response.headers['Rate-Limit-Left']
end
...
end
Запустите набор тестов еще раз, чтобы убедиться, что он не работает, и добавьте/замените следующие методы:
class RateLimiter
...
def rate_limit_headers
{
'Rate-Limit-Reached' => rate_limited?,
'Rate-Limit-Left' => requests_left
}
end
def requests_left
MAX_PER_WINDOW - request_counter.value
end
...
end
Это позволит получить проходящий набор тестов. Теперь мы реализовали первые два необходимых заголовка.
Давайте закончим с третьим заголовком Rate-Limit-Requests-Reset
. Снова начнем с нового теста:
class UsersControllerTest < ActionDispatch::IntegrationTest
...
test 'should return correct Rate-Limit-Reset header' do
get users_url
seconds_left = Time.parse(response.headers['Rate-Limit-Reset']) - Time.now.utc
assert seconds_left.between?(1.minute - 5.seconds, 1.minute)
50.times { get users_url } # Trigger rate limiter
seconds_left = Time.parse(response.headers['Rate-Limit-Reset']) - Time.now.utc
assert seconds_left.between?(1.minute - 5.seconds, 1.minute)
end
...
end
И логика, которую нужно добавить/заменить в промежуточном ПО:
class RateLimiter
...
def rate_limit_headers
{
'Rate-Limit-Reached' => rate_limited?,
'Rate-Limit-Left' => requests_left,
'Rate-Limit-Reset' => reset_time
}
end
def reset_time
ttl = Kredis.redis.ttl(kredis_key) # Ask Redis how long the key has left to live
(ttl >= 0 ? ttl.seconds.from_now : Time.zone.now).iso8601 # Create a datetime from TTL
end
...
end
Запустите набор тестов снова, все тесты должны пройти.
Подведение итогов
Теперь вы создали промежуточное программное обеспечение для реализации ограничителя скорости с набором тестов. Вы можете найти исходный текст приведенного выше приложения Rails здесь.
Основной целью этой статьи было показать вам, как разрабатывать промежуточное ПО, а не как разработать самый сложный в мире ограничитель скорости. Но я надеюсь, что она дала вам немного вдохновения!
Примечания
- Набор тестов немного медленный, потому что мы часто запускаем блоки
n.times { get users_url }
… Я предоставлю вам придумать рефактор набора тестов, чтобы увеличить производительность. Небольшой совет: подумайте о запуске двух циклов ответов (нормального и ненормального) и кэшировании результатов, чтобы каждый тест мог использовать кэшированные ответы. - Есть один важный случай, не протестированный в наборе, это момент, когда лимит запросов был сброшен. Обычно вы можете использовать
travel
, но TTL устанавливается в Redis, а не в приложении Rails. Вы можете доработать промежуточное ПО, чтобы оно принимало значение конфигурации для размера окна и устанавливало его на несколько секунд, чтобы его можно было протестировать.