בפוסט הקודם (״שלום אנגולר 2״), דיברנו בעיקר על רכיבים (Components) באנגולר - ולא כ"כ על שירותים (services).
בפוסט הזה נדבר קצת על שירותים, ומעט על מה שמסביבם.
שירות היא מחלקה, הכוללת לוגיקה שעומדת בחלק או כל התנאים הבאים:
- היא משותפת לכמה רכיבים - והשירות מאפשר שימוש-חוזר של הקוד.
- היא כוללת Business Logic (המעט שיש בצד-הלקוח) או Integration Logic (קריאה לצד-השרת) - שני סוגי לוגיקה שאנו לא רוצים להכליל ברכיב.
- החזקה של State לאורך זמן באפליקציה. רכיבים הם קצרי-חיים (עד ה navigation הבא) ושירותים חיים יותר (עד ה refresh הבא / פתיחה חדשה של האפליקציה).
במבט מהיר וביקורתי, ניתן לטעון ש"דחפו" לשירותים מספר תפקידים שונים שהמשותף להם: שהם לא מתאימים ל Component. באמת יש כמה Patterns וספריות - המחלקים את שלושת האחריויות שלמעלה לסוגים שונים של שירותים / שחקנים חדשים ושונים.
בגרסה הפשוטה, משתמשים בקונבנציות בשמות השירותים על מנת להדגיש תפקידים ספציפיים:
- שירות שמכיל Business Logic בלבד (כאלו שיכולים וצריכים להיבדק לגמרי ב Unit-Tests) - ייקרא Helper Service או Utility Service.
- שירות שתפקידו העיקרי הוא לטעון ולהחזיק בזיכרון נתונים לטווח ארוך מאורך החיים של רכיבים - ייקרא Data Service.
- רכיב אחר - ייקרא פשוט Service.
שירות פשוט
נעבור עכשיו לראות קצת קוד. נשתמש בשירות מאוד בסיסי שטוען הגדרה של מקצועות (מה שנקרא cob או class of business) מהשרת. השירות מינימליסטי - מסוג של Data Service.
לפני שנראה את קוד השירות, נראה את קוד הרכיב שמשתמש בו:
- בשדה errorStatus נאחסן מחרוזת - אם הייתה שגיאה. תקשורת לשרת, בהגדרה, עלולה להסתיים בתקלות - ואל לנו להתעלם מהמצבים הללו.
- הבנאי שלנו מכיל שדה מסוג CobService - זה השירות שלנו..
- הגדרה של שדה בתוך חתימת הבנאי היא כתיבה מקוצרת ב TypeScript להגדרה של שדה, קבלת פרמטר בבנאי - והשמת ערך הפרמטר לשדה.
- מאיפה מגיע המופע של השירות? מי מפעיל את הבנאי ושולח את מופע השירות?
אנגולר מרושתת ב Dependency Injection (בקיצור DI) שפועל כאן. כברירת-מחדל, המופע של השירות שאנחנו מקבלים הוא משותף לכל האפליקציה - כלומר Singleton. - המתודה של השירות שבה אנו משתמשים נקראת ()$getCobMap . סימן ה$ בסוף שם של פונקציה או משתנה הוא קונבנציה לכך שהמשתנה הוא מטיפוס / או הפונקציה מחזירה טיפוס של Observable.
- לבינתיים נסתפק בידיעה ש Observable הוא כלי יסוד בספריה בשם RxJs (שאנגולר משתמשת בה) הדומה ל Promise, אך הוא יכול להחזיר תשובה כמה פעמים (בניגוד ל Promise שיעשה זאת רק פעם אחת). במקרה שלנו תהיה רק חזרה אחת - מכיוון שיש רק תשובה אחת לקריאת ה HTTP שהשירות מבצע.
- בכדי לשלוף נתונים מתוך Observable אנו משתמשים באופרטור subscribe. ה Observable מייצג Stream של ערכים (או תקלות) שיכולים להמשיך ולהגיע עם הזמן. כפרמטר אנו מעבירים אובייקט (להלן: ה Observer) עם כמה פונקציות:
- next -פונקציה שתטפל באיבר הבא.
- error - פונקציה שתטפל בשגיאה. כאן אנו מציבים ערך בשדה errorStatus. קיום של ערך בשדה מלמד שיש שגיאה בטעינת הנתונים - כפי שנראה מיד.
- את subscribe אפשר להפעיל בתחביר מקוצר יותר - שלא השתמשתי בו פה.
- הפעלה של Subscribe מחזירה אובייקט Subscription שאותו אנחנו שומרים - בכדי "לנקות".
- פונקציית עזר כללית על הרכיב - שמשמשת אותנו בתוך ה Template.
- הייתה לי דוגמה אחרת לפונקציה כללית, אך נתקלתי במקרה קצה מוזר במנגנון ה Change Detection של אנגולר, וכך בעצם הפונקציה הזו גם פותרת בעיה - שקצת מורכב להסביר במסגרת הפוסט. למי שרוצה לצלול ולהעמיק.
- בעת הריסת הרכיב (בעיקר: navigation ל"מסך" אחר) עלינו לבצע פעולת Unsubscribe אחרת יהיה לנו memory leak ואירועים ימשיכו להישלח ל Subscription שלנו.
- כאשר יש כמה subscriptions באותו הרכיב, אזי מומלץ להשתמש באובייקט מרכזי (כמו subSink) לביטול הרישום - כך שלא נשכח לבצע את הפעולה.
- הצורך לזכור ולבטל רישום הוא אכן מטריד ולא רצוי. בהמשך הפוסט נראה כיצד כלי בשם ה AsyncPipe עוזר לנו להימנע מהצורך הזה - ברוב המקרים.
- כמובן שאנחנו מהרכיב שלנו מממש את הממשק OnDestory בכדי לבדוק שאין שגיאה בהגדרת חתימת הפונקציה (ואז היא לא תיקרא).
מכאן נמשיך ונציג את ה Template של הרכיב:
- אם קיים ערך ב errorStatus - אנחנו נציג אותו למשתמש. יש דרכים אלגנטיות יותר לנהל הודעות שגיאה - אבל זה מספיק טוב לצורך הפוסט.
- אנו לא רוצים להציג טבלה ריקה עם עדיין לא הגיעו הנתונים מהשרת (בכדי למנוע flickering) או אם יש מצב שגיאה. כל מבנה ה table לא יתווסף ל DOM על עוד אין לנו נתונים.
- את הנתונים עצמם אנו מציגים בטבלה, ולכן משתמשים ב ngFor* בכדי לעשות איטרציה על המפתחות של המפה, ואז מציגים את הערכים.
- בניגוד ללולאת for ב ES6 בה ניתן להשתמש גם ב in וגם ב of, ה directive של אנגולר ngFor* תומך רק ב of - מעבר על איטרטור / רשימה.
ניתן לראות את התוצאה של רינדור הרכיב שלנו:
אבל איפה השירות?!
נכון. עדיין לא ראינו את השירות עצמו. הנה הוא:
- Injectable@ הוא decorator שמסמן מחלקה המיועדת להזרקה במנגנון ה DI של אנגולר. מכיוון שאין ממש סיבה להזריק אובייקטים שהם לא שירותים (services) - אזי Injectible@ הוא סימן הזיהוי של שירותים בפרויקטי אנגולר.
- באפליקציית אנגולר יש היררכיה של Injectors:
- ה Injector הראשי נקרא root והוא שייך ל AppModule. הוא האב של כל ה injectors האחרים.
- לכל רכיב (component) יש Injector משלו, שהוא האב של ה injectors של רכיבים בנים.
- לכל מודול יש Injector שהוא אב לכל ה Injectors לרכיבים במודול - אך בן ל root injector.
- אנו רושמים את השירות שלנו ל Injector הראשי. כאשר מנהלים טעינה דינאמית של מודולים - נכון לשקול לרשום שירות למודולים ספציפיים.
- בבנאי אנו מבקשים Injection ל HttpClient - שירות סטנדרטי של אנגולר המספק יכולת לבצע קריאות HTTP.
- אנו משתמשים ב HttpClient בכדי לבצע קריאת GET. התשובה של קריאות מה HttpClient הן Observable - שהוא ה hook לחזרה של שהתשובה.
כאשר אנחנו רוצים "לשבת על ה Stream ולהתערב בתוכן" אנו משתמשים בפונקציה pipe של ספריית RxJs. ל pipe אנו מספקים שורה של פונקציות שיפעלו על האיברים שעוברים ב stream. במקרה שלנו: - אופרטור ה map (אופרטור = השם ב RxJs של פונקציה היכולה לפעול על Stream) פועל על כל איבר ב Stream ומבצע המרה שלו.
- Object.entities מחזיר רשימת Pairs מה properties של האובייקט.
- אנחנו ממירים את ה Pairs מ [string, any] ל [number, string].
- ה Pairs במפה של TypeScript מיוצגים כ tuples (מבנה נתונים ב TypeScript הנראה כמו מערך עם טיפוס מוגדר לכל מקום ברשימה).
- התחביר k+ הוא תחביר מקוצר ומקובל ב TypeScript להמרה של מחרוזת למספר. ההשמה ל string מתבצעת אוטומטית מתוך הגדרת ערך ההחזרה.
- אופרטור ה catchError פועל על כל שגיאה (Error) שעוברת ב Stream. לרוב מגדירים אותו בסוף ה pipe בכדי שיתפוס תקלות גם מכל הפונקציות שהוגדרו לפניו.
- הפונקציה שנשלחת ל catchError, מקבלת error ומחזירה Observable. יש את האופציה להחזיר Observable חדש שיחזיר ערכים מכל סוג. במקרה שלנו אנו זורקים את השגיאה מחדש, ולכן לא מחזירים Observable. אנו מצהירים על הפונקציה כמחזירה Observable של never - שזו הדרך לומר שלא יכול לחזור שם ערך. קונספט מאוד דומה ל Nothing בשפת קוטלין, למשל.
- אנו עושים איזו הבחנה מהירה אם זו תקלת צד-שרת או צד-לקוח וכותבים ללוג.
- ...ואז אנו זורקים שגיאה מחדש. throwError הוא אופרטור שיוצר Observable חדש ומיד זורק לתוכו את השגיאה שלי. אתם בוודאי יכולים לשים לב שזרקתי מחרוזת (string) כשגיאה ולא איזה "טיפוס Error גנרי". ב TypeScript אין טיפוס Error גנרי - ומי שמקבל את ה Error צריך להיות מודע לכך (או להסתכן בכשלון בזמן ריצה). האופרטור throwError מקבל any - שהוא הטיפוס הכללי ביותר TypeScript הרחב אפילו יותר מ object.
ה Error הנפוץ ביותר באפליקציות אנגולר הוא כנראה ה HttpErrorResponse - בשל השימוש הרחב בקריאות HTTP. קל להתרגל למבנה שלו ולהניח שזה תמיד ה Error שיחזור - אבל זה לא תמיד נכון.
שיפור העבודה הידנית
כפי שראינו קודם לכן, שירותים משתמשים ב HttpClient, שמחזיר Observable. הרבה פעמים גם עושים caching קצר-טווח על הנתונים, כך שבחזרה לאותו הרכיב שהיינו בו קודם לכן - אין צורך לבצע עוד קריאה לשרת.
מנגנון ה Observable פשוט יחסית, ונוח לשימוש - מלבד הצורך לבצע unsubscribe. חשבו על רכיב שעובד עם 5 שירותים או יותר - הצורך ב unsubscribe הוא תקורה שהולכת ומצטברת.
אנגולר מספקת מנגנון של "unsubscribe אוטומטי" בעזרת pipe בשם AsyncPipe. מכיוון שאנגולר שולטת במחזור החיים של הרכיב, היא יכולה לבצע unsubscribe אוטומטי כאשר הרכיב נהרס.
לצורך כך יש לשמור על הרכיב (כלומר: המחלקה ב TypeScript) מצביע ל Observable ולבצע את השליפה ("subscribe") בתוך ה Template בעזרת ה AysncPipe. בואו נראה איך זה קורה:
- כפי שציינתי, אנו שומרים מצביע ל Observable על הרכיב כשדה.
- כאשר אנו מציינים את הטיפוס של המשתנה (זו לא חובה), TypeScript תדע לדרוש מאיתנו את המקרה בו חוזרת מחרוזת (השגיאה שאנו שולחים) מה Stream - ולא רק מפה.
- אנו עדיין רוצים לבצע טיפול מיוחד כאשר מגיעה שגיאה ב stream. הפעם אנחנו לא "יחידת הקצה" (קרי: ה subscriber. ה Template עכשיו הוא יחידת הקצה) - אנחנו רק "יושבים על ה stream" ומתערבים בעזרת אופרטור ה pipe.
והנה ה Template:
- ה asyncPipe פועל על Observable (או Promise), עושה לו subscribe ושולף ממנו ערך. כל ערך נוסף שישלח ב stream - יגרום לעדכון של ה markup בדפדפן.
- מכיוון שאנחנו רוצים להשתמש בערך שנשלף מה Observable במקום נוסף ב template - אנו שומרים את הערך למשתנה בשם cobs בעזרת המילה השמורה as בשפת ה Template של אנגולר.
השימוש ב as הופך את הקוד לקריא יותר, וגם יעיל יותר מבחינת ביצועים - כי נמנעים מריבוי subscription לאותו observable. - הנה השימוש הנוסף במשתנה שהצבנו בשלב 1.
- keyValuePipe מפרק לנו את המפה (פועל גם על אובייקט) לרשימה של Pairs (אובייקט מסוים), עליהם אנו יכולים לקרוא לשדות key ו value.
אם צריך לבצע המרה של הנתונים מהאופן שהם הגיעו מהשירות - אנו לרוב נעשה זאת ב pipe מתוך הרכיב. ל Template הנתונים צריכים להגיע בפורמט הרצוי - כי אנו מוגבלים ביכולת לעבד אותם (וזה גם לא נכון, מבחינת חלוקת אחריות).
הגם שהכתיבה של async עדיפה, חשוב להבין מה קורה מאחורי הקלעים - ולכן הלכתי בדרך הארוכה, ופתחתי בעבודה עם subscribe.
בשלב הזה אנחנו אמורים להבין, בגדול, את הארכיטקטורה / מבנה+התנהגות של אפליקציית אנגולר - ואת שני מרכיבי הליבה: רכיב ושירות - וכיצד הם עובדים ביחד.
כשאנו עוברים לכתוב אפליקציית אנגולר גדולה ומורכבת יותר - אנו מגלים שניהול השירותים הוא לא תמיד פשוט. בעוד לרכיבים יש מבנה היררכי שהוא הגיוני ויכול לגדול, שירותים מסודרים במבנה שטוח (מלבד חלוקה למודולים, שלא מוסיפה ולא גורעת בעניין הזה).
כבר הזכרנו בפוסט הקודם את הרעיון של Container Presenter - רכיב אב שמייצג "מסך" שאחראי לניהול הנתונים והעבודה מול השירותים, ולא רכיבים בנים שאחראים על תצוגה בלבד - והם מקבלים את כל הנתונים מרכיב ה Container, דרך שדות המסומנים ב Input decorator@.
הדפוס של Container Presenter הוא מרכזי וחשוב - אבל לפעמים הוא מדגיש את הסרבול במבנה שירותים שטוח.
"מסך" (קרי Container component) מורכב, עשוי להזדקק לנתונים מ 10 או 20 שירותים. אם הנתונים צריכים לעבור טרנספורמציה וחיבור (סוג של "join" אפליקטיבי) - אזי מדובר בלא מעט קוד באותו רכיב Container.
מה קורה כאשר "מסכים" שונים זקוקים לחיבור נתונים דומה? שכפול קוד?!
דרך אחת לנהל זאת היא ליצור FacadeServices שמחברים / מעבדים נתונים, ומגישים נתונים ברמת הפשטה גבוהה יותר - לרכיבים. למשל: שירות Facade שמאגד 4 שירותים לגבי פרטים של לקוח לכדי אובייקט אחד עשיר - מה שמאפשר לעבוד עם פחות פרטי מידע, וניתן לעשות בו שימוש חוזר.
יש גם גרסה ראקטיבית לדפוס הזה, מבוססת BehaviorSubject.
Subject הוא סוג של Observable שמנגיש תמיד את הערך אחרון. לא צריך ליזום קריאה על מנת לגרום לקריאת HTTP שתביא שוב את הערך. שכבת השירותים יכולה לחשוף מגוון רחב של Subjects ברמות הפשטה שונות, כך שניתן להירשם ל Subject המספק אובייקט מורכב, משותף לרכיבים שונים במערכת (הנמצאים ב"מסכים" שונים) - מבלי לעבוד מול שירותים העוסקים בפרטים.
דרך התמודדות אחרת היא לפנות ל Framework לניהול State שמציב פרדיגמה סדורה לגבי ניהול State באפליקצית אנגולר - נושא שהופך למבלבל, ככל שהאפליקציה גדלה ונעשית מורכבת. אפשר לציין את 4 ה Frameworks הבאים, כמקובלים ביותר בשוק:
כבר כתבתי בעבר, על המלכודת ש "Framework שיש בו הכל" מציב. לכאורה לא ניתן "ליפול" עם כזה Framework, אבל בפועל רוב הסיכויים הוא להיסחב עם משקל עודף שיאט ויסבך אתכם. למשאית יש את הנתונים הגבוהים ביותר ברוב המדדים - אבל זה דיי מטופש להחזיק משאית (או אוטובוס) כרכב פרטי.
ב Frameworks אין לנו את האינטואיציה הזו, ולכן ההמלצה הגורפת שלי היא להתחיל ולהשקיע תקופה ב Framework הכי פשוט ורזה בתחום (במקרה הזה, כנראה שמדובר ב Observable-Store) - ורק לאחר שצוברים ניסיון ומבינים את העקרונות והצרכים בתחום - לבחור Framework לעבוד איתו.
הסרה / החלפה של Framework רזה היא לרוב עבודה קלה. עד שנבין ש Framework גדול הוא overkill עבורנו (עקומת הלמידה בד"כ היא תלולה) - ההסרה יכולה להיות קשה מספיק כך שתדחה במשך שנים.
זו שאלה ראשונית וסופר-חשובה למי שכותב אפליקציות אנגולר.
פייסבוק הציגו את Redux במידה רבה לפתור בעיה של race condition בעדכון נתונים באפליקציה. למשל: Server push מול עדכונים של המשתמש. הגישה הפונקציונלית מתאימה מאוד לשמירת עקביות ותמונה אחידה בנתונים (Immutable state משותף, שזוכה לעדכונים מנקודה יחידה במערכת). לא לכל האפליקציות יש את הבעיה הזו - ולכן לא כולן צריכות לאמץ Redux/NgRx - מכיוון שיש תקורה גבוהה באימוץ המודל הזה [א].
זו שאלה שטוב מאוד לשאול לפני - השקעות גדולות בארכיטקטורת המוצר.
בגדול אפשר לסווג את ה State של האפליקציה לאחד משלושה סוגים עיקריים
דבר ראשון שחשוב לציין הוא ש Local State הוא חשוב ושימושי.
למשל: אני רוצה לשמור משתנה מקומי לרכיב כמו ה errorStatus בדוגמה למעלה, או אינדיקציה שעלי להציג אנימציה "...Loading".
אפשר לשמור את ה state הזה ב store מרכזי, לשתף ולפעפע אותו ברחבי האפליקציה - אבל חבל.
זה לא נחוץ, זו תקורת פיתוח, וזה בהחלט מסבך את האפליקציה.
כל State שיכול להיות Local - עדיף שיהיה כזה. מבחינת שימושיות זה יהיה לרוב יותר מבלבל אם תזכרו עבור המשתמש כל selection על כל תא במסך שעשה. זה טוב לשכוח, מדי פעם.
Session State - אין לכם הרבה ברירה לגביו. אתם צריכים לשמור אותו ולשתף אותו בכל רחבי האפליקציה.
ברוב הפעמים ה session state הוא דיי קטן, ואפשר ליצור SessionService שיתחזק וינהל אותו.
השאלה הגדולה היא בעיקר לגבי ה Entity State שעלול להיות:
ככל ששלושת הצירים הללו מתקדמים יותר - כך סביר יותר שנזדקק לפתרון מורכב יותר מקבוצה של שירותים ו/או FacadeService / BehaviorSubjects.
אני מניח גם שאפשר לומר, באופן כללי, שציר 3 (עדכונים תכופים) גורר מורכבות גבוהה יתר מציר 2 (שיתוף רחב), שגורר מורכבות גבוהה יותר מציר 1 בלבד (גודל / כמות אובייקטים).
אם עלינו לנהל מאות Observable באפליקציה - היא כנראה תהיה גם אטית (מבחינת ביצועים) וגם מסורבלת מאוד לתחזוקה.
הסכנות שיש לשים לב אליהן הן בעיקר:
אם למשל מדובר באפליקציית CRUD שיכולה לעדכן את השרת על כל שינוי שמתבצע - כנראה שאין שום צורך בניהול State מרכזי.
שתי תכונות שנוטים לייחס לפתרונות לניהול State בצורה מוגזמת, וכנראה לא נכונה הן Testability טוב יותר (כנראה ההיפך הוא הנכון) וביצועים טובים יותר (Caches בהחלט עוזרים - אבל אפשר להשתמש ב caches גם ללא פתרון ניהול state).
בקיצור: בחרו בזהירות. ארכיטקטורה היא מה שיקר לשנות, ולכן חשוב לנסות ולדחות את ההחלטה עד שנבין את הדינמיקה בפניה אנו ניצבים - טוב יותר.
צללנו לשירותים באנגולר, וקצת לבעיות המעשיות שבפיתוח אפליקציות אנגולר.
אני מקווה שלא צללנו מהר מדי לפרטים, והצלחנו לשמור על תמונה מציאותית של העולם הזה.
שיהיה בהצלחה!
----ארגון שירותים
בשלב הזה אנחנו אמורים להבין, בגדול, את הארכיטקטורה / מבנה+התנהגות של אפליקציית אנגולר - ואת שני מרכיבי הליבה: רכיב ושירות - וכיצד הם עובדים ביחד.
כשאנו עוברים לכתוב אפליקציית אנגולר גדולה ומורכבת יותר - אנו מגלים שניהול השירותים הוא לא תמיד פשוט. בעוד לרכיבים יש מבנה היררכי שהוא הגיוני ויכול לגדול, שירותים מסודרים במבנה שטוח (מלבד חלוקה למודולים, שלא מוסיפה ולא גורעת בעניין הזה).
כבר הזכרנו בפוסט הקודם את הרעיון של Container Presenter - רכיב אב שמייצג "מסך" שאחראי לניהול הנתונים והעבודה מול השירותים, ולא רכיבים בנים שאחראים על תצוגה בלבד - והם מקבלים את כל הנתונים מרכיב ה Container, דרך שדות המסומנים ב Input decorator@.
הדפוס של Container Presenter הוא מרכזי וחשוב - אבל לפעמים הוא מדגיש את הסרבול במבנה שירותים שטוח.
"מסך" (קרי Container component) מורכב, עשוי להזדקק לנתונים מ 10 או 20 שירותים. אם הנתונים צריכים לעבור טרנספורמציה וחיבור (סוג של "join" אפליקטיבי) - אזי מדובר בלא מעט קוד באותו רכיב Container.
מה קורה כאשר "מסכים" שונים זקוקים לחיבור נתונים דומה? שכפול קוד?!
דרך אחת לנהל זאת היא ליצור FacadeServices שמחברים / מעבדים נתונים, ומגישים נתונים ברמת הפשטה גבוהה יותר - לרכיבים. למשל: שירות Facade שמאגד 4 שירותים לגבי פרטים של לקוח לכדי אובייקט אחד עשיר - מה שמאפשר לעבוד עם פחות פרטי מידע, וניתן לעשות בו שימוש חוזר.
יש גם גרסה ראקטיבית לדפוס הזה, מבוססת BehaviorSubject.
Subject הוא סוג של Observable שמנגיש תמיד את הערך אחרון. לא צריך ליזום קריאה על מנת לגרום לקריאת HTTP שתביא שוב את הערך. שכבת השירותים יכולה לחשוף מגוון רחב של Subjects ברמות הפשטה שונות, כך שניתן להירשם ל Subject המספק אובייקט מורכב, משותף לרכיבים שונים במערכת (הנמצאים ב"מסכים" שונים) - מבלי לעבוד מול שירותים העוסקים בפרטים.
דרך התמודדות אחרת היא לפנות ל Framework לניהול State שמציב פרדיגמה סדורה לגבי ניהול State באפליקצית אנגולר - נושא שהופך למבלבל, ככל שהאפליקציה גדלה ונעשית מורכבת. אפשר לציין את 4 ה Frameworks הבאים, כמקובלים ביותר בשוק:
- Akita - פריימוורק פשוט יחסית לניהול State, המושפע מ Domain Modeling של OO (למשל: Model/Entity ו Repository), ויכול לעבוד באותה המידה עם אנגולר, Vue, או React.
- NgRx - פרימוורק מקיף למדי לניהול state, ומאוד פופולארי בעולם של אנגולר. הוא מבוסס על עקרונות של תכנות פונקציונלי ומושפע מאוד מ Redux של React. לי אישית, הוא מזכיר קצת Enterprise Java בתפקידים הרבים שהוא מגדיר, וכללי-התקשורת המסודרים ביניהם. NgRx מכוון לאפליקציות סופר גדולות ומורכבות.
- נראה שרוב המשתמשים ב NgRx הם followers של כותבי-אפליקציות גדולות ומורכבות, ועבור הרוב הגדול של המשתמשים, NgRx הוא פשוט overkill. לא פעם יוצרי NgRx יוצאים בקריאה "אתם לא צריכים NgRx" - ולא סתם.
- Ngxs הוא "חיקוי" NgRx רזה יותר, המשתמש בכלים של TypeScript בכדי ליתר הרבה מה boilerplate code הנדרש ב NgRx (ויש הרבה כזה). Ngxs מגדישה את הדפוס של CQRS.
- יוצרי NgRx יצאו בעצמם עם הפסטה פשוטה יותר של NgRx/Store בשם NgRx/data, המאפשרת לעבוד עם המנוע של NgRx/Store אך ללא רבים מהסיבוכים וה boilerplate code של NgRx/Store. ע"פ הגדרתם זו "כניסה רכה ל NgRx/Store - עם אופציה לצלול לעומק ברגע שיהיה צורך". ממבט מהיר - נראה שההפשטה שהם יצרו דומה למודל של Akita. יש עוצמה רבה בפשטות.
כבר כתבתי בעבר, על המלכודת ש "Framework שיש בו הכל" מציב. לכאורה לא ניתן "ליפול" עם כזה Framework, אבל בפועל רוב הסיכויים הוא להיסחב עם משקל עודף שיאט ויסבך אתכם. למשאית יש את הנתונים הגבוהים ביותר ברוב המדדים - אבל זה דיי מטופש להחזיק משאית (או אוטובוס) כרכב פרטי.
ב Frameworks אין לנו את האינטואיציה הזו, ולכן ההמלצה הגורפת שלי היא להתחיל ולהשקיע תקופה ב Framework הכי פשוט ורזה בתחום (במקרה הזה, כנראה שמדובר ב Observable-Store) - ורק לאחר שצוברים ניסיון ומבינים את העקרונות והצרכים בתחום - לבחור Framework לעבוד איתו.
הסרה / החלפה של Framework רזה היא לרוב עבודה קלה. עד שנבין ש Framework גדול הוא overkill עבורנו (עקומת הלמידה בד"כ היא תלולה) - ההסרה יכולה להיות קשה מספיק כך שתדחה במשך שנים.
|
מתי בעצם צריך State Management?
זו שאלה ראשונית וסופר-חשובה למי שכותב אפליקציות אנגולר.
פייסבוק הציגו את Redux במידה רבה לפתור בעיה של race condition בעדכון נתונים באפליקציה. למשל: Server push מול עדכונים של המשתמש. הגישה הפונקציונלית מתאימה מאוד לשמירת עקביות ותמונה אחידה בנתונים (Immutable state משותף, שזוכה לעדכונים מנקודה יחידה במערכת). לא לכל האפליקציות יש את הבעיה הזו - ולכן לא כולן צריכות לאמץ Redux/NgRx - מכיוון שיש תקורה גבוהה באימוץ המודל הזה [א].
זו שאלה שטוב מאוד לשאול לפני - השקעות גדולות בארכיטקטורת המוצר.
- Session State - פרטים על המשתמשים וה Login, פרטי מעקב (sessionId/contextId), ומצב האפליקציה (האם כבר בחרתי xyz או שעדיין לא, ולכן אזור שלם באפליקציה חסום בפני).
- Entity State - אובייקטים עסקיים שהאפליקציה טענה, בכדי להציג / לעדכן / לשתף בלוגיקה כלשהי.
- Local State - פרטים מקומיים ל "מסך" או "חלק מסך" (רכיב) מסוים - שאין להם משמעות מעבר לאותו ה"מסך". למשל: מה הבחירה שלי ב Dropdown מסוים. אם אעבור לחלק אחר באפליקציה ואחזור - כנראה שלא אצפה שהבחירה שלי ב dropdown תישמר (אלא תחזור לערך ברירת-המחדל).
דבר ראשון שחשוב לציין הוא ש Local State הוא חשוב ושימושי.
למשל: אני רוצה לשמור משתנה מקומי לרכיב כמו ה errorStatus בדוגמה למעלה, או אינדיקציה שעלי להציג אנימציה "...Loading".
אפשר לשמור את ה state הזה ב store מרכזי, לשתף ולפעפע אותו ברחבי האפליקציה - אבל חבל.
זה לא נחוץ, זו תקורת פיתוח, וזה בהחלט מסבך את האפליקציה.
כל State שיכול להיות Local - עדיף שיהיה כזה. מבחינת שימושיות זה יהיה לרוב יותר מבלבל אם תזכרו עבור המשתמש כל selection על כל תא במסך שעשה. זה טוב לשכוח, מדי פעם.
Session State - אין לכם הרבה ברירה לגביו. אתם צריכים לשמור אותו ולשתף אותו בכל רחבי האפליקציה.
ברוב הפעמים ה session state הוא דיי קטן, ואפשר ליצור SessionService שיתחזק וינהל אותו.
השאלה הגדולה היא בעיקר לגבי ה Entity State שעלול להיות:
- גדול - מחלקות (classes) רבות, ו/או מופעים (objects) רבים.
- כאשר יש יותר אובייקטים ממה שסביר לשמור בו-זמנית בזיכרון האפליקציה - מבחינת צריכת-זיכרון.
- משותף לכלל האפליקציה. אם מסך אחד אומר שללקוח יש יתרה של $400, ומסך $350 - זה באג. לא מעניינת אותי מדיניות ה caches ופעפוע הנתונים.
- להתעדכן בצורה תכופה. הנתונים בשרת משתנים בטווח של דקות או אולי המשתמש מבצע עדכונים רבים - כי זה אופן השימוש באפליקציה.
ככל ששלושת הצירים הללו מתקדמים יותר - כך סביר יותר שנזדקק לפתרון מורכב יותר מקבוצה של שירותים ו/או FacadeService / BehaviorSubjects.
אני מניח גם שאפשר לומר, באופן כללי, שציר 3 (עדכונים תכופים) גורר מורכבות גבוהה יתר מציר 2 (שיתוף רחב), שגורר מורכבות גבוהה יותר מציר 1 בלבד (גודל / כמות אובייקטים).
אם עלינו לנהל מאות Observable באפליקציה - היא כנראה תהיה גם אטית (מבחינת ביצועים) וגם מסורבלת מאוד לתחזוקה.
הסכנות שיש לשים לב אליהן הן בעיקר:
- האם אפשר להגיע למצב של אי-עקביות של הנתונים? מה הסיכוי שנחזיק את אותם הנתונים כפול במערכת - והם לא יהיו זהים?
- לעתים, רק גודל של האפליקציה יכול לגרום למצב הזה, כי יד ימין #6 לא יכולה בדיוק לדעת מה יד שמאל #24 - עושה. למשל: נוצרים שני שירותים במערכת שמביאים מהשרת ומנהלים - את אותם הנתונים.
- מה מפת הקשרים בין החלקים באפליקציה שלנו?
- הרבה קשרים בין רכיבים לשירותים - זה לרוב בסדר, ומקסימום Facade Services יכולים לצמצם את כמות הקשרים.
- הרבה קשרים בין שירותים הם גם בסדר - אם יש להם היגיון ברור וסדור לאורך האפליקציה (למשל: ל FacadeServices יש הרבה קשרים לשירותים בסיסיים יותר).
- כאשר הקשרים בין השירותים הופכים להיות דו-כיוניים / צורך במעגלים - זה המקום לחשוב על ניהול מורכב יותר של State.
- Race conditions בעדכון הנתונים - כאשר התופעה רחבה, קשה מאוד לניהול בקשרים פשוטים בין שירותים.
- גם: אפליקציות מובייל כאשר ייתכן latency גבוה / לא עקבי בתגובה לקריאות HTTP ו/או ניתוקים אפשריים.
אם למשל מדובר באפליקציית CRUD שיכולה לעדכן את השרת על כל שינוי שמתבצע - כנראה שאין שום צורך בניהול State מרכזי.
שתי תכונות שנוטים לייחס לפתרונות לניהול State בצורה מוגזמת, וכנראה לא נכונה הן Testability טוב יותר (כנראה ההיפך הוא הנכון) וביצועים טובים יותר (Caches בהחלט עוזרים - אבל אפשר להשתמש ב caches גם ללא פתרון ניהול state).
בקיצור: בחרו בזהירות. ארכיטקטורה היא מה שיקר לשנות, ולכן חשוב לנסות ולדחות את ההחלטה עד שנבין את הדינמיקה בפניה אנו ניצבים - טוב יותר.
סיכום
צללנו לשירותים באנגולר, וקצת לבעיות המעשיות שבפיתוח אפליקציות אנגולר.
אני מקווה שלא צללנו מהר מדי לפרטים, והצלחנו לשמור על תמונה מציאותית של העולם הזה.
שיהיה בהצלחה!
קישורים שימושיים:
השוואה בין Akita, NgRx, ו Ngxs - פרימוורקים לניהול State באנגולר (או בכלל).
-----
[א] You Might Not Need Redux - פוסט מהיוצר של רידאקס.