להכיר את ABP דרך פעולה אחת: ביטול הזמנה
כמה קוד צריך לכתוב כדי לבטל הזמנה?
לכאורה, לא הרבה. מוצאים את ההזמנה ומשנים את הסטטוס שלה:
order.Status = OrderStatus.Cancelled;
אבל מי ביקש לבטל אותה? האם יש לו הרשאה? האם ההזמנה בכלל שייכת לארגון שלו? ומה אם היא כבר נשלחה?
ואחרי שענינו על כל השאלות האלה, צריך גם לשמור את השינוי בצורה תקינה.
פתאום שינוי של שדה אחד הפך לתהליך שכולל תקשורת, הרשאות, כללים עסקיים וגישה למסד הנתונים.
אלה בדיוק המקומות שבהם נכנסת התשתית של ABP Framework.
קודם כול, מה אנחנו מקבלים כאן?
התשתית של ABP בנויה על גבי ASP.NET Core. היא לא מחליפה אותו, אלא מוסיפה מנגנונים ומוסכמות שמיועדים לפיתוח מערכות עסקיות.
אפשר כמובן לבנות את אותה מערכת בלעדיה. אפשר לחבר בעצמנו מנגנון הרשאות, לארגן את שכבות הקוד, להגדיר איך שומרים נתונים ולהחליט איך כל הפעולות האלה משתלבות.
הרעיון של ABP הוא שחלק גדול מהעבודה הזאת חוזר ממערכת למערכת. במקום להתחיל בכל פעם מאוסף החלטות וחיבורים, מקבלים בסיס משותף.
זה לא אומר שהמערכת העסקית כבר כתובה. התשתית לא יודעת מתי מותר לבטל הזמנה. אבל היא יכולה לטפל בחלק ניכר ממה שסביב ההחלטה הזאת.
כדי להבין את ההבדל, ניצור בראש מערכת הזמנות ונעקוב אחרי בקשת ביטול אחת. לפני כן, כדאי לראות איפה בכלל כותבים את הקוד.
איך נראה פרויקט כזה בפועל?
לא חייבים לפתוח פרויקט ריק ולהרכיב את התשתית ידנית. אפשר ליצור פתרון מתוך תבנית באמצעות כלי שורת הפקודה של ABP.
בהנחה שמותקן .NET SDK מתאים, התקנת כלי ה־CLI הנוכחי נעשית כך:
dotnet tool install -g Volo.Abp.Studio.Cli
לאחר מכן אפשר ליצור פתרון. למשל:
abp new Acme.Orders -t app -u mvc -d ef --theme leptonx-lite
מה ביקשנו כאן?
- השם
Acme.Ordersהוא שם הפתרון. - הפרמטר
-t appבוחר בתבנית האפליקציה השכבתית. - הפרמטר
-u mvcבוחר בממשק המבוסס על ASP.NET Core MVC / Razor Pages. - הפרמטר
-d efבוחר ב־Entity Framework Core. - הפרמטר
--theme leptonx-liteבוחר את ערכת העיצוב.
זו דוגמה לכלי הנוכחי, לא לכל גרסת CLI שהייתה ל־ABP. האפשרויות והדרישות תלויות בגרסה ובתבנית; אפשר לבדוק אותן באמצעות abp new --help. חלק מהאפשרויות בפלטפורמה תלויות גם ברישיון. תיעוד כלי ה־CLI.
אחרי היצירה פותחים את קובץ ה־Solution ב־Visual Studio. לפני ההרצה משלימים את הגדרות הסביבה, החיבור למסד הנתונים ושאר הוראות האתחול שמגיעות עם התבנית.
בפתרון שכבתי טיפוסי נראה פרויקטים מהסוגים הבאים. זה עץ מקוצר, לא רשימה מלאה של כל קובץ ותיקייה שנוצרים:
Acme.Orders
├── src
│ ├── Acme.Orders.Domain.Shared
│ ├── Acme.Orders.Domain
│ ├── Acme.Orders.Application.Contracts
│ ├── Acme.Orders.Application
│ ├── Acme.Orders.EntityFrameworkCore
│ ├── Acme.Orders.HttpApi
│ ├── Acme.Orders.Web
│ └── Acme.Orders.DbMigrator
└── test
└── ...
ההרכב והמיקום המדויק של הפרויקטים משתנים לפי התבנית והאפשרויות שבחרנו. למשל, פתרון שמפריד את אירוח ה־API מממשק המשתמש יכלול מבנה שונה.
במבט ראשון אפשר לשאול: כל זה בשביל מערכת הזמנות?
אבל לא צריך ללמוד את כל העץ בבת אחת. נחבר אותו לפעולה שלנו:
בפרויקט Domain נכתוב את ישות ההזמנה ואת הכלל שאוסר לבטל הזמנה שכבר נשלחה. בפרויקט Application נכתוב את השירות שמנהל את תהליך הביטול.
בפרויקט Application.Contracts נגדיר את הממשקים ואת ה־DTOs, האובייקטים שמעבירים מידע אל השירותים ומהם. בפרויקט Domain.Shared יכולים להופיע טיפוסים משותפים כמו OrderStatus.
בפרויקט EntityFrameworkCore נמצאים ה־DbContext והמיפויים למסד הנתונים. בפרויקט Web נמצא ממשק המשתמש, ובפרויקט HttpApi נמצא הקוד הקשור לשכבת ה־API. הפרויקט DbMigrator מיועד לעדכון סכמת מסד הנתונים ולאתחול נתונים.
התבנית יוצרת את המבנה ואת החיבורים התשתיתיים. היא לא יוצרת מעצמה את ישות ההזמנה, את מסך הביטול או את הכללים העסקיים שלנו.
עכשיו יש לנו מקום לכתוב אותם.
המשתמש לחץ על ביטול. לאן הבקשה נשלחת?
נניח שיש לנו מסך שמציג הזמנות. המשתמש בוחר הזמנה ולוחץ על כפתור הביטול.
הדפדפן לא קורא ישירות למתודה בשפת #C. הוא שולח בקשת HTTP לשרת.
במערכת רגילה היינו עשויים לכתוב Controller שמקבל את מזהה ההזמנה ומעביר אותו לשירות. בתוך ה־Controller הייתה מופיעה פעולה כזאת:
[HttpPost("{id}/cancel")]
public async Task CancelAsync(Guid id)
{
await _orderAppService.CancelAsync(id);
}
מה הפעולה הזאת עושה? כמעט כלום. מקבלת בקשה ומעבירה אותה הלאה.
לכן ABP מאפשרת לחסוך את כתיבת ה־Controller הזה, באמצעות מנגנון שנקרא Conventional Controllers.
אנחנו מגדירים אילו Application Services ייחשפו כ־API, והתשתית משלבת אותם במנגנון ה־Controllers של ASP.NET Core לפי מוסכמות.
למשל, עבור שירות בשם OrderAppService ומתודה בשם CancelAsync(Guid id), במוסכמות ברירת המחדל מתקבלת נקודת קצה מהצורה:
POST /api/app/order/{id}/cancel
הלקוח שולח בקשה לכתובת הזאת בשרת שלנו, עם מזהה ההזמנה במקום {id}. התשתית מנתבת אותה למתודה המתאימה ומטפלת בהמרת הפרמטרים ובתשובת ה־HTTP.
כלומר, לא ביטלנו את תפקיד ה־Controller. חסכנו את הצורך לכתוב בעצמנו Controller שרק מעביר קריאות.
אפשר להתאים את הנתיבים ואת אופן החשיפה, ואפשר גם לכתוב Controller מפורש כשצריך. מבחינת מי שפונה למערכת, זה עדיין API רגיל.
לא כל קריאה לשירות צריכה לעבור דרך API
בדוגמה שלנו הדפדפן פונה לנקודת קצה של API. אבל זו לא הדרך היחידה להפעיל את הפעולה.
כשעובדים עם Razor Pages, למשל, בקשת הדפדפן יכולה להגיע ל־PageModel, המחלקה שמטפלת בפעולות של העמוד בצד השרת. המחלקה הזאת יכולה לקבל את שירות האפליקציה באמצעות Dependency Injection ולהפעיל אותו.
אם המימוש נמצא באותה אפליקציה, אין צורך לשלוח בקשת HTTP פנימית רק כדי לעבור דרך Controller.
זו הבחנה חשובה: שירות האפליקציה מייצג את הפעולה שאנחנו רוצים לבצע. ה־API הוא אחת הדרכים להגיע אליה.
כך אפשר להשתמש באותה פעולה גם מתוך ממשק משתמש בצד השרת וגם דרך API, בלי לשכפל את תהליך הביטול.
עכשיו צריך מישהו שינהל את הפעולה
הבקשה הגיעה לשרת. מי מחבר בין בדיקת ההרשאה, טעינת ההזמנה והביטול?
לשם כך נכתוב בפרויקט Application שירות אפליקציה:
public class OrderAppService : ApplicationService
{
private readonly IRepository<Order, Guid> _orders;
public OrderAppService(
IRepository<Order, Guid> orders)
{
_orders = orders;
}
[Authorize(OrdersPermissions.Cancel)]
public virtual async Task CancelAsync(Guid id)
{
var order = await _orders.GetAsync(id);
order.Cancel();
await _orders.UpdateAsync(order);
}
}
הדוגמה מניחה שהישות Order והמיפוי שלה למסד הנתונים קיימים, שה־Repository רשום ושההרשאה הוגדרה. השמטנו גם את הצהרות ה־using כדי להתמקד בפעולה עצמה.
השירות מתאר תרחיש שימוש: טען הזמנה, בקש ממנה להתבטל והעבר את השינוי לשמירה.
דרך הממשק IRepository<Order, Guid> אנחנו ניגשים להזמנות בלי לכתוב בשירות עצמו את פרטי הגישה למסד הנתונים. בדוגמה שלנו המימוש מתבסס על EF Core.
אבל אם מסתכלים על שלוש השורות במתודה, עדיין חסרים בהן לכאורה כמה דברים. איפה בודקים מי המשתמש? איפה בודקים למי שייכת ההזמנה? ואיפה הטרנזקציה?
כאן מתחילים לראות מה התשתית מוסיפה סביב הקוד שלנו.
לפני הביטול: האם מותר למשתמש לבצע אותו?
מעל המתודה כתבנו:
[Authorize(OrdersPermissions.Cancel)]
זו דרישה להרשאה מסוימת, ולא רק דרישה שהמשתמש יהיה מחובר.
אפשר, למשל, להגדיר שעובדי שירות לקוחות רשאים לצפות בהזמנות, אבל רק מנהלים רשאים לבטל אותן. המערכת של ABP מאפשרת להגדיר Permissions ולנהל את הענקתן.
הקבוע OrdersPermissions.Cancel הוא שם של הרשאה שאנחנו מגדירים. זו לא הרשאה מובנית ש־ABP המציאה עבור מערכת ההזמנות שלנו.
אם למשתמש אין את ההרשאה הנדרשת, הבקשה אמורה להיעצר לפני ביצוע הפעולה.
ומה אם פשוט נסתיר את כפתור הביטול ממי שאינו מנהל?
זה טוב לממשק, אבל לא מספיק להגנה על המערכת. אפשר לשלוח בקשת HTTP גם בלי ללחוץ על הכפתור שלנו. לכן הבדיקה מתבצעת בצד השרת.
הוא מנהל. אבל מנהל של איזה ארגון?
נוסיף פרט לדוגמה: המערכת שלנו משמשת כמה חברות.
לכל חברה יש משתמשים והזמנות משלה. מנהל בחברה אחת לא אמור להיות מסוגל לבטל הזמנה של חברה אחרת, גם אם הוא יודע את המזהה שלה.
זה המקום שבו נכנס המושג Multi-Tenancy.
כל חברה היא Tenant. אותה אפליקציה משרתת כמה ארגונים, אבל צריכה לשמור על הפרדה ביניהם.
ב־ABP אפשר לשייך ישויות ל־Tenant באמצעות הממשק IMultiTenant. כשמשתמשים במנגנון הסינון הנתמך ובהקשר של Tenant מסוים, שאילתות רגילות מוגבלות לנתונים שלו.
נחזור לשורה שלנו:
var order = await _orders.GetAsync(id);
במערכת שהוגדרה לכך, זו לא אמורה להיות בקשה לקבל כל הזמנה מכל ארגון. היא מתבצעת בתוך ההקשר של הארגון הנוכחי.
זה חוסך הוספה ידנית של תנאי לפי TenantId בכל שאילתה. אבל הוא לא פוטר אותנו מלהבין איך נקבע ה־Tenant ומה קורה כשעוקפים את הסינון, למשל באמצעות SQL ישיר.
וגם בתוך אותה חברה עשויות להיות הגבלות נוספות. אולי מנהל רשאי לבטל רק הזמנות מהסניף שלו. את הכלל הזה צריך לבדוק בנפרד. הפרדה בין ארגונים והרשאה לפעולה אינן מכסות אוטומטית כל הגבלת גישה.
הגענו להזמנה. האם בכלל אפשר לבטל אותה?
עד עכשיו טיפלנו בשאלות שקשורות למבקש ולגישה לנתונים.
עכשיו מגיעה שאלה אחרת: האם ההזמנה נמצאת במצב שמאפשר ביטול?
נניח שהכלל העסקי הוא שאי אפשר לבטל הזמנה שכבר נשלחה.
אפשר לכתוב את התנאי בתוך השירות. אבל מה יקרה אם בעתיד נרצה לבטל הזמנות גם מתוך תהליך רקע? נצטרך לזכור לבדוק את אותו תנאי גם שם.
במקום זאת, נכניס את ההתנהגות לישות ההזמנה שבפרויקט Domain:
public void Cancel()
{
if (Status == OrderStatus.Shipped)
{
throw new BusinessException(
"Orders:CannotCancelShippedOrder");
}
Status = OrderStatus.Cancelled;
}
רצוי גם שהמאפיין Status לא יהיה פתוח לשינוי חופשי מבחוץ, אחרת אפשר יהיה לעקוף את המתודה ואת הבדיקה שלה.
המחלקה BusinessException מייצגת כאן כישלון עסקי צפוי. אין תקלה בחיבור למסד הנתונים. הבקשה פשוט אינה מותרת לפי כללי המערכת.
עכשיו חלוקת האחריות ברורה יותר: השירות מנהל את תהליך הביטול, והישות מגינה על הכלל העסקי.
ההפרדה הזאת היא חלק מהגישה של Domain-Driven Design ש־ABP תומכת בה. אבל היא עדיין דורשת מאיתנו לחשוב על המערכת. התשתית לא יודעת אילו כללים שייכים להזמנה, ולא תעביר אותם לשם במקומנו.
הביטול מותר. עכשיו צריך לשמור אותו
בדוגמת השירות לא פתחנו טרנזקציה במפורש, וגם לא קראנו ל־SaveChangesAsync.
ב־ABP יש מנגנון בשם Unit of Work. הוא מגדיר גבול ליחידת עבודה, מרכז את פעולות הגישה לנתונים שמשתתפות בה ודואג לשמירת השינויים בסיום תקין.
בתרחיש כתיבה רגיל, עם EF Core ומסד נתונים רלציוני שתומך בכך, יחידת העבודה מנוהלת בדרך כלל גם בתוך טרנזקציה.
ומה לגבי הקריאה הזאת?
await _orders.UpdateAsync(order);
בדוגמה כתבנו עדכון מפורש באמצעות ה־Repository. אבל כשעובדים עם EF Core וההזמנה נטענה במעקב של אותו DbContext, אפשר גם להסתפק בשינוי הישות.
מנגנון ה־Change Tracking עוקב אחרי הישויות שנטענו, מזהה את השינויים שלהן ומאפשר לשמור אותם כשמתבצעת השמירה בסיום יחידת העבודה.
כלומר, הקריאה ל־UpdateAsync אינה תמיד הכרחית, וגם אינה בהכרח הרגע שבו מתבצעת כתיבה למסד הנתונים.
מה הטרנזקציה מבטיחה לנו?
כדי להבין מה אנחנו מקבלים מהטרנזקציה, כדאי לעצור לרגע על המושג ACID.
אלה ארבע תכונות של טרנזקציות:
- אטומיות, Atomicity: השינויים הכלולים בטרנזקציה נשמרים יחד, או שאף אחד מהם לא נשמר. אם הביטול כולל גם עדכון הזמנה וגם רישום סיבת הביטול באותו מסד נתונים ובאותה טרנזקציה, לא נרצה לשמור רק אחד מהם.
- עקביות, Consistency: הטרנזקציה אמורה להעביר את הנתונים ממצב תקין למצב תקין, תוך שמירה על האילוצים שהוגדרו. היא לא ממציאה את הכללים העסקיים שלנו. אם לא הגדרנו שאסור לבטל הזמנה שנשלחה, מסד הנתונים לא יבין זאת בעצמו.
- בידוד, Isolation: מוגדרת מידת ההפרדה בין טרנזקציות שמתבצעות במקביל. ההגנה בפועל תלויה ברמת הבידוד, ולא כל טרנזקציה מונעת כל התנגשות אפשרית.
- עמידות, Durability: לאחר שה־Commit הושלם בהצלחה, השינויים אמורים להישאר גם לאחר נפילה, בהתאם להבטחות מסד הנתונים ולהגדרות העמידות שלו.
חשוב להבדיל: המנגנון של Unit of Work מתאם את העבודה בקוד. את ההבטחות של הטרנזקציה מספק מסד הנתונים, במסגרת היכולות וההגדרות שלו.
גם בדוגמה שלנו צריך לחשוב על מקביליות. מה קורה אם תהליך אחד מבטל הזמנה בזמן שתהליך אחר מסמן אותה כנשלחה? עצם השימוש ב־ABP לא פותר את ההתנגשות. ייתכן שנצטרך בקרת מקביליות אופטימית, שמזהה שהרשומה השתנתה מאז שטענו אותה, או פתרון אחר שמתאים לכלל העסקי.
ואם הביטול כולל גם החזר כספי אצל ספק חיצוני, ההחזר אינו הופך לחלק מהטרנזקציה המקומית. כאן חוזרות בעיות התיאום שעליהן דיברנו במאמר על מיקרוסרוויסים.
נחזור לשלוש השורות שלנו
עכשיו אפשר לקרוא אותן אחרת:
var order = await _orders.GetAsync(id); order.Cancel(); await _orders.UpdateAsync(order);
אלה לא שלוש שורות שאמורות לפתור לבדן את כל התהליך.
הבקשה מגיעה לשירות דרך נקודת הכניסה שבחרנו. נבדקת ההרשאה לפעולה, וטעינת הנתונים יכולה להתבצע בהקשר של הארגון הנוכחי. בתוך Cancel נמצאים הכללים העסקיים, וסביב הגישה למסד הנתונים פועלת יחידת העבודה.
חלק מההתנהגות הזאת מופעל באמצעות Interceptors, מנגנונים שעוטפים את הקריאה למתודה ומבצעים עבודה לפניה ואחריה.
לכן יש משמעות גם לאופן שבו יוצרים ומפעילים את השירות. הוא אמור להיפתר דרך מנגנון ה־Dependency Injection ולהיקרא דרך התשתית המתאימה. יצירה ידנית באמצעות new, למשל, עלולה לעקוף התנהגות אוטומטית.
השימוש ב־virtual בדוגמה מאפשר יירוט באמצעות Proxy שמבוסס על המחלקה. כשעובדים באמצעות Interface Proxy, אפשר ליירט גם מתודות שאינן virtual. לכן לא נכון להסיק שבלי המילה הזאת שום מנגנון לא יעבוד, או שעצם הוספתה מבטיחה שהכול יופעל.
המסר החשוב הוא שלא כל קריאה למתודה מפעילה אוטומטית את כל מנגנוני ABP. צריך להבין איך הקריאה מגיעה אליה.
זה היתרון של התשתית, וזה גם חלק מהמחיר שלה: אנחנו כותבים פחות קוד חוזר, אבל צריכים להבין מה פועל מסביב לקוד שנשאר.
ואיפה המודולים משתלבים?
עד עכשיו עקבנו אחרי פעולה אחת בתוך תחום ההזמנות.
במערכת גדולה יהיו גם תחומים אחרים: לקוחות, חשבוניות, משתמשים וכדומה. המערכת של ABP מאפשרת לארגן יכולות כאלה במודולים ולהגדיר את התלויות ביניהם.
למשל, מודול ההזמנות יכול להשתמש בתשתית ההרשאות וביכולות ניהול הזהויות, בלי לממש אותן מחדש.
אגב, מודול אינו בהכרח פרויקט יחיד בעץ שראינו. מודול עסקי יכול להיות מחולק לכמה פרויקטים, בהתאם לשכבות שלו.
וגם מודול אינו בהכרח מיקרוסרוויס. כל המודולים יכולים לרוץ באותו תהליך ולהיפרס כמערכת אחת. חלוקה מסודרת בקוד לא מחייבת תקשורת רשת בין החלקים.
המטרה היא שקוד ההזמנות יתמקד בהזמנות, וישתמש ביכולות משותפות עבור צרכים שאינם ייחודיים לו.
אז האם כדאי לעבוד עם ABP?
בדוגמה אחת ראינו איך כמה יכולות מתחברות לתהליך שימושי. זה בעיניי מעניין יותר מרשימה ארוכה של פיצ’רים.
במערכת עסקית עם הרבה פעולות, הרשאות וכמה ארגונים, תשתית משותפת יכולה לחסוך עבודה ולהפוך את המימוש לאחיד יותר.
מצד שני, עבור API קטן עם מעט פעולות, ייתכן שהלמידה וההתאמה למוסכמות יהיו גדולות מהחיסכון. גם התלות במחלקות ובמנגנונים של התשתית היא שיקול שכדאי להביא בחשבון.
צריך גם להבחין בין התשתית בקוד פתוח לבין הכלים והמודולים המסחריים שסביבה. לא כל מה שמופיע בהדגמות כלול באותו מוצר או רישיון.
לפני בחירה, הייתי יוצר פתרון ומממש בו תהליך אחד כמו הביטול שתיארנו. לא רק את המסלול שבו הכול מצליח, אלא גם ניסיון ללא הרשאה, גישה להזמנה של ארגון אחר, ביטול של הזמנה שנשלחה ותקלה בזמן השמירה.
אחרי בדיקה כזאת אפשר להעריך לא רק כמה קוד חסכנו, אלא גם כמה ברור לנו מה המערכת תעשה כשהבקשה לא יכולה להסתיים בהצלחה.