Service کلاسها در پروژههای لاراولی خیلی محبوب و پراستفاده هستند به همین دلیل آشنایی و استفاده از آن بسیار حائز اهمیت است. در این نوشته مواردی از اینکه سرویسها چه چیزی هستند، چه زمانی و چرا باید از آنها استفاده کرد و چه چیزی را نباید در داخل سرویسها قرار داد، بیان شده است.
در ادامه با موارد زیر آشنا خواهید شد :
- کلاس Service چیست ؟
- دلیل استفاده از Service به جای Model
- چرا از Repositories استفاده نکنیم ؟
- کلاس Service را چگونه ایجاد کنیم ؟
- چگونه کلاس Service را از داخل کنترلر فراخوانی کنیم ؟
- مهم : Services نباید با مقادیر سراسری کار کنند.
- مهم : Services باید قابلیت استفاده مجدد را داشته باشند
کلاس Service چیست ؟
در حقیقت کلاس سرویس یک کلاس استاندارد زبان برنامه نویسی PHP است که منطق اجرای عملیات در آن قرار میگیرد. معمولا این کلاس از هیچ کلاس دیگری ارث بری نمیکند. در بیشتر مواقع برای اینکه از قراردادهای نامگذاری لاراول پیروی کند با پسوند Service
ایجاد میشود برای نمونه ArticleService
.
در اغلب مواقع کلاسهای سرویس برای افزودن منطق یا فرآیند اضافی در Eloquent مدل متناظر خود مورد استفاده قرار میگیرند، برای نمونه UserService
برای مدل User
کاربرد دارد. در جاهایی نیز برای افزودن فرایندها و توابع خاص موضوعی کاربرد دارند، مانند تابع ()charge
در PaymentService
.
در ادامه چند نمونه مثال از کاربرد کلاس سرویس آورده شده است :
namespace App\Services;
class UserService
{
public function store()
{
// Code to Create User and return User.
}
}
namespace App\Services;
class CartService
{
public function getFromCookie()
{
// Get Cart and return it.
}
}
namespace App\Services;
class PaymentService
{
public function charge($amount)
{
// Charge User
}
}
دلیل استفاده از Service به جای Model
اگر کلاس سرویسی به یک Eloquent مدل اشاره کند، با این اوصاف میتوان تمام توابع و منطق پیاده سازی شده در سرویس را به داخل خود مدل انتقال داد. پس چرا باید بخشی از منطق برنامه را از مدل جدا کرد و داخل سرویس متناظر قرار داد ؟
قراردادن تمام اکشنها داخل مدل یا استفاده از سرویس به سلیقه برنامه نویس برمیگردد. اما اگر ابعاد پروژه متوسط به بالا و بزرگ باشد، عملا امکان دارد Eloquent مدلها بالای 1000 خط کد داشته باشند.
در فلسفه من 😎، یک مدل نوعی کلاس تنظیمات روی Eloquent است. بنابراین باید کدهایی داخل آن قرار گیرد که به یکی از موارد زیر مرتبط باشد:
- فیلدها
- جداول پایگاه داده
- روابط
- اعمال فیلتر روی بعضی attributes (accessors و mutators)
برای مقایسه کلمه Model با کلمه Service مثالی از زندگی واقعی بسیار در درک تفاوتهای این دو کمک میکنند. تصور کنید Model چیزی ثابت مانند مجسمه و در مقابل آن Service فرآیندها برای ساخت مجسمه است.
برای مثال: مدل Order
که با مدل Invoice
رابطه دارد را در نظر بگیرید. اکشن ساخت فاکتور داخل آن پیادهسازی شده است.
app/Models/Order.php:
class Order extends Model
{
protected $fillable = ['user_id', 'details', 'status'];
public function invoice() : HasOne
{
return $this->hasOne(Invoice::class);
}
public function pushStatus(int $status) : void
{
$this->update(['status' => $status]);
// Maybe some other actions?
}
public function createInvoice() : Invoice
{
if ($this->invoice()->exists()) {
throw new \Exception('Order already has an invoice');
}
return DB::transaction(function() {
$invoice = $this->invoice()->create();
$this->pushStatus(2);
return $invoice;
});
}
}
داخل کنترلر خواهیم داشت :
app/Http/Controllers/Api/InvoiceController.php:
class InvoiceController extends Controller
{
public function store(Order $order)
{
try {
$invoice = $order->createInvoice();
} catch (\Exception $exception) {
return response()->json(['error' => $exception->getMessage()], 422);
}
return $invoice->invoice_number;
}
// ...
}
در این نمونه مورد بررسی کد زیادی داخل تابع ساخت فاکتور وجود ندارد اما ساخت فاکتور میتواند شامل ارسال نوتیفیکیشن، ساخت اطلاعات حمل و نقل و ... باشد. به همین سبب اندازه کلاس Eloquent مدل بیشتر و بیشتر خواهد شد.
بنابراین بهتر است تابع ساخت فاکتور را داخل Service قرار داد:
app/Services/OrderService.php:
class OrderService
{
public function pushStatus(Order $order, int $status): void
{
$order->update(['status' => $status]);
// Maybe some other actions?
}
public function createInvoice(Order $order): Invoice
{
if ($order->invoice()->exists()) {
throw new \Exception('Order already has an invoice');
}
return DB::transaction(function() use ($order) {
$invoice = $order->invoice()->create();
$this->pushStatus($order, 2);
return $invoice;
});
}
// ...
}
دراین صورت داخل کنترلر خواهیم داشت :
app/Http/Controllers/Api/InvoiceController.php:
class InvoiceController extends Controller
{
public function store(Order $order)
{
try {
$invoice = $orderService->createInvoice($order);
} catch (\Exception $exception) {
return response()->json(['error' => $exception->getMessage()], 422);
}
return $invoice->invoice_number;
}
// ...
}
چرا از Repositories استفاده نکنیم ؟
الگوی طراحی Repository برای ساخت توابعی استفاده شده که دادهها را دستکاری میکنند یا به عبارتی واسط بین لایههای برنامه هستند. با توجه به این ایده میتوان در آینده یک Repository را با Repository دیگری به راحتی جایگزین کرد.
اما اگر از زبان برنامهنویسی یا فریمورکی استفاده کنید که از مکانیزم Eloquent ORM بهره نمیبرد الگوی طراحی Repository خیلی ملموس و قابل درک خواهد بود.
در خصوص لاراول، Eloquent یک لایه میانی بین کنترلر و پایگاهداده است بنابراین در واقع با الگوی طراحی Repository پیادهسازی شده است.
در لاراول به جای :
SELECT * FROM users;
نوشته میشود :
User::all();
به همین خاطر است که افزودن Repository بر روی یک Repository موجود(Eloquent ORM) کار بیهوده است و کاربرد چندانی نخواهد داشت.
کلاس Service را چگونه ایجاد کنیم ؟
دستور php artisan make:service
در Artisan لاراول وجود ندارد. پس باید کلاس را بصورت دستی ایجاد کرد.
معمولا سرویسها را داخل مسیر app/Services
قرار میدهند پس ابتدا نیاز هست که یک پوشه با نام Services
داخل پوشه app
ایجاد کنیم (به صورت پیشفرض در ساختار لاراول این پوشه وجود ندارد) سپس اقدام به ایجاد PHP کلاس سرویس کنیم.
نمونه ای از پنجره باز شده نرم افزار PHPStorm برای ساخت PHP کلاس :
چگونه کلاس Service را از داخل کنترلر فراخوانی کنیم ؟
برای فراخوانی کلاس سرویس دو روش وجود دارد:
- بدون تزریق وابستگی
- با تزریق وابستگی
استفاده از کلاس سرویس UserService
که حاوی تابع store
است برای هر دو روش شرح داده شده است :
namespace App\Services;
class UserService
{
// ...
public function store(array $userData): User
{
// Code to Create User and return User.
}
// ...
}
همچنین در کنترلر، متد store
وجود دارد که پس از زدن دکمه عضویت فراخوانی میشود.
فراخوانی بدون تزریق وابستگی
فقط کافی است نمونهای از کلاس سرویس موردنظر را با استفاده از کلیدواژه new
ایجاد کنیم و سپس متد ()store
را صدا بزنیم.
class UserController extends Controller
{
public function store(UserStoreRequest $request)
{
(new UserService())->store($request->validated());
return redirect()->route('users.index');
}
}
اگر تعداد زیادی اکشن داخل کلاس سرویس تعریف کرده باشیم میتوان نمونه را داخل یک متغیر ذخیره کنیم تا از آن برای صدا زدن هر یک اکشنهای مورد نیاز استفاده کنیم.
class UserController extends Controller
{
public function store(UserStoreRequest $request)
{
$userService = new UserService();
$userService->store($request->validated());
$userService->sendGreetingsEmail($request->email);
// ... $userService->whateverElse();
return redirect()->route('users.index');
}
}
فراخوانی با تزریق وابستگی
لاراول این امکان را به ما میدهد که به صورت اتوماتیک نمونه موردنظر را داخل کنترلر ایجاد کنیم که به آن "auto-resolving" میگویند.
در متد ()store
داخل کنترلر متغیری از نوع UserService
و به اسم دلخواه در ورودی تابع میآوریم. به کد زیر دقت کنید :
class UserController extends Controller
{
public function store(UserStoreRequest $request, UserService $userService)
{
$userService->store($request->validated());
return redirect()->route('users.index');
}
}
روش دوم یعنی تزریق وابستگی باعث کوتاه شدن متد کنترلر میشود و در نهایت باعث تمیز و خوانا شدن کد میشود و از ایجاد نمونههای کلاس به صورت دستی جلوگیری میکند.
مهم : Services نباید با مقادیر سراسری کار کنند.
به طور کلی، کلاس سرویس و توابع داخل آن مشابه یک جعبه سیاه هستند:
- داخل کنترلر بعضی پارامتر هارا وارد میکنید.
- توابع سرویس کارهایی انجام میدهند.
- در نهایت نتایج را به کنترلر برمیگردانند.
به عبارت دیگر، سرویس نباید در مورد هیچ یک از اجزای سراسری مانند Auth، Session، Request URL و ... آگاهی داشته باشد و همچنین نباید پاسخی به مرورگر برگرداند.
در حقیقت، دلیلی داشته که کنترلر، کنترلر نامیده شده است و وظیفهاش کنترل خروجی است. برای مثال، در نمونه کد زیر سرویس به جای abort
کردن کاربر باید Exception برگرداند.
class VoteService
{
public function store($question_id, $value): Vote
{
$question = Question::find($question_id);
abort_if(
$question->user_id == auth()->id(),
500,
'The user is not allowed to vote to your question'
);
// ...
}
}
و این Exception میتواند داخل کنترلر رخ دهد یا به صورت یک عملگر Exception عمومی باشد. پس کد زیر را خواهیم داشت :
class VoteService
{
public function store($question_id, $value): Vote
{
$question = Question::find($question_id);
if ($question->user_id == auth()->id()) {
throw new \Exception('The user is not allowed to vote to your question');
}
// ...
}
}
و در نهایت داخل کنترلر Exception رخ خواهد داد:
class VoteController extends Controller
{
public function voice(StoreVoteController $request, VoteService $service)
{
try {
$voice = $service->store($request->input('question_id'), $request->input('value'));
} catch (\Exception $e) {
abort(500, $ex->getMessage());
}
// ...
}
}
بنابراین با توجه به مثال بالا و مطالب گفته شده کنترلر باید کار کنترل فرآیند و سرویس فقط باید Exception را برگرداند.
صبر کنید این بخش هنوز تمام نشده است 😁
یک مورد غیرایده آل دیگر هم در این مثال هست (شاید از کامل گرایی من هم باشه) : از تابع کمکی ()auth
استفاده کرده که یک متغیر سراسری محسوب میشود. ولی با توجه به مطالب بالا سرویس به منزله یک جعبه سیاه است و نباید هیچ چیزی از پروژه لاراول، متغیرهای سراسری، کاربران، سشنها و ... بداند. پس در اینجا باید ID به صورت متعیر به داخل سرویس ارسال شود:
class VoteService
{
public function store($question_id, $value, $user_id): Vote
{
$question = Question::find($question_id);
if ($question->user_id == $user_id) {
throw new \Exception('The user is not allowed to vote to your question');
}
// ...
}
}
class VoteController extends Controller
{
public function voice(StoreVoteController $request, VoteService $service)
{
try {
$voice = $service->store($request->input('question_id'), $request->input('value'), auth()->id());
} catch (\Exception $e) {
abort(500, $ex->getMessage());
}
// ...
}
}
مهم : Services باید قابلیت استفاده مجدد را داشته باشند
باید بدانیم که سرویسها لزوما از داخل کنترلر صدا زده نمیشوند. از داخل دستورات Artisan و همچنین Unit تستها هم صدا زده میشوند.
در مثال زیر کلاس سرویس CurrencyService
آورده شده است که اکشن تبدیل واحد پولی را انجام میدهد :
class CurrencyService
{
const RATES = [
'usd' => [
'eur' => 0.98
]
];
public function convert(float $amount, string $currencyFrom, string $currencyTo): float
{
$rate = self::RATES[$currencyFrom][$currencyTo] ?? 0;
return round($amount * $rate, 2);
}
}
سرویس موردنظر بدون دست زدن به پایگاهداده یا شبیهسازی درخواست مرورگر یا API به کار گرفته میشود. به مثال زیر که به راحتی از سرویس بالا در TestCase استفاده شده است دقت کنید :
class CurrencyTest extends TestCase
{
public function test_convert_usd_to_eur(): void
{
$priceUSD = 100;
$this->assertEquals(98, (new CurrencyService())->convert($priceUSD, 'usd', 'eur'));
}
public function test_convert_gbp_to_eur(): void
{
$priceUSD = 100;
$this->assertEquals(0, (new CurrencyService())->convert($priceUSD, 'gbp', 'eur'));
}
//
}
امیدوارم این نوشته به شما در درک کلاس های Service و نحوه استفاده از آن به صورت کاربردی، کمک کرده باشد.