Object-Oriented Programming تنها ایجاد کلاسی با چند متد و property نیست. این پارادایم برنامهنویسی دارای عمق و پیچیدگیهایی است که درک صحیح آن میتواند تفاوت چشمگیری در کیفیت نرمافزارهای تولیدی ایجاد کند. یکی از مهمترین جنبههای OOP، طراحی و پیادهسازی service object ها است که نقش محوری در معماری نرمافزارهای مدرن ایفا میکنند.

این مقاله بر اساس فصل دوم کتاب Object Design Style Guide نوشته Matthias Noback تهیه شده است و به بررسی عمیق تکنیکهای پیادهسازی سرویسها در OOP میپردازد. متأسفانه بسیاری از توسعهدهندگان، حتی با سالها تجربه، در زمینه طراحی و پیادهسازی صحیح OOP دچار نواقص عمدهای هستند و اصول best practice را رعایت نمیکنند. این امر در آینده منجر به بحرانها و مشکلات جدی برای توسعه و نگهداری نرمافزار خواهد شد.
در این مقاله به service object میپردازیم: تعریف آن، اصول طراحی، نحوه ارتباط بین سرویسها، روشهای تزریق وابستگیها و نکات کلیدی دیگر در مورد سرویسها. (تمامی نکات ارائه شده خلاصهای از فصل مذکور است و تمامی تصاویر و مثالها نیز از همان فصل اقتباس شدهاند).
نکته : خوندن این مقاله به افرادی که مباحث ابتدایی برنامه نویسی شی گرا را نمیدونند توصیه نمیشود. خوندن این مقاله نیازمند دانستن مباحث و مفاهیم ابتدایی شی گرا هست.
در این مقاله خواهید خواند:
نحوه ایجاد اشیاء سرویس
تزریق و اعتبارسنجی وابستگیها و مقادیر پیکربندی
ارتقاء آرگومانهای اختیاری constructor به آرگومانهای اجباری
آشکار کردن وابستگیهای پنهان
طراحی سرویسها بهگونهای که immutable باشند
در معماری object-oriented معمولاً با دو نوع آبجکت مواجه هستیم:
نوع اول: Service object که عملیاتی را انجام میدهد یا بخشی از داده را بازمیگرداند.
نوع دوم: آبجکتهایی که دادهها را نگهداری میکنند و به صورت اختیاری رفتارهایی برای بازیابی یا تغییر آن دادهها ارائه میدهند.
سرویسها قابلیت این را دارند که یک بار ساخته شوند و برای همیشه قابل استفاده باشند. هر بار استفاده از آنها هیچ تغییری در وضعیت داخلی سرویس ایجاد نمیکند.
آبجکت نوع دوم میتواند پیچیدهتر از نوع اول باشد، زیرا این آبجکتها توسط سرویسها به کار گرفته میشوند تا کار خود را تکمیل کنند. پس از انجام عملیات، به صورت اختیاری تغییری در آبجکت اعمال میشود یا نمیشود و سپس به سرویس بعدی ارسال میشود، مانند شکل زیر:

