ארכיון

ארכיון של מאי, 2010

UDP Messaging – The SOLID Way

הפוסט הזה הוא לא על UDP. כלומר, כן, הוא בהחלט על UDP. אבל הוא גם, ובעיקר, על כתיבת קוד נכונה, על OOP, על Dependency Injection, על Refactoring, ועל Design. וגם, מה המחיר של כתיבת קוד "נכונה", ואולי זה לא תמיד משתלם?

בפוסט הזה אני אציג UDP Listener די פשוט, שמבוסס על המחלקה UdpClient שבאה עם הדוט נט.

בהמשך אני אציג מחלקות נוספות שמתבססות על ה UDP Listener הזה. למשל: UDP Echo Server, וכן רכיב Forward, או "גב אל גב".

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

נתחיל מההתחלה: מה זה UDP בכלל, ולמה זה עוזר לנו?

כמה מילים על UDP

תקשורת בין מחשבים נעשית כיום בשתי דרכים עיקריות: TCP ו UDP.

תקשורת ב UDP היא כזו ששולחת חבילות מידע בצורה שהיא lossy: חבילות יכולות להגיע ליעדן ויכולות גם ליפול איפשהו. זה שימושי למשל בהזרמת אודיו/וידאו, כי שם אם איזשהו פריים "נפל", אז אפשר לעבור לפריים הבא, ושלום על ישראל. גם ב log4net ניתן לראות שימוש ב UDP, כפי שכתבתי בפוסטים קודמים. ראשי התיבות של UDP הן User Datagram Protocol. אפשר לומר, בתרגום חופשי, ש Datagram זו חבילת מידע שעוברת בפרוטוקול הזה.

ניכנס קצת יותר לפרטים. כל תקשורת ב UDP יוצאת ממחשב A דרך פורט X ומגיעה אל מחשב B, אל פורט Y. לפעמים A ו B הם אותו המחשב. בד"כ פורט היציאה (X) הוא רנדומלי, אבל הוא קיים. התמונה להמחשה בלבד:

UDP Message sent from A:X to B:Y

הודעת UDP שנשלחת ממחשב A ומפורט X למחשב B לפורט Y.

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

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

My UDP Listener

ניגש לקוד עצמו. מה שיש לנו, כנקודת מוצא, זה class שיורש מ Startable Base שהוא Thread Safe. את ה Startable הזה הכרנו בפוסט קודם, ואני אזכיר שזה base-class שאחראי על ניהול start/stop.

בנוסף יש לנו אירוע שמתרחש כאשר הגיעה הודעת UDP. ה delegate נראה כך:

public delegate void OnDatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint);

קדימה לקוד, זה נראה ככה:

using System;
using System.Net;
using System.Net.Sockets;
using Groundhog.Lib.MiniPatterns.Startable.ThreadSafe;

namespace Groundhog.Lib.Net.Udp
{
  public class Listener : ThreadSafeStartableBase, IListener
  {
    private readonly IPEndPoint localAddress;
    private readonly object syncLock = new object();
    private UdpClient udpClient;

    public Listener(int localPort)
      : this(new IPEndPoint(IPAddress.Any, localPort))
    {
    }

    private Listener(IPEndPoint localAddress) : base()
    {
      if (localAddress == null) throw new ArgumentNullException("localAddress");
      this.localAddress = localAddress;
    }

    protected override void OnStart()
    {
      lock (syncLock)
      {
        udpClient = new UdpClient(localAddress);
        BeginReceive();
      }
    }

    private void BeginReceive()
    {
      udpClient.BeginReceive(ReceiveCallback, udpClient);
    }

    private void ReceiveCallback(IAsyncResult ar)
    {
      var uc = (UdpClient) ar.AsyncState;
      IPEndPoint remoteEndPoint = null;
      try
      {
        var bytes = uc.EndReceive(ar, ref remoteEndPoint);
        InvokeDatagramReceived(bytes, remoteEndPoint);
      }
      catch(ObjectDisposedException) 
      {
        // this one occures right after we invoke Close method
        System.Diagnostics.Debug.WriteLine("swallowed the exception here");
      }
      lock (syncLock)
      {
        if (Status == StartableStatus.Started)
          BeginReceive();
      }
    }

