2015-03-06

ריילס: routing

נדבך חשוב בריילס הוא ה routing, המיפוי איזה Action (=מתודה) של איזה Controller תופעל ל URL נתון.
הרכיב שעושה את ה routing נקרא ActionDispatcher.

ה routing מתבצע באפליקציה בקובץ בשם config/routes.rb בעזרת סט פקודות מיוחדות (בעצם: DSL) שמגדיר את ה routes. סט הפקודות הזמין הוא עשיר ומגוון למדי, ולרוע המזל - אינו מתועד בצורה נוחה ללמידה. לא כ"כ באנגלית - ובוודאי שלא בעברית.


התחביר של ה routes הוא סוג של תחביר שקל לנחש את משמעותו, פחות או יותר, בעת קריאה - אך לא-קל לדייק בו בכתיבה או לתפוס את כל הפינות האפשריות. לא מחזה נדיר בריילס.

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

כפי שנראה ה routing של ריילס מבוסס עמוקות על עקרונות ה REST - ניתן לקרוא בקצרה על עקרונות ה REST בפוסט הזה על REST או בפוסט על HTTP.
לפני ריילס 3, הגדרת ה routes הייתה מסורבלת למדי. אני מתעלם מתחביר זה לחלוטין, ומתמקד במה שזמין בריילס 3 ו 4 (או ליתר דיוק: מתייחס למצב בריילס 4.2).





האנטומיה של route


הנה דוגמה ל route טיפוסי:

  1. HTTP verb / method
  2. pattern של URL יחסי, המכיל בתוכו:
  3. segment key (אחד או יותר) - המוגדר כ "symbol" ב path.
    segment key ממפה ארגומנטים שמועברים ב URL (או כ Query String[א]).
  4. יעד המיפוי, בפורמט: "controller#action". שם ה controller מופיע ללא המילה Controller ובאותיות קטנות (בכדי לקצר בכתיבה). Action הוא שם המתודה ב controller שמטפלת באירוע.
  5. רשימת אופציות ל routing. במקרה שלנו יש אופציה אחת בשם "as:" עם ערך של "purchase" (הסבר על אופציה זו - בהמשך).

routes יכולים להיות מוגדרים בצורות שונות, ואף מורכבות יותר - על מבנים אלו נדבר בהמשך.

בריילס 3, היה מקובל להגדיר routes פשוטים בעזרת פקודת match, למשל:

match 'products/:id' => 'products#show', via: :get

via הוא פרמטר שמגביל את ההתאמה ל HTTP verb/method מסוים (אפשר גם לשלוח רשימה - במערך), והוא היה אופציונלי עד גרסה 4 של ריילס. בגרסה 4 זו הפכה לחובה (RuntimeError ייזרק אם לא הוגדרה http method, אפשר להשתמש גם ב any:, אם כי לא מומלץ).

הדרישה להגבלת ה http method נולדה משיקולי אבטחה ואמינות של המוצר. מומלץ תמיד להגדיר HTTP verb יחיד ל route, ולצורך כך נוספו הקיצורים get, post, put וכו' - שהם כתיבה מקוצרת ל <match... via:<method.
מעתה והלאה נעבוד רק איתם, אך כדאי לזכור שבמקור הם קיצור תחבירי ל match ושאת הפרטים על האופציות השונות הזמינות ל route - יש עדיין לחפש בתיעוד של match.

הצורות הבאות, הן צורות מקבילות לחלוטין ל route שהגדרנו למעלה, שקצת פחות מקובלות היום (ישנות, ויותר ארוכות):

match 'products/:id', to: 'products#show', via: :get
match 'products/:id', controller: 'products', action: 'show', via: :get

אני מזכיר אותן, כי ייתכן שתתקלו בפרמטרים של to: ו controller: ב routes - ושתבינו את משמעותם / מקורם.

בקיצור, היום נכתוב את ה route הזה באופן הבא:

get 'products/:id' => 'products#show'

משמעות ההגדרה הזו היא:
אם יש קריאת GET עם URL המתאים לדפוס "<products/<x", קרא ל controller בשם ProductsController (שנמצא בתיקיה app/controllers/) ולמתודה בשם show, ושלח כארגומנט את המחרוזת x כערך של הפרמטר "id:".
את הערך ניתן לקרוא בתוך ה controller בעזרת המתודה params, למשל:

params[:id] # string "x"


