יום שבת, 17 בפברואר 2018

קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ח': קוטלין וג'אווה (interoperability)

פוסט זה הוא המשך של:
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק א': הבסיס
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ב': פונקציות
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ג': מחלקות
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ד': עוד על מחלקות, אובייקטים, ועוד...
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ה': DSLs
קוטלין (Kotlin) למפתחי ג'אווה ותיקים - חלק ו': Collections ו Generics


הפעם אני רוצה לדבר על Interoperability בין קוטלין וג'אווה.

באופן כללי, ה interoperability בין השתיים הוא מצוין - וייתכן ותוכלו לעבוד זמן ארוך מבלי להיתקל בבעיות.

מתישהו... במקרי הקצה - זה יגיע.
משהו בג'אווה לא יאהב משהו בקוטלין (או אולי ההיפך - אבל זה פחות נפוץ).


מקור


כיצד null יכול לצוץ לו בקוטלין ללא הודעה מוקדמת?


כבר כמה פעמים נשאלתי את השאלה: "האם אפשר לרשת מחלקת ג'אווה בקוטלין? קוטלין בג'אווה?"

בוודאי שאפשר! אחרת לא הייתי אומר ש interoperability ביניהן כ"כ מוצלח.

מיד נראה שזה אכן המצב, ועל הדרך נדגיש פינה חשובה לגבי nullability (שעלולה לקרות בהורשה, אך לא רק):

ערבוב של קוד קוטלין וג'אווה לצורך רצף הקריאות. במציאות כמובן שהקוד ישב בקבצים נפרדים.
  1. יצרנו מחלקה מופשטת A בשפת ג'אווה.
  2. הרחבנו את המחלקה בג'אווה A - בעזרת מחלקה בקוטלין B.
    1. מכיוון שברירת המחדל בקוטלין היא מחלקה final - עלינו להגדיר אותה כ open ע"מ שקוד הג'אווה יוכל לרשת את המחלקה C.
  3. ואכן הרחבנו את המחלקה בקוטלין B בג'אווה, ללא בעיה. כל שפה שומרת על הקונבנציות שלה (במידת האפשר)
  4. הממ... ה IDE מעיר לי פה משהו: 
Not annotated method overrides method annotated with @NotNull 

מה זה?
אני לא רואה Annotation בשם NotNull@ בקוד.



מה? java.lang.NullPointerException? - אבל אני כותב בקוטלין!?



בכדי להבין מה קורה, נחזור שלב אחר אחורה - למחלקה KotlinB.

במחלקה הזו דרסנו את המתודה ()getHelloMessage שהוגדרה בג'אווה.
ערך ההחזרה של המתודה שהוגדרה בג'אווה הוא String, אבל מה זה אומר עבור קוטלין: String או ?String, אולי?



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


והנה השימוש שלה בקוטלין:


ה IDE מסמן לי שערך ההחזרה של המתודה הזו הוא !String.

אין טיפוס כזה בקוטלין, וטעות נפוצה היא להניח ש !String הוא ההיפך מ ?String - כלומר: String שהוא בהכרח לא null.

מה שבאמת ה IDE מנסה לומר לנו הוא שהוא לא יודע אם ה String הוא null או לא. אני חושב שתחביר כמו [?]String היה יכול להיות יותר אינטואיטיבי.


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

other?.java?.object?.always?.might?.be?.null()


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

זה נוח, אבל גם יכול לגרום לשגיאות בלתי צפויות.


הנה דוגמה מהחיים:


jdbi הוא פריימוק רזה (וחביב) הכתוב בג'אווה, ומאפשר גישה לבסיס הנתונים.
אופן השימוש בו הוא להגדיר interface או abstract class עם מתודות ומוסיף להן annotation עם השאילתה שיש לממש.
jdbi, בעזרת reflection, מג'נרט (בזמן ריצה) אובייקט DAO שמממש את הממשק שהגדרתי. התוצאה היא אובייקט שמבצע את השאילות שהגדרתי ב annotations. קוד עובד.

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

