Inversion of Control ו Dependency Injection
Inversion of Control (להלן Ioc), היא תבנית עיצוב אשר ניתן להגדירה באופן כללי, כהעברת השליטה על תהליך כלשהו, כגון יצירת אובייקטים, הרצת קטע קוד וכו’, מהדרך הנקראת ‘רגילה’, על ידי תהליך מרכזי כלשהו, אל רכיב קוד אחר אשר יעשה הוא את העבודה. על ידי היפוך שליטה זה נרוויח לעיתים כמה דברים, כפי שתיכף ניראה.
את IoC אפשר לממש בכל מיני דרכים בהתאם לתהליך עליו אנחנו עובדים, אך הכלי שבעזרתו מממשים IoC הוא תבנית עיצוב נוספת בשם Dependency Injection.
לפעמים נרצה להשתמש ב IoC על ידי אינטרפייסים, כאשר הרעיון באופן כללי אומר שבמקום שתהליך כלשהו יצטרך להכיר את המחלקות המשתמשות בשירותיו, הוא פשוט צריך להגדיר Interface אשר רק אותו הוא מכיר, וכל מי שירצה להשתמש בשירותי המחלקה הזו יצטרך לממש את ה Interface הזה.
ולפעמים נשתמש בIoC על מנת לשנות את התהליך ‘הרגיל’ של התוכנה. במילה ‘רגיל’ אני מתכוון לתהליך שעובד באופן פרוצדורלי – ניתן לתאר זאת באמצעות ההבדל שבין Console Application שבה נשאלת שאלה ומתקבלת תשובה וחוזר חלילה, ושהשליטה על התהליך היא של התוכנה עצמה, לבין WinForm שבו יש תוויות עם שאלות ושדות להכנסת תשובות ולחיצה על כפתור שבה המשתמש הוא זה שמפעיל תהליך על ידי שליחת תשובות.
דרך זו ידועה באימרה – “Don’t call us, we’ll call you!!”
עוד שימוש נפוץ בIoC הוא ביצירת אובייקטים. במקום ליצור אובייקטים בתוך המחלקה שלי, אקרא למחלקה אחרת אשר תייצר היא עבורי את האובייקטים ובכך אני מקטין את התלות של המחלקה שלי במחלקות אותן אני מממש. שימוש זה דומה לשימוש בתבנית העיצוב Factory.
Dependency Injection
DI (הזרקת תלויות) הוא סוג של IOC – זוהי תבנית עיצוב שבה אנו יוצרים או משייכים אובייקטים שהמחלקה שלנו תלויה בהם – מחוץ למחלקה.
באופן ציורי אפשר לתאר זאת באופן הבא: במקום שאביא את ארוחת הצהריים שלי בעצמי – יהיה קייטרינג….. בכל מקרה אני יודע שיהיה אוכל בצהריים, אבל במקום שאני אדאג לזה, מישהו אחר דואג לזה.
בדרך כלל הזרקת התלויות תתרחש דרך ה Construcor.
דוגמא:
נניח שיש לי מחלקה בשם Till המתארת התנהגות של קופה בחנות, והיא צריכה להשתמש באמצעי תשלום. אני יוצר Interface בשם ITender, וכל אמצעי התשלום שלי מממשים אותו. ב Constructor של Till נצטרך ‘להזריק’ את אמצעי התשלום בו אנו רוצים להשתמש.
הקוד נראה כך:
1: ITender tender = new CreditCard();
2: Till till = new Till(tender);
3: public class Till
4: {
5: private readonly ITender _tender;
6:
7: public Till(ITender tender)
8: {
9: _tender = tender;
10: }
11: }
וכך באופן פשוט ביותר מימשנו DI.
ישנה אפשרות לממש DI לא על ידי ה Constructor אלא על ידי Setter של Property, באופן הבא:
1: ITender tender = new CreditCard();
2: Till till = new Till();
3: till.Tender = tender;
4: public class Till
5: {
6: public ITender Tender { get; set; }
7: }
8:
במקרה כזה צריך להזהר לא להשתמש בשדה הזה לפני שהוא אותחל.
כפי שניתן לראות, המחלקה Till לא יודעת שום דבר על כרטיסי אשראי, צ’קים, מזומן או כל אמצעי תשלום שימציאו בעתיד. היא מכירה רק את ITender ורק בזה היא תלויה. הזרקת התלות הזו, כלומר הגדרת אמצעי התשלום המדוייק, יעשה במחלקה אחרת.
דרך נוספת להזריק תלויות, אם כי דרך זו היא פחות מצויה, היא על ידי יצירת Interface נוסף אשר בו יש מתודת Inject ומי שמממש אותה (המחלקה Till בדוגמא שלנו) יאתחל את השדה הרצוי, על ידי הזרקה כמו בדוגמה הקודמת (ההבדל הוא שכאן זה מתבצע בתוך מתודה יעודית שנכפתה עלינו על ידי הInterface).
כמה חסרונות של DI הם:
א. אנחנו חושפים החוצה כמה נתונים שבדרך כלל יהיו מוסתרים במחלקה שלנו.
ב. אנחנו יוצרים את התלויות שלנו לפני שאנחנו באמת צריכים אותם.
ג. והנה חסרון שהוא גם יתרון – יותר קל לעשות unit test במקרה של DI בגלל של Interface אפשר לעשות Moq, אבל זה גם החיסרון, מכיוון שיתכן ולא נבדוק כראוי את מה שתכננו לבדוק.
בניית IoC
באופן כללי ניתן להגדיר ש IoC היא תשתית לצורך עבודה עם Dependency Injection. מהסיבה הזו יש כמה תשתיות מוכנות כגון UNITY של מיקרוסופט או castle of Windsor אשר חוסכות לנו לכתוב את התשתית בכל פעם מחדש.
ולמרות שנוח להשתמש בתשתיות אלו, טוב להבין באופן כללי איך הן עובדות ומה מתרחש מאחורי הקלעים.
נשתמש בקוד מהדוגמא של DI.
הקוד נראה כך:
1: class Program
2: {
3: static void Main(string[] args)
4: {
5: ITender tender = new CreditCard();
6: Till till = new Till(tender);
7: }
8: }
9:
10: public class Till
11: {
12: private readonly ITender _tender;
13:
14: public Till(ITender tender)
15: {
16: _tender = tender;
17: }
18: }
19:
20: public class CreditCard : ITender
21: {
22: }
23:
24: public interface ITender
25: {
26: }
כעת נעשה כמה שינויים.
נוסיף מתודת Charge במחלקה Till ומתודה זו תקרא למתודה זהה הקיימת בכל מי שמממש את ITender. מתודה זו מדפיסה הודעה כלשהי.
כמו כן ניצור בשביל הדוגמא עוד מחלקה בשם Cash לתשלום במזומן – גם מחלקה זו כמובן יורשת מ ITender.
1: public class Till
2: {
3: private readonly ITender _tender;
4:
5: public Till(ITender tender)
6: {
7: _tender = tender;
8: }
9:
10: public string Charge()
11: {
12: return _tender.Charge();
13: }
14: }
15:
16: public class CreditCard : ITender
17: {
18: public string Charge()
19: {
20: return "CreditCard";
21: }
22: }
23: public class Cash : ITender
24: {
25: public string Charge()
26: {
27: return "Cash";
28: }
29: }
30:
31: public interface ITender
32: {
33: string Charge();
34: }
כעת כמובן שנוכל להחליט האם אנחנו שולחים בConstructor של הקופה תשלום במזומן או באשראי וההודעה תודפס בהתאם.
אבל – זה עדיין לא כל כך נוח. הייתי מעדיף שלא להחליט בתוך הקוד באיזה אמצעי תשלום להשתמש אלא שמישהו אחר יעשה זאת עבורי. שאוכל לקרוא לאיזושהי מחלקה עם מתודה מסויימת והיא כבר תחזיר לי את אמצעי התשלום בדרך כלשהי. וכך אוכל למשל לשמור את אמצעי התשלום בקובץ XML או באופן אחר.
אם כך, אני מייצר מחלקה בשם Resolver ובה יש מתודה בשם ResolveTender המחזירה אובייקט מסוג ITender.
בעולם האמיתי תהיה לוגיקה הגיונית שתחליט מה להחזיר או מקור נתונים כלשהו, אבל בשביל הדוגמא כתבתי תנאי משתנה (האם השנייה כרגע זוגית או אי זוגית) כאשר בכל מקרה היא מחזירה אובייקט אחר.
המחלקה ניראת כך:
1: public class Resolver
2: {
3: public ITender ResolveTender()
4: {
5: if (DateTime.Now.Second % 2 == 0)
6: {
7: return new CreditCard();
8: }
9: else
10: {
11: return new Cash();
12: }
13: }
14: }
והשימוש בה יהיה כך:
1: Resolver resolver = new Resolver();
2: Till till = new Till(resolver.ResolveTender());
3: var message = till.Charge();
4: Console.WriteLine(message);
כעת, אם נריץ את התוכנה הקטנה שלנו היא תדפיס לפעמים Cash ולפעמים CreditCard.
זה הרעיון באופן כללי, אבל אנחנו מחפשים פיתרון יותר גנרי. היינו מעדיפים במקום השורה
1: Till till = new Till(resolver.ResolveTender());
משהו שנראה יותר דומה לזה
1: Till till = resolver.Resolve<Till>();
בשביל שדבר כזה יעבוד נצטרך קצת Reflection.
ראשית ניצור Dictionary שבעזרתו נמפה את האובייקטים שלנו.
השורה תיראה כך:
1: private Dictionary<Type,Type> _map = new Dictionary<Type, Type>();
כאשר הType הראשון מסמן את האינטרפייס (ITender במקרה שלנו)
והType השני מסמן מימוש קונקרטי שלו (למשל Cash)
בתוך המתודה Resolve שכזכור מקבלת T כאובייקט גנרי, ניקרא למתודת Resolve נוספת, אבל הפעם עם Type ספציפי.
נעשה את זה על ידי שימוש ב typeof.
במתודה החדשה שיצרנו נבדוק אם הType שקיבלנו נמצא בתוך ה Dictionary שלנו ואם לא נזרוק הודעת שגיאה.
1: Type resolvedType = null;
2: try
3: {
4: resolvedType = _map[typeToResolve];
5: }
6: catch (Exception)
7: {
8: throw new Exception(string.Format("Could not find {0}", typeToResolve.FullName));
9: }
כעת נירצה לאתחל אובייקט מהType שקיבלנו. אבל פה יש לנו בעיה, בגלל שאנחנו לא יודעים האם יש שם Constructor עם פרמטרים.
נתחיל במקרה הפשוט שבו אין פרמטרים.
ל Type יש מתודה בשם GetConstructors שתחזיר לנו (בתוספת First ) את הConstructor הראשון. ואז נוכל לבדוק מהם הפרמטרים על ידי שימוש במתודה GetParameters.
עכשיו ניצור תנאי –
אם אין לי פרמטרים אייצר מופע מהסוג שקיבלתי באופן הבא:
1: Activator.CreateInstance(resolvedType);
Activator מאפשר לי ליצור מופע חדש, בדיוק כמו במילה New, אלא שהיא עושה את זה כנגד Type.
אם הConstructor שלי צריך פרמטרים, אצור רשימה מסוג object המכילה את הפרמטרים שאני צריך וזאת על ידי שימוש במתודה Resolve שאותה אנחנו יצרנו. ולכן נשלח למתודה הזו לא את הפרמטר אלא את הטיפוס שלו.
1: IList<object> parameters = new List<object>();
2: foreach (var constructorParameter in constructorParameters)
3: {
4: parameters.Add(Resolve(constructorParameter.ParameterType));
5: }
כעת לא נוכל להשתמש בActivator כדי ליצור מופע חדש אלא נשתמש במתודת invoke המקבלת מערך של פרמטרים.
1: return firstConstructor.Invoke(parameters.ToArray());
כעת יש לנו Container של IoC שיצרנו בעצמנו.
אמנם אם נריץ את זה עכשיו, זה לא יעבוד בגלל שעדיין לא מיפינו את המחלקות שלנו.
לצורך המיפוי ניצור ב IoC שלנו מתודת Register המקבלת את המיפוי ומוסיפה לDictionary באופן הבא:
1: public void Register<TFrom, TTo>()
2: {
3: _map.Add(typeof(TFrom), typeof(TTo));
4: }
נצטרך לעשות את זה גם למחלקת Till – שממופה לעצמה.
וכן נצטרך למפות את אמצעי התשלום הרצוי ל Itender
1: resolver.Register<Till, Till>();
2: resolver.Register<ITender, Cash>()
כעת נוכל להריץ את הקוד ולקבל את התוצאה הרצויה.
כאמור – לא נצטרך לעשות את זה בעצמנו, יש תשתיות שעושות את זה עם תוספות רבות. אבל זה העיקרון שמאחורי הקלעים.
הקוד בכללותו למי שמעוניין:
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4:
5: namespace ConsoleApplication5
6: {
7: class Program
8: {
9: static void Main()
10: {
11: var resolver = new Resolver();
12: resolver.Register<Till, Till>();
13: resolver.Register<ITender, Cash>();
14: resolver.Register<ITender, CreditCard>();
15: var till = resolver.Resolve<Till>();
16: var message = till.Charge();
17: Console.WriteLine(message);
18: Console.Read();
19:
20: }
21: }
22:
23: public class Resolver
24: {
25: private Dictionary<Type, Type> _map = new Dictionary<Type, Type>();
26: public T Resolve<T>()
27: {
28: return (T)Resolve(typeof(T));
29: }
30:
31: private object Resolve(Type typeToResolve)
32: {
33: Type resolvedType;
34: try
35: {
36: resolvedType = _map[typeToResolve];
37: }
38: catch (Exception)
39: {
40: throw new Exception(string.Format("Could not find {0}", typeToResolve.FullName));
41: }
42: var firstConstructor = resolvedType.GetConstructors().First();
43: var constructorParameters = firstConstructor.GetParameters();
44: if (!constructorParameters.Any())
45: {
46: return Activator.CreateInstance(resolvedType);
47: }
48:
49: IList<object> parameters = constructorParameters.Select(constructorParameter => Resolve(constructorParameter.ParameterType)).ToList();
50: return firstConstructor.Invoke(parameters.ToArray());
51: }
52:
53: public void Register<TFrom, TTo>()
54: {
55: _map.Add(typeof(TFrom), typeof(TTo));
56: }
57: }
58:
59: public class Till
60: {
61: private readonly ITender _tender;
62:
63: public Till(ITender tender)
64: {
65: _tender = tender;
66: }
67:
68: public string Charge()
69: {
70: return _tender.Charge();
71: }
72: }
73:
74: public class CreditCard : ITender
75: {
76: public string Charge()
77: {
78: return "CreditCard";
79: }
80: }
81: public class Cash : ITender
82: {
83: public string Charge()
84: {
85: return "Cash";
86: }
87: }
88:
89: public interface ITender
90: {
91: string Charge();
92: }
93: }