ארכיון

רשומות עם התג ‘OOP’

מנשקים לפנים

8 פברואר, 2011 2 תגובות

"מִנְשַק", לפי האקדמיה ללשון, הוא התרגום התקני למונח interface. עם כל ההצדקות שבעולם, אני תמיד אמרתי "ממשק", וכנראה אמשיך ואומר "ממשק" וזה מה יש. התרגום התקני מוצג בפוסט הזה רק פעמיים עד עכשיו, ואני חושב שבזה זה יסתכם 🙂

אז ממשקים.

למה צריך ממשקים? מה הקטע שלהם?

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

נתחיל מ:

הגדרה לא פורמלית של "מה זה ממשק בדוט נט"

בדוט נט, הקוד שלנו כתוב כמעט תמיד במחלקות (class-ים). פה ושם אנחנו אולי נכתוב enum או struct למיטיבי לכת. פה ושם נכתוב אולי איזה delegate. אבל עיקר הקוד שלנו הוא במחלקות.

וכידוע, למחלקות יכולים להיות: שדות (fields), מאפיינים (properties), מתודות (methods) ואירועים (events). אני מעדיף לא להיכנס כאן לפינות של הקטע הפורמלי, אז סליחה מראש על אי הדיוקים.

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

אז בשורה התחתונה, הפונקציונאליות (או ההתנהגות) מתוארת ע"י הממשק. המימוש מתקיים במחלקה.

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

public interface IShape
{
  double GetArea();
}

ואז נוכל להצהיר על שתי צורות גיאומטריות. לקחתי שתיים יחסית "קלות" – ריבוע ועיגול:

public class Rectangle : IShape
{
  private readonly double a;
  private readonly double b;

  public Rectangle(double a, double b)
  {
    this.a = a;
    this.b = b;
  }

  public double GetArea()
  {
    return a*b;
  }
}

public class Circle : IShape
{
  private readonly double radius;

  public Circle(double radius)
  {
    this.radius = radius;
  }

  public double GetArea()
  {
    const double pi = Math.PI; // ?
    return pi * radius * radius;
  }
}

כפי שניתן לראות, אלו מחלקות שמממשות את הממשק IShape.

ומה עושים עם זה?

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

public static void DoSomething(IShape shape)
{
  Console.WriteLine("the area is {0:00}", shape.GetArea());
}

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

דוגמה קונקרטית

(כן, דוגמה אמיתית מהחיים האמיתיים!)

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

אז יש לנו כאן שתי משימות:

  1. למצוא את כל הלקוחות האלה
  2. לשלוח להם מייל

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

כדי לכתוב את המחלקה שמבצעת את זה, נוכל לכתוב קוד שפותח connection באופן ישיר ל DB שלנו, שולף את הרשומות בשאילתה, ושולח מייל.

זה יראה בערך כך:

public class CustomersReminder
{
  public void Remind()
  {
    var connectionString = "...";
    using (var connection = new OleDbConnection(connectionString))
    {
      connection.Open();
      var command = new OleDbCommand("select * from Customers where LastPurchasedAt < ?", connection);
      var todayMinus30 = DateTime.Today.AddDays(-30);
      var oleDbParameter = new OleDbParameter {OleDbType = OleDbType.Date, Value = todayMinus30};
      command.Parameters.Add(oleDbParameter);
      using (command)
      using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
      {
        while (reader.Read())
        {
          var firstName = (string) reader["FirstName"];
          var lastName = (string) reader["LastName"];
          var email = (string)reader["EmailAddress"];
          SendMailTo(firstName, lastName, email);
        }
      }
    }
  }

  private void SendMailTo(string firstName, string lastName, string email)
  {
    var mailMessage = new MailMessage("do-not-reply@canberra-shopping.com", email);
    mailMessage.Subject = "It's been a while...";
    mailMessage.Body = string.Format("Hi there, {0} {1}, please try our new sales!", firstName, lastName);
    var smtpClient = new SmtpClient();
    try
    {
      smtpClient.Send(mailMessage);
    }
    catch (Exception ex)
    {
      // do something here, like logging or alerting..
    }
  }
}

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