...עד הרגע שאני מפעיל את השאילתה עם job_id שלא קיים - וחוזר לי null.
מתישהו אני "חוטף" NullPointerException.

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

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

מה שצריך לעשות הוא להגדיר בצורה נכונה את ערך ההחזרה של הפונקציה. קוד הפונקציה, להזכיר, ממומש בג'אווה.
במקרה הזה הוא גם מג'ונרט בזמן ריצה - היכן שהקופיילר כבר סיים את עבודתו - אבל עצם הבעיה הוא חוסר ההבחנה של ג'אווה בין nullable string ל not-null String.



עלי לשים לב שמדובר בשאילתת SELECT, ויותר מכך: עם תנאי WHERE - ולכן יכול לחזור null.
עליו להנחות את הקופיילר בהתאם - ואז הכל יתנהג כצפוי.


במקרה הזה קוד הג'אווה אינו בידי - הוא של ספריית צד-שלישי.
אבל מה אם זה היה קוד שלי? מה יכולתי אני לעשות בקוד הג'אווה בכדי למנוע את הסיטואציה?

JebBrains (החברה מאוחרי קוטלין ו IntelliJ) סיפקה annotations לג'אווה שיכולים להנחות את ה IDE, מתי צפוי null ומתי לא ייתכן null. הנה דוגמה:



השימוש ב annotation מסיר מה IDE את הספק:


ואז הוא יכול להגן עלי.

אין כנראה פתרון טוב יותר: ואישהו בתפר בין ג'אווה וקוטלין עלולים "לזלוג" nulls מג'אווה לקוטלין.
סימון ה Nullability בעזרת annotations הוא לא תמיד אפשרי (למשל: ספריית צד-שלישי) וגם אותו אפשר לשכוח.


באופן דומה, אגב:

(Mutable)List<String>

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

הנה דוגמה לביטוי מורכב:

  • הרשימה ו/או האיברים בה עלולים להכיל ערך null.
  • הרשימה עשויה להיות mutable או לא.

קוד הג'אווה מאחורי המתודה ()getStrings הוא זה:


מה שמוביל אותנו לעניין נוסף שכדאי להכיר:
כאשר מתודה בג'אווה נקראת ב naming של JavaBeans, כלומר: ()getXxxx או ()setXxxx - קוטלין מתייחסת אליהם כתכונה בשם xxxx.

הנה הקוד בקוטלין שקורא לקוד הג'אווה הנ"ל:


אתם רואים שהשלפנים (getters) שכתובים בג'אווה נראים בקוטלין כמו תכונות לכל דבר.
מכיוון ש true היא מילה שמורה בקוטלין, יש לעטוף (escaping) אותה בגרש מוטה.

באופן סימטרי, תכונות (properties) שהוגדרו בקוטלין כ yyyy יופיעו בקוד הג'אווה כמתודות ()getYyyy ו/או ()setYyyy.


כדרך אגב, יכולת ה escaping של שמות בקוטלין - מאפשר לתת שמות קריאים יותר לפונקציות של בדיקות:


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




חשיפה מתוכננת


העיקרון המנחה ב interoperability בין קוטלין וג'אווה הוא שכל שפה תדבוק בקונבנציות שלה.

אם יש לי תכונה בשם yyyy בקוטלין (מה שטבעי בקוטלין), הגישה אליה תהיה בעזרת getYyyy ו setYyyy - מה שטבעי בג'אווה.

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


אאוץ. אאוץ!!

