דלג לתוכן הראשי
שכבת הפשטה ארכיטקטורת תוכנה בעברית
תקשורת בין אובייקטים

Observer Design Pattern: תבנית Observer למנוי לשינויים, אירועים וההבדל בינה לבין Pub/Sub

observer design pattern היא אחת מתבניות ההתנהגות החשובות ביותר בתכנון מונחה־עצמים: היא מאפשרת לאובייקט אחד להודיע לאובייקטים אחרים שמשהו השתנה, בלי להכיר את המחלקות הקונקרטיות שלהם ובלי לבנות תלות ישירה ביניהם.

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

המאמר הזה מתמקד בתבנית Observer כפי שהיא מוכרת מספרות התבניות הקלאסית, ובהבדל החשוב בינה לבין Pub/Sub. אם זו הפעם הראשונה שאתם נתקלים בתבניות עיצוב, כדאי להתחיל קודם עם מבוא לתבניות עיצוב, ואז לחזור לכאן עם ההקשר הרחב יותר.

מה הבעיה שתבנית Observer פותרת?

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

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

הפתרון הנאיבי הוא שהאובייקט המרכזי יקרא ישירות לכל הצרכנים. למשל: updateDashboard(), אחר כך sendAlert(), אחר כך writeAudit(). זה עובד בשלב הראשון, אבל מהר מאוד נוצרת בעיה: המקור מכיר יותר מדי פרטים על מי שתלוי בו. כל צרכן חדש דורש שינוי במחלקה המרכזית, וכל שינוי במחלקה המרכזית עלול להשפיע על רכיבים שלא באמת קשורים אליה.

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

ההגדרה הקלאסית של תבנית Observer

לפי ההגדרה המקובלת בספר Design Patterns, Gamma et al., 1994, תבנית Observer מגדירה תלות של אחד־לרבים בין אובייקטים, כך שכאשר אובייקט אחד משנה את מצבו, כל התלויים בו מקבלים הודעה ומתעדכנים אוטומטית.

במילים פשוטות: יש Subject שמחזיק מצב, ויש Observers שנרשמים אליו. כאשר המצב משתנה, ה־Subject מפעיל פעולה מוסכמת על כל ה־Observers. בדרך כלל הפעולה נקראת משהו כמו update, אבל השם עצמו פחות חשוב מהחוזה: “משהו השתנה, ואתם מקבלים הזדמנות להגיב”.

המשתתפים בתבנית והתפקיד של כל אחד

משתתף אחריות מה חשוב לשים לב
Subject מגדיר פעולות הרשמה, הסרה והודעה למאזינים לא אמור להכיר את המחלקות הקונקרטיות של המאזינים
ConcreteSubject מחזיק את המצב האמיתי ומפעיל הודעה בעת שינוי צריך להחליט מתי שינוי באמת מצדיק הודעה
Observer מגדיר ממשק עדכון משותף לכל המנויים הממשק צריך להיות קטן וברור
ConcreteObserver מגיב לשינוי בדרך הספציפית שלו לא כדאי להעמיס בו יותר מדי אחריות נסתרת

הנקודה המרכזית היא שהשיתוף נעשה דרך הפשטות. ה־Subject מחזיק רשימה של Observer, לא של Dashboard, EmailSender או RiskCalculator. זה חיבור חלש יותר, ולכן גם קל יותר להרחבה ולבדיקה.

observer design pattern בדוגמת Java מלאה ורצה

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

Java
import java.util.ArrayList;
import java.util.List;

interface StockObserver {
    void update(String symbol, double price);
}

interface StockSubject {
    void addObserver(StockObserver observer);
    void removeObserver(StockObserver observer);
    void notifyObservers();
}

class StockPriceFeed implements StockSubject {
    private final List<StockObserver> observers = new ArrayList<>();
    private String symbol;
    private double price;

    @Override
    public void addObserver(StockObserver observer) {
        observers.add(observer);
    }

    @Override
    public void removeObserver(StockObserver observer) {
        observers.remove(observer);
    }

    public void setPrice(String symbol, double price) {
        this.symbol = symbol;
        this.price = price;
        notifyObservers();
    }

    @Override
    public void notifyObservers() {
        List<StockObserver> snapshot = new ArrayList<>(observers);
        for (StockObserver observer : snapshot) {
            observer.update(symbol, price);
        }
    }
}

class PriceDisplay implements StockObserver {
    @Override
    public void update(String symbol, double price) {
        System.out.println("Display: " + symbol + " is now " + price);
    }
}

class PriceAlert implements StockObserver {
    private final double threshold;

    PriceAlert(double threshold) {
        this.threshold = threshold;
    }

    @Override
    public void update(String symbol, double price) {
        if (price >= threshold) {
            System.out.println("Alert: " + symbol + " reached " + price);
        }
    }
}

