Shahin Sorkh's Blog

A full stack dev journal, maybe?

 updated at 05 Jul 2023
With ♥️ by Shahin Sorkh Theme by mattgraham
Can be found at Telegram , LinkedIn , StackOverflow Can be called via email or mobile

لاراول فراتر از CRUD: دوم. کار کردن با دیتا

Laravel   Domain oriented   Programming

متن اصلی در Laravel beyond CRUD: 02. Working with data نوشته Brent.

در هسته همه پروژه‌ها می‌تونید دیتا پیدا کنید. تقریبا هر اپلیکیشنی رو می‌تونیم به این صورت خلاصه کنیم: جوری که بیزینس نیاز داره، دیتا رو ارائه بده تفسیر کن و تغییر بده.

احتمالا خودتون هم متوجه شدید: با شروع یک پروژه شما شروع به ساختن کنترلرها و جاب‌ها نمی‌کنید، بلکه با - به قول لاراول - مدل‌ها شروع می‌کنید. پروژه‌های بزرگ از ERDها و انواع دیگه دیاگرام‌ها استفاده می‌کنن که مدل مفهومی دیتایی که قراره باش کار کنن رو پیاده کنن. فقط درصورتی که مدل مفهومی نوع دیتا مشخص باشه، می‌تونید شروع کنید به ساختن بخش‌های مختلف اپلیکیشن و استفاده از دیتا.

در این قسمت نگاهی می‌اندازیم به نحوه ساختارمند کار کردن با دیتا، به طوری که همه توسعه‌دهنده‌های تیمتون بتونن اپلیکیشنی بنویسن که به شکل قابل پیش‌بینی و امن با این دیتا کار کنه.

ممکنه از حالا به فکر مدل‌ها باشید، ولی قبلش قدم‌هایی هست که باید برداریم.

تئوری تایپ (type)

برای اینکه کاربرد شئ‌های حامل دیتا (Data Transfer Objects) - که موضوع اصلی این بخش هستن - رو درک کنید، باید کمی درباره‌ی تایپ‌سیستم‌ها بدونید.

ممکنه همه با اصطلاحات تایپ‌سیستم‌ها هم‌رای نباشن. پس من تعریف خودم از کلماتی که به کار می‌برم رو می‌گم.

قدرت یک تایپ‌سیستم - تایپ‌های ضعیف یا قوی - یعنی اینکه نوع یک متغیر بعد از تعریف قابل تغییره یا نه.

به عنوان یک مثال ساده: یک متغیر از نوع استرینگ تعریف می‌کنیم مثل $a = 'test;'؛ یک تایپ‌سیستم ضعیف به شما اجازه می‌ده که این متغیر رو با یه تایپ دیگه مجددا تعریف کنید، مثلا $a = 1;، به عنوان یک اینتجر.

زبان PHP، یک زبان با تایپ‌سیستم ضعیفه. اجازه بدید یک مثال در دنیای واقعی بزنم:

$id = '1'; // مثلا از ریکوئست اومده

function find(int $id): Model
{
    // ورودی '1' بصورت خودکار تبدیل به اینتجر می‌شه
}

find($id);

واضح بگم: تایپ‌سیستم ضعیف برای PHP منطقیه. وقتی زبانی هستی که اصولا با درخواست‌های HTTP سروکار داری، همه چیز استرینگیه.

ممکنه فکر کنید که با PHP مدرن، می‌تونید از این تغییر تایپ در پشت صحنه جلوگیری کنید، درسته ولی نه کاملا. فعال کردن فیچر strict_types از پاس دادن تایپ‌های دیگه به تابع جلوگیری می‌کنه، ولی هنوز ممکنه نوع متغیر درون تابع تغییر کنه:

declare(strict_types=1);

function find(int $id): Model
{
    $id = '' . $id;

    /*
     * این اتفاق کاملا پذیرفته شده‌است!
     * متغیر ورودی الان دیگه استرینگه
     */

    // …
}

find('1'); // این یک خطای تایپ می‌ده (TypeError)

find(1); // این بدون خطا اجرا می‌شه

حتی با strict_types و type hints، تایپ‌سیستم PHP ضعیفه. وقتی از type hints استفاده می‌کنیم، نوع متغیر فقط در همون لحظه گارانتی می‌شه، درباره اینکه بعدا چه اتفاقی ممکنه برای نوع متغیر بیفته هیچ تضمینی نمی‌ده.