הנה רשימת בעיות:
  • כאשר אני קורא לתכונה now מג'אווה - שם הפונקציה מופיע כ ()getNow, ומסיבה כזו או אחרת אני רוצה להשתמש בשם now כ field.
  • המילה transient היא מילה שמורה בג'אווה - אך לא בקוטלין. אי אפשר לקרוא לפונקציה הזו מתוך ג'אווה, ואין escaping בג'אווה המאפשר להשתמש בשמות שאינם תקינים בשפה.
  • אני לא יכול ליהנות מהערך ברירת המחדל של המתודה repeat. אין קונספט של default value בג'אווה - ולכן אני נדרש לשלוח את שני הפרמטרים בכל קריאה. בריבוי קריאות - זה יכול להיות מעצבן!
  • יצרתי companion object על מנת "לחקות" מתודות סטטיות בג'אווה - אבל הדרך לקרוא ל foo היא באופן: ()KotlinProducer.Companion.foo. מסורבל!


מה עושים?

הנה הפתרון, מבוסס annotations - אך עובד:


JvmOverloads היא הנחיה להשתמש ב default values על מנת לג'נרט מופעים שונים של פונקציות בקומבינציות השונות, מה שנקרא בג'אווה Method Overloading. אני מניח ששאר ה annotations הן self-explanatory.

הוספתי גם דוגמה לשימוש ב extension function. איך משתמשים ב extension functions מתוך ג'אווה?!


הנה קוד הג'אווה שמשתמש בקוד הקוטלין, "בהנאה":


דיי אלגנטי, מלבד ה Extension Function שבאמת לא משתווה לשימוש בקוטלין.
יש לציין את שם המחלקה (כל קובץ של קוטלין מתתרגם למחלקה עם Kt בסופה) נקודה שם המתודה - כאשר הפרמטר הראשון הוא האובייקט עליו אנו רוצים לפעול.

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


סיכום


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

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

ה interoperability בין ג'אווה וקוטלין פשוט עובד!

יצא לראות לא מעט קוד קוטלין (צד-שרת) שעובד:
  • בצורה אינטנסיבית עם ספריות של ג'אווה.
  • ספריות ותיקות, שנכתבו לג'אווה - עוד לפני שקוטלין הייתה מעניינת.
  • ספריות שמבצעות reflection והורשה לקוד הקוטלין שנכתב (למשל: JDBI, Guice, או Jackson שמקודד עשרות רבות של מחלקות ל json ובחזרה לקוטלין)
  • והעבודה הייתה בסה"כ חלקה מאוד!
    • במקרים מעטים היה צורך / או היה יפה יותר להשתמש בכלים שסיפקתי בפוסט הזה.
    • במקרים מעטים נאלצנו לכתוב קוד "java-like" בקוטלין, על מנת שדברים יעבדו. עם הזמן צצו wrappers לקוטלין שהקלו על הדברים, ואפשרו להשתמש בסגנון "קוטליני" בחופשיות.



שיהיה בהצלחה!



יום ראשון, 7 בינואר 2018

מודל אנמי (Anemic Domain Model) - דפוס עיצוב שלילי


מודל אנמי (Anemic Domain Model, או בקיצור ADM) הוא תיאור שמתייחס למודל "מוחלש" בצורה שאתאר מיד.

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

  • במבט ראשון המחלקות נראות הגיוניות: יש להן גודל הגיוני, תחום התעסקות הגיוני, מתודות הגיוניות וכו'.
  • במבט שני אנו שמים לב שבעצם רוב הפעולות של ה state מתבצעות במקום "מרוחק" - כל פעילות על Order מתבצעת ב OrderManager.

באופן טיפוסי המחלקה בעלת המתודות תיקרא בשמות כגון xxxManager, xxxService, xxxHandler או xxxUtils - שמות המעידים על קשר ברור, אך אחריות כללית שקשה להגדירה.

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

בצורה יותר פורמאלית ניתן לומר שיש לנו שתי מחלקות בעלות high cohesion (קרבה רעיונית גדולה) אבל גם high coupling (תלות חזקה). מדוע אם כן אלו שתי מחלקות, ולא מחלקה אחת?!

מה הבעיה לחבר ביניהן?