public class ObserverDemo {
    public static void main(String[] args) {
        StockPriceFeed feed = new StockPriceFeed();

        StockObserver display = new PriceDisplay();
        StockObserver alert = new PriceAlert(100.0);

        feed.addObserver(display);
        feed.addObserver(alert);

        feed.setPrice("ABC", 95.5);
        feed.setPrice("ABC", 101.2);

        feed.removeObserver(display);

        feed.setPrice("ABC", 103.7);
    }
}

בדוגמה הזו, StockSubject הוא הממשק של הנושא הנצפה, ו־StockPriceFeed הוא המימוש הקונקרטי שמחזיק את המצב. StockObserver הוא ממשק המאזין, ואילו PriceDisplay ו־PriceAlert הם מאזינים קונקרטיים. שימו לב שה־StockPriceFeed לא יודע אם המאזין מדפיס למסך, שולח התראה או מעדכן חישוב פנימי. מבחינתו, כולם מממשים את אותו חוזה.

איך מיישמים Observer שלב אחר שלב?

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

  1. זהו את האובייקט שמחזיק את המצב. זהו המועמד להיות Subject. למשל מודל, מקור נתונים, אגרגט עסקי או רכיב תצורה.
  2. זהו מי צריך להגיב לשינוי. אם יש צרכן אחד בלבד, ייתכן שאין צורך בתבנית מלאה. אם יש כמה צרכנים או שצפויים להתווסף כאלה, Observer מתחילה להיות רלוונטית.
  3. הגדירו ממשק מאזין קטן. אל תעבירו יותר מידע ממה שצריך. לפעמים מספיק להעביר ערך חדש; לפעמים צריך להעביר אובייקט אירוע קטן.
  4. הוסיפו פעולות הרשמה והסרה. ללא הסרה, קל ליצור דליפות זיכרון או מאזינים שכבר אינם רלוונטיים.
  5. החליטו מתי להודיע. האם כל קריאה ל־setPrice מפעילה הודעה? רק אם הערך השתנה? רק אם השינוי עבר סף?
  6. בדקו את סדר הקריאות. אם סדר העדכון חשוב, הוא צריך להיות מתועד וברור. אם הוא לא חשוב, אל תבנו עליו במקרה.

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

איפה בדרך כלל נתקעים?

מאזין משנה את רשימת המאזינים בזמן הודעה

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

הודעות כפולות או מיותרות

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

  • האם הערך החדש שונה מהקודם?
  • האם השינוי משמעותי מספיק?
  • האם צריך לאחד כמה שינויים להודעה אחת?

תלויות נסתרות בין מאזינים

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

קשה להבין מי מגיב למה

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

Observer לעומת Pub/Sub: הדמיון מטעה

Observer ו־Pub/Sub דומים בכך שבשניהם יש מפרסם כלשהו ויש צרכנים שמגיבים לשינויים. ההבדל הוא ברמת הצימוד ובתיווך.

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

ב־Pub/Sub יש בדרך כלל מתווך: broker, event bus, topic, queue או רכיב תשתיתי אחר. המפרסם מפרסם הודעה לנושא מסוים, והמנויים מקבלים אותה דרך המתווך. המפרסם לא בהכרח יודע מי המנויים, כמה הם, איפה הם רצים, או אם הם פעילים כרגע.

מאפיין Observer Pub/Sub
צורת קשר נושא מחזיק מאזינים וקורא להם פרסום דרך מתווך או ערוץ
טווח נפוץ בתוך תהליך או מודול בין מודולים, שירותים או מערכות
תלות תלות בממשק מאזין תלות בחוזה הודעה או topic
סינכרוניות לרוב סינכרוני, אלא אם תוכנן אחרת לעיתים קרובות אסינכרוני
מורכבות תפעולית נמוכה יחסית גבוהה יותר בגלל תשתית ותצפיות

לכן, לא נכון לומר ש־Pub/Sub הוא פשוט “Observer עם שם אחר”. Pub/Sub מתאים במיוחד כאשר רכיבים צריכים להיות מופרדים יותר, כאשר יש תקשורת בין שירותים, או כאשר רוצים buffering, retries, topics והרשמה דינמית ברמת מערכת. Observer מתאים כאשר רוצים שיתוף אירועים פשוט וברור בתוך מודל אובייקטים.

הקשר ל־SOLID ולעיצוב מונחה־עצמים

Observer מתחברת היטב לכמה עקרונות מוכרים של תכנון מונחה־עצמים. עקרון Open/Closed מורגש במיוחד: ניתן להוסיף מאזין חדש בלי לשנות את ה־Subject, כל עוד הממשק נשאר יציב. גם Dependency Inversion מופיע כאן, משום שה־Subject עובד מול הפשטה ולא מול מחלקות קונקרטיות.