همون‌طور که قبلا گفتم: تایپ‌سیستم ضعیف برای PHP منطقیه، چون همه ورودی‌هایی که باشون سروکار داریم ابتدا به ساکن استرینگ‌اند. هرچند با تایپ‌های قوی ویژگی جالبی به وجود میاد: تایپ‌سیستم‌های قوی تضمین‌هایی دارن. متغیری که با یه تایپ مشخص تعریف می‌شه، دیگه قابل تغییر نیست! و این یعنی بخش بزرگی از رفتارهای نامتعارف هرگز نمی‌تونن اتفاق بیفتند.

از لحاظ ریاضی می‌شه اثبات کرد که اگه یه برنامه با تایپ قوی کامپایل بشه، غیرممکنه باگ‌هایی در اون برنامه ببینیم که فقط تو زبان‌های با تایپ‌سیستم ضعیف ممکنه. به عبارت دیگه، تایپ قوی تضمین بهتری به برنامه‌نویس می‌ده که کدی که می‌نویسه واقعا همونجوری کار می‌کنه که قراره کار کنه.

البته به این معنی نیست که زبان‌های با تایپ‌سیستم قوی نمی‌تونن باگ داشته باشن! کاملا ممکنه که پیاده‌سازی باگ داشته باشه. ولی وقتی یه برنامه با تایپ قوی کامپایل می‌شه، می‌تونیم مطمئن باشیم که برخی باگ‌ها - که ناشی از تفاوت نوع واقعی متغیر با انتظار برنامه‌نویسه - نمی‌تونن وجود داشته باشن.

تایپ‌سیستم‌های قوی باعث می‌شن برنامه‌نویس هنگام نوشتن برنامه دید بهتری نسبت به چیزی که می‌نویسه داشته باشه، بدون اینکه نیار به اجرای برنامه داشته باشه.

یه مفهوم دیگه هم هست که باید تعریف کنیم: تایپ‌های ایستا و پویا - از اینجا به بعد همه چیز جذاب می‌شه.

همون‌طور که احتمالا می‌دونید، PHP یک زبان تفسیر شدنیه (interpreting). یعنی یه اسکریپت PHP هنگام اجرا تبدیل به زبان ماشین می‌شه. وقتی یه درخواست به سروری می‌فرستید که قراره PHP اجرا کنه، سرور فایل‌های خام .php رو برمی‌داره و متنشون رو تجزیه می‌کنه به چیزی که پردازشگر می‌تونه اجرا کنه.

دوباره، این یکی از قدرت‌مندی‌هاییه که PHP داره: اسکریپت رو می‌نویسید، صفحه رو رفرش می‌کنید و همه چیز اونجاست. این تفاوت بزرگیه در مقایسه با زبانی که باید حتما کامپایل بشه تا قابل اجرا کردن باشه. [قطعا هزینه‌هایی هم داره که البته قابل چشم‌پوشیه. م.]

واضحه که سیستم‌های کشینگ هم وجود دارن که پروسه رو بهبود بدن، لذا جمله بالا خیلی ساده‌انگارانه است. ولی برای اینکه نکته بعدی رو درک کنید کفایت می‌کنه.

یک بار دیگه، یه نقطه ضعفی هست: چون PHP فقط موقع اجرا می‌تونه تایپ‌ها رو بررسی کنه، بررسی تایپ‌ها ممکنه وسط اجرا با خطا مواجه بشه. یعنی، شاید برای دیباگ کردن خطا واضح باشه، ولی خب برنامه کرش کرده و از دست رفته.

بررسی تایپ‌ها در هنگام اجرا، باعث می‌شه PHP زبانی با تایپ پویا (dynamically typed) باشه. از طرف دیگه زبانی با تایپ ایستا، همه تایپ‌ها رو قبل اینکه بخواد اجرا بشه بررسی کرده.

از PHP 7.0 به بعد، تایپ‌سیستم زبان خیلی بهتر شده. به قدری که اخیرا ابزارهایی مثل PHPStan، phan و psalm محبوبیت خیلی بیشتری پیدا کردن. این ابزارها روی کدی که به زبان پویا - که PHP باشه - نوشته شدن آنالیزهای آماری ایستا انجام می‌دن.