ובכן - לכו ובדקו.


בד"כ זה לא עניין של קובץ גדול מדי (כמה גדול יכול להיות ה state?!). סיכויים טובים שתגלו ש:
  • אובייקט ה Order הוא Data Access Object (מכיל ידע על שמירה לבסיס הנתונים) או Data Transfer Object (מכיר ידע על serialization בכדי לעבור על גבי הרשת) - קוד שמרגיש לא נכון לערבב אותו עם "Business Logic", או
  • יש יותר ממחלקה אחת המבצעת פעולות על Order. למשל:


דפוס שכדאי לשים לב אליו הוא מתודות ב Service/Manager/Handler המקבלות את האובייקט כולו לצורך פעולות של שינוי state. משהו בנוסח:

updateItemPrices(Order o) // updates the order with up-to-date item prices
מתודה כאלו נקראות external methods.




מקור

"אחי, מה כואב לך?"


מה אכפת לנו, בעצם?

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

אז מה זה משנה שיש לנו Anemic Domain Model (בקיצור: ADM)?


נתחיל בדוגמה ממקום קצת אחר.

מכירים את המצב בו יש לנו מערכת חיצונית בה משתמשות מספר מחלקות במערכת, מערכת לשליחת מיילים - למשל?


האם נרשה לכל מחלקה במערכת לגשת אליה ישירות?

מה פתאום!!!

בסיטואציה כזו, הקונצנזוס הוא גורף ומיידי!
  • "ומה אם נרצה להחליף את מערכת צד השלישי לספק אחר?"
  • "ומה אם נרצה להוסיף פונקציונליות (למשל throttling או logging) גורפת לכל העולות מול אותו ספק?"

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


מדוע אם כן אנו שלווים, כאשר יש מספר מחלקות הניגשות ל state במערכת, שלא ע"י נקודת גישה אחת?

  • מה יקרה אם נרצה לשנות את הייצוג של ה state של Order / לבצע refactoring?
  • מה יקרה אם נרצה להוסיף פונקציונליות (אימות נתונים, או Logging) גורפת על ה state הזה?

הסיטואציות הן שקולות. 
יתרה מכך: שינוי state באובייקט ליבה של הדומיין הוא נפוץ יותר מהחלפה של ספק חיצוני.

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



בטבע פותרים אנמיה בעזרת תזונה טובה יותר.
בתוכנה פותרים אנמיה בעזרת הכמסה ו/או Rich Domain Model.


מה הבעיה עם ADM? - ניתוח פורמאלי



הבעיה העיקרית של ADM היא המחסור בהכמסה (Encapsulation).

האובייקט Order לא יכול "לשלוט" במה שקורה ל properties שלו - כי "כל העולם נוגע ב properties בלי לשאול אותו". כלומר: אין מתודות שליטה בגישה ל properties - בהן אפשר לבצע validations או לקבוע התנהגויות.

כתוצאה מכך:
  • יווצר קוד כפול שבודק את תקינות ה properties של Order - באופן אקראי במחלקות A, B, C.
  • קוד הבדיקה, ותפעול (שינוי הערכים) של Order properties, במחלקות A, B, C - לא יהיה עקבי, וייתכן שיכיל חוסר-התאמות ואף סתירות.
  • מכאן יווצרו באגים, שיאטו את הפיתוח בשני אופנים:
    • הצורך לתקן את הבאגים - שקשה למנוע את היווצרותם.
    • הפחד מלבצע שינויים במערכת (המחשבה ש״שינוי״ יגרום ל״באג״) -> הקוד פחות מתחדש, פחות מתעדכן לצרכים העדכניים מהמערכת -> יותר עבודה להכניס שינויים.
  • הבעיה תלך ותחמיר ככל שהמערכת תגדל: יותר קוד (A עד F שעובדים עם Order, במקום A עד C), ויותר מפתחים שעובדים על ה codebase. 
    • הבאגים שיווצרו יהיו גם הם - קשים יותר לאיתור.
  • עם הזמן הערכת הזמנים לשינויים בקוד הופכת להיות פחות אמינה: שיניתי ב A את הדברים, אבל רק לקראת סיום גיליתי שב C יש מנגנון שלם מסביב ל state שנגעתי בו - וצריך להמשיך בפיתוח בלתי-מתוכנן.
  • זו לא בעיה מסוג הבעיות ש״מתפוצצות״, אלא מסוג הבעיות שהן רגרגסיות הדרגתיות שלא מבחינים בהן בזמן שהן מתרחשות.