במקום הקוד הזה, בואו נניח שיש לנו שתי מחלקות: CustomersFetcher ו EmailSender. נעביר את הקוד הרלוונטי למחלקות האלו. בואו נראה את המחלקה CustomersFetcher:

public class CustomersFetcher
{
  public IEnumerable<CustomerDetails> GetSleepingCustomers()
  {
    List<CustomerDetails> result = new List<CustomerDetails>();
    var connectionString = "...";
    using (var connection = new OleDbConnection(connectionString))
    {
      connection.Open();
      var command = new OleDbCommand("select * from Customers where LastPurchasedAt < ?", connection);
      var todayMinus30 = DateTime.Today.AddDays(-30);
      var oleDbParameter = new OleDbParameter { OleDbType = OleDbType.Date, Value = todayMinus30 };
      command.Parameters.Add(oleDbParameter);
      using (command)
      using (var reader = command.ExecuteReader(CommandBehavior.CloseConnection))
      {
        while (reader.Read())
        {
          var customerDetails = new CustomerDetails
                        {
                          FirstName = (string) reader["FirstName"],
                          LastName = (string) reader["LastName"],
                          EmailAddress = (string) reader["EmailAddress"]
                        };
          result.Add(customerDetails);
        }
      }
    }
    return result;
  }
}

public class CustomerDetails
{
  public string FirstName;
  public string LastName;
  public string EmailAddress;
}

המחלקה הזו מבצעת שתי פעולות: שליפת הנתונים מה DB והמרה שלהם ל class דוט נטי "טהור", מה שנקרא גם POCO.

ועכשיו, בואו נחזור אל ה Remind שלנו:

public class CustomersReminder
{
  public void Remind()
  {
    var customersFetcher = new CustomersFetcher();
    var emailSender = new EmailSender();
    foreach (var customer in customersFetcher.GetSleepingCustomers())
    {
      emailSender.SendRemindMessage(customer.FirstName, customer.LastName, customer.EmailAddress);
    }
  }
}

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

האחריות של המתודה Remind היא עדיין לא פשוטה: המתודה הזו מייצרת instance (ובעברית "מופע") של המחלקות CustomersFetcher ו EmailSender. כלומר יש במתודה את שתי השורות הראשונות שבהן יש new. בואו נחסוך את ה new הזה. בואו נקבל את ה instance-ים האלה כפרמטרים:

public class CustomersReminder
{
  public void Remind(CustomersFetcher customersFetcher, EmailSender emailSender)
  {
    foreach (var customer in customersFetcher.GetSleepingCustomers())
    {
      emailSender.SendRemindMessage(customer.FirstName, customer.LastName, customer.EmailAddress);
    }
  }
}

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

var customersReminder = new CustomersReminder();
customersReminder.Remind(new CustomersFetcher(), new EmailSender());

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

אם כך, נוכל ליצור שני ממשקים, ICustomersFetcher ו IEmailSender, שמתארים את ההתנהגות הזו:

public interface ICustomersFetcher
{
  IEnumerable<CustomerDetails> GetSleepingCustomers();
}

public interface IEmailSender
{
  void SendRemindMessage(string firstName, string lastName, string email);
}

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

public class CustomersFetcher : ICustomersFetcher
{ ... }

עכשיו נחזור אל המתודה Remind, ונשנה את החתימה שלה באופן הבא:

public void Remind(ICustomersFetcher customersFetcher, IEmailSender emailSender)
{ ... }

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

יופי, אבל מה זה נותן?

כאשר אנחנו מתכנתים מול ממשק, אנחנו נהנים ממספר יתרונות:

  1. אנחנו יכולים בכל שלב שהוא לשנות את המימוש, והקוד של המתודה Remind ישאר כפי שהוא. למשל, אם יום אחד נרצה לשלוח מייל דרך איזה WebService ששולח מיילים, אז נכתוב מימוש חדש ונשלח את המימוש הזה כפרמטר למתודה Remind, והקוד של המתודה Remind ימשיך לעבוד כמו שצריך. זה יתרון תחזוקתי, כי אז שינוי במימוש (נוספה מחלקה) לא גורם לשינוי בחתימה של המתודה Remind ולא בקוד שלה. במילים אחרות, ממשק הוא סוג של פרוטוקול של האפליקציה שלנו. כל עוד הפרוטוקול נשאר זהה, נוכל להחליף את המחלקות שמממשות אותו באחרות, ובקלות רבה.
  2. הקטנת הצימוד: המחלקה שלנו תלויה אך ורק בממשקים שהוגדרו. אם היא היתה תלויה במימוש, אז כל שינוי במימוש היה יכול להשפיע על המחלקה שלנו.
  3. אנחנו יכולים למקבל את הפיתוח: מתכנת א' יצור את המימוש של ICustomersFetcher ומתכנת ב' יצור את המימוש של IEmailSender. אף אחד לא מפריע לשני, וזה מקדם את הפרויקט מהר יותר לפרודקשן. למי שעובד בצוות, זה יתרון אדיר.
  4. הקוד שלנו הופך להיות יותר טסטבילי, כלומר יותר מוכן לבדיקות. בהקשר הזה, הקוד של המתודה Remind יכול להיבדק ע"י unit testing באמצעות mocking (עוד על mocking בפוסט עתידי כלשהו).

וכמובן, לא הכל דבש. עבודה עם ממשקים מייצרת עוד קבצים לתחזק, ועלולה להביא ל"הינדוס יתר" (over-engineering).

עוד קצת מוטיבציה

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

בואו נראה דוגמה שממחישה את הנקודה האחרונה.

נניח שאנחנו רוצים לכתוב מתודה שמקבלת URL של תמונה, והיא צריכה:

  1. להוריד את התמונה מהאינטרנט
  2. לשנות את הגודל של התמונה כך שהרוחב והגובה לא יעלו על 100 פיקסלים (ולשמור על פרופורציית גובה-רוחב)
  3. לשמור את התמונה החדשה בפורמט PNG בתיקיה מסויימת

עכשיו, בואו נכתוב את המתודה בחשיבה שהיא "מונחית ממשקים". נוכל, אם כן, לכתוב את הקוד הבא:

public class ImageProcessing
{
  public void ProcessImage(Uri imageUrl, IDownloader downloader, IImageResizer imageResizer, IFileSystem fileSystem)
  {
    byte[] imageBytes = downloader.Get(imageUrl);
    Image bitmap = Image.FromStream(new MemoryStream(imageBytes));
    Image afterResize = imageResizer.ResizeToMax(bitmap, 100);
    fileSystem.StoreImage(afterResize);
  }
}

public interface IFileSystem
{
  void StoreImage(Image image);
}

public interface IImageResizer
{
  Image ResizeToMax(Image bitmap, int maxWidthOrHeight);
}

public interface IDownloader
{
  byte[] Get(Uri imageUrl);
}

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

למה זה טוב?

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

למה לא לכתוב פונקציות?

כי פונקציות זה כל כך תכנות פרוצדורלי :-).
טוב, ברצינות: עקרון חשוב מאוד בהנדסת תוכנה הוא SRP, ראשי תיבות של Single Responsibility Principle, שזה אומר שכל מחלקה אחראית על תחום אחד בלבד. אני אחזור על זה: מחלקה אמורה להיות אחראית על תחום אחד בלבד. כתיבה של פונקציות באותה מחלקה גורמת למחלקה להיות אחראית על יותר מתחום אחד: גם הורדה של קבצים, גם עיבוד תמונה וגם התעסקות עם מערכת הקבצים. זה אומר שמספיק שמשהו אחד משתנה (נניח, האופן שבו מבצעים עיבוד תמונה) כדי שכל המחלקה הזו תשתנה. לדעתי האישית, אגב, מרגע שכותבים קוד לפי SRP מתחילים להבין את המשמעות של OOP ושל הנדסת תוכנה.

עדיין לא השתכנעתם?

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

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

וסיומת בפרפאזה על הסיומת הקבועה של משה קפלן: ממשקים לפתח!

אפשר להוריד את הקוד מכאן.

קטגוריות:תכנות תגיות:, , ,

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