با استفاده از این کتابخانه‌ها، بدون اینکه نیاز باشه کد رو اجرا یا تست کنید، دید بسیار خوبی از برنامه بهتون می‌دن. یه IDE مثل PhpStorm هم بسیاری از این بررسی‌های ایستا رو بدون نیاز به برنامه خارجی یا تنظیمات اضافی می‌تونه ارائه بده.

با درنظر داشتن این اطلاعات پایه، حالا وقت اینه که بریم به هسته برنامه‌مون: دیتا.

ساختمارمند کردن دیتای بدون ساختار

تاحالا شده با «آرایه‌ای از چیزها» کار کنید که درواقع چیزی بیشتر از یه لیست بودن؟ از کلیدهای آرایه به عنوان فیلد استفاده کردید؟ و تاحالا این سختی رو کشیدید که نمی‌دونید دقیقا چی داخل اون آرایه است؟ یا اینکه مطمئن نباشید دیتایی که داخل اون آرایه است، همونیه که انتظار دارید یا چه فیلدهایی قابل دسترسیه؟

بذارید چیزی که می‌گم رو به تصویر بکشم: درخواست‌های لاراول رو در نظر بگیرید. فرض کنیم این مثالیه از یه برنامه CRUD ساده که اطلاعات مشتری رو آپدیت می‌کنه:

function store(CustomerRequest $request, Customer $customer) 
{
    $validated = $request->validated();
    
    $customer->name = $validated['name'];
    $customer->email = $validated['email'];
    
    // …
}

احتمالا متوجه مشکل شدید: ما نمی‌دونیم دقیقا چه چیزهایی در آرایه $validated ذخیره شده. با اینکه آرایه‌ی PHP ساختمان‌داده‌ی بسیار پرکاربرد و قدرتمندیه، به محض اینکه برای ارائه هر چی به جز «لیستی از چیزها» استفاده بشه، راه‌های بهتری برای حل مشکل وجود داره.

قبل از اینکه به راه‌حل برسیم، از روش‌های زیر هم می‌تونیم به عنوان راهکار استفاده کنیم:

  • سورس‌کد رو مطالعه کنیم
  • مستندات رو بخونیم
  • متغیر $validated رو دامپ کنیم ببینیم توش چیه
  • از یه دیباگر استفاده کنیم که ببینیم داخل متغیر چیه

حالا برای یک دقیقه تصور کنید که برای این پروژه با تیمی متشکل از چندین توسعه‌دهنده کار می‌کنید و همکارتون این تکه از کد رو ۵ ماه پیش نوشته: من تضمین می‌دم بدون استفاده از راهکارهای بالا، عمرا نمی‌دونید با چه دیتایی کار می‌کنید.

به نظر میاد ترکیب تایپ‌سیستم‌های قوی با آنالیز ایستا می‌تونه برای فهم اینکه دقیقا با چی کار می‌کنید کمک بزرگی باشه. زبان‌هایی مثل Rust، به عنوان مثال، راه‌حل تمیزی برای این موقعیت دارن:

struct CustomerData {
    name: String,
    email: String,
    birth_date: Date,
}

چیزی که ما نیاز داریم یه struct است! متاسفانه PHP فقط آرایه و شئ داره، چیزی به عنوان struct وجود نداره.

به هرحال.. اشیاء و کلاس‌ها ممکنه کافی باشن:

class CustomerData
{
    public string $name;
    public string $email;
    public Carbon $birth_date;
}

تا جایی که می‌دونم؛ خصیصه‌های با تایپ معین (typed properties)، از PHP 7.4 قابل استفاده است. بسته به اینکه چه زمانی این کتاب رو می‌خونید، شاید هنوز امکان استفاده از این فیچر رو نداشته باشید - به خوندن ادامه بدید، در ادامه براتون راه‌حل دارم.

برای کسانی که می‌تونن از PHP 7.4 یا جدیدتر استفاده کنن، می‌تونید به این شکل بنویسید:

function store(CustomerRequest $request, Customer $customer) 
{
    $validated = CustomerData::fromRequest($request);
    
    $customer->name = $validated->name;
    $customer->email = $validated->email;
    $customer->birth_date = $validated->birth_date;
    
    // …
}

آنالایزر ایستایی که در IDE مورد استفاده‌تون تعبیه شده، همیشه می‌تونه درباره دیتای دردسترس کمک کنه.

