#C,  Design Patterns,  ארכיטקטורת תוכנה | Software Architecture

הכסף ירד מחשבון אחד. מה עם החשבון השני? להכיר את Unit of Work

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

מבחינת הקוד, אפשר להתחיל כך:

sourceAccount.Withdraw(amount);
targetAccount.Deposit(amount);

אבל שינוי של אובייקט בזיכרון אינו שינוי של הרשומה במסד הנתונים. צריך גם לשמור.

אז נוסיף שמירה אחרי כל פעולה:

sourceAccount.Withdraw(amount);
await accountRepository.SaveAsync(sourceAccount);

targetAccount.Deposit(amount);
await accountRepository.SaveAsync(targetAccount);

לצורך הדוגמה, נניח שכל קריאה ל־SaveAsync שומרת ומאשרת את השינוי באופן עצמאי, ואין טרנזקציה משותפת שעוטפת את שתיהן.

ההפחתה מהחשבון הראשון נשמרה. בזמן השמירה של החשבון השני התרחשה תקלה.

מה המצב עכשיו?

מבחינת כל פעולת שמירה בנפרד, ההתנהגות ברורה: הראשונה הצליחה והשנייה נכשלה. מבחינת הפעולה העסקית, קיבלנו חצי העברה.

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

מי מחליט מתי שומרים?

נוסיף להעברה גם רשומת תיעוד:

var transfer = new Transfer(
    sourceAccount.Id,
    targetAccount.Id,
    amount);

await transferRepository.SaveAsync(transfer);

עכשיו יש לנו שני חשבונות ורשומת העברה. ייתכן שהם מטופלים באמצעות שני Repositories שונים, אבל מבחינתנו שלושת השינויים שייכים לאותה פעולה.

ה־Repository של החשבונות יודע לגשת לחשבונות. ה־Repository של ההעברות יודע לגשת לרשומות ההעברה. אף אחד מהם, בפני עצמו, לא אמור להחליט שכל הפעולה הסתיימה ואפשר לאשר את השמירה שלה.

את ההחלטה הזאת צריך לקבל במקום שמכיר את התהליך כולו.

כאן נכנסת תבנית העיצוב Unit of Work, או בקיצור UoW.

יחידת עבודה משותפת

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

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

בדוגמה שלנו, הקוד יכול להיראות כך:

var sourceAccount = await accountRepository.GetAsync(sourceId);
var targetAccount = await accountRepository.GetAsync(targetId);

sourceAccount.Withdraw(amount);
targetAccount.Deposit(amount);

transferRepository.Add(
    new Transfer(sourceId, targetId, amount));

await unitOfWork.SaveChangesAsync();

בדוגמה הזאת, החשבונות נטענים במעקב של יחידת העבודה, והקריאה ל־Add רושמת אובייקט חדש להוספה. היא אינה שומרת אותו מיד במסד הנתונים.

השורה האחרונה מבקשת לשמור את כל השינויים שנאספו.

אבל יש כאן תנאי חשוב: שני ה־Repositories חייבים להשתתף באותה יחידת עבודה. אם כל אחד מהם מנהל מנגנון שמירה עצמאי, עצם הוספת המשתנה unitOfWork לא תחבר ביניהם.

שם של מחלקה אינו מנגנון תיאום.

רגע, זו לא פשוט טרנזקציה?

לא בדיוק.

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

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

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

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

בעבודה עם EF Core, כברירת מחדל וכאשר ספק מסד הנתונים תומך בטרנזקציות, השינויים בקריאה אחת ל־SaveChanges נשמרים במסגרת טרנזקציה. אם אחת מפקודות הכתיבה נכשלת, הטרנזקציה מבוטלת ולא נשמר חלק מהשינויים בלבד.

יכול להיות שאתם כבר משתמשים בתבנית

אם עבדתם עם EF Core, כנראה שהקוד הבא ייראה מוכר:

var account = await db.Accounts.SingleAsync(
    account => account.Id == accountId);

account.ChangeDisplayName(newName);

await db.SaveChangesAsync();

לא קראנו כאן למתודה ששומרת את החשבון מיד אחרי שינוי השם. שינינו את האובייקט, ובהמשך ביקשנו לשמור את השינויים.

ה־DbContext עוקב אחרי ישויות שנטענו במעקב. בזמן השמירה, מנגנון ה־Change Tracking מזהה את השינויים ומאפשר לכתוב אותם למסד הנתונים.

כלומר, ה־DbContext כבר ממלא את התפקיד של Unit of Work. לא צריך ליצור מחלקה בשם הזה כדי להשתמש ברעיון. הוא גם מיועד למחזור חיים קצר של יחידת עבודה, ולא לאובייקט משותף שמלווה את כל המערכת.

