Как создать промежуточное ПО в приложении Rails

Создание собственного промежуточного программного обеспечения в приложении 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. Вы можете доработать промежуточное ПО, чтобы оно принимало значение конфигурации для размера окна и устанавливало его на несколько секунд, чтобы его можно было протестировать.

Оцените статью
devanswers.ru
Добавить комментарий