Skip to content
← Wszystkie wpisy
4 min czytania Dawid Skłodowski

Rack middleware: co dzieje się przed twoim kontrolerem

Każde żądanie Rails przechodzi przez stos middleware Rack, zanim w ogóle dotrze do twojego kontrolera. Deep-dive o tym, czym jest Rack, jak działa stos middleware i jak napisać własne do logowania, autoryzacji i więcej.

Większość programistów Rails pracuje szczęśliwie latami, nigdy nie zastanawiając się, co dzieje się przed dotarciem żądania do akcji kontrolera. Ale jest tam na dole cała warstwa — Rack i stos middleware siedzący na nim — która obsługuje sesje, parsuje parametry, ustawia ciasteczka i kilkanaście innych rzeczy, wszystko zanim twoje def show w ogóle się uruchomi. Zrozumienie jej zamienia klasę „magii” w coś, co możesz zobaczyć, debugować i rozszerzać. Oto czym jest Rack i jak go używać.

Rack: kontrakt pod każdą aplikacją webową Ruby

Rack to pięknie mała idea: standardowy interfejs między serwerami webowymi Ruby a frameworkami webowymi Ruby. Aplikacja Rack to cokolwiek, co odpowiada na call, bierze hash środowiska i zwraca tablicę trzech rzeczy: kodu statusu, hasha nagłówków i ciała odpowiadającego na each.

class HelloApp
  def call(env)
    [200, { "Content-Type" => "text/plain" }, ["Hello from Rack"]]
  end
end

To kompletna, uruchamialna aplikacja webowa. env to hash opisujący żądanie — ścieżka, metoda, nagłówki, query string, wszystko. Wartość zwracana to odpowiedź. Ta jedna konwencja jest powodem, dla którego Puma, Unicorn i WEBrick mogą uruchamiać Rails, Sinatrę i twoją trzylinijkową aplikację zamiennie: wszystkie mówią Rackiem. Rails sam, na dole, jest bardzo wyrafinowaną aplikacją Rack.

Middleware: stos opakowań

Middleware to naprawdę potężna część. Kawałek middleware Rack to obiekt, który opakowuje inną aplikację Rack — bierze następną aplikację w konstruktorze, a jego call może coś zrobić przed przekazaniem env w dół i coś z odpowiedzią w drodze w górę:

class RequestTimer
  def initialize(app)
    @app = app
  end

  def call(env)
    started = Time.now
    status, headers, body = @app.call(env)        # wywołaj resztę stosu
    duration = ((Time.now - started) * 1000).round
    headers["X-Runtime-Ms"] = duration.to_s
    [status, headers, body]
  end
end

Ponieważ każde middleware opakowuje następne, komponują się w cebulę: żądanie przechodzi do wewnątrz przez każdą warstwę do aplikacji w rdzeniu, a odpowiedź wraca na zewnątrz przez te same warstwy w odwrotnej kolejności. Każde middleware może zobaczyć i zmodyfikować żądanie w drodze do środka i odpowiedź w drodze na zewnątrz. To struktura, która pozwala przekrojowym sprawom — rzeczom, których potrzebuje każde żądanie — żyć w jednym miejscu zamiast być powtarzanym w każdym kontrolerze.

Rails to middleware aż do dołu

Powodem, dla którego to ma znaczenie dla programistów Rails, jest to, że ogromna część tego, co uważasz za „Rails”, jest faktycznie middlewarem. Możesz zobaczyć cały stos:

$ rake middleware
use Rack::Sendfile
use ActionDispatch::Static
use Rack::Runtime
use ActionDispatch::RequestId
use Rack::MethodOverride
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Head
run YourApp::Application.routes