אני לא יודע כיצד להדגיש מספיק עד כמה הבעיה הזו הרסנית למערכת, במיוחד אם מדובר בקוד בליבת המערכת (core domain model), ובמיוחד כאשר מדובר במערכת בעלת חוקים עסקיים מורכבים.

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

כאשר אנחנו "מגלים" שיש לנו מצב לא תקין בשלב 4 - נעשו כבר נזקים, ויותר קשה לנו לאתר את שלב 1 - בו באמת קרתה התקלה.

הנטיה הטבעית, קצרת הטווח, היא להוסיף הגנות נוספות ב Class W, ולאחר שגילינו שדברים הסתבכו - עוד בדיקות ב Class Y. התוצאה: כפילות קוד, חוסר קונסיסטניות, ואי פתרון שורשי של הבעיה!

תופעת לוואי נוספות הן בדיקות פחות יעילות: מכיוון שקוד אימות ה state נמצא ״עמוק״ ב branching של הקוד - סביר יותר שהבדיקות יריצו תסריטים בעייתים מבלי שהמצב יתגלה:


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

הפתרון הוא כמובן פשוט מאוד - Fail Fast!
ואת זה משיגים בעזרת בדיקות תקינות ה state - ברגע שהוכנס ערך, ולא שורה מאוחר יותר.


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



אני רוצה לחדד פעם נוספת את הבעיה שביכולת לבצע שינויים ב state ממקומות שונים, ומדוע בעיות של data integrity  (להלן ״שלמות נתונים״) גורמות להאצה וסיבוך של באגים בקוד.

ניקח כדוגמה את המחלקה Triangle - משולש. לא מחלקה פשוטה מדי (אחרת ייתכן ואין בעיה), אבל מספיק פשוטה בכדי להשתמש בה בפוסט.

הנה דוגמה:


יש המון וריאציות של משולשים תקינים, אך לא כולם כאלו.

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

זו לא דוגמה ליעילות תפעולית.
זו גם דוגמה לבעיה שאפשר להניף בה הרבה אצבעות מאשימות, ולפספס בכלל שמדובר בבעיה ארכיטקטונית (הרי מישהו כתב קוד במחלקה C. למה הוא לא בדק מה מתרחש במחלקה B?!).


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

הנה שיפור, עם יותר constraints, ויותר ערכים מחושבים (derived values):


כאן כבר לא נוכל לקבוע משלוש עם סך זוויות פנימיות של 190 מעלות, או ארבע זוויות פנימיות. זהו שיפור.

אבל עדיין נוכל לקבוע:
  • זווית של 0 מעלות
  • זווית של 200 מעלות, ולגרור מכך זוויות עם ערכים שליליים של זוויות
  • 3 זוויות שמסתכמות ל 180 מעלות, ושלוש צלעות סבירות לכאורה - שהן עדיין לא משולש הגיוני.
שימוש ב derived values הוא כלי רב-עוצמה לאימות נתונים פשוט ויעיל, אבל לא תמיד פשוט ו/או נכון לגזור נתון מנתונים אחרים - זה יכול לסבך את הקוד יתר על המידה.

המגבלה שאיני יכול לקבוע את הזווית השלישית בצורה ישירה - יכולה בקלות לסרבל את הקוד שמתשמש ב Triangle.


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

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


