מהו ולמה צריך Rack Middleware ביישומי Rails

16/03/2017

הספריה המרכזית בריילס לאימות משתמשים נקראת Devise. היא עובדת בגדול עד שמנסים לעשות דברים ש Devise לא אוהבת ואז היא נשברת כמו דומינו. קבלו סיפור קצר עם קוד שממחיש שלפעמים צריך לצאת מהקופסא בשביל להגיע לפתרון הפשוט.

1. הבעיה (שלי) עם Devise

פרויקט שאני עובד עליו עכשיו יאפשר לכל אחד להעלות מיני-סייט בצורה פשוטה, ולאנשים אחרים להתחבר לאותו מיני-סייט. המערכת מורכבת ממערכת ניהול ראשית ומהמיני-סייטים שאנשים יוצרים, ומשתמשים יכולים להתחבר עם שם משתמש וסיסמא לכל אחד מהסייטים או למערכת הניהול. מטבע הדברים לא נרצה שמשתמש שהתחבר למיני-סייט אחד יהיה מחובר גם לאחרים.

עכשיו Devise בנוי על warden, וזה האחרון מאפשר שמירה של פרטי המשתמשים תחומים לפי מאפיינים, כך שמבחינת warden הפתרון האידאלי יהיה להדביק לכל משתמש מזהה scope שיכלול גם את מזהה המיני סייט ואז ממילא במעבר בין סייטים ה scope יהיה שונה והמשתמש יזוהה כמשתמש אחר. הבעיה? Devise כבר השתלט על שדה ה scope לצרכים שלו. יש אפשרות להיות מחוברים למספר חשבונות במקביל (למשל משתמש רגיל ומנהל מערכת), אבל כל החשבונות צריכים להיות מוגדרים כנתיבים סטטיים בקובץ config/routes.rb. במקרה של סייטים הנתיב עצמו הוא דינמי ומחושב לפי פרמטר.

במילים אחרות devise מאפשר התחברות בתור מספר משתמשים במקביל אבל רק בתנאי שהוא יודע מראש את כל ה scopes האפשריים, ואילו במקרה שלי scope מחושב דינמית ואין אפשרות להגדיר סטטית את כל האפשרויות.

2. אל דאגה, אני יודע לכתוב Rack Middleware

הפתרון הבא לא מושלם אבל מספיק טוב עד שתציעו בתגובות משהו יותר טוב. הרעיון להגדיר Middleware שבודק אם עברת למיני-סייט אחר ואם כן מנתק אותך כך שתוכל להתחבר מחדש עם המשתמש במיני-סייט השני. כך זה נראה בקובץ lib/verify_site_user.rb

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

  def call(env)
    if changed_site?(env)
        env['warden'].logout(:user)
        throw :warden
    end
    @app.call(env)
  end

  private

  def changed_site?(env)
    # check if user moved to a new mini-site
  end
end

בנוסף כדי לגרום לריילס להשתמש ב Middleware החדש יש להוסיף לקובץ config/application.rb את השורה:

config.middleware.use VerifySiteUser

ובראש אותו קובץ להוסיף את שורת הטעינה:

require_relative '../lib/verify_school_user'

3. מבנה ה Middleware

ל Rack Middleware יש גישה לשני דברים: האחד הוא פרמטרי איתחול שאפשר להעביר בפרמטרים נוספים לפקודה config.middleware.use והיא דואגת להעביר אותם כפרמטרים ל initialize של ה Middleware. השני בתוך הפונקציה call הוא המשתנה env. הפונקציה call עצמה נקראת עבור כל בקשה. הפונקציה צריכה להחזיר מערך שכולל את השדות:

 [status, headers, response_body]

אפשר לייצר כזה בעצמכם או להפעיל את @app.call שתגרום לפניה ליישום הריילס שלכם ותחזיר את המערך שזה החזיר. במקרה שלי רציתי להריץ קוד לפני שיישום הריילס מקבל את הבקשה ולכן הקוד הופיע מעל הקריאה. אפשר גם לכתוב קוד אחרי הקריאה או לשלב, כמו בדוגמת ה Logger הבאה:


module Middleware
  class Logger
    def initialize(app, logger)
      @app = app
      @logger = logger
    end

    def call(env)

      headers = env.select {|k,v| k.start_with? 'HTTP_'}
      .map {|pair| [pair[0].sub(/^HTTP_/, ''), pair[1]].join(": ")}
      .sort

      request_params = env['rack.input'].read

      @logger.info "Request: #{env["REQUEST_METHOD"]} #{env["PATH_INFO"]} #{headers} #{request_params}"

      @app.call(env).tap do |response|
        status, headers, body = *response

        @logger.info "Response: #{status}"
        @logger.info "Headers: #{headers}"
        @logger.info "Response:"

        body.each do |line|
          @logger.info line
        end
      end
    end
  end
end

המשתנה env עצמו כולל את הגדרות הסביבה ומספק גישה לכל שדות הבקשה הנכנסת ולמידע ש Middlewares אחרים הוסיפו לפניכם. כאן יש דוגמא לאיך נראה תוכן של משתנה env כזה, אבל באמת שאפשר גם להוסיף נקודת עצירה ולהדפיס אצלכם:

{
  "GATEWAY_INTERFACE" => "CGI/1.1",
  "PATH_INFO" => "/index.html",
  "QUERY_STRING" => "",
  "REMOTE_ADDR" => "::1",
  "REMOTE_HOST" => "localhost",
  "REQUEST_METHOD" => "GET",
  "REQUEST_URI" => "http://localhost:3000/index.html",
  "SCRIPT_NAME" => "",
  "SERVER_NAME" => "localhost",
  "SERVER_PORT" => "3000",
  "SERVER_PROTOCOL" => "HTTP/1.1",
  "SERVER_SOFTWARE" => "WEBrick/1.3.1 (Ruby/2.0.0/2013-11-22)",
  "HTTP_HOST" => "localhost:3000",
  "HTTP_USER_AGENT" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:26.0) Gecko/20100101 Firefox/26.0",
  "HTTP_ACCEPT" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
  "HTTP_ACCEPT_LANGUAGE" => "zh-tw,zh;q=0.8,en-us;q=0.5,en;q=0.3",
  "HTTP_ACCEPT_ENCODING" => "gzip, deflate",
  "HTTP_COOKIE" => "jsonrpc.session=3iqp3ydRwFyqjcfO0GT2bzUh.bacc2786c7a81df0d0e950bec8fa1a9b1ba0bb61",
  "HTTP_CONNECTION" => "keep-alive",
  "HTTP_CACHE_CONTROL" => "max-age=0",
  "rack.version" => [1, 2],
  "rack.input" => #<StringIO:0x007fa1bce039f8>,
  "rack.errors" => #<IO:<STDERR>>,
  "rack.multithread" => true,
  "rack.multiprocess" => false,
  "rack.run_once" => false,
  "rack.url_scheme" => "http",
  "HTTP_VERSION" => "HTTP/1.1",
  "REQUEST_PATH" => "/index.html"
}

4. דברים נוספים שאפשר לעשות עם Rack Middleware

יש אנשים שמאוד אוהבים לפצל את המערכת שלהם להמון תתי מערכות עצמאיות, ואלה בטח מאוד יאהבו (או כבר אוהבים) להשתמש ב Middlewares. קשה להתווכח עם היתרון של קוד שמנותק לגמרי מהמערכת שלנו ומתקשר דרך ממשק מוגדר היטב. מצד שני הייתי נזהר להעביר פונקציונאליות ליבה של המערכת ל Middleware, ועל פניו נשמע לי שאת רוב הדברים יותר קל לעשות בקוד היישום שלכם.

בכל מקרה יש פה בקישור רשימה של המון Rack Middlewares שאנשים אחרים כתבו ושיתפו, שווה לראות אם יש משהו שאתם אוהבים: https://github.com/rack/rack/wiki/List-of-Middleware

והחידה היומית (בעצם שתיים): הצלחתם לראות את הבאג בפתרון ה Middleware שלי? יש לכם פתרון טוב יותר? ספרו בתגובות.