    protected override void OnStop()
    {
      lock (syncLock)
      {
        udpClient.Close();
      }
    }

    public event OnDatagramReceived DatagramReceived;

    private void InvokeDatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint)
    {
      OnDatagramReceived received = DatagramReceived;
      if (received != null) received(bytes, fromThisEndPoint);
    }
  }
}

הסבר:

הקריאה ל OnStart יוצרת מופע חדש של UdpClient, שזו מחלקה דוט נטית.
מיד לאחר מכן, יש קריאה ל BeginReceive, שזו מתודה אסינכרונית (כלומר היא תתבצע על ת'רד אחר).
הכל עטוף ב lock, כי אולי, תוך כדי ביצוע תהליך ה Start, מישהו יקרא למתודת Stop, ועלולות להתקבל תוצאות בעייתיות.
הקריאה ל Stop גורמת לכך שה Socket עצמו יסגר.

חדי העין שביניכם (שהרי רובנו גיקים עם משקפיים) בוודאי שמו לב שבתוך המתודה ReceiveCallback יש "בליעה" של Exception.
למה זה?
שאלה מצויינת: מסתבר שיש באג (מה, במיקרוסופט?? באג??) שגורם לקריאה ל Callback הנ"ל גם אחרי הקריאה ל Close. לביל גייטס פתרונים. הנה תיעוד מ Microsoft Connect. אז אולי אפשר להימנע מה try/catch, אבל אז מתחילים להסתבך עם דגלים ועם נעילות עליהם (שהרי הקוד אמור לרוץ על יותר מת'רד אחד). לכן נראה לי הגיוני יותר להשאיר את ה try/catch הידוע לשימצה ופשוט לחיות עם זה.

Start it up

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

כדי להאזין להודעות UDP שמתקבלות בפורט 9777 (וזה סתם מספר שרירותי לצורך העניין) במחשב המקומי, ניצור לנו Console Application ונכתוב כך:

using System;
using System.Net;

namespace Groundhog.ConsoleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("starting up...");
      var listener = new Lib.Net.Udp.Listener(9777);
      listener.DatagramReceived += OnReceived;
      listener.Start();
      Console.WriteLine("started, press ENTER to quit");
      Console.ReadLine();
      listener.Stop();
    }

    private static void OnReceived(byte[] bytes, IPEndPoint remoteEndPoint)
    {
      Console.WriteLine("Get {0} bytes from {1}", bytes.Length, remoteEndPoint);
    }
  }
}

לבדיקה, איך נשמע?

בדיקות.. אבל איך?

אז מה היה לנו? רכיב שמאזין להודעות UDP על פורט מסויים, ו Console Application שמחזיק את הרכיב הזה חי. איך בודקים שהסיפור הזה עובד?

בדיקת אינטגרציה בעידן אחר

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

כן, מסתבר ש Unit Testing זה לא תרופת פלא ולא קסם לכל דבר. ואגב, גם TDD טהור זה לא כזה גליק (ועל כך בפעם אחרת).

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

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

  1. אנחנו כותבים את הקוד ל UdpSender
  2. מישהו אחר כתב את הקוד ל UdpSender, או שיש ממש תוכנת מדף מוכנה לעניין זה.

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

אז נלך על אופציה 2. ובכן, גם כאן אפשר להתפלפל ולנסות להגיע לתוכנת מדף (ואני בטוח שיש כזו), אבל אני דווקא בחרתי לקחת code-snippet בשפת Ruby. למה Ruby? כי הקוד שם כל כך קצר (למרות שהסינטקס, איך לומר בעדינות, קצת מוזר), וגם כי נחמד להכיר. (עוד על רובי בויקיפדיה)

הקוד ששולח את ההודעה "hello" ל UdpListener שלנו נראה ככה ברובי:

require 'socket'
s = UDPSocket.new
s.send("hello", 0, 'localhost', 9777)

סבבצ'יק, ועכשיו, אם נריץ את כל הסיפור נקבל ב Console Application שלנו את הפלט הבא:

starting up...
started, press ENTER to quit
Get 5 bytes from 127.0.0.1:52086

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

private static void OnReceived(byte[] bytes, IPEndPoint remoteEndPoint)
{
  var text = System.Text.Encoding.ASCII.GetString(bytes);
  Console.WriteLine("Get '{0}' from {1}", text, remoteEndPoint);
}

ואכן:

starting up...
started, press ENTER to quit
Get 'hello' from 127.0.0.1:59855

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

בניית UDP Echo Server

אחד היישומים הפשוטים של ה UDP Listener שלנו הוא UDP Echo Server, שזה רכיב שרץ ומאזין להודעות UDP על פורט מסויים, וכל הודעה שמגיעה – שולח בחזרה אל המקור.
בשביל מה זה טוב? בעיקר כדי לבדוק תקשורת UDP (נניח, כדי לוודא שה FireWall מאפשר העברת הודעות UDP בין מחשבים, וכו'). מכיוון שזה כל כך פשוט, לא התאפקתי וכתבתי את הגירסה שלי לעניין.

קודם כל, הוצאתי interface מתוך ה UdpListener:

public interface IListener : IStartable
{
  event OnDatagramReceived DatagramReceived;
}

ואח"כ כתבתי את ה EchoServer כך:

using System;
using System.Net;
using System.Net.Sockets;
using Groundhog.Lib.MiniPatterns.Startable;

namespace Groundhog.Lib.Net.Udp
{
  public class EchoServer : IStartable
  {
    private readonly IListener listener;
    private readonly UdpClient sender = new UdpClient();

    public EchoServer(int port) : this(new Listener(port))
    {
    }

    public EchoServer(IListener listener)
    {
      if (listener == null) throw new ArgumentNullException("listener");
      this.listener = listener;
      listener.DatagramReceived += DatagramReceived;
    }

    private void DatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint)
    {
      if (bytes == null) throw new ArgumentNullException("bytes");
      if (fromThisEndPoint == null) throw new ArgumentNullException("fromThisEndPoint");
      try
      {
        sender.Send(bytes, bytes.Length, fromThisEndPoint);
      }
      catch (Exception)
      {
      }
    }

    public void Start()
    {
      listener.Start();
    }

    public void Stop()
    {
      listener.Stop();
    }

    public StartableStatus Status
    {
      get { return listener.Status; }
    }
  }
}

נעבור קצת על הקוד:

  • הקונסטרקטור EchoServer(IListener listener) מראה לנו שכל הפונקציונליות שנדרשת מה Listener היא חיצונית ל Echo Server שלנו, ולכן "מוזרקת" אליו פנימה. במילים אחרות, יש כאן Dependency Injection (ובקיצור, DI). צורת העבודה הזו מאפשרת לכל מפתח שהוא ליצור Listener משלו, ולהשתמש בו בקונסטרקטור הזה. כל עוד ה Listener הנ"ל יממש את ה interface שהגדרנו, הכל ימשיך לעבוד כרגיל. זה היופי של DI.
  • עם כל הכבוד ל DI, ויש כבוד, אנחנו יכולים בהחלט לחוס על המפתחים תמימי הלב ורכי הנפש שרק רוצים להריץ UDP Echo Server על פורט מסויים. לכן אנחנו יוצרים עוד קונסטרקטור ש"יזריק" את המימוש שכבר כתבנו ל IListener. כך נוכל לתת מענה מיידי בלי להתקשקש על הרחבות והזרקות, רק פורט להאזין בו ולסגור עניין.
  • כל קריאת start/stop/status מועברת הלאה ל IListener שיש לנו. הלוגיקה הזו כבר מוטמעת שם היטב, אין צורך להמציא אותה מחדש
  • הרכיב שמבצע את שליחת ההודעות הוא UdpClient הדוט נטי המובנה. מתכנתים שנוטים לקוד טהור יותר בהיבטים של OOP ושל SOLID יוכלו לטעון שגם זה יכול להיות מוזרק פנימה. ואני אומר, אוקיי, אפשר, אבל כמה כבר יכול להיות שונה המימוש של שליחת הודעה ב UDP? נקודה למחשבה. נגיע לזה בהמשך.
  • השאר הוא רק להאזין לאירוע של ה Listener ולשלוח בחזרה בדיוק אותה הודעה. סה טו.