כיצד זה עובד?


הנה דוגמת קוד מינימלית לשימוש ב routing, controller, ו view:



  • בקובץ ה routes.rb, הגדרנו route בסיסי - שאתם אמורים כבר להבין
  • ה Controller (הקטן ביותר האפשרי, בערך) מכיל מתודה (= action) בשם show שאליה ה route שהגדרנו יפנה. היא מחפשת במודל את המוצר ושומרת אותו כ product@.
  • כשה Controller יוצר את ה View, ריילס באופן "פלאי" (הסבר) מעתיק את ה instance variables, על ערכיהם, מה Controller (כדוגמת product@) ל View. משם, ניתן לגשת לשדות השונים בתוך ה product.
  • שימו לב לפקודה בשם link_to אותה תראו הרבה ב views של ריילס. היא מייצרת עבורנו link עם הכותרת שהגדרנו ("Show Details") לפעולה מסוימת של ה Controller. כיצד זה עובד? - נסביר בהמשך.


אופציות מתקדמות יותר להגדרת routes


Segment Keys אופציונליים
ממש כמו optional parameters בפונקציה, יש גם Segment Keys אופציונליים ב route. למשל:

get 'products/:id(/:facet)' => 'products#show'

יתאים ל 2 צורות של url, למשל:

http://site.com/products/441
http://site.com/products/441/specifications
במקרה הראשון יהיה ערך רק לפרמטר id: (הערך = '441'), ו facet: יהיה nil.
במקרה השני facet: יהיה שווה 'specifications'

שימו לב לצורה הנפוצה הבאה:

get 'products/:id(.:format)' => 'products#show'

המשמעות שלה היא matching ל url מהנוסח הבא:

http://site.com/products/441.json

כאשר הפרמטר format: מקבל את הערך 'json'.
הנקודה שליד שם הפרמטר מתארת את חלק מה Path.