מצד שני, התבנית לא פוטרת מאחריות. אם ממשק ה־Observer גדול מדי, כל מאזין ייאלץ לקבל מידע שלא מעניין אותו. אם ה־Subject חושף את עצמו בשלמותו ל־Observer, המאזינים עלולים להתחיל למשוך ממנו מידע בצורה חופשית מדי. כך נוצרת תלות רחבה יותר ממה שהתכוונתם.

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

איך מתארים Observer ב־UML?

ב־UML class diagram טיפוסי נראה את Subject כממשק או מחלקה מופשטת עם פעולות כמו attach, detach ו־notify. לצידו יהיה Observer עם פעולה כמו update. מחלקה קונקרטית של subject תחזיק אוסף של observers, ומחלקות קונקרטיות של observers יממשו את פעולת העדכון.

במילים, הקשרים נראים כך:

  • ConcreteSubject מממש או יורש מ־Subject.
  • ConcreteObserver מממש את Observer.
  • Subject מחזיק אוסף של Observer.
  • בעת שינוי מצב, ConcreteSubject מפעיל notify, שמפעילה update על כל מאזין.

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

Trade-offs: מה מרוויחים ומה משלמים?

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

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

אבל יש גם מחיר:

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

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

מתי לא להשתמש ב־Observer?

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

בנוסף, אם האירועים צריכים לעבור בין שירותים שונים בארכיטקטורת microservices, Observer בתוך הזיכרון של שירות אחד אינה מספיקה. שם לרוב צריך לחשוב במונחים של חוזי API, הודעות, topics, idempotency, תיעוד אירועים ותצפיות תפעולית. Observer יכולה להופיע בתוך כל שירות, אבל היא לא מחליפה תכנון תקשורת בין שירותים.

עוד סימן אזהרה: אם כל שינוי קטן במערכת גורם לשרשרת ארוכה של מאזינים, ומפתחים מתקשים לענות על השאלה “מה יקרה אם הערך הזה ישתנה?”, ייתכן שהגיע הזמן לפשט את המודל או להעביר חלק מהזרימה למנגנון מפורש יותר.

שאלות נפוצות שקוראים באמת שואלים

האם Observer חייבת להיות אסינכרונית?

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

האם צריך להעביר את כל ה־Subject ל־Observer?

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

מה ההבדל בין Observer ל־Listener?

במקרים רבים Listener הוא שם מעשי למימוש של אותו רעיון. בסביבות רבות משתמשים במונח listener עבור מחלקה שמגיבה לאירוע. Observer הוא שם התבנית והמבנה העקרוני; listener הוא לעיתים שם קונקרטי בממשק או בספרייה.

האם Observer מתאימה ל־API design?

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

סיכום: תבנית קטנה עם השפעה גדולה

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

ההבדל בינה לבין Pub/Sub חשוב: Observer היא בדרך כלל קשר ישיר דרך ממשק בתוך מודל אובייקטים, בעוד Pub/Sub מוסיפה תיווך, הפרדה רחבה יותר ולעיתים גם אסינכרוניות ותשתית. הבחירה ביניהן אינה עניין של שם, אלא של גבולות מערכת, רמת צימוד, מורכבות תפעולית והצורך להבין את זרימת האירועים.

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

נכתב על ידי

רועי פרידמן : כותב

כותב על תבניות העיצוב של GoF ועל תקשורת בין אובייקטים. אינו מרצה באוניברסיטה, אינו יועץ ארכיטקטורה ואינו מגייס — מסביר כל תבנית דרך בעיה מוחשית ודוגמת קוד, ומציין מתי היא לא שווה את המחיר.

להמשך קריאה

לכל המאמרים בנושא

תקשורת בין אובייקטים

תבנית Chain of Responsibility: שרשרת מטפלים שבה כל חוליה מחליטה אם לטפל או להעביר — מימוש מלא

Chain of Responsibility מנתקת את שולח הבקשה מהמקבל שלה בכך שהיא נותנת ליותר מאובייקט אחד הזדמנות לטפל בה. ההגדרה לפי ספר ה-GoF, המשתתפים, מימוש מלא ב-Java ובפייתון על שרשרת אישורי רכש, הווריאציה של צינור מסננים, ומה קורה כשאף חוליה לא מטפלת.

8 דק׳ קריאה

תקשורת בין אובייקטים

תבנית Mediator: אובייקט מתווך שמחליף רשת של תלויות הדדיות — מימוש מלא על טופס עם רכיבים תלויים

Mediator מגדירה אובייקט שמכמס את האופן שבו קבוצת אובייקטים מתקשרת, ומונעת מהם להפנות זה לזה ישירות. ההגדרה לפי ספר ה-GoF, המשתתפים, מימוש מלא ב-Java ובפייתון על טופס שבו רכיבים תלויים זה בזה, ואיך לא להפוך את המתווך לאובייקט־אל.

8 דק׳ קריאה