به این الگوی پوشوندن (wrapping) دیتای بدون ساختار درون تایپ‌ها، به طوری که باعث می‌شه به روش مطمئن‌تری از دیتا استفاده کنیم، «شئ‌های حامل دیتا» (Data Transfer Objects یا DTOها) می‌گیم. این اولین الگوی بنیادیه که شدیدا توصیه می‌کنم در پروژه‌های لاراولی بزرگتون حتما استفاده کنید.

وقتی درباره این کتاب با همکارانتون، دوستانتون یا در کامیونیتی لاراول صحبت می‌کنید، ممکنه با کسانی برخورد کنید که نظر متفاوتی درباره تایپ‌سیستم‌های قوی دارن. درواقع آدم‌های زیادی هستن که دوست دارن جنبه‌های پویا و ضعیف‌تر PHP رو با ارزش و امتیاز بدونن. و قطعا حرف‌هایی برای گفتن دارن.

البته به تجربه من وقتی می‌خواید با یه تیم بزرگ زمان زیادی روی یه پروژه کار کنید، تایپ‌سیستم‌های قوی برتری‌های خیلی بیشتری دارن. نباید هیچ فرصتی برای کاهش بار شناخته شده [منظور باگ‌های احتمالیه که می‌دونیم با تایپ‌سیستم‌های ضعیف پیش میاد درحالی که با سیستم‌های قوی غیرممکنن م.] رو از دست بدید. لزومی نداره توسعه‌دهنده مجبور باشه همه کد نوشته شده رو دیباگ کنه که بفهمه داخل یه متغیر چی ذخیره شده. این اطلاعات باید دم دست باشن تا توسعه‌دهنده بتونه روی چیزی که مهمه تمرکز کنه: ساختن اپلیکیشن.

صد البته، استفاده از DTOها هزینه‌بره: تعریف کردنشون فقط سربار نداره؛ باید همه چیز، مثلا دیتای درخواست رو هم مپ کنید به DTO.

فایده استفاده از DTOها قطعا سینگین‌تر از هزینه‌ایه که مجبورید بپردازید. هرچی زمان برای نوشتن این کد صرف کنید، در درازمدت کمتر از حالتیه که از آرایه‌ها استفاده کنید.

یه سوال دیگه می‌مونه: چجوری از دیتای «خارجی» DTO بسازیم؟

فکتوری‌های DTO

چجوری DTOها رو بسازیم؟ دو روش می‌گم و همچنین توضیح می‌دم که خودم کدوم رو ترجیح می‌دم.

اولین روش بهترین روشه: استفاده از یه فکتوری مستقل.

class CustomerDataFactory
{
    public function fromRequest(
       CustomerRequest $request
    ): CustomerData {
        return new CustomerData([
            'name' => $request->get('name'),
            'email' => $request->get('email'),
            'birth_date' => Carbon::make(
                $request->get('birth_date')
            ),
        ]);
    }
}

استفاده از یه فکتوری مستقل، در طول پروژه کد رو تمیز نگه‌می‌داره. منطقی‌ترین حالت اینه که این فکتوری در لایه اپلیکیشن قرار بگیره.

هرچند این روشِ درسته، احتمالا متوجه شدید که تو مثال قبل از یه میانبر استفاده کردم، روی خود کلاس DTO: CustomerData::fromRequest.

این روش چه ایرادی داره؟ خب لاجیک مختص اپلیکیشن رو میاره تو سطح دامنه. کلاس DTO که تو دامنه است نیاز داره درباره کلاس CustomerRequest بدونه که تو سطح اپلیکیشنه.

use Spatie\DataTransferObject\DataTransferObject;

class CustomerData extends DataTransferObject
{
    // …
    
    public static function fromRequest(
        CustomerRequest $request
    ): self {
        return new self([
            'name' => $request->get('name'),
            'email' => $request->get('email'),
            'birth_date' => Carbon::make(
                $request->get('birth_date')
            ),
        ]);
    }
}

واضحه که قاطی کردن کد مختص به اپلیکیشن با دامنه بهترین ایده نیست. به هرحال من اینجور ترجیح می‌دم. به دو دلیل.

یکم: ما تصمیم گرفتیم که DTOها نقطه شروع ورود دیتا به کدبیس باشن. به محض اینکه با دیتا سروکار داریم، می‌خوایم که تبدیلش کنیم به DTO. به هرحال یه جایی باید دیتا رو تبدیل کنیم، پس بهتره تو کلاسی این کارو انجام بدیم که از قبل وجود داره.