ניישם באמצעותו את ההעברה שלנו:

public async Task TransferAsync(
    Guid sourceId,
    Guid targetId,
    decimal amount,
    CancellationToken cancellationToken = default)
{
    if (amount <= 0)
        throw new ArgumentOutOfRangeException(nameof(amount));

    if (sourceId == targetId)
        throw new ArgumentException(
            "Source and target accounts must be different.");

    var sourceAccount = await _db.Accounts.SingleAsync(
        account => account.Id == sourceId,
        cancellationToken);

    var targetAccount = await _db.Accounts.SingleAsync(
        account => account.Id == targetId,
        cancellationToken);

    sourceAccount.Withdraw(amount);
    targetAccount.Deposit(amount);

    _db.Transfers.Add(
        new Transfer(sourceId, targetId, amount));

    await _db.SaveChangesAsync(cancellationToken);
}

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

זו דוגמה לתיאום שמירה, לא מימוש מלא של מערכת בנקאית. הרשאות, מטבעות, מניעת בקשות כפולות ובקרת מקביליות דורשים טיפול נוסף.

שימו לב שאין כאן קריאה ל־Update עבור כל חשבון. בדוגמה הזאת שניהם כבר במעקב של אותו DbContext, ולכן אפשר לשנות אותם ולשמור בסיום.

איפה ה־Repository משתלב?

אפשר להשתמש ב־Unit of Work עם Repositories, ואפשר גם בלעדיהם, כמו בדוגמה האחרונה.

כאשר בוחרים להוסיף Repositories, כדאי להגדיר בבירור את חלוקת האחריות:

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

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

השירות שמנהל את ההעברה מכיר את התרחיש העסקי ומחליט מתי לבקש את השמירה.

בדוגמה שלנו, לא הייתי מכניס SaveChangesAsync לתוך כל מתודת שינוי ב־Repository. אחרת, מי שקורא לה עלול לשמור שינויים עוד לפני שסיים להכין את ההעברה כולה.

יש כאן גם פרט שקל לפספס: כאשר כמה Repositories משתמשים באותו DbContext, קריאה ל־SaveChangesAsync שומרת את כלל השינויים הממתינים בו, לא רק את אלה ששייכים ל־Repository שממנו בוצעה הקריאה.

האם צריך לכתוב ממשק משלנו?

אפשר להגדיר ממשק קטן:

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(
        CancellationToken cancellationToken = default);
}

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

אם המטרה היא ששירותי האפליקציה יהיו תלויים בחוזה מצומצם לשמירה במקום ב־DbContext, זו יכולה להיות בחירה שימושית. היא יכולה להתאים גם למבנה שבו הגישה לנתונים כבר מוסתרת מאחורי Repositories.

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

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

שמירה אחת לא פותרת מקביליות

נחזור לחשבון המקור. יש בו 100 שקלים, ושתי בקשות שונות מנסות להעביר ממנו 80 שקלים באותו הזמן.

כל בקשה טוענת את החשבון לפני שהשנייה שמרה. שתיהן רואות יתרה של 100, ולכן מבחינת כל אחת מהן יש מספיק כסף.

העובדה שכל בקשה משתמשת ב־Unit of Work משלה אינה מונעת את ההתנגשות הזאת.

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

ב־EF Core, כאשר מוגדר Concurrency Token ומתגלה התנגשות בעדכון, מתקבלת DbUpdateConcurrencyException. צריך להחליט איך מטפלים בה. ניסיון חוזר, אם מתאים לבצע אותו, צריך להתבסס על נתונים עדכניים ועל בדיקה מחודשת של הכללים העסקיים.

זו בעיה נפרדת מהשאלה אם שני צדי ההעברה נשמרים יחד. במערכת אמיתית צריך לתת מענה לשתיהן.

הגבול של יחידת העבודה

גם שתי קריאות ל־SaveChangesAsync על אותו DbContext אינן הופכות אוטומטית לטרנזקציה אחת:

await _db.SaveChangesAsync();

// More changes...

await _db.SaveChangesAsync();

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

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

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

מה לבדוק בקוד שלכם

כדי להבין אם השמירה במערכת שלכם באמת מתואמת, כדאי לעקוב אחרי פעולה עסקית אחת מתחילתה ועד סופה:

  • איפה נטענות הישויות, והאם הן משתתפות באותה יחידת עבודה?
  • האם אחד ה־Repositories שומר שינויים באמצע התהליך?
  • מי מחליט שהפעולה מוכנה לשמירה?
  • אילו שינויים נכללים בפועל באותה טרנזקציה?
  • מה קורה אם בקשה נוספת משנה את אותם הנתונים במקביל?

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

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

כתיבת תגובה

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