איך בודקים את זה? אה, זה כבר יותר מורכב, כי כאן אנחנו צריכים שני מחשבים. בגדול, מריצים את ה UDP Echo Server על מחשב X, ושיאזין על פורט P. הולכים למחשב Y, מריצים WireShark (או כל network monitor אחר שיודע לנטר UDP), ושולחים הודעת UDP אל מחשב X ואל פורט P. ה WireShark כבר יראה לנו מהו פורט היציאה של ההודעה ואם התקבלה הודעה חוזרת. אני מעדיף לחסוך את תמונות המסך, תצטרכו לנסות בעצמכם או פשוט להאמין לי.

בניית UDP Forwarder

רכיב כמו UDP Forwarder הוא רכיב שמצד אחד מקבל הודעות UDP, ומצד שני הוא שולח אותן הלאה לכתובת אחרת. מכאן שהוא מתפקד כ Forwarder, "מקדם" או "מקפיץ" את ההודעות לכתובת אחרת.
מתי שימושי? בזמנו, כשעסקנו ב Video Streaming ובפרוטוקולים כמו SIP ו RTP, יצא לנו להגיע למצב שבו המערכת היתה חייבת להזרים וידאו מכתובת מסויימת, גם אם מקור ה streaming לא היה מאותה כתובת (וזה ככה מטעמי security). אז הרצנו UDP Forwarder שכזה (ובחרנו לו שם ציורי יותר: Back to Back UDP server), שמצד אחד קיבל את הוידאו מכתובת מסויימת, ומצד שני רק המשיך את הזרמת הוידאו אל כתובת אחרת.

ניגש ישר לקוד, ונראה שההבדלים בין ה UDP Forwarder ל UDP Echo Server הם ממש זעירים. לא מפתיע, כי שניהם מבצעים כמעט אותה פעולה: ה Echo Server שולח את ההודעה שהתקבלה למקור שממנו הגיעה, וה Forwarder שולח את ההודעה שהתקבלה ליעד מוגדר מראש. בסה"כ שני החבר'ה האלה הם ממש דומים. אולי נעשה קצת refactoring אח"כ? אולי. הנה הקוד, בכל מקרה:

using System;
using System.Net;
using System.Net.Sockets;
using Groundhog.Lib.MiniPatterns.Startable;

namespace Groundhog.Lib.Net.Udp
{
  public class Forwarder : IStartable
  {
    private readonly IListener listener;
    private readonly IPEndPoint destination;
    private readonly UdpClient sender = new UdpClient();

    public Forwarder(int port, IPEndPoint destination) : this(new Listener(port), destination)
    {
    }

    public Forwarder(IListener listener, IPEndPoint destination)
    {
      if (listener == null) throw new ArgumentNullException("listener");
      if (destination == null) throw new ArgumentNullException("destination");
      this.listener = listener;
      this.destination = destination;
      listener.DatagramReceived += DatagramReceived;
    }

    private void DatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint)
    {
      if (bytes == null) throw new ArgumentNullException("bytes");
      try
      {
        sender.Send(bytes, bytes.Length, destination);
      }
      catch (Exception)
      {
      }
    }

    public void Start()
    {
      listener.Start();
    }

    public void Stop()
    {
      listener.Stop();
    }

    public StartableStatus Status
    {
      get { return listener.Status; }
    }
  }
}

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

To Refactor Or Not To Refactor

Keep it DRY

יש לנו שני classים ביד, שלמעשה מבצעים כמעט אותה עבודה. כלומר יש להם הרבה מן המשותף. אחד העקרונות בתכנות הוא DRY, קרי Don't Repeat Yourself. בעברית זה יוצא "אתעע" :-). במילים אחרות, אם יוצא לנו לכתוב קוד מאוד דומה, כדאי למצוא את המכנה המשותף ולכתוב אותו פעם אחת בלבד. למה? כי אם יש באג, אז צריך לפתור אותו בשני מקומות, וצריך בכלל לזכור שיש שני מקומות כאלה מלכתחילה. אם מתכנת זיהה באג ברכיב אחד, אין לו שום יכולת לדעת שאותו באג קיים גם ברכיב השני, כל עוד שניהם כתובים בנפרד.

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

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