Czytanie tej listy jest pouczające. Ciasteczka, sesje, flash, parsowanie parametrów, nadpisanie _method pozwalające formularzowi zrobić PATCH — nic z tego nie jest funkcją kontrolera; to middleware, które już się uruchomiło, zanim twoja akcja się wykona. Hash env dociera do twojego kontrolera wstępnie wypełniony przez wszystko powyżej. Gdy raz przeczytasz rake middleware, sporo „magii” Rails zamienia się w „a, to middleware Cookies”.

Pisanie i wstawianie własnego

Dodajesz middleware do stosu w config/application.rb, wybierając, gdzie siedzi względem istniejących:

# config/application.rb
config.middleware.use RequestTimer
config.middleware.insert_before ActionDispatch::Cookies, MaintenanceMode
config.middleware.insert_after Rack::Runtime, RequestLogger

Pozycja ma znaczenie, bo determinuje, co już się uruchomiło. Middleware blisko góry uruchamia się pierwsze w drodze do środka i ostatnie w drodze na zewnątrz, i widzi najsurowsze żądanie; middleware blisko dołu uruchamia się tuż przed twoją aplikacją i widzi wszystko, co dodały górne warstwy. Kilka rzeczy, do których middleware jest właściwym narzędziem:

  • Tryb konserwacji. Sprawdź flagę i zwróć z góry 503, zanim żądanie w ogóle dotknie Railsów — bez kontrolera, bez bazy, po prostu płaska odpowiedź:

    class MaintenanceMode
      def initialize(app); @app = app; end
      def call(env)
        if File.exist?("tmp/maintenance.txt")
          [503, { "Content-Type" => "text/html" }, ["<h1>Zaraz wracamy</h1>"]]
        else
          @app.call(env)
        end
      end
    end
  • Przekrojowe logowanie, metryki i identyfikatory żądań, które powinny dotyczyć każdego żądania jednolicie, kontrolerów i assetów na równi.

  • Odrzucanie złego ruchu wcześnie — blokowanie IP, wymuszanie HTTPS, rate limiting (to dokładnie to, czym jest Rack::Attack) — zanim zużyje jakiekolwiek zasoby aplikacji.

  • Endpointy health-check, które muszą odpowiadać, nawet jeśli aplikacja za nimi się męczy, bo siedzą przed nią.

Zasada przewodnia: middleware jest do spraw dotyczących samego żądania i mających szerokie zastosowanie, zwłaszcza gdy chcesz je obsłużyć przed — albo zamiast — pełnym stosem Rails. Rzeczy potrzebujące twoich modeli domeny albo specyficzne dla jednej akcji należą do kontrolera; rzeczy dotyczące każdego żądania i chcące uruchomić się wcześnie należą do middleware.

Kiedy po nie nie sięgać

Middleware jest na tyle potężne, by je nadużyć. Logika biznesowa tam nie należy — nie ma czystego dostępu do twoich modeli ani kontekstu routingu i staje się trudna do testowania i znalezienia. Jeśli młodszy programista pójdzie szukać „co obsługuje zwroty”, spojrzy w kontrolery i usługi, nie w middleware Rack, więc umieszczenie tam logiki domeny ją ukrywa. Trzymaj middleware do naprawdę przekrojowych spraw na poziomie żądania i trzymaj ich liczbę umiarkowaną — każde middleware uruchamia się na każdym żądaniu, więc rozdęty stos to podatek od wszystkich.

Werdykt

Rack to jedna z najelegantszych abstrakcji w ekosystemie Ruby: trzyelementowy kontrakt pozwalający serwerom, frameworkom i middleware współdziałać oraz model stosu-opakowań czyniący przekrojowe sprawy komponowalnymi. Dla programisty Rails zrozumienie tego opłaca się dwukrotnie — demistyfikuje dużą część tego, co framework robi za ciebie, i daje ci czyste, potężne miejsce na obsługę rzeczy dotyczących każdego żądania, zanim dotrą do twoich kontrolerów. Uruchom rake middleware, przeczytaj stos, napisz jedno małe middleware, a warstwa, która kiedyś była niewidoczna, staje się narzędziem, po które sięgasz świadomie.