Modulus Counter – Code Reuse vs. Micro Class
הערה מקדימה: לפעמים אפשר לכתוב קוד סתם בשביל הכיף.
בפוסט הזה אני אראה קוד שנראה די פשוט, שלא לומר על סף הטריוויאלי, ואח"כ נראה אם הוא מתאים למשימות פרקטיות. לאחר מכן נבדוק האם המחלקה ModCounter שמוצגת כאן היא over-engineering או באמת "חומר טוב" לשימוש חוזר בקוד.
ברמת הקוד, הנושא העיקרי כאן הוא פעולת המודולו, שמסומנת ב C# ע"י הסימן אחוז (%).
אז מה יהיה כאן:
- דוגמה פשוטה להתחלה
- מעבר מ inline-code ל class
- סתם בשביל הכיף – שעון
- בשביל הפרקטיקה – מוניטור לכשלונות רצופים
- סיכום – האם הגזמנו עם ה ModCounter הזה?
דוגמה פשוטה להתחלה
קצת קוד מעולם לא הזיק לאיש. אז בשביל הסיפתח:
using System; using System.Threading; namespace Groundhog.ConsoleApp { class Program { static void Main(string[] args) { Console.WriteLine("started"); DoSomeLengthyActivity(); Console.WriteLine("done, press ENTER to quit"); Console.ReadLine(); } private static void DoSomeLengthyActivity() { for (int i = 0; i < 100; i++) { Thread.Sleep(100); } } } }
המתודה DoSomeLengthyActivity מייצגת פעילות ממושכת בלולאה. כלומר פעילות שכל איטרציה שלה לוקחת די הרבה זמן (נניח 100 מילישניות) ויש לה די הרבה איטרציות כאלו.
החלק המהותי הוא ש DoSomeLengthyActivity היא מתודה ארוכה לביצוע ויש בה לולאה (או כל פירוק אחר להרבה מאוד תתי-משימות).
התוצאה היא שהמשתמש רואה הודעת פתיחה, ואז לא רואה כלום במשך 10 שניות, ועלול לחשוב שהתוכנה נתקעה. רק אחרי 10 שניות הוא מקבל את הודעת הסיום.
כדי להימנע מזה, נוכל להוסיף הודעה בכל איטרציה, כך שלמשתמש תהיה אינדיקציה שהכל עובד:
using System; using System.Threading; namespace Groundhog.ConsoleApp { class Program { static void Main(string[] args) { Console.WriteLine("started"); DoSomeLengthyActivity(); Console.WriteLine("done, press ENTER to quit"); Console.ReadLine(); } private static void DoSomeLengthyActivity() { for (int i = 0; i < 100; i++) { Console.WriteLine("still working"); Thread.Sleep(100); } } } }
אלא שעכשיו המתשמש יראה 100 הודעות שרצות מהר מאוד. אמנם יש למשתמש אינדיקציה שהתוכנה פעילה, אבל עכשיו הוא מוצף באינדיקציות כאלו, ויכול להתעלם מכולן או מחלקן כאילו היו רעש לבן.
אז כמו כל דבר בחיים, הסוד הוא המינון, ולא צריך להפריז. נוכל לאזן את האינדיקציות ע"י בדיקה קטנה של הערך של i, באופן הבא:
using System; using System.Threading; namespace Groundhog.ConsoleApp { class Program { static void Main(string[] args) { Console.WriteLine("started"); DoSomeLengthyActivity(); Console.WriteLine("done, press ENTER to quit"); Console.ReadLine(); } private static void DoSomeLengthyActivity() { for (int i = 0; i < 100; i++) { if (i % 10 == 0) Console.WriteLine("still working"); Thread.Sleep(100); } } } }
הגענו אל האיזון הנכסף: המשתמש מקבל אינדיקציה כל 10 איטרציות, שזה בערך פעם בשניה.
אבל מי שמתעסק עם הקוד הזה לא בהכרח מבין מיידית למה עובדים עם המשתנה i ועושים עליו modulus.
ומעבר לזה, לא ניתן לעבוד עכשיו עם לולאה מסוג אחר, כמו foreach, אלא רק עם לולאת for, כי הלוגיקה של האינדיקציות למשתמש "כבולה" למשתנה הלולאה.
הרשו לי להציג: ModCounter
הלוגיקה של פעולת המודולו יכולה, אם כך, לעבור למחלקה נפרדת, נקרא לה ModCounter. בתור התחלה, הקוד נראה כך:
public class ModCounter { private readonly int modBase; private int theValue; public ModCounter(int modBase) : this (modBase, 0) { } public ModCounter(int modBase, int initialValue) { if (modBase <= 1) throw new ArgumentOutOfRangeException("modBase", "value must be greater than 1"); this.modBase = modBase; theValue = initialValue % modBase; } public int Increase() { var increased = theValue + 1; if (increased == modBase) increased = 0; theValue = increased; return increased; } public bool IsZero { get { return theValue == 0; } } public int Value { get { return theValue; } } }
לפני סקירת הקוד, בואו נראה את השימוש הפשוט ב class הזה
static void Main(string[] args) { Console.WriteLine("ModCounter example"); ModCounter myCounter = new ModCounter(3); Console.WriteLine(myCounter.Value); // value is 0 myCounter.Increase(); Console.WriteLine(myCounter.Value); // value is 1 myCounter.Increase(); Console.WriteLine(myCounter.Value); // value is 2 myCounter.Increase(); Console.WriteLine(myCounter.Value); // value is 0 Console.WriteLine(myCounter.IsZero); // True Console.WriteLine("done, press ENTER to quit"); Console.ReadLine(); }
והפלט:
ModCounter example 0 1 2 0 True done, press ENTER to quit
סקירה קצרה על הקוד
- הקונסטרקטור – מקבל בסיס למודולו (חייב להיות גדול מ 1) וערך התחלתי כלשהו (ברירת מחדל – אפס). הערך ההתחלתי מנורמל לתחום שבין 0 לבסיס.
- השימוש בפעולת המודולו עצמה מתבצע רק פעם אחת – בקונסטרקטור. הרווחנו משהו, כי זו נחשבת פעולה "יקרה", אבל זה רווח זניח.
- פעולת Increase מוסיפה 1 לערך הנוכחי, ואם הוא מגיע לגבול העליון – הערך מתאפס.
עכשיו נכניס את ה ModCounter לתוכנית המקורית ונראה איך הקוד משתנה
private static void DoSomeLengthyActivity() { var myCounter = new ModCounter(10); for (int i = 0; i < 100; i++) { myCounter.Increase(); if (myCounter.IsZero) Console.WriteLine("still working"); Thread.Sleep(100); } }
מה שקבלנו בינתיים זה שהאינדיקציות אינן תלויות במשתנה הלולאה (i), אבל עדיין התחושה היא שהקוד לא מספיק קריא. אולי נשפר קצת את ה ModCounter כך שיהיה לו event באופן הבא:
public class ModCounter { public event Action OnZero; private void InvokeOnZero() { Action action = OnZero; if (action != null) action(); } // .. code .. public int Increase() { var increased = theValue + 1; var shouldResetToZero = increased == modBase; if (shouldResetToZero) { theValue = 0; InvokeOnZero(); } else theValue = increased; return theValue; } }
ועכשיו נוכל לכתוב את הקוד המקורי בצורה קצת יותר קריאה:
private static void DoSomeLengthyActivity() { var myCounter = new ModCounter(10); myCounter.OnZero += () => Console.WriteLine("still working"); for (int i = 0; i < 100; i++) { myCounter.Increase(); Thread.Sleep(100); } }
לסיכום שלב א' – העברנו את הלוגיקה של האינדיקציות ממשתנה הלולאה אל מחלקה חיצונית בשם ModCounter.
בתוך ה ModCounter, כל קריאה למתודה Increase מקדמת את הערך הפנימי ב 1, ומתבצעת בדיקה: אם הוא הגיע לגבול העליון אז הוא מתאפס והאירוע OnZero מופעל.
בשביל הכיף – ClockCounter
נשחק קצת עם ה ModCounter שלנו וניצור מחלקה חדשה בשם ClockCounter, שמדמה שעון, שאפשר לתת לו ערך התחלתי ולקדם אותו בשניה מתי שרוצים.
נכון, המלעיזים יגידו שאפשר לעשות את זה עם TimeSpan, אבל זה רק בשביל הכיף, אז המלעיזים יכולים להלעיז חופשי, אני בכלל עם אוזניות.
הקוד? הנה הקוד:
public class ClockCounter { private readonly ModCounter seconds; private readonly ModCounter minutes; private readonly ModCounter hours; public ClockCounter() : this(0, 0, 0) { } public ClockCounter(TimeSpan initialTime) : this(initialTime.Hours, initialTime.Minutes, initialTime.Seconds) { } public ClockCounter(int hours, int minutes, int seconds) { this.seconds = new ModCounter(60, seconds); this.minutes = new ModCounter(60, minutes); this.hours = new ModCounter(24, hours); this.seconds.OnZero += () => this.minutes.Increase(); this.minutes.OnZero += () => this.hours.Increase(); } public void IncreaseSecond() { this.seconds.Increase(); } public TimeSpan Time { get { return new TimeSpan(hours.Value, minutes.Value, seconds.Value); } } }
והשימוש בקוד:
static void Main(string[] args) { Console.WriteLine("started ClockCounter example"); var clockCounter = new ClockCounter(22, 59, 59); Console.WriteLine(clockCounter.Time); clockCounter.IncreaseSecond(); Console.WriteLine(clockCounter.Time); Console.WriteLine("now let's try again"); clockCounter = new ClockCounter(23, 59, 59); Console.WriteLine(clockCounter.Time); clockCounter.IncreaseSecond(); Console.WriteLine(clockCounter.Time); Console.WriteLine("done, press ENTER to quit"); Console.ReadLine(); }
והפלט, כצפוי:
started ClockCounter example 22:59:59 23:00:00 now let's try again 23:59:59 00:00:00 done, press ENTER to quit
חביב? -בהחלט. כמעט כל הקוד הוא ב wiring-up בקונסטרקטור, וזה מגניב. שימושי? -לא ממש. 😛
בשביל הפרקטיקה – HealthMonitor
בואו ננסה בכל זאת לעשות משהו פרקטי – מוניטור על פעילות תקינה של מערכת.
נניח שיש לנו Web-Service שאמור לקבל request-ים לעיתים מזדמנות, כלומר לא באופן אינטנסיבי ולא לעיתים רחוקות, נניח פעם בחצי שעה (בממוצע).
ה Web-Service הזה צריך לבצע הרבה פעולות לכל request, ובסופן יש לו מדד אחד ויחיד: הצליח או נכשל. "הצליח" משמעותו שהמערכת קלטה בהצלחה את ה request וטיפלה בו במלואו, ו"נכשל" משמעותו שמשהו השתבש בדרך, ואין מה לעשות כרגע. ההסתברות לשיבוש היא לא זניחה, ונרצה לעשות קצת live-monitoring על המערכת שלנו. הלוגיקה של ה monitoring (בעברית זה "ניטור") היא פשוטה: אחרי X כשלונות רצופים – להודיע משהו למישהו במייל. במידה שהיתה הצלחה אחרי כשלון מספר 1, 2, או כל ערך קטן מ X – מונה הכשלונות מתאפס ומתחילים מחדש.
לשם כך נצטרך רק להוסיף את המתודה Reset ב ModCounter שלנו (שפשוט תאפס את המונה הפנימי שלו) ולעטוף אותו ב class חדש שנקרא לו HealthMonitor, שנראה כך:
public class HealthMonitor { public int MaxConsequtiveFailures { get; private set; } private readonly ModCounter modCounter; public HealthMonitor(int maxConsequtiveFailures) : this(new ModCounter(maxConsequtiveFailures)) { MaxConsequtiveFailures = maxConsequtiveFailures; } private HealthMonitor(ModCounter modCounter) { this.modCounter = modCounter; this.modCounter.OnZero += InvokeReachedMaxConsequentFailures; } public void Pass() { modCounter.Reset(); } public void Fail() { modCounter.Increase(); } public event Action ReachedMaxConsequentFailures; private void InvokeReachedMaxConsequentFailures() { Action failures = ReachedMaxConsequentFailures; if (failures != null) failures(); } }
השימוש ב HealthMonitor מאפשר להפריד בין הלוגיקה של דיווח על הצלחה/כשלון (שמגיע מתוך ה Web-Service) לבין הטיפול ברצף של כשלונות (שיכול להיות בכל מקום אחר באפליקציה), מתוך הנחה שיש instance יחיד של ה HealthMonitor באפליקציה שלנו. אפשר לממש את זה בכל מיני צורות (סינגלטון או סתם משתנה סטאטי באחת המחלקות של האפליקציה). לצורך הדוגמה, ניקח את המקרה הפשוט של משתנה סטאטי זמין לכל, ונאתחל אותו ב Global.asax. הנה הקוד:
public class Global : System.Web.HttpApplication { public static HealthMonitor HealthMonitor; protected void Application_Start(object sender, EventArgs e) { HealthMonitor = new HealthMonitor(3); HealthMonitor.ReachedMaxConsequentFailures += SendMailToAppAdmin; } void SendMailToAppAdmin() { // code to send a warning mail // to the application administrator } // .. code .. }
ואז הקוד שלנו ב Web-Service יראה כך:
public class MyService : System.Web.Services.WebService { [WebMethod] public void DoSomething() { bool allOk = false; // do a lot of stuff, // set allOk to false if needed // ... if (allOk) Global.HealthMonitor.Pass(); else Global.HealthMonitor.Fail(); } }
סקירה קצרה
- הקוד של ה Web-Service לא מטפל ברצף של כשלונות, הוא רק מודיע על הצלחה או כשלון.
- הקוד הוא לדוגמה בלבד, ואין כאן התייחסות להיבט של Multi-Threading (שאכן מתקיים בסביבת Web).
- הקוד המוצג כאן מגדיל את הצימוד בין ה Web-Service לבין המחלקה Global (שהיא ה Global.asax.cs) שלנו. וזה, במילים עדינות, לא להיט.
אני רק מציין את הדברים האלה, כדי שזה יהיה איפשהו בראש אם מישהו יאמץ את הדוגמאות כאן, אבל אני לא משנה את הקוד המוצע, פשוט כי זה לא הפוקוס של הפוסט.
סיכום
בפוסט הזה הצגתי מחלקה סופר-פשוטה בשם ModCounter. כל תפקידה הוא קידום מונה פנימי שישמור תוצאה שהיא מודולו בסיס מסויים. תוך כדי הפוסט הרחבתי אותה קצת פה ושם, והיא עדיין נשארה פשוטה. למעשה ה ModCounter הוא state-machine (ואני, מה לעשות, די מחבב state-machines). הראיתי שימוש פרקטי ב ModCounter ע"י מחלקה שעוטפת אותו, שמבצעת monitoring למערכת. האם הייתי משתמש ב ModCounter בפועל? התשובה היא כן, המחלקה HealthMonitor היא לא המצאה בשביל הפוסט, היא באמת נכתבה למערכת קיימת (עם שינויים קלים).
ועדיין נשארת אצלי השאלה, האם באמת כדאי להכניס לוגיקה יחסית פשוטה לתוך class. איפה עובר הגבול בין good-practice לבין over-engineering ("הינדוס יתר"). היתרון הגדול של לוגיקה פשוטה בתוך מחלקה משל עצמה הוא שאפשר לבדוק אותה בקלות. מרגע שהיא נבדקה ונמצאה תקינה (עד כמה שאפשר) – אפשר להשתמש במחלקה הזו בכל הזדמנות, ואז גם קבלנו שימוש חוזר בקוד. יתרה מכך, זה קוד שאנחנו סומכים עליו שהוא תקין.
החסרון הוא מהצד של over-engineering – פעולות כל כך פשוטות יכולות להישאר כקוד שחוזר על עצמו. אין טעם ליצור micro-class, כי אז הקוד הופך להיות לא מספיק קריא. וזה עוד לפני שדברנו על להוסיף רפרנס לאסמבלי/פרוייקט חיצוני.
אז חזרנו לדרך האמצע, לאיזון הנכסף, בין קוד קריא לקוד שקל לתחזק אותו. בין שימוש חוזר לבין over-engineering. במה אתם תבחרו? איפה אתם עומדים בסקאלה הזו?
האמת, בלי קשר לבחירה שלכם – כמו שכתבתי בתחילת הפוסט, לפעמים זה כיף לכתוב קוד גם אם הוא לא הכי שימושי :-).
תכנות נעים!
וכמובן – כל הקוד זמין כאן.
אם יורשה לו להגיב