این UML به وضوح نشان میدهد که چگونه سرویسها سرویسهای دیگر را فراخوانی میکنند. آبجکتها به عنوان آرگومان متدها داده میشوند یا به عنوان داده بازگشتی برمیگردانند.
نکته مهم: ممکن است یک آبجکت در داخل هر سرویسی که آن را دریافت میکند تغییر یابد یا دادهای از آن استخراج شود.
نامگذاری سرویسها معمولاً به کاری که انجام میدهند اشاره دارد، مانند: Controller، FileManager، Router، Calculator. بنابراین در نامگذاری سرویسها دقت ویژهای داشته باشید.
معمولاً هر سرویس برای انجام وظیفه خود به سرویسهای دیگر و یا تنظیمات خاصی نیاز دارد. این وابستگیها باید توسط پارامترهای constructor تزریق و مقداردهی شوند.
دلیل این کار آن است که سرویس پس از ساخته شدن آماده انجام کار باشد و نیازی به هیچ تنظیمات اضافی نداشته باشد. برای مثال به سرویس FileLogger نگاه کنید:
interface Logger { public function log(string $message): void; } interface Formatter { public function format(string $message): string; } final class FileLogger implements Logger { private Formatter $formatter; private string $logFilePath; public function __construct(Formatter $formatter, string $logFilePath) { $this->formatter = $formatter; $this->logFilePath = $logFilePath; } public function log(string $message): void { $formattedMessage = $this->formatter->format($message); file_put_contents($this->logFilePath, $formattedMessage, FILE_APPEND); } } $logger = new FileLogger(new SomeFormatter(), '/path/to/log.txt'); $logger->log('A message');
Formatter وابستگی FileLogger است که از طریق constructor تزریق شده و LogFilePath تنظیمات این سرویس محسوب میشود.
نکته: این مقادیر پیکربندی ممکن است به طور سراسری در اپلیکیشن شما وجود داشته باشند، مثلاً در نوعی Setting Object یا مدلهای ساختار داده بزرگ.
توجه کنید که کل Configuration Object را تزریق نکنید و فقط همان تنظیمات مورد نیاز سرویس را تزریق کنید.
یک سرویس نباید کل یک آبجکت Configuration را دریافت کند. با این حال، بعضی از مقادیر پیکربندی وجود دارند که همیشه به طور همزمان استفاده میشوند. تزریق جداگانه آنها انسجام طبیعی آنها را مختل میکند. برای رفع این مشکل میتوان یک آبجکت پیکربندی اختصاصی تعریف، مقداردهی و تزریق کرد، برای مثال:
final class MySQLTableGateway { public function __construct( string host, int port, string username, string password, string database, string table ) { //... } }
در اینجا مقادیر پیکربندی اتصال به MySQL به صورت تکی داده شده که انسجام کاهش مییابد.
final class MySQLTableGateway { public function __construct( ConnectionConfiguration connectionConfiguration, string table ) { // ... } }
در اینجا مقادیر مرتبط با هم در یک آبجکت اختصاصی تعریف شدهاند (به جز Table چون نام جدول بخشی از اطلاعات مورد نیاز برای برقراری ارتباط با پایگاه داده نیست).
هر فریمورک یا کتابخانهای که استفاده میکنید، به اندازه کافی بزرگ یا پیچیده باشد، نوع خاصی از آبجکت را ارائه میدهد که هر سرویس و مقادیر پیکربندی که ممکن است بخواهید استفاده کنید را در داخل خود نگهداری میکند.
نامهای رایج برای چنین آبجکتی شامل: Service Locator، Manager، Registry، Container میباشد.
یک Service Locator خود یک سرویس است که وظیفه بازیابی سرویسهای دیگر را بر عهده دارد. مثال سادهای از Service Locator:
final class ServiceLocator { private array $services; public function __construct() { $this->services = [ 'logger' => new FileLogger(/* ... */) ]; } public function get(string $identifier): object { if (!isset($this->services[$identifier])) { throw new LogicException('Unknown service: ' . $identifier); } return $this->services[$identifier]; } }
در اینجا متد get() کلید آن سرویسی که مدنظر است را میگیرد و اگر موجود نباشد Exception پرتاب میکند.
نکته: Service Locator مثل نقشهای است که میتوانید سرویسهای مدنظرتان را با آن دریافت کنید. کلید معمولاً نام یا رابط کلاسی است که میخواهید دریافت کنید. معمولاً در Service Locatorهای واقعی (نه مثال) تمام مسئولیت نمونهسازی و مقداردهی پارامترهای constructor یک سرویس را بر عهده میگیرند.
حال ممکن است وسوسه شوید این Service Locator را تزریق کنید، اما این کار باعث میشود کد شما از آن فراخوانی بگیرد. به فرض مثال، HomePageController شما نیاز دارد بداند چطور سرویس مورد نظر خود را صدا بزند و همچنین دسترسی به تعداد زیادی آبجکت و چیزهای نامربوط از طریق Service Locator پیدا میکند.
برای جلوگیری از تمامی این مشکلات میتوانیم از قاعده زیر پیروی کنیم:
هر زمان که سرویسی برای انجام کارش به سرویس دیگری نیاز داشت، باید به طور صریح و واضح آن را به عنوان وابستگی اعلام کند و از طریق پارامترهای constructor آن را دریافت کند، مانند مثال زیر که یک Controller است:
final class HomepageController { private EntityManager $entityManager; private ResponseFactory $responseFactory; private TemplateRenderer $templateRenderer; public function __construct( EntityManager $entityManager, ResponseFactory $responseFactory, TemplateRenderer $templateRenderer ) { $this->entityManager = $entityManager; $this->responseFactory = $responseFactory; $this->templateRenderer = $templateRenderer; } public function execute(Request $request): Response { $user = $this->entityManager ->getRepository(User::class) ->getById($request->get('userId')); return $this->responseFactory ->create() ->withContent( $this->templateRenderer->render( 'homepage.html.twig', [ 'user' => $user ] ), 'text/html' ); } }
Service Locator در اینجا نیاز اصلی ما نیست و نیازهای اصلی را به صورت واضح بیان میکنیم.

در نسخه اولیه، اگر میخواستیم Service Locator را دریافت کنیم، Controller به نظر میرسید یک وابستگی دارد. حال در این نسخه، وقتی Service Locator را حذف کردیم، مشخص شد سه وابستگی داریم.
حال که در مورد وابستگی واقعی و صادقانه صحبت میشود، میتوانیم و باید یک تکرار دیگر انجام دهیم. ما به EntityManager برای این نیاز داریم که فقط User Repository را دریافت کنیم. پس به جای آن، خود Repository User را دریافت میکنیم:
final class HomepageController { private UserRepository $userRepository; // ... public function __construct( UserRepository $userRepository // ... ) { $this->userRepository = $userRepository; // ... } public function execute(Request $request): Response { $user = $this->userRepository ->getById($request->get('userId')); // ... } }
در اینجا وابستگی Controller کاملاً واضح و صریح است.
گاهی اوقات ممکن است فکر کنید وابستگی اختیاری است و آبجکت بدون آن وابستگی نیز میتواند به خوبی کار کند. ممکن است یک وابستگی را دغدغه ثانویه در حال حاضر در نظر بگیرید و آن را در پارامترهای constructor اختیاری قرار دهید. اما این کار باعث میشود کد ما به طور غیرضروری پیچیده شود در داخل کلاس. هر زمان بخواهید با آن وابستگی کار کنید، باید بررسی کنید آن مقداردهی شده یا خیر. اگر بررسی نکنید، یک خطای Fatal دریافت میکنید، مثال:
final class BankStatementImporter { private ?Logger $logger; public function __construct(?Logger $logger = null) { $this->logger = $logger; } public function import(string $bankStatementFilePath): void { // Import the bank statement file // Every now and then log some information for debugging... if ($this->logger instanceof Logger) { $this->logger->log('A message'); } } } $importer = new BankStatementImporter();
در اینجا، زمان ساخت BankStatementImporter نیازی به ارسال آرگومان اجباری نیست، اما در داخل کد پیچیدگی اضافه میشود.
برای رفع این موضوع باید تمام آرگومانهای constructor را اجباری تعریف کنیم. این موضوع برای تنظیمات (config) نیز صدق میکند.
مثلاً نباید آرگومان constructor برای تنظیمات را مقدار پیشفرض قرار دهید یا بدتر از این، مقدار پیشفرض را در داخل کد پیادهسازی کنید، مانند مثال زیر:
final class FileLogger implements Logger { private ?string $logFilePath; public function __construct(?string $logFilePath = null) { $this->logFilePath = $logFilePath; } public function log(string $message): void { // ... file_put_contents( $this->logFilePath !== null ? $this->logFilePath : '/tmp/app.log', $formattedMessage, FILE_APPEND ); } }
در این کد، کلاس FileLogger فردی که از آن استفاده میکند اگر بخواهد بداند مسیر پیشفرض چیست، باید به داخل کد نگاهی بیندازد. همچنین مسیر پیشفرض اکنون جزو پیادهسازی کد در نظر گرفته میشود و به راحتی میتواند تغییر کند بدون اینکه فردی که از آن کلاس استفاده میکند متوجه شود. پس برای رفع این مشکل، تمام آرگومانهای constructor را اجباری کنید تا فرد خودش انتخاب کند. در آن موقع با یک نگاه به آرگومانهای constructor میفهمد چه تنظیمات و وابستگیهایی در خود استفاده کرده است.
یک ترفند که میتوان برای وابستگیهای اختیاری گفت، تزریق وابستگیها توسط Setterها است که اجازه میدهد پس از ساخته شدن کلاس، در صورت تمایل، وابستگی را تزریق کنید، مانند کد زیر:
final class BankStatementImporter { private ?Logger $logger = null; public function __construct() { } public function setLogger(Logger $logger): void { $this->logger = $logger; } // ... } $importer = new BankStatementImporter(); $importer->setLogger($logger);
این علاوه بر اینکه مثل موضوع قبلی کد را پیچیده میکند، دو قاعده دیگر که در ادامه مقاله به آنها میپردازیم را نیز نقض میکند:
آبجکتی در حالت ناقص نباید بتواند ایجاد شود
سرویسها بعد از اینکه ساخته شدند باید غیرقابل تغییر باشند
خلاصه اینکه از Setterها برای تزریق استفاده نکنید.
دو بخش قبلی را میتوان اینطور خلاصه کرد: "شما چیزی به عنوان وابستگی اختیاری ندارید". شما یا به چیزی وابستگی دارید یا ندارید. حال با این حال، فرض کنید ثبت Log واقعاً یک دغدغه ثانویه است. حال باید چه کار کرد؟
در بسیاری از مواقع میتوانید به استفاده از یک آبجکت جایگزین روی بیاورید که دقیقاً شبیه آبجکت واقعی است اما کاری انجام نمیدهد، مانند مثال زیر که NullLogger از رابط Logger استفاده کرده:
final class NullLogger implements Logger { public function log(string $message): void { // Do nothing } } $importer = new BankStatementImporter(new NullLogger());
چنین آبجکت بیضرری معمولاً به عنوان Null Object یا گاهی اوقات Dummy شناخته میشود.
میتوانید از روش مشابه استفاده کنید. مقادیر تنظیمات هنوز باید اجباری باشند، اما باید راهی برای کاربر آن کلاس فراهم کنید تا مقادیر تنظیمات منطقی مورد نظرش را به دست بیاورد، مانند کد زیر:
final class MetadataFactory { public function __construct(Configuration $configuration) { // ... } } $metadataFactory = new MetadataFactory( Configuration::createDefault() );
به جای اینکه آرگومان سازنده MetaDataFactory را اختیاری کنیم، یک کلاس تنظیمات با مقادیر پیشفرض تزریق میکنیم.
شاید فکر کنید تمام وابستگیها و مقادیر تنظیمات را از طریق constructor تزریق کردهاید و وابستگی دیگری وجود ندارد. اما هنوز هم ممکن است وابستگی پنهانی وجود داشته باشد. این وابستگی پنهان است و به همین دلیل با یک نگاه سریع به آرگومانهای constructor نمیتوان آن را تشخیص داد.
در اکثر برنامهها، متدهایی داریم که در کل برنامه هستند و در دسترس هستند، مثل: Cache::get(). هر وقت دیدید در سرویس چنین وابستگی استفاده شده، به طوری بازنویسی کنید که از طریق constructor قابل تزریق باشد. این باعث میشود تمام وابستگیها سریع و واضح باشند. به کد زیر نگاه کنید:
// Before: final class DashboardController { public function execute(): Response { $recentPosts = []; if (Cache::has('recent_posts')) { $recentPosts = Cache::get('recent_posts'); } // ... } } // After: final class DashboardController { private Cache $cache; public function __construct(Cache $cache) { $this->cache = $cache; } public function execute(): Response { $recentPosts = []; if ($this->cache->has('recent_posts')) { $recentPosts = $this->cache->get('recent_posts'); } // ... } }
گاهی اوقات وابستگی پنهان به صورت تابعی وجود دارد که معمولاً این توابع بخشی از آن زبان برنامهنویسی که استفاده میکنید هستند. پیچیدگی پشت این توابع به طوری است که اگر میخواهید خودتان بسازید، شامل سرویسهای متفاوتی میشود و در نهایت برای استفادهاش نیز باید تزریق شود. پس این وابستگی پنهان که معمولاً تابع است را باید وابستگی آبجکت محور کرد، مثل مثال زیر که تابع json_encode() است:
// Before: final class ResponseFactory { public function createApiResponse(array $data): Response { return new Response( json_encode($data, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT), [ 'Content-Type' => 'application/json' ] ); } } // After: final class JsonEncoder { /** * @throws RuntimeException */ public function encode(array $data): string { try { return json_encode( $data, JSON_THROW_ON_ERROR | JSON_FORCE_OBJECT ); } catch (RuntimeException $previous) { throw new RuntimeException( 'Failed to encode data: ' . var_export($data, true), 0, $previous ); } } } final class ResponseFactory { private JsonEncoder $jsonEncoder; public function __construct(JsonEncoder $jsonEncoder) { $this->jsonEncoder = $jsonEncoder; } public function createApiResponse(array $data): Response { return new Response( $this->jsonEncoder->encode($data), [ 'Content-Type' => 'application/json' ] ); } }
این کار باعث میشود کاربر کلاس با یک نگاه به constructor راحت بتواند تصویری از کاری که کلاس انجام میدهد را تجسم کند.
حال آیا نیازی هست که تمام توابع آبجکت محور شوند؟
خیر، نیازی به این کار نیست. در پایین سه معیار میآورم که زمانی خواستید این کار را انجام دهید، از خود بپرسید:
آیا بعداً میخواهید یا ممکن است رفتار ارائه شده توسط این تابع را تغییر دهید یا جایگزین کنید؟
آیا رفتار این وابستگی آنقدر پیچیده است که نتوان آن را با چند خط کد ساده پیادهسازی کرد؟
آیا این تابع با آبجکت سروکار دارد یا نه، فقط مقادیر ساده (رشته و عدد)؟
اگر جواب همه اینها بله است، یعنی نیاز به تبدیل به وابستگی آبجکت محور دارید.
زیرمجموعهای از توابع و کلاسهایی که یک زبان برنامهنویسی ارائه میدهد، به عنوان وابستگی ضمنی (Implicit Dependency) در نظر گرفته میشوند. یعنی با دنیای بیرون اپلیکیشن شما ارتباط دارند، مانند time() و file_get_content().
final class MeetupRepository { private Connection connection; public function __construct(Connection $connection) { $this->connection = $connection; } public function findUpcomingMeetups(string $area): array { now = new DateTime(); return $this->findMeetupsScheduledAfter(now, $area); } public function findMeetupsScheduledAfter( DateTime $time, string $area ): array { // ... } }
استفاده از زمان در سرویس در کد بالا چیزی نیست که به عنوان وابستگی بتوان آن را از آرگومانهای constructor تزریق کرد یا توسط آرگومانهای متد آن را دریافت کرد. پس Time یک وابستگی پنهان است. سرویسی هم در حال حاضر وجود ندارد که بتوان با تزریق آن به آرگومانهای constructor آن را گرفت. پس سرویس را خودمان میسازیم:
interface Clock { public function currentTime(): DateTime; } final class SystemClock implements Clock { public function currentTime(): DateTime { return new DateTime(); } } final class MeetupRepository { // ... private Clock $clock; public function __construct( Clock $clock, /* ... */ ) { $this->clock = $clock; } public function findUpcomingMeetups(string $area): array { now = $this->clock->currentTime(); // ... } } $meetupRepository = new MeetupRepository(new SystemClock()); $meetupRepository->findUpcomingMeetups('NL');
با انتقال دریافت ساعت سیستم به یک سرویس دیگر، سرویس فعلی ما را نیز تستپذیرتر میکند. به این دلیل تستپذیرتر میشود که دیگر نتیجه تستی که میخواهیم بنویسیم به زمان اجرای آن تست بستگی ندارد و بعد از تاریخ معینی تست شکست نمیخورد. ما میتوانیم زمان ثابتی را پاس دهیم به کلاس برای تست.
اگرچه باید یک سرویس تمام وابستگیهای خود را از طریق آرگومانهای constructor دریافت کند، بعضی از اطلاعات مربوط به یک وظیفه یا Task خاص (Contextual Information) باید به آرگومانهای متد پاس داده شوند. برای فهم بیشتر به کلاس پایین نگاه کنید که UserId را از Session که از آرگومان constructor تزریق شده میگیرد:
final class ContactRepository { private Session session; public function __construct(Session session) { this.session = session; } public function getAllContacts(): array { return this.select() .where([ 'userId' => this.session.getUserId(), 'companyId' => this.session.get('companyId') ]) .getResult(); } }
در اینجا یک سرویس ContactRepository نمیتواند به جز گرفتن مخاطبان جدا از آنچه در Session فعلی وجود دارد، مخاطبان دیگری را بگیرد. این سرویس به عبارت دیگر فقط میتواند در یک زمینه (Context) اجرا شود. به این دلیل باید داده مرتبط با یک کار یا وظیفهای به آرگومان متد داده شود تا سرویس بتواند در زمینههای مختلف قابل استفاده باشد.
نکته: برای اینکه تصمیم بگیرید که باید به آرگومان constructor بدهید یا متد، این سؤال را از خودتان بپرسید:
آیا میتوانم این سرویس را به صورت دستهای (Batch) اجرا کنم بدون اینکه بخواهم آن را از نو بسازم؟
در حال حاضر با پرسیدن این سؤال در سرویس فعلی ما، نمیتوان آن را به صورت دستهای برای دریافت مخاطبان کاربرها و شرکتهای مختلف استفاده کرد. پس باید به آرگومانهای متد پاس دهیم، کد زیر را نگاه کنید:
public function getAllContacts(string $userId, string $companyId): array { return this.select() .where([ 'userId' => $userId, 'companyId' => $companyId ]) .getResult(); }
نکته: در واقع واژه "Current" (فعلی یا جاری) سیگنال مفیدی است که نشان میدهد این اطلاعات زمینهای (Contextual) هستند که باید به آرگومانهای متد پاس داده شوند.
همانطور که بالاتر گفتیم، با تزریق وابستگی اختیاری پس از ایجاد سرویس، رفتار آن سرویس را تغییر میدهیم و این باعث غیرقابل پیشبینی بودن سرویس میشود. این موضوع درباره متدهایی که چیزی تزریق نمیکنند اما از خارج رفتار سرویس را تغییر میدهند نیز صدق میکند، مثل مثال زیر:
final class Importer { private bool $ignoreErrors = true; public function ignoreErrors(bool $ignoreErrors): void { $this->ignoreErrors = $ignoreErrors; } // ... } $importer = new Importer(); // ... $importer->ignoreErrors(false); // ...
در اینجا ما هر وقت بخواهیم میتوانیم رفتار سرویس را تغییر دهیم. باید مطمئن شد چنین اتفاقی نمیافتد. همیشه باید زمان ایجاد، تمامی وابستگیها و مقادیر تنظیمات وجود داشته باشند و پس از ساخت سرویس، دوباره قابل تغییر تنظیمات نباشند.
اجازه دادن تغییر یافتن پس از ایجاد به سرویسی باعث میشود برای کسانی که از آن سرویس استفاده میکنند غیرقابل پیشبینی شود. هر بار مسیر اجرایی آن سرویس تغییر کند، مانند عکس زیر:

اگر شما جوری سرویسی را درست کنید که تغییر نکند و وابستگی اختیاری نداشته باشد، باعث میشود نتیجه آن سرویس در طول زمان قابل پیشبینی باشد و بر اساس اینکه چگونه یک شخص آن را فراخوانی میکند، رفتارش تغییر نکند.
موضوع مهم: "شاید در اپلیکیشنی که من میسازم واقعاً به سرویسهای قابل تغییر نیاز دارم"
در اپلیکیشنهای سمت وب عمدتاً اصلاً نیازی نیست سرویس قابل تغییر داشته باشید و در زمان ساخت سرویس به راحتی میتوان رفتار آن سرویس را تعیین کرد.
اما ممکن است در اپلیکیشنهای دیگر مثل بازیسازی یا موارد دیگر، زمانی که کاربر یک کار را انجام داد، با استفاده از متدهای سرویستان بخواهید چیزی از آن حذف یا اضافه کنید. در این مورد توصیه میشود که درباره روشهایی فکر کنید که اجازه ندهید آبجکتها از طریق متدهای عمومی، رفتار سایر آبجکتها را دوباره تنظیم کنند.
ساختن یک سرویس به معنای مقداردهی ورودیهای constructor است تا سرویس آماده شود. کار واقعی که سرویس انجام میدهد در متدهای آن انجام میشود. اما شاید وسوسه شوید که کاری را در constructor آن سرویس انجام دهید، مانند ساخت یک دایرکتوری اگر وجود نداشت:
final class FileLogger implements Logger { private string $logFilePath; public function __construct(string $logFilePath) { $logFileDirectory = dirname($logFilePath); if (!is_dir($logFileDirectory)) { mkdir($logFileDirectory, 0777, true); } touch($logFilePath); $this->logFilePath = $logFilePath; } // ... }
در اینجا یک آبجکت بر روی فایلهای سیستم تأثیر میگذارد حتی اگر هیچ وقت متدی از آن فراخوانی نشود. رفتار خوب در طراحی آبجکت این است که جز اعتبارسنجی آرگومانهای constructor و سپس مقداردهی Properties، کاری انجام نشود. حتی ممکن است فکر کنید با انتقال ساخت دایرکتوری به یکی از متدهای Private یا داخلی سرویس، کاملاً مشکل حل میشود. تا حدودی از حالت اول بهتر است، اما بهترین کار این نیست. بهترین کار این است که قبل از ساخت FileLogger این دایرکتوری را داشته باشیم. پس آن را کلاً به بیرون constructor و سرویس میآوریم:
final class FileLogger implements Logger { private string $logFilePath; public function __construct(string $logFilePath) { if (!is_writable(logFilePath)) { throw new InvalidArgumentException( "Log file path \"$logFilePath\" should be writable" ); } $this->logFilePath = $logFilePath; } public function log(string $message): void { // ... } }
در اینجا برای ایجاد FileLogger انتظار داریم که از قبل فایل ما ایجاد شده باشد و قابل نوشتن باشد. باید به این توجه داشته باشید که انتقال تنظیمات FileLogger به بیرون constructor آن، قرارداد را عوض میکند.
در حالت قبلی ما مسیر فایل Log را میدادیم و انتظار میرفت که تمام کارها را خود FileLogger انجام دهد، مانند ساخت دایرکتوری. اما در حالت فعلی، FileLogger انتظار دارد مسیر فایلی که میدهید از قبل وجود داشته باشد و قابل نوشتن باشد.
بیایید نگاهی بیندازیم به مثالی که ظریفتر از آبجکتی است که داخل constructor خودش کاری انجام میدهد:
final class Mailer { private Translator $translator; private string $defaultSubject; public function __construct(Translator $translator) { $this->translator = $translator; // ... $this->defaultSubject = $this->translator ->translate('default_subject'); } // ... }
اگر ترتیب تخصیصها را تغییر دهید، چه اتفاقی میافتد؟
حال translate() بر روی null صدا زده میشود و یک خطای Fatal دریافت میکنید. به همین دلیل قاعده "فقط در constructor ویژگیها (Properties) را مقداردهی کن" وجود دارد، چون ممکن است این مقداردهی ترتیبی باشد و این یعنی شما در constructorتان کار (منطق یا عملیات) انجام دادهاید که نباید انجام میدادید.
زمانی که کلاینت (کدی که از کلاس استفاده میکند) آرگومانی نامعتبر بدهد، معمولاً Type Checkerها آن را بررسی میکنند و به شما اخطار میدهند، مثلاً اگر انتظار String باشد اما Boolean پاس داده شود.
اما تکیه بر Type Systemها کافی نیست. ممکن است دادهای که پاس میدهد درست باشد اما واقعگرایانه نباشد که بشود از آن استفاده کرد. تصمیم برای پرتاب نکردن Exception نیز میتواند گزینهای باشد، به شرط اینکه باعث خراب شدن کار آبجکت در مراحل بعدی نشود. برای مثال Router زیر را ببینید:
final class Router { private array $controllers; private string $notFoundController; public function __construct(array $controllers, string $notFoundController) { // Should we check if the controllers array is empty? $this->controllers = $controllers; $this->notFoundController = $notFoundController; } public function match(string $uri): string { foreach ($this->controllers as $pattern => $controller) { if ($this->matches($uri, $pattern)) { return $controller; } } return $this->notFoundController; } private function matches(string $uri, string $pattern): bool { // ... } } $router = new Router( [ '/' => 'homepage_controller' ], 'not-found' ); $router->match('/');
در اینجا آیا نیاز داریم آرگومان Controllers را واقعاً Validate (اعتبارسنجی) کنیم که شامل یک جفت URL و Controller باشد؟
قاعدتاً نه، چون خالی بودن آرایه Controllers باعث خراب شدن کار Router نمیشود. اگر خالی باشد، تابع match() کنترلر Not-Found را برای ما برمیگرداند. این رفتاری است که از Router انتظار میرود، بنابراین این کار نباید باعث شکست کارهای بعدی Router شود. اما به هر حال باید چک کنید که مقادیر در داخل آرایه همه رشته هستند تا باعث شود اشتباهات برنامهنویسی زودتر شناسایی شود:
final class Router { private array $controllers; public function __construct(array $controllers) { foreach (array_keys($controllers) as $pattern) { if (!is_string($pattern)) { throw new InvalidArgumentException( 'All URI patterns should be provided as strings' ); } } foreach ($controllers as $controller) { if (!is_string($controller)) { throw new InvalidArgumentException( 'All controllers should be provided as strings' ); } } $this->controllers = $controllers; } // ... }
حتی میتوانید این را طوری بنویسید که از Type Systemها استفاده شود با اضافه کردن تابع addController() خصوصی (Private) به کلاس. پارامترهای رشتهای نظیر Pattern و Controller آنها را جفت کنید:
final class Router { private array $controllers = []; public function __construct(array $controllers) { foreach ($controllers as $pattern => $controller) { $this->addController($pattern, $controller); } } private function addController(string $pattern, string $controller): void { $this->controllers[$pattern] = $controller; } // ... }
زمانی که فریمورک اپلیکیشن، Controller شما را صدا میزند، میتوانید فرض کنید تمام وابستگیها مشخص هستند. برای مثال، یک Controller به Repositoryهایی نیاز دارد که داده مورد نظرش را دریافت کند یا نیاز به Template Engine دارد که قالب خود را رندر کند و غیره. تمام این وابستگیها وقتی با دقت در پارامترهای constructor تعریف شوند، میتوانند یکباره ایجاد شوند که معمولاً باعث ایجاد یک گراف بزرگ از آبجکتها میشود. اگر فریمورک یک Controller دیگری را فراخوانی کند، از گراف متفاوتی از آبجکتها برای انجام وظیفه خودش استفاده میکند. پس میتوان Controllerها را به عنوان نقطه ورودی گراف آبجکت برنامه در نظر گرفت:

این گراف شامل تمامی سرویسهای یک برنامه است و سرویس Controller نقطه ورود این گراف است. Controller تنها سرویسی است که به صورت مستقیم میشود آن را بازیابی کرد. تمامی سرویسها جز Controller باید فقط برای این در دسترس باشند که به عنوان وابستگی تزریق شوند.
بیشتر برنامهها چیزی شبیه Service Container دارند که توضیح میدهد چگونه تمام سرویسهای برنامه ساخته شوند، وابستگی آنها چه چیزهایی هستند و چگونه میتوان آنها را ساخت. این Service Container همچنین میتواند به عنوان Service Locator نیز عمل کند. میتوانید از آن بخواهید که یکی از سرویسهایش را برگرداند تا بتوانید از آن استفاده کنید. در بالاتر خواندیم که فقط باید وابستگیهای واقعی را تزریق کنید با فرض موارد زیر:
همه سرویسهای اپلیکیشن، یک گراف بزرگ از آبجکت را تشکیل میدهند
نقطه ورودی برنامه و گراف ما، Controllerها هستند
هیچ سرویسی نباید برای گرفتن سرویسهای دیگر به سراغ Service Locator برود
طبق این سه مورد نتیجه میگیریم که Service Container متدهای عمومیاش فقط برای گرفتن Controllerها باشد و بقیه متدهای آن که سرویسهای دیگر جز Controller را درست میکنند باید خصوصی (Private) باشند، چون فقط به عنوان وابستگی به Controllerها داده میشوند. برای ملموستر شدن به کد زیر توجه کنید:
final class ServiceContainer { public function homepageController(): HomepageController { return new HomepageController( $this->userRepository(), $this->responseFactory(), $this->templateRenderer() ); } private function userRepository(): UserRepository { // ... } private function responseFactory(): ResponseFactory { // ... } private function templateRenderer(): TemplateRenderer { // ... } // ... } if ($uri == '/') { $controller = $serviceContainer->homepageController(); $response = $controller->execute($request); // ... } elseif (/* ... */) { // ... }
یک Service Container امکان این را نیز میدهد که از سرویسها استفاده مجدد داشته باشیم (یعنی یک بار ساخته شود و سپس تا هر چند استفاده شود). به همین دلیل هر کدام از Controllerها به عنوان نقطه ورودی، شاخه گراف آبجکت مستقلی ندارند. برای مثال، یک Controller دیگری نیز جز HomePageController میتواند از TemplateRenderer استفاده کند. به همین دلیل باید سرویسها را به گونهای طراحی کنیم که پیشبینیپذیر باشند. اگر موضوعاتی که در این مقاله گفته شد را رعایت کنید، به یک گراف آبجکت خواهید رسید که میتوانیم آن را یک بار ایجاد و سپس بارها استفاده کنیم.
سرویسها باید در یک مرحله ساخته شوند، به طوری که تمام وابستگیها و مقادیر پیکربندی (Config) آنها به صورت آرگومانهای constructor فراهم شود. تمام وابستگیهای سرویس باید شفاف و مشخص باشند و باید به صورت شی (Object) تزریق شوند. تمام مقادیر پیکربندی (Config) باید اعتبارسنجی شوند. وقتی constructor آرگومانی دریافت کند که به هر نحوی نامعتبر است، باید یک Exception پرتاب کند.
پس از ساخته شدن، سرویس باید تغییرناپذیر باشد، به طوری که رفتار آن با فراخوانی هیچ متدی نباید تغییر کند.
تمام سرویسهای اپلیکیشن در کنار هم یک گراف بزرگ و تغییرناپذیر آبجکت میسازند که معمولاً توسط یک Service Container مدیریت میشود. Controllerها نقاط ورود این گراف هستند. سرویسها میتوانند فقط یک بار ساخته شوند و چندین بار مورد استفاده قرار گیرند.
اگر تا اینجای مقاله را خوانده و دنبال کردهاید، یعنی به مطالب مطرح شده علاقه دارید. در این مقاله سعی شد موضوعات به صورت خلاصه بیان شوند، طوری که نکات مهم آن از قلم نیفتد. اما بسنده کردن به اطلاعاتی که اینجا در مورد بخش کوچکی از دنیای جذاب OOP گفته شد، کار اشتباهی است.
از طرف من به شما پیشنهاد میشود که به دانش بیشتر در مورد OOP روی بیاورید و یک چیز سطحی که فقط یک کلاس و متد باشد را OOP در نظر نگیرید. موضوعاتی که اینجا گفته شد نیاز به تمرین در پروژههای واقعی دارد. اگر هم نمیتوانید و دسترسی به پروژه واقعی ندارید، یک پروژه تمرینی نزدیک به پروژه واقعی برای خود تعریف کنید که در آنجا موضوعاتی که گفته شد را به کار بگیرید.
در نهایت، آخرین سخن من به شما: با یادگیری عمیقتر این مباحث یا هر مبحث دیگری، شما را از انبوه برنامهنویسان جدا میکند و تمایزی در خود نسبت به بقیه ایجاد میکنید. این تخصص عمیق همان چیزی است که شما را از یک برنامهنویس معمولی به یک توسعهدهنده حرفهای تبدیل میکند.