בואו ניקח את הצד של התביעה ונעשה כאן קצת refactoring. התוצאה היא base-class (שוב ירושה, מה קורה לי בזמן האחרון?), שנראה כך:

using System;
using System.Net;
using System.Net.Sockets;
using Groundhog.Lib.MiniPatterns.Startable;

namespace Groundhog.Lib.Net.Udp
{
  public class UdpReplierBase : IStartable
  {
    private readonly IListener listener;
    private readonly UdpClient udpClient = new UdpClient();
    private readonly Func<IPEndPoint, IPEndPoint> destinationChooser;

    public UdpReplierBase(int port, Func<IPEndPoint, IPEndPoint> destinationChooser) : this(new Listener(port), destinationChooser)
    {
    }

    public UdpReplierBase(IListener listener, Func<IPEndPoint, IPEndPoint> destinationChooser)
    {
      if (listener == null) throw new ArgumentNullException("listener");
      if (destinationChooser == null) throw new ArgumentNullException("destinationChooser");
      this.listener = listener;
      this.destinationChooser = destinationChooser;
      listener.DatagramReceived += DatagramReceived;
    }

    private void DatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint)
    {
      if (bytes == null) throw new ArgumentNullException("bytes");
      try
      {
        var destination = destinationChooser(fromThisEndPoint);
        udpClient.Send(bytes, bytes.Length, destination);
      }
      catch (Exception)
      {
      }
    }

    public void Start()
    {
      listener.Start();
    }

    public void Stop()
    {
      listener.Stop();
    }

    public StartableStatus Status
    {
      get { return listener.Status; }
    }
  }
}

ועכשיו היורשים הגאים הצטצמו פלאים, והם נראים ככה:

using System;
using System.Net;

namespace Groundhog.Lib.Net.Udp
{
  public class EchoServer : UdpReplierBase
  {
    private static IPEndPoint DestChooser(IPEndPoint x)
    {
      return x; // same as input
    }

    public EchoServer(int port)
      : base(port, DestChooser)
    {
    }

    public EchoServer(IListener listener)
      : base(listener, DestChooser)
    {
    }
  }

  public class Forwarder : UdpReplierBase
  {
    public Forwarder(int port, IPEndPoint destination)
      : base(port, x => destination)
    {
    }

    public Forwarder(IListener listener, IPEndPoint destination)
      : base(listener, x => destination)
    {
    }
  }
}

קומפקטי, לא? קבלנו אותה פונקציונליות, ואנחנו לא חוזרים על עצמנו (יש חזרה קטנטונת ב lamba expression שב Forwarder, אבל זה בקטנה).
יתרה מכך, שמרנו בדיוק על אותם קונסטרקטורים ועל אותו API חיצוני, כך שאם כבר יש קוד כתוב – הקוד יכול להשאר כפי שהוא, בלי לשנות כלום.
התביעה חוגגת, ההגנה נחה.

שיפורים, שיפורים

הפוסט הזה, כמו שכתבתי בתחילתו, הוא לא רק על UDP. יש כאן הרבה מעבר לפרוטוקול עצמו. נכנסתי כאן גם ל Dependency Injection, שזאת אחת השיטות לממש את ה D ב SOLID, והראיתי קצת Refactoring. ה UDP הוא מצע נוח יחסית להמחיש עקרונות בתכנות.