הנה דוגמה לאובייקט משולש עם אימות נתונים קפדני יותר:

הקוד לא נבדק. זהו pseudo code שנכתב בזריזות
עדכון: תודה לעידו שהעיר שהקוד הזה לא ישים. יש לאפשר מתודה set המקבלת את שלושת הזווית / צלעות - כי כמעט כל שינוי במשולש כך שיישר תקין מצריך לפחות זוג של שינויים. אני מניח שעדיין אפשר להבין את העיקרון.
הערה נוספת: שווה להוסיף בדיקה ל value גדול מ 0. 

אימות הנתונים הקפדני שווה רק כל עוד יש הכמסה על ה state.
אם מסירים את ה private מהשדות הפנימיים - כל האימות הופך לפרוץ עד חסר-משמעות.


מודל אנמי - שלב 2


עוד בעיה שמתרחשת עם מודל אנמי היא טרנזיטיביות של התלויות.

נניח שיש לנו מודל מורכב יותר (composite) של הזמנה - ופריט בהזמנה (להלן LineItem). למשל: ההזמנה היא הזמנה מהסופר, וה LineItems הם קורנפלקס, חמאת בוטנים, ולחם אחיד.

האם נכון שה Service Classes (או אובייקטים אחרים) המכירים את Order, יכירו גם את LineItem ואם כן - באיזו מידה?



הערה חשובה: מדובר על מצב בו בין Order ו LineItem יש קשר של composition (ב UML: מעוין שחור / "כתף מלאה"), כלומר: לאובייקט LineItem אין קיום או משמעות ללא אובייקט Order.

אם מחלקת שירות יכולה לשנות ערכים מ LineItem מבלי שה Order יידע או יוכל להגיב - מדובר בהפרת ההכמסה.
אם, גרוע יותר, מחלקת שירות יכולה לכתוב ולקרוא LineItem ישירות מה DB מבלי שה Order יהיה מעורב - מדובר בהפרת הכמסה.

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

הסכנה אם כן היא שמחלקת שירות תבצע שינויים על LineItem כלשהו, ותיצור:
  • סתירה / חוסר-התאמה עם תכונה של אובייקט ה Order
    • למשל אובייקט ה Order צריך לדעת אם צריך משלוח עם קירור, אבל הוספה של קוטג' למשלוח תשנה את המצב מבלי שהוא יהיה מודע לו.
  • סתירה / חוסר-התאמה עם ה state של LineItem אחר.
    • למשל: יש כלל שאסור להכניס לאותה הזמנה בשר וחלב. אם יש פריטים מסוג "חלבי" אין לאפשר הוספהש של פריטים מסוג "בשרי" להזמנה (ולהיפך).
הערה: אם אף אחד מהתנאים הללו לא מתרחש, ולא הגיוני שיתרחש עם התפתחות המערכת - אז כנראה שאין בעיה לאפשר למחלקות אחרות גישה לאובייקט הכפוף (LineItem) ללא מתן שליטה לאובייקט המרכז (Order).


אם כן אפשרנו גישה ללא-הכמסה, התוצאות הן שוב:
  • שכפול לוגיקת אימות-נתונים בין הצרכנים של Order.
  • ריבוי תלויות במערכת, הגברת ה fragility (הסבירות ליצירת באגים תוך כדי שינויי קוד - עולה) ול rigidity (שינוי אחד במערכת, מאלץ אותנו לשינויים לא-צפויים בחלקים אחרים של המערכת).
  • באגים קשים לאיתור, ובדיקות פחות יעילות.
שום "פיצוץ" - רק דעיכה מתמשכת.
אני מזכיר את זה בשל קיומם של מנהלים שרגישים לשבירות ("באג בפרודקשיין! חמור מאוד!"), אבל אטומים בפני הסדקים שגורמים לשבירות הללו ("קוד לא יפה? קוד שלכם, באחריותכם. לא מעניין אותי.").


