תבנית Chain of Responsibility: שרשרת מטפלים שבה כל חוליה מחליטה אם לטפל או להעביר — מימוש מלא
Chain of Responsibility מנתקת את שולח הבקשה מהמקבל שלה בכך שהיא נותנת ליותר מאובייקט אחד הזדמנות לטפל בה. ההגדרה לפי ספר ה-GoF, המשתתפים, מימוש מלא ב-Java ובפייתון על שרשרת אישורי רכש, הווריאציה של צינור מסננים, ומה קורה כשאף חוליה לא מטפלת.
בקשת רכש של 350 שקל מאושרת על ידי ראש הצוות. בקשה של 6,500 עוברת למנהל המחלקה. בקשה של 42,000 מגיעה לסמנכ"ל. העובד שמגיש את הבקשה לא צריך לדעת מי יאשר אותה — הוא מגיש אותה לראש הצוות, וזה מעביר אותה הלאה אם היא מעבר לסמכותו. תבנית Chain of Responsibility — chain of responsibility design pattern — מתארת בדיוק את המבנה הזה בקוד: שרשרת של מטפלים, שכל אחד מהם מחליט אם הבקשה באחריותו, ואם לא — מעביר אותה לחוליה הבאה. השולח מכיר רק את החוליה הראשונה, ואינו יודע מי בסוף יטפל.
הכוונה
ספר ה-Gang of Four מגדיר את הכוונה של Chain of Responsibility כך: להימנע מצימוד בין שולח הבקשה לבין המקבל שלה, בכך שנותנים ליותר מאובייקט אחד הזדמנות לטפל בבקשה. משרשרים את האובייקטים המקבלים ומעבירים את הבקשה לאורך השרשרת עד שאובייקט כלשהו מטפל בה. זו תבנית התנהגות ברמת האובייקט; באתר הזה היא מופיעה תחת תקשורת בין אובייקטים, לצד Mediator ו-Observer.
הדוגמה בספר היא עזרה תלוית הקשר בממשק משתמש: משתמש לוחץ על עזרה מעל כפתור. אם לכפתור יש טקסט עזרה משלו, הוא מציג אותו. אם לא, הבקשה עוברת לתיבת הדו־שיח שמכילה אותו, ומשם לחלון היישום. העזרה שמוצגת היא הספציפית ביותר שקיימת, והכפתור אינו צריך לדעת מה מעליו.
הבעיה: שולח שיודע יותר מדי
בלי התבנית, הקוד שמקבל בקשת רכש מכיל את כל כללי האישור: אם הסכום עד אלף — שלח לראש הצוות; אם עד עשרת אלפים — למנהל; אחרת — לסמנכ"ל. זה עובד, עד שהכללים משתנים. מחלקה אחת מוסיפה דרג ביניים, מחלקה אחרת מוותרת על ראש צוות, ובחודש התקציב הסמכויות משתנות. כל שינוי כזה נוגע בקוד ששולח את הבקשה, למרות שתפקידו רק לשלוח.
הבעיה העמוקה היא שהידע "מי מטפל במה" נמצא במקום הלא נכון. כל מאשר יודע מה הסמכות שלו — זה חלק מהגדרת התפקיד. Chain of Responsibility מחזירה את הידע לשם: כל חוליה יודעת רק את הכלל שלה ואת החוליה הבאה, והמבנה של השרשרת נקבע במקום אחד, בזמן ריצה.
המשתתפים והמבנה
בתרשים המחלקות יש מחלקה מופשטת Handler עם מתודה לטיפול בבקשה, וקשר הרכבה ממנה אל עצמה — ה-successor, החוליה הבאה. מתחתיה מחלקות ConcreteHandler. ה-Client מחזיק הפניה ל-Handler אחד בלבד — ראש השרשרת. בתרשים אובייקטים, בזמן ריצה, רואים רשימה מקושרת: לקוח, ואחריו חוליה, חוליה, חוליה.
| משתתף | תפקיד | בדוגמה שלנו |
|---|---|---|
| Handler | מגדיר ממשק לטיפול בבקשות, ולעיתים מממש את הקישור לחוליה הבאה | Approver |
| ConcreteHandler | מטפל בבקשות שבאחריותו, ניגש לחוליה הבאה, ומעביר אליה את מה שאינו מטפל בו | TeamLead, Manager, Director |
| Client | שולח את הבקשה לחוליה כלשהי בשרשרת, בדרך כלל לראשונה | Main |
מימוש מלא ב-Java
import java.util.List;
// The request travelling along the chain.
final class Purchase {
final String item;
final int amount;
Purchase(String item, int amount) {
this.item = item;
this.amount = amount;
}
@Override public String toString() { return item + " (" + amount + ")"; }
}
// Handler: holds the successor; by default it forwards the request.
abstract class Approver {
private final Approver successor;
protected Approver(Approver successor) { this.successor = successor; }
String handle(Purchase purchase) {
if (successor != null) {
return successor.handle(purchase);
}
return "unhandled: " + purchase;
}
}
// Concrete handlers: handle what is within their limit, forward the rest.
class TeamLead extends Approver {
TeamLead(Approver successor) { super(successor); }
@Override String handle(Purchase purchase) {
if (purchase.amount <= 1_000) {
return "team lead approved " + purchase;
}
return super.handle(purchase);
}
}
class Manager extends Approver {
Manager(Approver successor) { super(successor); }
@Override String handle(Purchase purchase) {
if (purchase.amount <= 10_000) {
return "manager approved " + purchase;
}
return super.handle(purchase);
}
}
class Director extends Approver {
Director(Approver successor) { super(successor); }
@Override String handle(Purchase purchase) {
if (purchase.amount <= 50_000) {
return "director approved " + purchase;
}
return super.handle(purchase);
}
}
public class Main {
public static void main(String[] args) {
// The chain is assembled in one place; the client only sees its head.
Approver chain = new TeamLead(new Manager(new Director(null)));
List<Purchase> requests = List.of(
new Purchase("keyboard", 350),
new Purchase("laptop", 6_500),
new Purchase("server", 42_000),
new Purchase("office renovation", 180_000));
for (Purchase purchase : requests) {
System.out.println(chain.handle(purchase));
}
}
}הפלט:
team lead approved keyboard (350)
manager approved laptop (6500)
director approved server (42000)
unhandled: office renovation (180000)שלושה דברים כדאי לראות כאן. הראשון: הלולאה ב-main אינה יודעת כלום על סכומים או על תפקידים. השני: הוספת דרג — נניח סגן מנהל עם סמכות עד 3,000 — היא מחלקה אחת ושינוי בשורה שמרכיבה את השרשרת, בלי לגעת באף מאשר קיים. השלישי, והחשוב ביותר: השורה האחרונה. אף חוליה לא טיפלה בבקשה, והשרשרת הגיעה לסופה. זו לא תקלה במימוש — זו תכונה של התבנית, ונחזור אליה בסעיף ההשלכות.
המבנה של handle במחלקת הבסיס הוא בדיוק המבנה שבספר: מימוש ברירת מחדל שמעביר הלאה, ומחלקות־בת שדורסות אותו ומחליטות. מחלקת־בת שאינה דורסת את המתודה היא חוליה שקופה שמעבירה הכול.
אותו מבנה בפייתון
class Approver:
def __init__(self, title, limit, successor=None):
self.title, self.limit, self.successor = title, limit, successor
def handle(self, item, amount):
if amount <= self.limit:
return f"{self.title} approved {item} ({amount})"
if self.successor is not None:
return self.successor.handle(item, amount)
return f"unhandled: {item} ({amount})"
chain = Approver("team lead", 1_000, Approver("manager", 10_000, Approver("director", 50_000)))
for item, amount in [("keyboard", 350), ("laptop", 6_500), ("server", 42_000), ("office renovation", 180_000)]:
print(chain.handle(item, amount))בגרסת הפייתון כל החוליות הן מאותה מחלקה, עם סף שונה. זה מספיק כשכל החוליות נבדלות רק בנתון. כשכל חוליה מפעילה לוגיקה שונה לגמרי — אחת בודקת תקציב, אחת בודקת ספק מאושר, אחת בודקת חתימה — חוזרים למחלקה לכל סוג חוליה, כמו בגרסת ה-Java.
וריאציה: צינור שבו כל חוליה פועלת
בהגדרה המקורית, חוליה אחת מטפלת והבקשה נעצרת. בשימוש יומיומי מופיעה לעיתים קרובות וריאציה שבה כל חוליה עושה משהו ואז מחליטה אם להמשיך: מסנני בקשות בשרתי אינטרנט, שבהם מסנן אחד בודק הרשאה, השני רושם ביומן והשלישי דוחס את התשובה; או אירועים בממשק משתמש שמבעבעים מהרכיב הפנימי כלפי ההורים שלו, וכל רמה יכולה לטפל באירוע ולעצור את הבעבוע. זו עדיין שרשרת של מטפלים עם קישור לחוליה הבאה, אבל הכוונה זזה מ"מי יטפל?" ל"מה כל אחד מוסיף?". כשכל החוליות תמיד מעבירות הלאה, המבנה קרוב יותר לשרשרת של קשטנים.
הרכבת השרשרת: בקוד או בהגדרות
בדוגמה שלנו השרשרת נבנית בשורה אחת של קוד: new TeamLead(new Manager(new Director(null))). זה מספיק כשהמבנה קבוע. כשהוא משתנה בין לקוחות, מחלקות או סביבות, כדאי להוציא אותו להגדרות: רשימה מסודרת של סוגי חוליות וספים, שממנה קוד אחד בונה את השרשרת בעלייה. כך שינוי בסמכויות הוא שינוי בקובץ הגדרות, בלי קומפילציה.
שתי שאלות כדאי לשאול בשלב הזה. הראשונה: האם החוליות בלתי תלויות זו בזו? אם כן, אפשר לסדר אותן מחדש בחופשיות. אם חוליה מניחה שחוליה אחרת כבר רצה לפניה — למשל, שמישהו כבר אימת את המשתמש — יש תלות סמויה בסדר, וכדאי לתעד אותה ולבדוק אותה. השנייה: מה קורה בסוף? אם יש כלל ברור לבקשה שלא טופלה, אפשר לסיים את השרשרת בחוליה שתמיד מטפלת — "דחה ושלח הודעה לכספים", למשל — ואז ההבטחה שהבקשה תטופל חוזרת.
מי שכתב קוד עם חריגות מכיר את הרעיון מזווית אחרת. מתודה שאינה יודעת לטפל בחריגה אינה תופסת אותה, והחריגה עולה למתודה שקראה לה, וממנה הלאה, עד שמישהו תופס — או שהתוכנית נעצרת. זו בדיוק שרשרת אחריות, שהשפה בונה מתוך מחסנית הקריאות.
השלכות
- צימוד מופחת. השולח אינו יודע מי יטפל, והמטפלים אינם יודעים את המבנה המלא של השרשרת — כל אחד מכיר רק את הבא אחריו.
- גמישות בחלוקת האחריות. אפשר להוסיף חוליה, להסיר חוליה או לשנות את הסדר בזמן ריצה, בלי לשנות את הקוד של אף מטפל.
- אין הבטחה שהבקשה תטופל. הספר מציין זאת במפורש כהשלכה: לבקשה אין מקבל מפורש, ולכן היא יכולה ליפול מסוף השרשרת. בדוגמה שלנו מחלקת הבסיס מחזירה הודעה מפורשת, כדי שזה לא יקרה בשקט. במערכת אמיתית כדאי להחליט מראש: לזרוק חריגה, להחזיר תוצאה מיוחדת, או לסיים את השרשרת בחוליה שתמיד מטפלת.
מתי לא להשתמש
- תמיד ברור מי מטפל. אם לכל סוג בקשה יש מטפל קבוע אחד, טבלה שממפה סוג למטפל פשוטה וקריאה יותר משרשרת.
- החוליות צריכות לדעת זו על זו. אם החלטה של חוליה אחת תלויה במה שחוליה אחרת החליטה, השרשרת אינה המבנה הנכון — זה תיאום, והוא שייך ל-Mediator.
- השרשרת ארוכה והבקשות רבות. כל בקשה עוברת, במקרה הגרוע, את כל החוליות. בשרשרת של עשרות חוליות על נתיב חם, זה מחיר שכדאי למדוד.
תבניות קשורות
Chain of Responsibility משמשת לעיתים קרובות יחד עם Composite: בעץ שבו לכל צומת יש הפניה להורה, ההורה הוא החוליה הבאה הטבעית — וזה בדיוק המבנה של דוגמת העזרה מהספר. Command נפגשת איתה כשהבקשה עצמה היא אובייקט: במקום מחרוזת או סכום, השרשרת מעבירה פקודה, וכל חוליה מחליטה אם היא יודעת לבצע אותה. ו-Mediator היא החלופה כשצריך החלטה מרכזית ולא העברה מיד ליד. הרקע הכללי נמצא במבוא לתבניות עיצוב.
טעויות נפוצות
- לשכוח להעביר. חוליה שאינה מטפלת ואינה קוראת לבאה "בולעת" את הבקשה. המבנה שבו מחלקת הבסיס מעבירה כברירת מחדל, וכל חוליה קוראת ל-
super, מצמצם את הסיכון. - מעגל בשרשרת. אם החוליה האחרונה מצביעה בטעות על הראשונה, בקשה שאף אחד לא מטפל בה תרוץ עד שהמחסנית תתמלא.
- סדר שגוי. אם המנהל נמצא לפני ראש הצוות, הוא יאשר גם בקשות קטנות, וראש הצוות לעולם לא יראה בקשה. הסדר הוא חלק מהתכנון, ויש לבדוק אותו.
- שרשרת שמורכבת בכמה מקומות. כל הגמישות של התבנית נעלמת אם בכל מקום בקוד בונים שרשרת משלו. הרכיבו אותה פעם אחת, והעבירו את הראש.
- בקשה שנופלת בשקט. החזרה של
nullמסוף השרשרת היא הדרך הבטוחה לגלות את הבעיה רק בייצור.
Chain of Responsibility מתאימה כשיש כמה מטפלים אפשריים, הבחירה ביניהם תלויה בבקשה עצמה, ואתם רוצים להיות מסוגלים לשנות את סדרם בלי לגעת בשולח. כשכל אחד מהתנאים האלה חסר, יש בדרך כלל מבנה פשוט יותר.