יחד עם זאת, יש מקומות בקוד שעוד יכולים להשתפר:

  • כמו שציינתי קודם לכן, ה Sender יכול לקבל אבסטרקציה מלאה. אם הוא יהיה אבטרקטי, כלומר יהיה לנו interface עם מתודה אחת ויחידה כמו Send, אז נוכל גם לכתוב Unit Testing לקוד שלנו.
  • הקוד לא מתייחס עד הסוף להיבט של ת'רדים. למשל, האם ה Echo Server צריך לשלוח את ההודעה שהוא קיבל על אותו ת'רד שמטפל באירוע קבלת ההודעה? אולי כדאי לשלוח בת'רד נפרד? אם היינו כותבים את האבטרקציה ל Sender, אפשר היה לבחור במימושים שונים ל Sender: כזה ששולח על אותו ת'רד, או כזה ששולח בת'רד נפרד.

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

namespace Groundhog.Lib.Net.Udp
{
  public interface ISender
  {
    void Send(byte[] dgram, int bytes, System.Net.IPEndPoint endPoint);
  }
}

Simple Implementation

נמשיך עם מימוש פשוט, ע"י ה UdpClient הדוט נטי:

using System.Net;
using System.Net.Sockets;

namespace Groundhog.Lib.Net.Udp
{
  public class BlockingSender : ISender
  {
    private readonly UdpClient udpClient = new UdpClient();

    public void Send(byte[] dgram, int bytes, IPEndPoint endPoint)
    {
      udpClient.Send(dgram, bytes, endPoint);
    }
  }
}

Multi-Threaded Implementation

ועכשיו למימוש עם ת'רד נוסף, לצורך הפשטות – נממש בעזרת ה ThreadPool:

using System;
using System.Net;
using System.Threading;

namespace Groundhog.Lib.Net.Udp
{
  public class ThreadPoolSender : ISender
  {
    private class MyState
    {
      public byte[] dgram;
      public int bytes;
      public IPEndPoint endPoint;
    }

    private readonly ISender blockingSender;

    public ThreadPoolSender(ISender blockingSender)
    {
      if (blockingSender == null) throw new ArgumentNullException("blockingSender");
      this.blockingSender = blockingSender;
    }

    public void Send(byte[] dgram, int bytes, IPEndPoint endPoint)
    {
      var myState = new MyState()
                {
                  dgram = dgram,
                  bytes = bytes,
                  endPoint = endPoint
                };
      ThreadPool.QueueUserWorkItem(MyCallback, myState);
    }

    private void MyCallback(object state)
    {
      var myState = (MyState) state;
      blockingSender.Send(myState.dgram, myState.bytes, myState.endPoint);
    }
  }
}

קצת review על הקוד:

שימו לב שגם כאן יש Dependency Injection: אין כאן עבודה מול Sender קונקרטי, אלא מול ISender, שאמור להתקבל בקונסטרקטור. זאת משום שהאחריות היחידה של ה class הזה היא בביצוע על ת'רד אחר. כאן אנחנו נוגעים באות נוספת מה SOLID: ה S מייצגת את העקרון SRP, שזה Single Responsibility Principle.

Putting it all together

ועכשיו "נתפור" את ה ISender עם ה UdpReplierBase, שהרי זו היתה המוטיבציה לכך מלכתחילה, ונראה את התוצר הסופי:

using System;
using System.Net;
using Groundhog.Lib.MiniPatterns.Startable;

namespace Groundhog.Lib.Net.Udp
{
  public class UdpReplierBase : IStartable
  {
    private readonly IListener listener;
    private readonly ISender sender;
    private readonly Func<IPEndPoint, IPEndPoint> destinationChooser;

    public UdpReplierBase(int port, Func<IPEndPoint, IPEndPoint> destinationChooser)
      : this(new Listener(port), destinationChooser)
    {
    }

    public UdpReplierBase(IListener listener, Func<IPEndPoint, IPEndPoint> destinationChooser)
      : this(listener, new BlockingSender(), destinationChooser)
    {
    }

    public UdpReplierBase(IListener listener, ISender sender, Func<IPEndPoint, IPEndPoint> destinationChooser)
    {
      if (listener == null) throw new ArgumentNullException("listener");
      if (sender == null) throw new ArgumentNullException("sender");
      if (destinationChooser == null) throw new ArgumentNullException("destinationChooser");
      this.listener = listener;
      this.sender = sender;
      this.destinationChooser = destinationChooser;
      listener.DatagramReceived += DatagramReceived;
    }

