اسکریپت تراکنش
منطق کسبوکار را بر اساس رویههایی سازماندهی میکند که هر رویه یک درخواست از لایهٔ ارائه را مدیریت میکند.
Martin Fowler
رابط عمومی یک سامانه را میتوان مجموعهای از تراکنشهای کسبوکار دانست که مصرفکنندگان قادر به اجرای آنها هستند؛ شکل ۵-۱. این تراکنشها میتوانند اطلاعات تحت مدیریت سامانه را بازیابی کنند، تغییر دهند یا هر دو کار را انجام دهند. این الگو منطق کسبوکار سامانه را بر اساس رویهها سازماندهی میکند؛ هر رویه عملیاتی را پیاده میکند که مصرفکننده از طریق رابط عمومی اجرا میکند. در عمل، عملیات عمومی سامانه به مرزهای کپسولهسازی تبدیل میشوند.
شکل ۵-۱ — رابط Transaction Script.
پیادهسازی
هر رویه بهصورت یک اسکریپت رویهای ساده و مستقیم پیادهسازی میشود. میتواند برای اتصال به مکانیزمهای ذخیرهسازی از یک لایهٔ انتزاعی باریک استفاده کند، یا مستقیماً به پایگاه داده دسترسی داشته باشد.
تنها الزام اساسی این رویهها رفتار تراکنشی است. هر عملیات باید یا بهطور کامل موفق شود یا شکست بخورد و هرگز سامانه را در وضعیت نامعتبر باقی نگذارد. حتی اگر اجرای Transaction Script در نامناسبترین لحظه شکست بخورد، سامانه باید سازگار بماند؛ یا تمام تغییرات انجامشده تا زمان شکست را Rollback کند یا اقدامات جبرانی اجرا کند. همین رفتار تراکنشی در نام الگو منعکس شده است.
نمونهٔ زیر یک Transaction Script برای تبدیل دستهای فایلهای JSON به XML است:
DB.StartTransaction();
var job = DB.LoadNextJob();
var json = LoadFile(job.Source);
var xml = ConvertJsonToXml(json);
WriteFile(job.Destination, xml.ToString();
DB.MarkJobAsCompleted(job);
DB.Commit()
آنقدرها هم ساده نیست!
وقتی این الگو را در کلاسهای DDD معرفی میکنم، دانشجویان اغلب با تعجب میپرسند: «واقعاً ارزش وقت گذاشتن دارد؟ مگر برای الگوها و تکنیکهای پیشرفتهتر نیامدهایم؟»
واقعیت این است که Transaction Script پایهٔ الگوهای پیشرفتهتر پیادهسازی منطق کسبوکار است که در فصلهای بعد خواهید آموخت. افزون بر این، با وجود ظاهر ساده، یکی از آسانترین الگوها برای پیادهسازی اشتباه است. تعداد قابل توجهی از خطاهای Production که نویسنده در رفع آنها کمک کرده، به نوعی به پیادهسازی نادرست رفتار تراکنشی منطق کسبوکار برمیگردند.
سه نمونهٔ رایج و واقعی از فساد داده در اثر پیادهسازی نادرست Transaction Script را بررسی کنیم.
نبود رفتار تراکنشی
نمونهای ساده از شکست در رفتار تراکنشی، اجرای چند Update بدون تراکنش فراگیر است. متد زیر رکوردی در جدول Users را Update میکند و سپس رکوردی در VisitsLog درج میکند:
01 public class LogVisit
02 {
03 ...
04
05 public void Execute(Guid userId, DataTime visitedOn)
06 {
07 _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
08 visitedOn, userId);
09 _db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
10 VALUES(@p1, @p2)", userId, visitedOn);
11 }
12 }
اگر پس از Update جدول Users در خط ۷ و پیش از موفقشدن درج Log در خط ۹ مشکلی رخ دهد، سامانه در وضعیت ناسازگار قرار میگیرد. Users تغییر کرده اما رکورد متناظر در VisitsLog نوشته نشده است. دلیل میتواند قطع شبکه، Timeout یا Deadlock پایگاه داده، یا Crash سروری باشد که فرایند را اجرا میکند.
راهحل، قراردادن هر دو تغییر در یک تراکنش واقعی است:
public class LogVisit
{
...
public void Execute(Guid userId, DataTime visitedOn)
{
try
{
_db.StartTransaction();
_db.Execute(@"UPDATE Users SET last_visit=@p1
WHERE user_id=@p2", visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
VALUES(@p1, @p2)", userId, visitedOn);
_db.Commit();
} catch {
_db.Rollback();
throw;
}
}
}
بهدلیل پشتیبانی بومی پایگاههای دادهٔ رابطهای از تراکنشهای چندرکوردی، این اصلاح ساده است. اما اگر پایگاه داده از تراکنش چندرکوردی پشتیبانی نکند یا چند مکانیزم ذخیرهسازی داشته باشیم که نتوان آنها را در یک تراکنش توزیعشده متحد کرد، موضوع پیچیدهتر میشود.
تراکنشهای توزیعشده
در سامانههای توزیعشدهٔ مدرن رایج است که ابتدا داده در پایگاه داده تغییر کند و سپس با انتشار پیام روی Message Bus، سایر اجزای سیستم از آن تغییر مطلع شوند. فرض کنید در مثال قبل بهجای ثبت بازدید در جدول، باید رویداد را روی Message Bus منتشر کنیم:
01 public class LogVisit
02 {
03 ...
04
05 public void Execute(Guid userId, DataTime visitedOn)
06 {
07 _db.Execute("UPDATE Users SET last_visit=@p1 WHERE user_id=@p2",
08 visitedOn,userId);
09 _messageBus.Publish("VISITS_TOPIC",
10 new { UserId = userId, VisitDate = visitedOn });
11 }
12 }
مانند مثال قبلی، شکست میان خط ۷ و خط ۹ وضعیت را خراب میکند: جدول Users تغییر کرده اما بهدلیل شکست انتشار پیام، سایر اجزا مطلع نمیشوند.
این بار اصلاح به آن سادگی نیست. تراکنشهای توزیعشده میان چند مکانیزم ذخیرهسازی پیچیده، دشوار برای مقیاسپذیری و مستعد خطا هستند و معمولاً از آنها اجتناب میشود. فصل ۸ نشان میدهد چگونه از CQRS برای پرکردن چند ذخیرهساز استفاده کنیم و فصل ۹ الگوی Outbox را معرفی میکند که انتشار قابل اتکای پیام پس از Commit تغییرات در پایگاه داده را ممکن میسازد.
تراکنشهای توزیعشدهٔ ضمنی
متد بهظاهر سادهٔ زیر را در نظر بگیرید:
public class LogVisit
{
...
public void Execute(Guid userId)
{
_db.Execute("UPDATE Users SET visits=visits+1 WHERE user_id=@p1",
userId);
}
}
این متد بهجای ثبت تاریخ آخرین بازدید، شمارندهٔ بازدید هر کاربر را نگه میدارد و با هر فراخوانی آن را یک واحد افزایش میدهد. فقط یک مقدار، در یک جدول و یک پایگاه داده تغییر میکند؛ بااینحال هنوز یک تراکنش توزیعشده است و میتواند وضعیت ناسازگار بسازد.
دلیل این است که عملیات اطلاعات را هم به پایگاه داده و هم به فرایند خارجی فراخواننده منتقل میکند؛ شکل ۵-۲.
شکل ۵-۲ — عملیات LogVisit داده را بهروزرسانی و موفقیت یا شکست را به فراخواننده اطلاع میدهد.
گرچه Execute مقدار بازگشتی ندارد، موفق یا ناموفق بودن عملیات را منتقل میکند؛ در صورت شکست، فراخواننده Exception دریافت میکند. حال اگر متد موفق شود اما انتقال نتیجه به فراخواننده شکست بخورد چه؟ برای نمونه، اگر LogVisit بخشی از REST Service باشد و شبکه قطع شود، یا اگر هر دو در یک Process باشند اما Process پیش از ثبت موفقیت توسط فراخواننده Crash کند.
در هر دو حالت مصرفکننده تصور میکند عملیات شکست خورده و دوباره LogVisit را اجرا میکند. اجرای دوباره شمارنده را یک بار اضافهٔ دیگر افزایش میدهد و در مجموع مقدار ۲ بهجای ۱ زیاد میشود. در نتیجه، Transaction Script باز هم رفتار تراکنشی را درست تضمین نکرده است.
برای این مسئله راهحل جهانی و سادهای وجود ندارد و همهچیز به دامنهٔ کسبوکار بستگی دارد. در این مثال یک روش، Idempotent کردن عملیات است؛ یعنی حتی اگر چندبار تکرار شود نتیجهٔ نهایی یکسان بماند.
مثلاً میتوان از مصرفکننده خواست مقدار شمارنده را ارسال کند. مصرفکننده ابتدا مقدار فعلی را میخواند، محلی یک واحد اضافه میکند و مقدار جدید را پارامتر قرار میدهد. تکرار اجرای عملیات نتیجه را عوض نمیکند:
public class LogVisit
{
...
public void Execute(Guid userId, long visits)
{
_db.Execute("UPDATE Users SET visits = @p1 WHERE user_id=@p2",
visits, userId);
}
}
راه دیگر، کنترل همزمانی خوشبینانه (Optimistic Concurrency Control) است. فراخواننده پیش از اجرای LogVisit مقدار فعلی را خوانده و بهعنوان پارامتر ارسال میکند. LogVisit فقط وقتی مقدار را افزایش میدهد که هنوز برابر مقدار خواندهشده باشد:
public class LogVisit
{
...
public void Execute(Guid userId, long expectedVisits)
{
_db.Execute(@"UPDATE Users SET visits=visits+1
WHERE user_id=@p1 and visits = @p2",
userId, visits);
}
}
اجرای بعدی با همان پارامترها داده را تغییر نمیدهد، چون شرط visits = @p2 دیگر برقرار نیست.
چه زمانی از Transaction Script استفاده کنیم؟
این الگو برای سادهترین دامنههای مسئله مناسب است؛ جایی که منطق کسبوکار شبیه عملیات رویهای ساده است. نمونهٔ کلاسیک، عملیات ETL است: استخراج داده از منبع، تبدیل آن به فرم دیگر و بارگذاری نتیجه در مقصد؛ شکل ۵-۳.
شکل ۵-۳ — جریان دادهٔ Extract–Transform–Load.
Transaction Script بهطور طبیعی با زیردامنههای پشتیبان که منطق ساده دارند سازگار است. همچنین میتواند بهعنوان Adapter برای یکپارچهسازی با سیستمهای خارجی یا زیردامنههای عمومی و نیز بخشی از ACL استفاده شود.
مزیت اصلی الگو سادگی است: انتزاع کم، سربار اجرایی کم و فهم آسان. اما همین سادگی عیب آن نیز هست. هرچه منطق کسبوکار پیچیدهتر شود، احتمال تکرار منطق در تراکنشهای مختلف بیشتر میشود و با ناهمگام شدن کد تکراری، رفتار ناسازگار ایجاد میشود. به همین دلیل Transaction Script هرگز گزینهٔ مناسبی برای زیردامنهٔ هستهای با منطق بسیار پیچیده نیست.
همین سادگی به Transaction Script شهرتی دوگانه داده و گاهی آن را Antipattern میدانند. اگر منطق پیچیده بهشکل Transaction Script نوشته شود، دیر یا زود به Big Ball of Mud غیرقابل نگهداری تبدیل میشود. بااینحال باید توجه داشت که این الگو در توسعهٔ نرمافزار بسیار فراگیر است و تقریباً همهٔ الگوهای پیادهسازی منطق کسبوکار که در ادامه بررسی میکنیم به نحوی بر آن بنا شدهاند.
رکورد فعال (Active Record)
شیئی که یک ردیف در جدول یا View پایگاه داده را دربر میگیرد، دسترسی به پایگاه داده را کپسوله میکند و منطق دامنه را بر آن داده اضافه میکند.
Martin Fowler
مانند Transaction Script، Active Record برای منطق سادهٔ کسبوکار مناسب است؛ با این تفاوت که منطق میتواند روی ساختارهای دادهٔ پیچیدهتر کار کند. بهجای رکوردهای تخت، میتوان درختها و سلسلهمراتب شیئی با روابط یکبهچند و چندبهچند داشت؛ شکل ۵-۴.
شکل ۵-۴ — مدل دادهٔ پیچیدهتر با روابط one-to-many و many-to-many.
کار با چنین ساختارهایی از طریق Transaction Script ساده باعث تکرار زیاد کد، بهویژه کد نگاشت داده به نمایش درونحافظهای، میشود.
پیادهسازی
الگو از اشیای اختصاصی به نام Active Record برای نمایش ساختارهای دادهٔ پیچیده استفاده میکند. علاوه بر ساختار داده، این اشیا متدهای دسترسی برای ایجاد، خواندن، بهروزرسانی و حذف رکوردها، یعنی CRUD، را نیز پیادهسازی میکنند. در نتیجه Active Record به ORM یا چارچوب دسترسی دادهٔ دیگری وابسته است. نام الگو از همین «فعال بودن» ساختار داده و داشتن منطق دسترسی داده میآید.
منطق کسبوکار همچنان به شکل Transaction Script سازماندهی میشود، اما بهجای دسترسی مستقیم به پایگاه داده، Transaction Script اشیای Active Record را دستکاری میکند. عملیات باید در پایان یا بهطور کامل Commit شود یا شکست بخورد:
public class CreateUser
{
...
public void Execute(userDetails)
{
try
{
_db.StartTransaction();
var user = new User();
user.Name = userDetails.Name;
user.Email = userDetails.Email;
user.Save();
_db.Commit();
} catch {
_db.Rollback();
throw;
}
}
}
هدف الگو کپسولهکردن پیچیدگی نگاشت شیء در حافظه به Schema پایگاه داده است. Active Record علاوه بر Persistence میتواند منطق کسبوکار نیز داشته باشد، مانند اعتبارسنجی مقادیر جدید یا رویههایی که دادهٔ شیء را تغییر میدهند. بااینحال ویژگی متمایز آن جدابودن ساختار داده و رفتار است: معمولاً فیلدها Getter و Setter عمومی دارند و رویههای خارجی میتوانند State شیء را تغییر دهند.
چه زمانی از Active Record استفاده کنیم؟
چون Active Record اساساً Transaction Scriptای است که دسترسی به پایگاه داده را بهینه میکند، فقط منطق نسبتاً ساده مثل CRUD و حداکثر اعتبارسنجی ورودی کاربر را پشتیبانی میکند.
بنابراین مانند Transaction Script برای زیردامنههای پشتیبان، یکپارچهسازی راهحلهای خارجی در زیردامنههای عمومی یا تبدیل مدل مناسب است. تفاوت اصلی آن است که Active Record پیچیدگی نگاشت ساختارهای دادهٔ پیچیده به Schema پایگاه داده را حل میکند.
Active Record گاهی با عنوان «مدل دامنهٔ کمخون (Anemic Domain Model)» و بهعنوان Antipattern معرفی میشود. نویسنده ترجیح میدهد از بار منفی این اصطلاحات پرهیز کند. این یک ابزار است؛ مانند هر ابزار دیگری در زمینهٔ درست مسئله را حل میکند و در زمینهٔ نادرست میتواند بیشتر از منفعت، آسیب ایجاد کند. وقتی منطق کسبوکار ساده است، استفاده از Active Record ایرادی ندارد. در مقابل، بهکارگیری الگوی پیچیدهتر برای منطق ساده نیز با ایجاد پیچیدگی تصادفی آسیبزا خواهد بود. فصل بعد Domain Model را معرفی و تفاوت آن با Active Record را روشن میکند.