مدیریت روابط چندریختی در خروجی API
پرسش
معرفی چالش
در یک پروژه لاراول مبتنی بر API رستفول، چالشی در فرایند تبدیل و استانداردسازی خروجی APIها وجود دارد، بهویژه زمانی که مدلها دارای رابطه چندریختی (Polymorphic Relation) باشند.
شرح مسئله
زمانی که یک موجودیت (Entity) یا مدل، شامل رابطه چندریختی است، خروجی API میتواند در حالتهای مختلف، ساختار متفاوتی داشته باشد. این امر سبب میشود که سمت کلاینت (در اینجا ژوپیتر نامیده میشود) مجبور باشد شرطها یا if/elseهای متعددی را بررسی کند تا بر اساس نوع موجودیت دریافتی (مثلاً اگر از نوع X بود)، رفتار مناسبی را پیادهسازی کند.
وجود منطق شرطی در کلاینت برای تشخیص و مدیریت انواع مختلف موجودیتهای چندریختی، منجر به وابستگی شدید (High Coupling) و کد غیرقابل نگهداری میشود.
راهکارهای پیشنهادی
برای غلبه بر این چالش، دو پیشنهاد اصلی مطرح شده است که ترکیب هر دوی آنها قابل استفاده است. با این حال، شیوه پیادهسازی بهینه این راهکارها هنوز مشخص نبوده و ابهام دارد.
پیشنهاد اول: تعریف رابطههای جداگانه
این راهکار مبتنی بر تعریف رابطههای جداگانه است. بر اساس این پیشنهاد، باید به ازای تکتک موجودیتهایی که در جدول مقصد، رابطه چندریختی را مصرف میکنند، یک رابطه جداگانه با استفاده از اسم همان موجودیتها تعریف شود.
پیشنهاد دوم: استانداردسازی خروجی (خروجی حداقلی)
این راهکار بر استانداردسازی خروجی تمرکز دارد. پیشنهاد میشود که خروجی کلاس ترنسفورمر، در مورد فیلدی که رابطه چندریختی را ایجاد کرده است، یک خروجی حداقلی با فیلدهای استاندارد باشد.
این خروجی باید همواره شامل دو فیلد مشخص باشد:
- یک فیلد شناسایی (Identifier)
- یک فیلد نمایشی (Display Field)
ابهام در پیادهسازی بهینه
با وجود این پیشنهادات، سؤال یا ابهام اصلی اینجاست که چگونه میتوان این استانداردسازی (مخصوصاً در مورد خروجی حداقلی) را به بهترین شکل پیادهسازی کرد؟
سه راهکار اصلی برای پیادهسازی مطرح شده است:
راهکار اول: تصمیمگیری در لایه ترنسفورمر
لایه ترنسفورمر که دادههای موجودیت چندریختی را در اختیار دارد، خودش مستقیماً تصمیم بگیرد که این دادهها را در کدام فیلد خروجی قرار دهد.
راهکار دوم: استانداردسازی در لایه ریپوزیتوری
در لایه ریپوزیتوری، کوئری با استفاده از آلیاس (Alias) زده شود تا خروجی استانداردسازی شود.
راهکار سوم: استفاده از متدهای مدل و قرارداد (Contract)
- برای فیلد شناسایی (Identifier)، از متد
getPrimaryKey()مدلهای لاراول استفاده شود - برای فیلد نمایشی (Show Field)، یک متد باید روی همه مدلها تعریف یا قرارداد (Contract) شود
سؤالات کلیدی
در پاسخ خود، موارد زیر را در نظر بگیرید:
- اگر جایی میگویید فلان چیز بیزینس لاجیک است یا نیست، علت را هم توضیح دهید
- همچنین اگر جایی میگویید آنتیپترن است، علت را توضیح دهید
در مورد راهکار سوم، ابهام مهمی وجود دارد:
آیا انجام این کار (تعریف متد Show Field در مدل) صحیح است یا خیر؟
این تردید به دلیل آن است که فیلد نمایشی (Show Field) اساساً کاربرد ارائهای یا پرزنتیشن (Presentation) دارد.
پاسخ
موضوع: تحلیل معماری و تصمیمگیری برای مدیریت روابط چندریختی (Polymorphic) در خروجی API
بسیار خب، تیم فنی. مسئلهای که مطرح شد، یکی از کلاسیکترین چالشها در طراحی APIهای RESTful و مرز بین سرور و کلاینت است. بیایید این موضوع را با دقت و بر اساس اصول مهندسی نرمافزار کالبدشکافی کنیم.
۱. صورت مسئله و محدودیتها (Context)
ما یک API لاراولی داریم. در این API، روابط چندریختی (Polymorphic Relations) باعث شده خروجی JSON ما ساختار یکسانی نداشته باشد. در نتیجه، کلاینت ما (تیم ژوپیتر) مجبور شده منطق if/else پیادهسازی کند تا نوع دادهی دریافتی را تشخیص دهد و متناسب با آن رفتار کند.
تحلیل اولیه بحران
این وضعیت یک آنتیپترن آشکار و یک خط قرمز مستقیم در معماری ماست. ما در حال نشت دادن منطق تجاری (Business Logic Leak) به سمت کلاینت هستیم.
چرا این Business Logic است؟
اینکه یک Comment میتواند به Post یا Video تعلق داشته باشد، یک قانون و واقعیت در دامنه (Domain) بیزینس ماست. کلاینت (ژوپیتر) نباید و نمیتواند مسئول درک و مدیریت این پیچیدگی داخلی سرور باشد.
نقض اصول
Coupling (وابستگی):
ما یک وابستگی شدید (High Coupling) بین پیادهسازی داخلی سرور و پیادهسازی کلاینت ایجاد کردهایم. هر بار که ما در سرور یک نوع جدید (مثلاً Article) به این رابطه چندریختی اضافه کنیم، تیم کلاینت باید کد خود را تغییر دهد و یک else if جدید اضافه کند.
این نقض مستقیم اصل Open/Closed Principle (OCP) است.
SoC (جداسازی concerns):
مسئولیت «مدیریت انواع موجودیتهای قابلکامنت» به اشتباه از سرور به کلاینت منتقل شده است.
هدف ما
هدف ما باید صفر کردن منطق شرطی در کلاینت برای مدیریت این رابطه باشد. کلاینت باید یک قرارداد (Contract) ثابت و استاندارد از سرور دریافت کند.
۲. تحلیل راهکارهای پیشنهادی
بیایید گزینههای مطرحشده را بررسی کنیم.
پیشنهاد اول: تعریف روابط جداگانه
این پیشنهاد که «به ازای هر موجودیت، یک رابطه جداگانه تعریف شود» را قاطعانه رد میکنم.
این کار پاک کردن صورت مسئله و نادیده گرفتن کامل مزیت استفاده از Polymorphism است. این طراحی:
اصل OCP را نقض میکند:
- با افزودن هر موجودیت جدید، باید مدل اصلی را تغییر دهی و یک رابطه جدید اضافه کنی
اصل DRY را نقض میکند:
- منطق کوئری زدن تکرار خواهد شد
اصل SRP را نقض میکند:
- مدل ما سنگین و آگاه به تمام مصرفکنندگان خود میشود
پیشنهاد دوم: استانداردسازی خروجی (خروجی حداقلی)
این، جهتگیری معمارانهی درستی است.
ما باید خروجی را آبستره (Abstract) کنیم. کلاینت نباید Post یا Video را بشناسد؛ کلاینت باید یک «موجودیت قابلنمایش» (Displayable Entity) را بشناسد که دارای id و name (یا title) است.
حالا به سراغ سه راهکار پیادهسازی این پیشنهاد میرویم:
۳. مقایسه تطبیقی گزینههای پیادهسازی
چگونه این «خروجی استاندارد» را تولید کنیم؟
راهکار ۱: تصمیمگیری در لایه ترنسفورمر (Transformer)
شرح:
در خود ترنسفورمر (مثلاً Fractal یا Eloquent API Resources) با if/else یا instanceof چک کنیم که مدل از چه نوعی است و بر اساس آن، فیلد title یا name را در خروجی قرار دهیم.
تحلیل:
این راهکار کمی بهتر از نشت منطق به کلاینت است، چون حداقل منطق را در سرور نگه میدارد (در لایه Presentation).
اما این یک Code Smell و نقض صریح OCP است.
اگر فردا موجودیت Article اضافه شود، تو مجبوری به این ترنسفورمر برگردی و یک else if ($model instanceof Article) اضافه کنی. سیستم برای توسعه (افزودن Article) باز است، اما برای تغییر (تغییر در Transformer) بسته نیست.
نقض Tell, Don't Ask:
ما داریم از مدل «میپرسیم» که تو چه هستی؟ (instanceof)، بهجای اینکه به او «بگوییم» چه کاری انجام دهد.
نتیجه:
رد میشود. این یک راهحل شکننده و غیرقابلنگهداری است.
راهکار ۲: استانداردسازی در لایه ریپوزیتوری (Query Alias)
شرح:
در کوئری دیتابیس با استفاده از AS (مثلاً SELECT title AS display_name) خروجی را یکسان کنیم.
تحلیل:
این یک آنتیپترن خطرناک و نقض شدید SoC است.
چرا آنتیپترن است؟
لایه ریپوزیتوری (یا لایه Data Access) مسئول واکشی موجودیتهای دامنه (Domain Entities) یا *Data Transfer Object (DTO)*های خالص است. این لایه نباید هیچ دانشی درباره «نحوه نمایش» داده در API داشته باشد.
وقتی تو در ریپوزیتوری title را به display_name آلیاس میکنی، یعنی لایه داده را به لایه نمایش (Presentation Layer) وابسته کردهای. اگر فردا بخواهیم در خروجی API اسم فیلد را به display_label تغییر دهیم، باید کوئری دیتابیس را تغییر دهیم! این فاجعه است.
نتیجه:
قاطعانه رد میشود. این کار لایهبندی معماری (Layering) را نابود میکند.
راهکار ۳: استفاده از متد مدل (Interface Contract)
شرح:
یک Interface (قرارداد) در PHP تعریف کنیم (مثلاً DisplayableContract). این اینترفیس مدلها را مجبور به پیادهسازی دو متد میکند: getDisplayIdentifier() و getDisplayName().
تحلیل:
این صحیحترین، پایدارترین و حرفهایترین راهحل است.
پاسخ به ابهام (آیا این منطق Presentation است؟):
خیر. اینکه «چه فیلدی» نمایندهی متنی یک موجودیت است، یک «قانون دامنه» (Domain Rule) و بخشی از هویت آن موجودیت است، نه منطق ارائهای.
تفکیک مفهومی:
- منطق ارائهای (Presentation Logic) یعنی: «این متن را با رنگ آبی در یک تگ H1 نشان بده».
- منطق دامنه (Domain Logic) یعنی: «یک
Postباtitleآن شناخته میشود و یکUserباfull_nameآن». این دانش، متعلق به خود مدل است.
انطباق با اصول SOLID:
OCP (Open/Closed)
ترنسفورمر ما فقط به DisplayableContract وابسته است. اگر فردا Article اضافه شود، فقط کافیست Article این اینترفیس را implement کند. ترنسفورمر هرگز تغییر نمیکند.
LSP (Liskov Substitution)
ترنسفورمر میتواند با هر آبجکتی که DisplayableContract را پیادهسازی کرده (چه Post چه Video) کار کند، بدون اینکه رفتارش به هم بریزد.
DIP (Dependency Inversion)
ماژول سطح بالا (Transformer) به ماژول سطح پایین (Post, Video) وابسته نیست. هر دو به یک Abstraction (یعنی DisplayableContract) وابسته هستند.
انطباق با سایر اصول:
Tell, Don't Ask:
- ترنسفورمر دیگر از مدل «نمیپرسد» تو کی هستی، بلکه به آن «میگوید» نام نمایشیات را به من بده (
$model->getDisplayName()).
SoC:
- هر بخش کار خودش را میکند. مدل میداند نام نمایشیاش چیست. ترنسفورمر میداند چطور آن را در JSON بستهبندی کند. کلاینت فقط آن را نمایش میدهد.
۴. توصیه نهایی (Final Recommendation)
راهکار قطعی، پیادهسازی «گزینه سوم» (استفاده از Interface Contract) است.
هر راهکار دیگری منجر به بدهی فنی (Technical Debt) و شکنندگی سیستم در آینده خواهد شد. ما کدهای if/else را (چه در کلاینت و چه در ترنسفورمر سرور) تحمل نخواهیم کرد.
۵. طرح اجرایی گامبهگام (Action Plan)
گام ۱: تعریف قرارداد (Interface)
یک اینترفیس در لایه Domain یا Application (بسته به ساختار پروژه) تعریف کن:
namespace App\Contracts;
interface DisplayableContract
{
/**
* شناسه یکتای موجودیت را برمیگرداند.
* @return int|string
*/
public function getDisplayIdentifier();
/**
* نام نمایشی موجودیت را برمیگرداند.
* @return string
*/
public function getDisplayName(): string;
}
گام ۲: پیادهسازی قرارداد در مدلها
تمام مدلهایی که در آن رابطه چندریختی استفاده میشوند (Post, Video, ...) باید این اینترفیس را پیادهسازی کنند.
use App\Contracts\DisplayableContract;
class Post extends Model implements DisplayableContract
{
// ... (existing code)
public function getDisplayIdentifier()
{
return $this->id; // یا getRouteKey()
}
public function getDisplayName(): string
{
return $this->title;
}
}
use App\Contracts\DisplayableContract;
class Video extends Model implements DisplayableContract
{
// ... (existing code)
public function getDisplayIdentifier()
{
return $this->id;
}
public function getDisplayName(): string
{
return $this->name;
}
}
گام ۳: اصلاح ترنسفورمر (API Resource)
ترنسفورمر (یا کلاسی که مسئول تبدیل مدل به آرایه است) نباید هیچ منطق شرطی داشته باشد.
// فرض کنیم در ترنسفورمرِ مدلی هستیم که رابطه
// چندریختی 'commentable' را دارد.
public function toArray($request)
{
/** @var \App\Contracts\DisplayableContract $commentable */
$commentable = $this->whenLoaded('commentable');
// بررسی Robustness: مطمئن شو حتما لود شده و از نوع قرارداد ماست
if (!($commentable instanceof DisplayableContract)) {
// Fast Fail: اگر لود نشده یا نوع اشتباهی دارد، خطا بده یا نال برگردان
return [
// ...
'related_item' => null,
];
}
return [
'id' => $this->id,
'body' => $this->body,
// ...
'related_item' => [
'id' => $commentable->getDisplayIdentifier(),
'name' => $commentable->getDisplayName(),
// 'type' => $this->commentable_type, // (اختیاری، اگر کلاینت واقعا نیاز دارد)
],
];
}
متد getPrimaryKey() که در صورت سوال بود خوب است، اما getDisplayIdentifier() در اینترفیس صریحتر است.
گام ۴: پاکسازی کلاینت (تیم ژوپیتر)
تیم کلاینت باید تمام بلوکهای if/else مربوط به تشخیص نوع را حذف کند و فقط و فقط از آبجکت استاندارد related_item و فیلدهای id و name استفاده کند.
۶. شناسایی ریسکها و طرح جایگزین (Fallback)
ریسک ۱: تعداد مدلهای درگیر در این رابطه چندریختی زیاد است و پیادهسازی اینترفیس زمانبر است
پاسخ:
این refactoring الزامی است. این زمان، سرمایهگذاری برای جلوگیری از هزینههای نگهداری آتی است.
ریسک ۲: برخی مدلها فیلد title یا name واضحی ندارند
پاسخ:
این یک مشکل در مدلسازی دامنه است. باید با تحلیلگر بیزینس صحبت شود که «نماینده متنی» آن موجودیت چیست. شاید ترکیبی از دو فیلد باشد. این باید در متد getDisplayName() همان مدل کپسوله شود.
طرح جایگزین (Fallback)
هیچ طرح جایگزینی که اصول معماری را نقض کند (مانند راهحل ۱ و ۲) پذیرفته نیست.
تنها fallback قابلقبول، فازبندی پیادهسازی است. اما خروجی نهایی API باید از روز اول استاندارد شود، حتی اگر پیادهسازی اینترفیسها در چند مرحله انجام شود.
نتیجهگیری
این تحلیل را مبنای کار قرار بده و اجرای گامبهگام را شروع کن.