    private void DatagramReceived(byte[] bytes, IPEndPoint fromThisEndPoint)
    {
      if (bytes == null) throw new ArgumentNullException("bytes");
      try
      {
        var destination = destinationChooser(fromThisEndPoint);
        sender.Send(bytes, bytes.Length, destination);
      }
      catch (Exception)
      {
      }
    }

    public void Start()
    {
      listener.Start();
    }

    public void Stop()
    {
      listener.Stop();
    }

    public StartableStatus Status
    {
      get { return listener.Status; }
    }
  }
}

נוכל לכתוב למחלקות היורשות עוד קונסטרקטור. ניקח למשל את ה EchoServer שלנו:

using System.Net;

namespace Groundhog.Lib.Net.Udp
{
  public class EchoServer : UdpReplierBase
  {
    private static IPEndPoint DestChooser(IPEndPoint x)
    {
      return x; // same as input
    }

    public EchoServer(int port)
      : base(port, DestChooser)
    {
    }

    public EchoServer(IListener listener)
      : base(listener, DestChooser)
    {
    }

    public EchoServer(IListener listener, ISender sender)
      : base(listener, sender, DestChooser)
    {
    }
  }
}

כדי להפעיל את ה EchoServer שלנו ב Console Application, נוכל לכתוב כך:

using System;
using Groundhog.Lib.Net.Udp;

namespace Groundhog.ConsoleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("starting up...");
      var echoServer = new EchoServer(9777);
      echoServer.Start();
      Console.WriteLine("started, press ENTER to quit");
      Console.ReadLine();
      echoServer.Stop();
    }
  }
}

אבל זו ההפעלה שלו בתצורה של ברירת מחדל. אם נרצה, למשל, ששליחת Datagrams בחזרה תתבצע על ת'רד אחר, נצטרך להזריק פנימה instance של ThreadPoolSender:

using System;
using Groundhog.Lib.Net.Udp;

namespace Groundhog.ConsoleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("starting up...");
      var threadPoolSender = new ThreadPoolSender(new BlockingSender());
      var echoServer = new EchoServer(new Listener(9777), threadPoolSender);
      echoServer.Start();
      Console.WriteLine("started, press ENTER to quit");
      Console.ReadLine();
      echoServer.Stop();
    }
  }
}

מה הרווחנו בינתיים?

עניין ראשון הוא שה classים שיש לנו הם יותר loosely coupled, כלומר הקטנו את הצימוד בין ה UdpReplierBase ל Sender. מה שנשאר לנו ביד זה החוזה, או ההתנהגות של ה Sender. מעכשיו, ה UdpReplierBase בכלל לא מתעניין במימוש של השליחה, הוא רק יודע שאמור להיות לו איזה רפרנס ל Sender שכזה. במילה אחת, עשינו decoupling.

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

ומה עוד הרווחנו? עכשיו אפשר לעשות קצת Unit Testing ל classים שלנו (לא להכל). בקוד המצורף ניתן לראות את הטסטים עצמם, כאן בפוסט אני אציג רק טסט אחד, שהוא למעשה הטסט העיקרי ל Echo Server, כי דרכו אפשר לוודא שהקוד שכתבנו אכן יעבוד כמצופה, כל עוד המימושים של ה Listener ושל ה Sender יעבדו כראוי (וזאת המהות של unit testing – לבדוק את ה unit, ולא שום דבר אחר):

using System.Net;
using Groundhog.Lib.Net.Udp;
using Moq;
using NUnit.Framework;

namespace Groundhog.Lib.Testing
{
  [TestFixture]
  public class EchoServerTests
  {
    [Test]
    public void ReceivedMessageIsSentToSource()
    {
      var listenerMock = new Mock<IListener>();
      var senderMock = new Mock<ISender>(MockBehavior.Strict);
      var echoServer = new EchoServer(listenerMock.Object, senderMock.Object);
      echoServer.Start();
      byte[] dgram = {1, 2, 3, 4, 5};
      var sourceEndPoint = new IPEndPoint(new IPAddress(new byte[] {192, 168, 1, 5}), 5544);
      senderMock.Setup(x => x.Send(dgram, dgram.Length, sourceEndPoint)).AtMostOnce();
      listenerMock.Raise(x => x.DatagramReceived += null, dgram, sourceEndPoint);
      senderMock.VerifyAll();
    }
  }
}

הבדיקות נעשו ב NUnit כאשר ה Mocking Framework הוא Moq. אני לא מרחיב על עניין הבדיקות, גם ככה הפוסט הזה ארוך…

מה הפסדנו?

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

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

שנית, אפשר לכתוב מתודות סטטיות, כמו Create, שיוצרות instance בקלות יתרה. לדוגמה:

using System.Net;

namespace Groundhog.Lib.Net.Udp
{
  public class EchoServer : UdpReplierBase
  {
    // code, code, code...

    public static EchoServer CreateMultiThreaded(int port)
    {
      var threadPoolSender = new ThreadPoolSender(new BlockingSender());
      var echoServer = new EchoServer(new Listener(port), threadPoolSender);
      return echoServer;
    }
  }
}

והקריאה עצמה:

using System;
using Groundhog.Lib.Net.Udp;

namespace Groundhog.ConsoleApp
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("starting up...");
      var echoServer = EchoServer.CreateMultiThreaded(9777);
      echoServer.Start();
      Console.WriteLine("started, press ENTER to quit");
      Console.ReadLine();
      echoServer.Stop();
    }
  }
}

ובאופן דומה, אפשר לכתוב Factory class שעושה אותו הדבר.

אז מה היה לנו?

בפוסט הזה הראיתי איך אפשר לקחת משימה יחסית פשוטה כמו UDP Echo Server ולהדגים דרכה מספר מרכיבים בתכנות מודרני:

  • כתיבת קוד OOP, עם מימושים שונים של Solid, ביניהם DI ו SRP.
  • Refactoring
  • Unit Testing

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

כמו כן, קיים הבדל משמעותי בין מספר הקבצים שצריך לתחזק (ולדבג) כאשר כותבים קוד בצורה יותר "נכונה". לכתוב UDP Echo Server אפשר גם ב class יחיד. כן, אתם קוראים נכון: אחד, Uno, ואחד, one. וכאן, אחרי כל הפירוקים וההפרדות שלנו, הגענו לשבע מחלקות רק בפוסט הזה, ועוד כמה שקשורות לפנוקציונליות של Start/Stop מהפוסטים הקודמים. אז במקום class אחד יש לנו בסביבות 10 מחלקות אחרות. זה המון. האם זה מוצדק? לפעמים כן, במיוחד כאשר החלק של Unit-Testing יותר משמעותי (וחוסך בדיקות אינטגרציה בפועל), ולפעמים לא. כמו תמיד, אין תשובה אחת, וזה תלוי במידת ה code-reuse שמתקבל (ועד כמה זה באמת תורם ברמה הפרקטית), במידת הבדיקתיות, ובגורמים נוספים. לא תמיד אפשר לדעת מראש שיתקבלו כל כך הרבה מחלקות, ובכל מקרה תמיד אפשר לסדר קצת את הקוד שלנו ע"י הוספת תיקיות מתאימות.

בשורה התחתונה, כתיבת קוד OOP בגישת Solid היא מצוינת, אבל יש לה לפעמים מחיר. כמה יוצא לנו לשלם בהווה על תחזוקת קוד? וכמה אנחנו חוסכים בעתיד (כשהכל מפורק ליחידות משנה)? אלו שאלות פתוחות. עקרונות ה Solid הם עקרונות חשובים, אבל חשוב שישארו בגדר עקרונות מנחים. לא כדאי להכפיף את המציאות אליהם. לפעמים תהיה לנו מחלקה שאחראית על שני דברים (ולכן חורגת ל SRP). לשנות אותה יעלה לנו בכמה ימי עבודה. שווה לשלם את המחיר? לפעמים כן, ולפעמים לא.

תכנות נעים!

אתם מוזמנים להוריד את הקוד לפוסט הזה (בתצורה הסופית שלו).

Quantcast