دوم، که دلیل مهم‌تریه: من این روش رو ترجیح می‌دم بخاطر یکی از محدودیت‌های PHP؛ پشتیبانی نکردن از پارامترهای با نام معین (named parameters).

ببینید، مطمئنا نمی‌خواید DTOهاتون تبدیل بشن به سازنده‌هایی که به ازای هر خصیصه یک پارامتر می‌گیرن؛ این قابل توسعه نیست، همچنین موقع کار کردن با خصیصه‌های با مقدار پیش‌فرض یا تهی‌پذیر (nullable) باعث سردرگمی می‌شن. به همین دلیل من ترجیح می‌دم یه آرایه به DTO بدم و خودش براساس دیتایی که داخل آرایه است خودشو بسازه. به عنوان نکته اضافی: ما از پکیج spatie/data-transfer-object استفاده می‌کنیم که دقیقا همین کارو کنیم.

چون پارامترهای با نام معین پشتیبانی نمی‌شن، آنالیز ایستا هم ممکن نیست، یعنی درباره اینکه زمان ایجاد DTO به چه دیتایی نیازه در تاریکی هستیم. من ترجیح می‌دم این «در تاریکی بودن» رو بیارم داخل کلاس DTO که بدون نگرانی از لاجیک خارجی بتونیم ازش استفاده کنیم.

هرچند اگر PHP چیزی شبیه پارمترهای با نام معین رو پشتیبانی می‌کرد، می‌گفتم الگوی فکتوری بهترین روشه:

public function fromRequest(
    CustomerRequest $request
): CustomerData {
    return new CustomerData(
        'name' => $request->get('name'),
        'email' => $request->get('email'),
        'birth_date' => Carbon::make(
            $request->get('birth_date')
        ),
    );
}

دقت کنید که زمان ساختن CustomerData خبری از آرایه نیست.

تا زمانی که PHP اینو پشتیبانی کنه، من روش واقع‌بینانه رو به روش نظری ترجیح می‌دم. هرچند انتخاب با شماست. روشی رو استفاده کنید که تو تیمتون بهتر جواب می‌ده.

جایگزینی برای خصیصه‌های با تایپ معین

همون‌طور که گفتم، جایگزینی بجای خصیصه‌های با تایپ معین برای پشتیبانی از DTOها وجود داره: داک‌بلاک‌ها (docblocks). پکیج DTOی ما که بالاتر لینکشو گذاشتم از اینم پشتیبانی می‌کنه.

use Spatie\DataTransferObject\DataTransferObject;

class CustomerData extends DataTransferObject
{
    /** @var string */
    public $name;
    
    /** @var string */
    public $email;
    
    /** @var \Carbon\Carbon */
    public $birth_date;
}

هرچند به صورت پیش‌فرض، داک‌بلاک‌ها هیچ تضمینی نمی‌دن که تایپ متغیرها همونیه که نوشته شده. خوشبختانه API بازتابی PHP وجود داره، و به کمک اون، خیلی چیزا ممکنه.

می‌تونید راه‌حل ارائه شده توسط این پکیج رو افزونه‌ای بر تایپ‌سیستم PHP درنظر بگیرید. با اینکه نهایتا در هنگام اجرا و کد کاربره، هنوز می‌تونه با ارزش باشه. اگه نمی‌تونید از PHP 7.4 استفاده کنید و اطمینان بیشتری می‌خواید که تایپ‌های داک‌بلاک‌ها رعایت بشن، این پکیج هواتونو داره.


از اونجایی که دیتا در هسته هر پروژه‌ای وجود داره، یکی از مهم‌ترین بلاک‌های سازنده است. شئ‌های حامل دیتا راهی پیش پاتون می‌ذارن که بتونید به روشی ساختارمند، با تایپ امن و قابل پیش‌بینی با دیتا کار کنید.

در طول این کتاب متوجه می‌شید که DTOها خیلی زیاد استفاده می‌شن. به همین خاطر خیلی مهم بود که همین اول عمیقا مورد بررسی قرار بگیرن. به همین ترتیب، بلاک سازنده اساسی دیگه‌ای داریم که احتیاج به توجه زیاد داره: اکشن‌ها. موضوع قسمت بعدیه که هفته آینده بهش خواهیم پرداخت.



سری پست‌های لاراول فراتر از CRUD: