ארכיון

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

Don't enum Data

14 אוקטובר, 2011 12 תגובות

מכירים את enum?

-מכירים.

אז מתי כדאי להשתמש בו וליצור enum בקוד שלנו?

-הממממם… כשיש כמה קבועים מאותה "קבוצה".

ופיסת הקוד הזו:

public class Util
{
  public static void SaveAs(Image image, ImageFormat format, string path)
  {
    switch (format)
    {
      case ImageFormat.Bitmap:
        SaveAsBitmap(image, path);
        break;
      case ImageFormat.Jpeg:
        SaveAsJpeg(image, path);
        break;
      case ImageFormat.Png:
        SaveAsPng(image, path);
        break;
      default:
        throw new ApplicationException(
          string.Format("enum '{0}' changed", typeof (ImageFormat).FullName)
          );
    }
  }

  // implementations here...
}

נראית הגיונית?

-כן. נראה בסדר גמור.

טעות. הקוד הזה מפר את עקרון OCP, קיצור של Open/closed principle.

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

לפני שנמשיך עם הדוגמה הקודמת, בואו נראה דוגמה אחרת, שבה ה enum הוא הגיוני, מוצדק, טבעי וכו':

using System;
namespace System.Data
{
  public enum ConnectionState
  {
    Closed = 0,
    Open = 1,
    Connecting = 2,
    Executing = 4,
    Fetching = 8,
    Broken = 16
  }
}

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

ועכשיו נחזור לדוגמה הראשונה. אם בעוד כמה חודשים נצטרך לתמוך בשמירת האובייקט Image לפורמט אחר, נניח לפורמט WebP, אז נצטרך להוסיף עוד ערך לקבוצת הערכים של ה enum שלנו. ולקמפל מחדש. ולבדוק בכל מיני מקומות מה ההשפעה של זה. וכו' וכו'. כמה שינויים בקוד בשביל להוסיף תמיכה בפורמט נוסף!

למה זה קורה?

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

בקיצור, יצרנו enum סביב data, וזו הטעות.

זאת טעות נפוצה מאוד. גם אני הייתי שם, בעיקר בגלל שזה נתן לי תחושה של type safety – יש לי השלמה אוטומטית של הויז'ואל סטודיו, יש Resharper שיכול לצעוק עלי שאני לא בודק את כל הערכים האפשריים – משמע אני לא יכול לטעות. זה אולי נכון במיקרו, אבל מרוב תחושה של "הנה אני כותב קוד עם מינימום אפשרות לטעות" – לא ראיתי את הטעות במאקרו.

איך בכל זאת כדאי לעצב ולכתוב קוד עם רעיון דומה?

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

בדוגמה שלנו, הממשק הוא כזה ששומר תמונה לדיסק:

using System.Drawing;

namespace Silicate.Lib
{
  public interface IImagePersister
  {
    void SaveAs(Image image, string path);
  }
}

וכל מימוש יוכל לכתוב את הקובץ בפורמט אחר. ניקח לדוגמה מימוש של png:

using System.Drawing;

namespace Silicate.Lib
{
  public class PngPersister : IImagePersister
  {
    public void SaveAs(Image image, string path)
    {
      image.Save(path, System.Drawing.Imaging.ImageFormat.Png);
    }
  }
}

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

אוקיי, אבל איך הכל מתחבר ביחד?

הרעיון המרכזי הוא לוותר על ה switch, כי אז הוספה (או גריעה) של פורמט לשמירת תמונה תגרום לקימפול מחדש. במקום ערכים של enum נעבוד עם string, שיכול להחזיק ערכים בצורה גמישה יותר. האלטרנטיבה ל switch היא לטעון באופן דינמי את ה plugin הרלוונטי ולהשתמש בו. טעינה דינמית שכזו יכולה להתבצע ע"י Reflection, או ע"י IoC Container (ובקיצור IoCC). אני אראה בפוסט הזה את הדרך של IoCC. אם מישהו יבקש (נניח בהערות לפוסט) – אז אני אוסיף גם קוד שמדגים ע"י Reflection.

נסכם את המהלך בטבלה קטנה ואז נמשיך:

לפני אחרי
סוג המזהה ערך מתוך enum string
סוג הטעינה סטטית ע"י switch + new דינמית ע"י Reflection או IoCC
הרחבה גורמת ל… שינויים בקוד + rebuild 🙁 שינויים בקונפיגורציה בלבד 🙂

נחבר את הכל עם Unity

ה IoCC שאדגים באמצעותו את השינויים בקוד הוא Unity. קודם כל – הקונפיגורציה:

<unity>
  <namespace name="Silicate.Lib" />
  <assembly name="Silicate.Lib" />
  <container>
    <register type="IImagePersister" mapTo="BitmapPersister" name="bitmap" />
    <register type="IImagePersister" mapTo="JpegPersister" name="jpeg" />
    <register type="IImagePersister" mapTo="PngPersister" name="png" />      
  </container>
</unity>

שימו לב שיש יותר ממימוש אחד לממשק IImagePersister, ולכן יש גם התייחסות לשם (או מזהה) של המימוש.

מרגע שהכל מקונפג כמו שצריך, נוכל לוותר על ה switch ולכתוב את הקוד באופן הבא:

public class Util
{
  private static readonly UnityContainer Container = BuildContainer();

  private static UnityContainer BuildContainer()
  {
    var container = new UnityContainer();
    var section = (UnityConfigurationSection)
      ConfigurationManager.GetSection("unity");
    section.Configure(container);

    return container;
  }

  public static void SaveAs(Image image, string format, string path)
  {
    var persister = Container.Resolve<IImagePersister>(format.ToLowerInvariant());
    persister.SaveAs(image, path);
  }
}

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

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

שנית, ה string שקיבלנו מייצג את הפורמט הרצוי. כלומר, מי שקורא לפונקציה הזו – אמור לדעת בדיוק איך לרשום את הטקסט שמייצג את הפורמט הרצוי. למשל "bitmap" ולא "bmp". זה מקשה קצת על מי שאמור להשתמש בקוד. זה אומר שהוא יצטרך לקרוא את התיעוד איפשהו.

עניין נוסף – מי שמבצע את הטעינה (בפועל) של המחלקה הרלוונטית הוא ה Unity. העברנו אליו את האחריות לבצע את זה. [אנחנו, ראש קטן אנחנו, כמה שפחות אחריות – יותר טוב ;-)]

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

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

  • מי שקורא לפונקציה הזו – אמור לדעת את הערכים האפשריים ל string הרלוונטי. זה פחות נוח מההשלמה האוטומטית של הערכים מתוך ה enum הקיים.
  • הכנסנו פנימה IoCC, וזה אומר שעכשיו יש לנו תלות ב DLLים נוספים, וכן בקונפיגורציה שלו.
  • נוספו קבצים לפרויקט: קובץ הממשק, וקבצי המימושים.

ונעבור ליתרונות:

  • המערכת שיש לנו ביד – פתוחה להרחבות, וללא צורך בשינוי כלשהו בקוד עצמו (וב rebuild שלו). כלומר, אנחנו עומדים בעקרון שנקרא OCP. אם יש לנו קבוצה של 10 פורמטים, ונרצה להוסיף את הפורמט ה 11 שנרצה לתמוך בו – אין שום בעיה.
  • הקוד שיש לנו קריא יותר, ולכן התחזוקה שלו קלה יותר.
  • במקרים אחרים (ואולי פחות בדוגמה הזו) – הקוד יותר טסטבילי, כלומר יותר מוכן לבדיקות.

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

ולשאלה המתבקשת…

השאלה המתבקשת שעולה עכשיו, והיא שאלת מיליון הדולר: מתי כדאי להעדיף את הצורה הראשונה (enum+switch) ומתי נעדיף את הצורה השניה?

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

תלוי כמה הפרויקט גדול.

תלוי כמה מפתחים בוחשים בקדרה הזו.

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

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

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

תכנות נעים!

קבצי הקוד: גירסה ראשונה – עם enum וגירסה שניה – בצורה של plugins.

[התמונה מתוך אתר flickr, שם משתמש tj scenes, לינק ישיר לתמונה, תחת רשיון CC BY 2.0]

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

IoC Container Explained

14 יולי, 2011 7 תגובות

בפוסט הזה אני רוצה להסביר את עיקרי הדברים סביב IoC, שזה ראשי תיבות של Inversion of Control. למעשה אני רוצה לספר מעט על התיאוריה ולהרחיב על הפרקטיקה.

בקיצור, אני רוצה להסביר ולהדגים מה זה IoC Container.

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

הנה קצת לינקים חיצוניים, שמסבירים (באנגלית, מן הסתם) את הנושא:

  • אחד מהמאמרים המפורסמים ביותר בנושא הוא של Martin Fowler. המאמר נקרא
    "Inversion of Control Containers and the Dependency Injection pattern"
  • בויקיפדיה אפשר למצוא את:

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

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

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

namespace Canberra.InterfacesDemo.Lib
{
  public class CustomersReminder
  {
    public void Remind(ICustomersFetcher customersFetcher, IEmailSender emailSender)
    {
      foreach (var customer in customersFetcher.GetSleepingCustomers())
      {
        emailSender.SendRemindMessage(customer.FirstName, 
		  customer.LastName, 
		  customer.EmailAddress);
      }
    }
  }
}

השאלה שנותרה פתוחה היא: "מי אמור לספק instance של מחלקות שמממשות ICustomerFetcher ו IEmailSender?"

אפשרות אחת היא ליצור instance כחלק מהקוד עצמו, שקורא לפונקציה Remind. הקוד יראה כך:

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

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

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

כלומר, אם כתבנו בפרויקט נפרד, שנקרא לו MyMail, את המחלקה EmailSender, שמממשת את IEmailSender, ונרצה להשתמש בה, אז אנחנו צריכים רפרנס לפרויקט MyMail. הרפרנס יכול להיות בצורה של רפרנס בין פרויקטים באותו solution, והרפרנס יכול להיות ל DLL של MyMail. זה לא משנה לצורך העניין.

מה שמשנה כאן הוא שיש תלות בין הפרויקט שלנו, לרפרנס של מימוש ספציפי של הממשק IEmailSender.

אם נרצה יום אחד להשתמש במימוש אחר לממשק IEmailSender, נצטרך לבנות מחדש את את הפרויקט שלנו.

בואו נבדוק לרגע מה זה אומר:

  • לבנות מחדש פרויקט – Build
  • לבדוק שוב שהכל תקין – QA
  • להפיץ את התוצר לאן שצריך – Deploy

כל זה רק כי רצינו לשנות את המימוש של ממשק מסוים.
זה לא מעט עבודה…

אז מה האלטרנטיבה?

הבעיה העיקרית שלנו היא איך להיפטר מהפעולה new בקוד שלנו.

נרצה להגיע לקוד שבו:

  • אין את הפעולה new
  • אין התייסות בקוד עצמו או ברפרנסים של הפרויקט לאסמבלי שנרצה להשתמש בו

ובמילים אחרות:

הבחירה באסמבלי שיממש את הממשק תהיה בקונפיגורציה.

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

או שלא בא.

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

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

IoC Container

IoC זה ראשי תיבות של Inversion of Control. לצערי IoC זה מושג שקשה עד בלתי אפשרי לתרגם אותו לעברית, ולכן אני אשאר איתו כמו שהוא. IoC Container זה איזשהו אמצעי/כלי/מיכל/קונטיינר/פריימוורק/ספריה לביצוע הפעולות של IoC. באופן כללי, זה אמצעי ליצור instance של מחלקה בלי שיהיה לה רפרנס בפרויקט.

בסביבת דוט נט יש מספר IoC Containers נפוצים. מתוכם אני שמח להזכיר את:

  • Spring.NET
  • Castle Windsor
  • StructureMap
  • Autofac
  • Unity
  • Ninject

וזו רשימה חלקית. מי שרוצה לראות השוואות בין הפריימוורקים מוזמן לגגל או להיכנס ל stackoverflow ולהמשיך משם.

באופן אישי יצא לי לעבוד עם Spring.NET, עם Ninject, עם StructureMap (כיום) ועם Unity (גם כיום; למעשה אנחנו בתהליך של החלפת SM ב Unity).

בואו נחזור לבעיה שלנו ונראה איך משתמשים ב Unity כדי לקבל instance של הממשק IEmailSender.

הדגמה עם Unity

קודם כל, צריך רפרנס מהפרויקט שלנו ל Unity. נוסיף. יש.

עכשיו, לפקודה עצמה. במקום הקוד הזה:

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

נרשום כך:

var container = new UnityContainer();
var section = (UnityConfigurationSection)ConfigurationManager.GetSection("unity");
section.Configure(container);

var customersReminder = new CustomersReminder();
customersReminder.Remind(new CustomersFetcher(), container.Resolve<IEmailSender>());

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

-נצטרך לקנפג אותה כמובן.

נוכל להשתמש בקובץ ה config הסטנדרטי לצורך העניין. בואו נראה דוגמה:

<?xml version="1.0"?>
<configuration>
  <configSections>
    <section 
		name="unity" 
		type="Microsoft.Practices.Unity.Configuration.UnityConfigurationSection, 
			Microsoft.Practices.Unity.Configuration" 
	/>
  </configSections>  
  <unity>
    <typeAliases>
      <typeAlias 
		alias="IEmailSender" 
		type="Canberra.InterfacesDemo.Lib.IEmailSender, 
			Canberra.InterfacesDemo.Lib" 
	  />
      <typeAlias 
		alias="EmailSender" 
		type="MyMail.EmailSender, MyMail" 
	  />
    </typeAliases>
    <containers>
      <container>
        <types>
          <type type="IEmailSender" mapTo="EmailSender"/>          
        </types>
      </container>
    </containers>
  </unity>
</configuration>

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

בדוגמה הזו, כנגד הבקשה למימוש של IEmailSender, כלומר בקשת ה Resolve, ה Unity תחזיר לנו instance של המחלקה MyMail.EmailSender, שנמצאת באסמבלי MyMail.

כדי שזה באמת יקרה, קבצי ה dll של MyMail (וכל התלויות שלהם) צריכים להיות באותה תיקיה של ה exe של הפרויקט שלנו (או בתיקיית bin במקרה של web application).

זהו, עכשיו אפשר לקפוץ משמחה.

כדי להנות קצת יותר, במקרה של אפליקציית web, מומלץ לאתחל את ה Unity ב Application_Start שב global.asax, ולהגדיר משתנה סטאטי שתמיד נפנה אליו:

using System;
using System.Configuration;
using Microsoft.Practices.Unity;
using Microsoft.Practices.Unity.Configuration;

namespace Canberra.InterfacesDemo.WebApp
{
  public class Global : System.Web.HttpApplication
  {
    public static UnityContainer Container;

    protected void Application_Start(object sender, EventArgs e)
    {
      Container = new UnityContainer();
      var section = (UnityConfigurationSection)ConfigurationManager
  		.GetSection("unity");
      section.Configure(Container);
    }
  }
}

וכך, הקוד שלנו שמשתמש ב Resolve של Unity יצטמצם באופן הבא:

var customersReminder = new CustomersReminder();
customersReminder.Remind(new CustomersFetcher(), 
    Global.Container.Resolve<IEmailSender>());

והשמחה רבה 🙂

אחרי ששמחנו ונהנינו, בואו נסכם את התהליך של שימוש ב IoC Container:

  • מוסיפים רפרנס ל IoC Container עצמו
  • במקום new בקוד שלנו, משנים לתחליף שמציע ה IoC Container. בדוגמה שלנו עם Unity זה Global.Container.Resolve<IEmailSender>()
  • מגדירים עבור ה IoC Container מה יהיה המימוש לכל interface שנרצה
  • מוודאים שכל קבצי האסמבלי והתלויות שלהם נמצאים בתיקיית ה bin של הפרויקט ה"ראשי", אחרת מקבלים אקספשן…

עוד כמה מילים על IoC Container

השימוש הבסיסי ביותר ל IoC Container (להלן IoCC) הוא ליצור instance של מחלקה לפי interface, או לפי כל type אחר. הרעיון המרכזי הוא להימנע מ new בקוד.

קונפיגורציה – בקובץ או דרך קוד?

הקונפיגורציה של ה IoCC יכולה להיות בקובץ חיצוני או בקוד עצמו. היתרונות של קובץ קונפיגורציה הם ברורים – לא צריך לבנות מחדש את האפליקציה וכו'. עם זאת, הקונפיגורציה יכולה להיות מעיקה וארוכה, ולכן יש לא מעט אנשים שמעדיפים לקנפג את ה IoC Container בקוד עצמו. למשל, מה שמציע Ninject זה קונפיגורציה רק דרך קוד. זה מאוד נקי, ואפשר להעביר לקונפיגורציה רק מה שצריך באמת.

אתחול של אובייקטים מורכבים

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

IoCC as a Factory

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

ועוד הרחבות

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

לסיכום

בפוסט הזה הראיתי איך אפשר להשתמש ב IoC Container (ובקיצור – IoCC).

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

למה זה טוב?

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

למה זה לא טוב?

-כי לפעמים זה מורכב מדי למתכנתים מתחילים;

-כי זה לא כל כך פשוט להרכיב את הפאזל הזה: גם לקנפג וגם לוודא שהקבצים יגיעו לתיקיית bin ונגזרותיה… קצת כאב ראש;

-באופן כללי, קצת קשה לעשות back-tracking לקוד ולהבין מה מתבצע בפועל

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

ובשורה התחתונה, חייבים לעבוד ככה?

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

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

 

הקוד לפוסט הזה נמצא כאן.

הינדוס נעים!

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

מנשקים לפנים

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, ויתן פתרון גם לעניין האחרון שהעליתי כאן.

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

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

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