מה עושים?
בד"כ בעזרת עבודה עם Rich Domain Model (להן RDM).
אובייקט שהוא Composite (נקרא גם Aggregate) הוא בעל האחריות לגישה לאובייקטים הכפופים שלו.

פתרון מקובל הוא לחשוף interface המכיל גישה read-only (ומגובלת מעבר לכך ע"פ הצורך) לאובייקט הבן.
אובייקט ה Order יחזיר LineItemInfo ע"פ הצורך במתודות שלו.
בכדי לבצע שינוי ב LineItem, יש לקרוא ל Order, עם אובייקט ה LineItemInfo כפרמטר: "זה? תן לו 2 בכמות", או לסירוגין "כל האובייקטים שנוצרו אתמול - בטל אותם".

אופי האינטרקציה הזה מאפשר ל Order לוודא ש "2 בכמות" הוא מצב תקין, שלא מפר אף כלל עסקי.


כלל אצבע שימושי הוא שאסור לאפשר למחלקת השירות - לגשת ל id (מזהה ייחודי) של אובייקט ה LineItem.

ברגע שיש אחיזה ב id - מתחילה ההידרדרות הפוטנציאלית לכיוון שבירת ההכמסה: בהתחלה שמירת ids אצל מחלקת השירות, שעכשיו מסוגלת לזהות את המופעים השונים. בהמשך, אח"כ אולי גישה אליהם דרך flow אחר, או (חס וחלילה) ישירות ב DB.

אם מחלקת השירות לא יכולה להגיע ל Id - אז היא לא יכולה להתדרדר לעוולות הללו.

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

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

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





קונטרה


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


Rich Domain Model הוא Overhead מיותר. כתיבה של קוד מיותר.

זה נכון: Anemic Model היא גישה חסכונית יותר בכתיבת קוד. אפשר לפתח איתה מהר יותר - עד גודל מסוים של קוד. גישה זו נקראת גם transaction script.

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

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

אם הקדמתם סטארט-אפ, ולאחר שנה אתם מסתכלים על הקוד והוא נראה טוב - כנראה שעשיתם over-engineering.
בכל זאת, בשלב מסוים - חשוב לעשות את המעבר ולכתוב קוד robust יותר.

אם המערכת היא מערכת CRUD, שעיקר עיסוקה הוא לשלוף נתונים, לקבץ אותם ולהעביר לצד הלקוח - יש מצב ש RDM אינו מצדיק את התקורה הנלווית.



בואו נעשה RDM בצורה עצלה: נתחיל לכתוב קוד כ ADM, וע"פ הצורך - נעבור ל RDM.

לכאורה זהו טיעון הגיוני מאוד. מאוד אג'ייל.
התשובה שלי היא כזו:
  • ככל שהאובייקט גדל, עלות ההמרה ADM->RDM תגדל גם כן. זה יכול להגיע לשעות, ואולי אפילו ימים.
  • עלות שכזו, באופן מעשי, היא חסם ממשי לביצוע המעבר. למשל:
    • אני עובד על פיצ'ר ואז מבין שהוספתי שדה שיכול להיות חלק מ state לא תקין.
    • מה יותר טבעי? להעלים עין ולתת למפתח הבא לבצע מעבר ל RDM, או להשקיע עוד 3 שעות בלתי צפויות לקראת סיום כתיבת הפיצ'ר?
  • בקיצור: לאנשים מאוד ממושמעים, זה יכול לעבוד. כישראלים - עדיף להשקיע את עלות ה RDM בעוד האובייקט קטן, כל עוד זוהי "מדרגה" קטנה ולא כ"כ מורגשת. נוסיף בכך עלות קטנה לפיתוח - אך עלות שתשתלם ככל שהמערכת תגדל ותסתבך.


העיקרון הפתוח סגור (OCP) מחזק את גישת ה ADM. נראה לי ש ADM הוא יותר SOLID מ RDM, לא?

למשל: למה לחזור ולשנות את Order שוב ושוב ולהסתכן בשבירת קוד? יותר אמין לכתוב קוד חדש ב Class A, ולאחר מכן ב Class B, וכו' - וכך להיות "open for extension, but closed for modification"!

על זה אני עונה:
  • ה OCP הוא עקרון שנוי-במחלוקת. הוא מבוסס על אמיתה נכונה, אך יש בו חור גדול: חסרה בו הנחיה מתי יישום OCP הוא טוב, ומתי הוא גורם לנזק. 
    • כן - הוא בהחלט עלול לגרום לנזק.
  • core domain model הוא בדיוק המקום שאתם רוצים לשנות קוד שוב ושוב, תחת הסיכון ליצירת רגרסיה בהתנהגות. 
    • אחרת מה? הקוד שלכם יתפזר לכל עבר?
    • איך באמת נראה לכם שזה יהיה יותר SOLID?!


ADM הוא בעצם תכנות פונקציונלי (Functional Programming). תכנות פונקציונלי הוא בטוח דבר טוב, והפרדה בין נתונים לפעולה - היא בעצם Best Practice.
  • יש פיל שעיר, מדיף ריחות עזים, ורועש במרכז החדר - שמשנה את התמונה מקצה לקצה. בואו לא נתעלם ממנו.
    אם לא הבחנתם בה - זוהי תכונת ה Immutability.
  • בתכנות פונקציונלי ה Service Classes לא מסוגלים להגיע ל state לא תקין, כי הם לא יכולים לשנות state של אובייקט שהוא immutable.
  • בנוסף, גם בתכנות פונקציונלי טוב שאובייקט המתאר state יבצע בעצמו את כל האימותים. מקום אחד + אחריות מלאה.
  • להזכיר: כל רעיון טוב ניתן לממש בצורה שגויה. Functional Programming - הוא לא יוצא דופן.

ההבדל בין mutable state ל immutable state הוא תהומי כאן.
להכמסה יש גם חשיבות עבור קריאת ערכים שלא לצורך שינוי: צמצום התלויות והיכולת לשנות מבנה פנימי מבלי לזעזע את המערכת - אך היא משנית לחשיבות של שמירה על state עקבי ותקין.



סיכום


Anemic Domain Model, או בקיצור ADM, הוא דפוס שלילי (Anti-Pattern) של מבנה שעלול לגרום לנזק מתמשך והרסני למערכת שלכם, בצד הארכיטקטורה.

כמו כל כלל, יש גם לו יוצאים מן הכלל. כשהמערכת בחיתוליה ו/או כאשר יש אפליקציית CRUD [א] - RDM עשויה ליצור עלות נוספת ולא-משתלמת.

בסה"כ, אם אתם בעולם של Business Software, תוכנה המבטאת ומיישמת כללים עסקיים מורכבים - Rich Domain Model הוא כנראה הבסיס לכתיבת קוד יעיל לצמיחה ולתחזוקה.  No way around it.

נ.ב - ברשת ניתן למצוא רפרנסים ל RDM שגם אחראים על גישה לבסיס הנתונים / Persistence.
זה לא באמת RDM, אלא דפוס עיצוב אחר בשם Active Record. יש לו יתרונות, וחסרונות - אבל זה כבר דיון אחר.


שיהיה בהצלחה!


---

לינקים רלוונטיים

כמובן שגם ב Bliki יש רשומה על ADM. איך לא?
Anemic vs. Rich Domain Objects—Finding the Balance
כתבה שביסודה שגיאהThe Anaemic Domain Model is no Anti-Pattern, it’s a SOLID design, אך שגיאה נפוצה.


---

[א] למי שלא מכיר crud = "קקי" באמריקאית. אני משוכנע שראשי התיבות לא יצא סדר האותיות הזה במקרה.