ספציפית לגבי format:, זהו פרמטר עם התנהגות מיוחדת: פקודת respond_to שבשימוש בתוך ה controllers בודקת את הערך שלו, ולפיו מחליטה כיצד לפעול (האם להחזיר HTML או json - בד"כ).


Redirect
ניתן להגדיר route שיבצע redirect ל URL אחר ברשת. למשל:

get 'products/:id', to: redirect('v2/products/:id')

המילה to: מגיעה מתוך התחביר הישן של match שהזכרנו למעלה.
הערך של to: הוא Rack Endpoint שיכול להיות קוד inline (בשימוש בפונקציות lambda או proc) או שם של אפליקציית Rack אחרת (שיושבת בתיקייה app/metal/), או סתם URL אחר (כמו במקרה שלנו).


Constraints
אופציה זו מאפשרת לנו להציג תנאים נוספים (בדמות RegEx) על ה matching של ה route. למשל:

get 'products/:id' => 'products#show', constraints: {:id => /\d+/}

תתאים רק כאשר ה id: הוא מספר (ולא כולל אותיות, למשל).
בגלל ש constraint הוא כ"כ נפוץ על שדה שנקרא id:, נוצר לכך קיצור מיוחד בריילס. את הדוגמה הנ"ל ניתן לכתוב גם כך:

get 'products/:id' => 'products#show', id: /\d+/

ניתן להשתמש ב constraints על מנת לעשות בדיקת קלט למשתמש - אך זה לא מומלץ!
הכלי הוא יחסית מוגבל, וקובץ ה routing הוא לא בהכרח המקום הנכון לעשות זאת.
הכלל המנחה הוא להוסיף constraint על route, אם יש לכם route אחר שיתאים במקום. למשל:

get 'products/:id' => 'v2/products#show', id: /\d+/
get 'products/:id' => 'v1/products#show'


כלומר: בעבר קיבלתי מוצרים עם id: מכל סוג, ומתישהו (v2) הגבלתי אותם רק למספרים.
אם מופיע בקשה עם id: שאינו מספר - שלח אותה לטיפול ב controller הישן.

ל constraints יש גמישות נוספת מעבר לבדיקת ערכי segment keys, ניתן לקרוא עוד בנושא בפוסט הבא.


Wildcard Segment
אם מבנה ה URL מכיל מידע, ויכול להופיע בווריאציות שונות, ניתן להשתמש ב wildcard segment. כלומר, ה route:
get 'products/*other' => 'products#show'
יתאים ל URL כמו:

http://site.com/products/my/id/366/type/special/

וישלח ל controller משתנה בשם other: שערכו הוא 'my/id/366/type/special'.

ניתן להרכיב Wildcard Segments, עם Segment Keys רגילים. אם אתם זקוקים לעוד מידע בנושא חפשו את המונחים Wildcard Segment או Route Globbing.



רשימת יכולות שכיסינו כאן כנראה מכסה את רוב השימושים הנפוצים.
ניתן לקרוא בתיעוד הרשמי, Rails Routing from the Outside In, על אפשרויות נוספות.






Named Routes


מנגנון ה Named Routes בא לפשט (אפילו יותר) את העבודה עם routes.

כאשר נותנים ל route את השם "abc", ייווצרו שתי מתודות הזמינות לשימוש ב Controllers וה Views: אחת בשם abc_url, והשנייה abc_path.
  • המתודה abc_url היא תייצר url מלא שיתמפה ל route שהגדרנו.
  • המתודה abc_path תייצר את את חלק ה path של ה url, בלי protocol/host/port.

מנגנון ה Routing של ריילס בעצם משמש לשני כיוונים: גם לפענוח URL והתאמתו ל route (ומשם ל controller#action), וגם לצורך generation של URL (או path) שיוביל ל route שהוגדר.

הנה דוגמה:

get 'products/:id' => 'products#show', as: :show_product


תיצור את 2 המתודות show_product_url ו show_product_path על אובייקט ה app שזמין ל controllers וה views.

עכשיו אפשר ליצור קיצור ל path הזה מתוך אחד ה views בעזרת פקודת link_to. הפקודה, בגרסתה הארוכה, מקבלת hash כפרמטר:

link_to "הצג מוצר",
  controller: "products",
  action: "show",
  id: 12

אך בעקבות השימוש ב as:, יש לנו דרך מקוצרת להפעיל אותה:

link_to "הצג מוצר", show_product_url(id: 12)

יש לנו את מתודת העזר show_product_url שחוסכת מאתנו להגדיר Controller ו Action בנפרד. זה גם יותר קצר, וגם קוד שקל יותר לתחזוקה. (למשל: במידה ומשנים את שם ה Controller)


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

אם הערך שאתם רוצים לספק כפרמטר לפונקציית ה abc_url הוא id: - אז אתם יכולים לשלוח את הערך וזהו, בלי hash. למשל:

link_to "הצג מוצר", show_product_url(12)

בד"כ זה לא יהיה ערך עצמו אלא משתנה. למשל product.id, כאשר product הוא המודל שהכנתם ב controller. במקרה כזה ניתן לקצר אפילו יותר ולשלוח פשוט אובייקט:

link_to "הצג מוצר", show_product_url(product)

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


עבור debugging, ניתן להריץ את הפונקציות xxx_url/xxx_path אובייקט ה app (מבלי להריץ את ה view). למשל:

app.show_product_path(12) # '/products/12'



שאלה של סגנון
מה ההבדל בין הפונקציה show_product_url לפונקציה show_product_path? מתי יש להשתמש בכל אחת מהן?

זה בעיקר עניין של סגנון:
  • show_product_url מייצרת Fully Qualified URL (כלומר: URL מלא ועצמאי), כפי שנדרש בתקן ה HTTP בפעולות redirect.
  • show_product_path, מייצרת URL רלטיבי שהדפדפן ידע להפוך אותו למלא בעת הצורך. ה URL קצר יותר ולכן נוח יותר לעבוד עם קבצי ה html שנוצרו. 
אז מה אתם מעדיפים? להיות דקדקנים (url, לעבוד ע"פ התקן) או לא לכתוב קצר (path, הדרך של ריילס)? עניין שלכם.
אני בד"כ מעדיף להיות דקדקן, אבל גם לפני ריילס עבדתי עם URLs יחסיים - וכך נראה לי שאמשיך.

בכל מקרה, כדאי להכיר של helper functions יש עלות של ביצועים. בזמן ריצה הן מחפשות בטבלת ה routing מה שלוקח זמן. קריאה ל link_to עם controller ו action - היא אפילו יותר יקרה.






Scoping


scope הוא מנגנון שעוזר לארגן את ה routes בקובץ ה routes.rb, ולחסוך כמה שורות קוד על הדרך. יש לו הרבה מאוד וריאציות של קיצור - אציג כמה מהעיקריות שבהן.
  # original
  get 'drivers/new' => 'drivers#new', as: :driver_new
  get 'drivers/edit/:id' => 'drivers#edit', as: :driver_edit
  post 'drivers/reassign/:id' => 'drivers#reassign', as: :driver_reassign

  # scope - DRY a bit with controller (1)
  # alternatives: "controller :drivers do ...", "scope :driver do"
  scope controller: :drivers do
    get 'drivers/new' => :new, as: :driver_new
    get 'drivers/edit/:id' => :edit, as: :driver_edit
    post 'drivers/reassign/:id' => :reassign, as: :driver_reassign
  end

  # scope - DRY a bit more with path (2)
  scope path: '/drivers', controller: :drivers do
    get 'new' => :new, as: :driver_new
    get 'edit/:id' => :edit, as: :driver_edit
    post 'reassign/:id' => :reassign, as: :driver_reassign
  end

  # scope - DRY a bit further with default params (3)
  scope '/drivers', controller: :drivers, as: 'driver' do
    get 'new' => :new, as: 'new'
    get 'edit/:id' => :edit, as: 'edit'
    post 'reassign/:id' => :reassign, as: 'reassign'
  end
במקור היו לנו 3 routes שהיינו צריכים להקליד לכל אחד כמה וכמה תווים...
  1. הגדרנו, בעזרת הפקודה scope, לכולם controller אחיד, וכך קיצרנו את הגדרות ה controller#action לשם ה action בלבד. יש עוד 2 דרכים להגיע לתחביר המקוצר הזה (בהערה, השני יכול לבלבל).
  2. הגדרנו path, שמקצר את חלק ה relative URL ב route.
  3. הגדרנו as, שמהווה prefix ל as: של ה routes הספציפיים, והצבנו את ה path כארגומנט הראשון לפונקציה scope, מה שמאפשר לנו לוותר את המפתח path:.
  4. אפשר לוותר על כל המופעים של אותיות a ו e - וריילס ישלים אותם בעצמו על סמך מילון מוכן מראש. סתאאםם.
אם אתם רואים את התוצאה הסופית וחושבים שניתן היה לקצר אותה יותר - אתם צודקים.

עוד פרמטר פופולרי של scope הוא module: :abc שמניח ש:
  1. ה Controller שייך ל Module (שפת רובי) בשם Abc. כלומר Abc::SomeController.
  2. => ה Controller נמצא בתת תיקיה בשם abc/

ישנה פונקציית קיצור בשם namespace שפועלת כך:
  namespace :drivers do
    # routes
  end

  # syntactic sugar of / equivalent to writing:
  scope path: '/drivers', module: :drivers, as: 'driver' do
    # routes
  end



הקיצור האולטימטיבי ליצירת routes


אם אתם מכירים את ריילס, קשה לדמיין שכאן יגמרו הקיצורים. הקיצור "האולטימטיבי" להגדרת routes היא פקודה בשם resources שדחיתי את העיסוק בה עד רגע זה.

כחלק מההתאמה של ריילס למודל ה REST, הוגדר ל Controller סט ה Actions הגנרי הבא:
  • index - הצגת רשימה של אובייקטים
  • create - יצירת אובייקט חדש (מתוך פעולת POST)
  • new - החזרת template ליצירת אובייקט חדש (ללא יצירת האובייקט בפועל). בד"כ מדובר בהחזרת טופס וובי, "יצירת אובייקט חדש", למשתמש.
  • show - הצגת הפרטים של אובייקט מסוים
  • update - עדכון אובייקט מסוים
  • edit - החזרת template לעדכון אובייקט חדש. בד"כ מדובר בהחזרת טופס וובי "עדכון" אובייקט שבעקבותיו תגיע פעולת update.
  • destroy - מחיקת אובייקט מסוים

הפקודה הבאה, תמפה סדרה של routes עבור שבע הפעולות:
resources :products

# syntactic sugar of / equivalent to writing:
get 'products(.:format)' => 'products#index', as: :products # e.g. products_url()
post 'products(.:format)' => 'products#create',
get 'products/new(.:format)' => 'products#new', as: :new_product # e.g. new_product_url(12)
get 'products/:id/edit(.:format)' => 'products#edit', as: :edit_product # e.g. edit_product_url(12)
get 'products/:id(.:format)' => 'products#show', as: :product # e.g. product_url(12)
patch 'products/:id(.:format)' => 'products#update',
delete 'products/:id(.:format)' => 'products#delete'

למיטב הבנתי, ה named routes נוצר רק לפעולות ה get, מכיוון שרק לפעולות אלו ניתן לעשות re-direct בפדפדפן.

שימו לב שאם יש לנו route כמו הבא, שמופיע אחרי שורת ה resources:

get 'products/poll' => 'products#poll'

לעולם לא יגיעו אליו. ריילס תתאים אותו ל products/:id שנוצר בעקבות פקודת ה resources. הדרך היחידה שיוכלו להגיע אליו הוא אם נציב את ה route הזה לפני פקודת ה resources. ההתאמה ל routes נעשית בסדר שבו הם מופיעים בקובץ.

אם אתם רוצים להשתמש בפקודת ה resources עבור רק כמה מהפעולות, אין בעיה. פשוט כתבו:

resources :products, only: [:index, :show, :edit]

או השתמשו בפקודה ההופכית: except:.







קינון routes


קינון של routes מתבצע לרוב בין שני אובייקטים (REST resources) שיש בניהם קשר של שייכות. דוגמה שחוקה: כתבה, והערות לכתבה. מהות הקשר שמומלץ לנהל עבורו routes מקוננים הוא קשר "חזק" של הרכבה (composition), כאשר לאובייקט התלוי (למשל הערה) אין משמעות ללא אובייקט האב. למשל: כאשר מוחקים כתבה, טבעי שמוחקים את ההערות שלה. לא טבעי יהיה, בד"כ, למחוק את אובייקט המחבר - וזו אינדיקציה טובה שלא כדאי לקנן את המחבר תחת כתבה, אפילו שיש ביניהם קשר.

הנה דוגמה לקינון:
resources :articles do
  resources :comments
end

# generates 7 routes for articles + routes such as:
  get 'articles/:article_id/comments' => 'comments#show'
  get 'articles/:article_id/comments/new' => 'comments#new'
  put 'articles/:article_id/comments/:id' => 'comments#update'
... את הרשימה המלאה של ה routes הנוספים שנוצרים ניתן להסיק או לבדוק את הרשימה בתיעוד הרשמי.
כמובן שה routes של article יפנו ל ArticlesController וה routes המקוננים יפנו ל CommentsController. הקינון, חוץ מזה שהוא נוח מבחינת מידול ה REST, מאפשר ל CommentsController לקבל גם את ה article_id:.

עוד כלל הוא לעולם לא לעשות nesting עמוק (כלומר: 2

הנה עוד 2 כלים נפוצים, member ו collection, המאפשרים להוסיף פעולות חדשות:
resources :articles do
  member do
    get :preview
  end
  collection do
    get :search
  end
end

member מוסיף תחת ה resource עוד פעולה, אולי כזו שלא מוגדרת ב default, למשל preview. הפעולה היא כזו שמוגדרת על אובייקט יחיד - ולכן יש צורך במזהה. למשל, במקרה הנ"ל ייווצר ה route הבא:

/articles/:id/:preview

collection הוא דומה מאוד, אבל מגדיר פעולה על סט ה resources, כזו שלא צריכה מזהה. בדוגמה הנ"ל:

/articles/search



בדיקת ה routes בפועל

כדי לבדוק שה routes נכונים, יש בריילס 2 כלים שימושיים:

  • מ command line, בעזרת הפקודה rake routes. ניתן לבדוק Controller ממוקד ע"י שימוש בפרמטר, למשל: rake routes CONTROLLER=articles.
  • כאשר השרת רץ (rails s), ניתן לראות את רשימת ה routes תחת ה path הבא: rails/info/routes/




סיכום


הגדרת ה routing בריילס היא עניין מורכב, שכולל הרבה אפשרויות ודרכי פעולה.
ניסיתי בפוסט זה לסכם את העיקר בקצרה (?), ובצורה קלה לצריכה.

כפי שאתם רואים אני עוד שקוע ריילס - ויש עוד כברת דרך....


שיהיה בהצלחה!




-----

לינקים רלוונטיים

פיצול ה routes לקבצים שונים


-----

[א] ה route:

get 'some_path/:a/:b/:c' => ...

יקבל ערכים זהים עבור 2 ה urls הבאים:

/some_path/xx/yy/zz
/some_path/xx?a=yy&b=zz


אין תגובות:

הוסף רשומת תגובה