ایجاد درایور لاگ در لاراول (ذخیره لاگ در دیتابیس)

منبع عکس: sergeyzhuk.meلاراول یه فریم‌ورک خیلی منعطفه، شاید برای همینه که توی دنیای PHP داره یکه تازی می کنه. یکی از چیزهایی که خیلی به لاراول قدرت و انعطاف داده، استفاده از یک Design Pattern معروف به اسم Strategy هست. این Design Pattern این امکان رو فراهم می کنه که در حین اجرای برنامه،‌ کارکرد یک Class تغییر کنه. به عنوان مثال، شما حتما توی لاراول با دیتابیس کار کردید و می دونید که می تونید خیلی راحت از طریق .env مشخص کنید که می خواید به چه دیتابیسی متصل بشید و لاراول به طرز جادویی‌ای کوئری های منحصر به اون دیتابیس رو برای شما تولید می کنه. خیلی خوبه نه؟لاراول از این Design Pattern خیلی جاهای دیگه هم استفاده کرده، مثلا برای Log، Cache، Storage و ... .لاراول علاوه بر اینکه برای مواردی که گفتیم پیاده‌سازی های مختلفی رو همراه خود فریم‌ورک به شما تحویل می ده (مثلا می تونید از MySQL، PostgreSQL و ... به عنوان Database استفاده کنید)، به شما اجازه می ده Driver دلخواه خودتون رو هم بنویسید (مثلا درایور MongoDB که این پکیج پیاده‌‌سازیش کرده). توی این پست می خوایم یه درایور (یا به قول خود لاراول Channel) برای Log بنویسیم. هدف اصلی این پست، خوندن کدهای لاراول و سر در آوردن از کارشه، ولی داریم این کار رو با یه مثال عملی انجام می دیم. برای همین ممکنه یکم طولانی باشه، ولی اگه تجربه این کار رو ندارید، به نظرم ارزشش رو داره.برای شروع، یه پروژه لاراول تر تمیز می سازیم و توی routes/web.php یه چندتا لاگ می ذاریم:Route::get('/', function() { Log::debug(&quotThe first log.&quot); Log::debug(&quotThe second log.&quot); Log::debug(&quotThe third log.&quot); return view('welcome');});خوب با توجه به اینکه توی .env به طور پیش‌فرض LOG_CHANNEL=stack قرار داده شده، انتظار داریم که توی مسیر storage/logs/laravel.log یه فایل ببینیم که این خطوط توش نوشته شده باشه:[2021-12-16 15:01:55] local.DEBUG: The first log. [2021-12-16 15:01:55] local.DEBUG: The second log. [2021-12-16 15:01:55] local.DEBUG: The third log. تا اینجا هیچ کار خاصی نکردیم. اما می خوایم دو تا فیچر به لاگ لاراول اضافه کنیم: نمایش اطلاعات اضافه‌ در مورد لاگ (مثل کاربری که لاگین کرده و ..)ذخیره لاگ توی دیتابیسبرای شروع یه دایرکتوری به اسم Classes توی app می سازیم (اسم دایرکتوری رو هرچی بخواید می تونید بذارید، اما من عادت دارم کلاس های متفرقه رو توی یه دایرکتوری به اسم Classes ایجاد می کنم). بعد از اون برای دسته بندی بهتر یه دایرکتوری دیگه داخل Classes به اسم Logs می سازیم. فرض کنید که می خوایم اسم Log Channel جدیدی که می خوایم بسازیم رو Database بذاریم. برای همین یه دایرکتوری دیگه توی Logs به اسم Database می سازیم (همه این کارها رو فقط برای مرتب بودن ساختار پروژه کردیم، وگرنه هیچ اجباری نیست).ساختار دایرکتوری هااولین فایلی که نیازه ساخته بشه، فایل Logger هست. توی این فایل دو تا چیز رو مشخص می کنیم: «چه چیزی» باید لاگ شه«چطوری» باید لاگ شهپس یه فایل به اسم Logger توی دایرکتوری Database می‌سازیم:<?php namespace App\Classes\Logs\Database; use Monolog\Logger as MonologLogger; use Psr\Log\LoggerInterface; class Logger { /** * @param array $config * @return LoggerInterface */ public function __invoke(array $config): LoggerInterface { $logger = new MonologLogger('database'); $logger->pushProcessor(new Processor()); $logger->pushHandler(new Handler()); return $logger; } }همونطور که می بینید این فایل فقط یه تابع به اسم __invoke داره که یه آرگومان از جنس آرایه می گیره و یه instance که اینترفیس LoggerInterface رو پیاده‌سازی کرده باشه بر می گردونه. آرگومان تابع __invoke تنظیمات درایورمونه که توی فایل کانفیگ می تونیم مشخص می کنیم. مثلا می تونیم اسم کانکشن دیتابیس رو از طریق کانفیگ بگیریم که به Developer این قابلیت رو بدیم که انتخاب کنه توی چه دیتابیسی لاگ‌ها ریخته بشن. اما فعلا نمی خوایم پیچیده بشه برای همین از روش می پریم.بدنه تابع مجموعا 4 خط بیشتر نیست: ایجاد یه نمونه از یک کلاس که اینترفیس LoggerInterface رو پیاده سازی کرده (برای سادگی کار ما از Monolog استفاده کردیم، ولی می تونید حتی این قسمت رو هم خودتون بنویسید)مشخص کردن اینکه «چه چیزی» باید لاگ شه (pushProcessor)مشخص کردن اینکه «چطوری» باید لاگ بشه (pushHandler)کلاس های Processor و Handler وجود ندارن و باید خودمون بسازیمشون. برای همین کنار فایل Logger.php کلاس های Processor.php و Handler.php رو می سازیم.به عنوان قدم بعد می ریم فایل Processor.php رو توسعه می دیم. داخل این کلاس هم یه تابع با نام __invoke باید باشه که یه آرگومان از جنس آرایه می گیره:<?php namespace App\Classes\Logs\Database; class Processor { public function __invoke(array $record): array { } }آرگومان تابع __invoke دیتایی هستی که به صورت عادی لاراول برای لاگ استفاده می کنه. وقتی $record رو dd کنیم همچین خروجی‌ای رو خواهیم دید:فرمت پیش فرض لاگ‌های Monologما توی این دیتا هر تغییری رو که بخوایم می تونیم بدیم. چیزی کم یا اضافه کنیم. بیاید یه سری اطلاعات اضافه در مورد کاربر به این دیتا اضافه کنیم:<?php namespace App\Classes\Logs\Database; use Illuminate\Support\Facades\Auth; class Processor { public function __invoke(array $record): array { $record['userID'] = Auth::id(); $record['userAgent'] = request()->userAgent(); $record['ip'] = request()->ip(); $record['params'] = request()->all(); return $record; } }الان اگه باز هم قبل از return مقدار $record رو dd کنیم یه همچین چیزی خواهیم دید:فرمت جدید لاگکارمون با Processor همینجا تموم می شه. البته شما می تونید خیلی کارهای باحال دیگه بکنید و جزئیات بیشتری به لاگ اضافه کنید.فایل Handler.php باید حاوی یک کلاس باشه که از کلاس AbstractProcessingHandler ارث‌بری کرده باشه. وقتی می خواید از کلاس AbstractProcessingHandler ارث‌بری کنید، مجبور هستید تابع write رو پیاده‌سازی کنید، چون این تابع به صورت abstract تعریف شده. اما این دلیل نمی شه که ما بقیه توابع کلاس AbstractProcessingHandler رو نگاه نکنیم. پس قبل از پیاده سازی کلاس Handler بریم ببینیم که AbstractProcessingHandler چطوری کار می کنه.کلاس AbstractProcessingHandler یه همچین شکل و شمایلی داره:abstract class AbstractProcessingHandler extends AbstractHandler implements ProcessableHandlerInterface, FormattableHandlerInterface { use ProcessableHandlerTrait; use FormattableHandlerTrait; /** * {@inheritDoc} */ public function handle(array $record): bool { if (!$this->isHandling($record)) { return false; } if ($this->processors) { /** @var Record $record */ $record = $this->processRecord($record); } $record['formatted'] = $this->getFormatter()->format($record); $this->write($record); return false === $this->bubble; } /** * Writes the record down to the log of the implementing handler * * @phpstan-param FormattedRecord $record */ abstract protected function write(array $record): void; /** * @return void */ public function reset() { parent::reset(); $this->resetProcessors(); } }همونطور که می بینید خود این کلاس، از یه کلاس دیگه ارث‌بری کرده، چند اینترفیس رو پیاده‌سازی کرده و از چند Trait استفاده کرده. اما نترسید، اونقدرا هم پیچیده نیست. تابعی که خوندن کد رو باید ازش شروع کنید تابع handle هست (معمولا تابع handle توی لاراول تابعی هست که باید خوندن رو ازش شروع کنیم، مثل توی Job ها). این تابع 3 تا کار مهم می کنه: اگه Processorی تعریف شده باشه، با استفاده از اون(ها) یه Record لاگ رو پردازش می کنه. ما اینجا می دونیم که قراره تابع __invoke توی Processorی که پیشتر نوشتیم فراخونی بشه.با استفاده از تابع format یه کلید با نام formatted به Record لاگ اضافه می شه. این کلید، دیتای فرم داده شده رو نگهداری می کنه. مثلا وقتی می خوایم توی فایل لاگ رو ذخیره کنیم، دقیقا محتوایی که باید توی فایل ذخیره بشه رو نگهداری می کنه. حالا که می خوایم توی دیتابیس ذخیره کنیم، باید محتوایی که قراره توی دیتابیس ذخیره بشه رو بسازیم. مثلا اگه داریم از دیتابیسی مثل MySQL استفاده می کنیم، باید قبل از ذخیره‌سازی، مقادیر کلیدهای context، extra و params رو به JSON تبدیل کنیم، چون امکان ذخیره‌سازی آرایه توی MySQL وجود نداره.در نهایت با فراخونی تابع write عملیات ذخیره‌سازی رو انجام می ده. تابع write به صورت abstract تعریف شده و پیاده سازیش به عهده ماست.مرحله 1 که با نوشتن Processor و فراخونی pushProcessor توی فایل Logger.php به درستی داره کار می کنه و نیاز نیست براش کار دیگه‌ای انجام بدیم. می مونه مورد 2 و 3. آره می تونیم عملیات تبدیل آرایه به JSON رو توی تابع write انجام بدیم، ولی بذارید همه چیز اصولی و تر‌ و تمیز جلو بره. پس باید عملکرد تابع format رو تغییر بدیم. تابع format روی خروجی getFormatter فراخونی شده، پس باید ببینیم که getFormatter چیه و چطوری کار می کنه.تابع getFormatter توی FormattableHandlerTrait تعریف شده و FormattableHandlerTrait به عنوان Trait در AbstractProcessingHandler استفاده شده. اگه یه نگاه بهش بندازیم همچین چیزی می بینیم:/** * {@inheritDoc} */ public function getFormatter(): FormatterInterface { if (!$this->formatter) { $this->formatter = $this->getDefaultFormatter(); } return $this->formatter; }این تیکه کد داره می گه اگه پیش‌تر، از طریق setFormatter، مقداری برای formatter تعیین شده باشه، از اون استفاده بشه، در غیر این صورت از تابع getDefaultFormatter (که توی همین Trait تعریف شده) برای مقداردهی به formatter استفاده بشه.پس ما دو تا راه داریم، یا یه Formatter بنویسیم و از طریق setFormatter به Handler معرفیش کنیم، یا اینکه عملکرد getDefaultFormatter رو با Override کردن تابع تغییر بدیم. من گزینه دوم رو می خوام جلو برم. پس باید نگاه کنم که getDefaultFormatter چطوری پیاده‌سازی شده:protected function getDefaultFormatter(): FormatterInterface { return new LineFormatter(); }می بینیم که به صورت پیش فرض از LineFormatter که یه کلاس توی پکیج Monolog هست داره استفاده می شه. اما ما می تونیم هر Formatter دیگه ای رو جایگزینش کنیم. با یه نگاه به LineFormatter می فهمیم که یه Formatter کلاسیه که اینترفیس FormatterInterface رو پیاده‌سازی کرده باشه. پس یه کلاس می نویسیم که FormatterInterface رو پیاده سازی کرده باشه و بعد getDefaultFormatter رو طوری تغییر می دیم که یک instance از کلاس Formatter ما رو برگردونه://Handler.php protected function getDefaultFormatter(): FormatterInterface { return new Formatter(); }طبیعتا باید کلاس Formatter رو هم بسازیم:<?php namespace App\Classes\Logs\Database; use Monolog\Formatter\FormatterInterface; use Monolog\Utils; class Formatter implements FormatterInterface { public function format(array $record) { $record['context'] = $this->toJson($record['context']); $record['extra'] = $this->toJson($record['extra']); $record['params'] = $this->toJson($record['params']); return $record; } public function formatBatch(array $records) { foreach($records as $key => $record) { $records[$key] = $this->format($record); } return $records; } /** * Return the JSON representation of a value * * @param mixed $data * * @return string if encoding fails 'null' is returned */ protected function toJson($data): string { return Utils::jsonEncode($data, Utils::DEFAULT_JSON_FLAGS, false); } }تابع format عملیات format رو برای یک Record لاگ انجام می ده، formatBatch یه تعداد Record رو به یکباره format می کنه. تابع toJson هم خودمون برای تبدیل آرایه به json نوشتیم. دلیل اینکه از json_encode استفاده نکردیم این بود که ممکنه در آینده بخوایم فلگ‌هایی به json_encode اضافه یا کم بکنیم و برای اینکه هی نخوایم کدها رو تکرار کنیم، یه تابع ساختیم که بدون دغدغه فقط صداش کنیم. داخلش هم به جای تابع json_encode از تابع jsonEncode کلاس Utils پکیج Monolog استفاده کردیم که کارمون راحت تر باشه.خوب برگردیم عقب، ما توی کلاس Handler بودیم و تا الان کلاسمون اینطوری شده:<?php namespace App\Classes\Logs\Database; use Monolog\Formatter\FormatterInterface; use Monolog\Handler\AbstractProcessingHandler; class Handler extends AbstractProcessingHandler { protected function write(array $record): void { } protected function getDefaultFormatter(): FormatterInterface { return new Formatter(); } }اگه توی تابع write یه dd بذاریم، خروجی همچین چیزی خواهد شد:چیزی که برای تابع write ارسال می شهمی بینیم که طبق انتظارمون کلید formatted ایجاد شده اما اونطوری که کلاس Formatter که الان نوشتیم تعیین کرده (به مقدار context، extra و params دقت کنید که تبدیل به JSON شدن).حالا همه چیز آمادست برای اینکه لاگ رو توی دیتابیس ذخیره کنیم. برای ارتباط با دیتابیس بهتره که Model و Migration مربوطه رو ایجاد کنیم. من فقط محتوای این دو فایل رو براتون می ذارم، چون می‌دونم که نحوه ساخت و معنی هر خطش رو خیلی خوب بلدید:<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; class CreateLogsTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('logs', function(Blueprint $table) { $table->id(); $table->string('message'); $table->json('context'); $table->integer('level')->unsigned(); $table->string('level_name'); $table->integer('userID')->unsigned()->nullable(); $table->string('userAgent')->nullable(); $table->string('ip')->nullable(); $table->json('params'); $table->timestamp('datetime'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('logs'); } }<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Log extends Model { use HasFactory; protected $fillable = [ &quotmessage&quot, &quotcontext&quot, &quotlevel&quot, &quotlevel_name&quot, &quotdatetime&quot, &quotuserID&quot, &quotuserAgent&quot, &quotip&quot, &quotparams&quot, ]; }حالا می تونیم تابع write توی Handler رو خیلی آسون پیاده‌سازی کنیم:<?php namespace App\Classes\Logs\Database; use App\Models\Log; use Monolog\Formatter\FormatterInterface; use Monolog\Handler\AbstractProcessingHandler; class Handler extends AbstractProcessingHandler { protected function write(array $record): void { $log = new Log($record['formatted']); $log->save(); } protected function getDefaultFormatter(): FormatterInterface { return new Formatter(); } }طبیعتا باید migrate کنید تا جدول logs ساخته بشه.و حالا قدم آخر! اول پست گفتیم که لاراول به طور پیش‌فرض از کانال stack برای لاگ استفاده می کنه. برای همین همه لاگ ها داره توی storage/logs/laravel.log نوشته می شه. ما باید به لاراول بگیم که آقای لاراول لطفا از درایور دیتابیسی که نوشتم استفاده کن. برای این کار باید اول از همه فایل config/logging.php رو باز کنیم و توی قسمت channels این تیکه کد رو اضافه کنیم تا یه کانال به اسم database درست کنیم و از لاراول بخوایم که وقتی که بهش می گیم از کانال database استفاده کنه، برای لاگ کردن از کلاس Logger که پیشتر نوشتیم استفاده کنه:... 'channels' => [ ... 'database' => [ 'driver' => 'custom', 'via' => Logger::class ], ], ];کانال database ساخته شده. کافیه توی .env بگیم که از کانال database استفاده کنه:LOG_CHANNEL=databaseحالا وقتی توی مرورگر پروژه رو باز کنیم، باید بلافاصله ببینیم این خطوط توی دیتابیس اضافه شده:دیگه چه کارهایی می‌شه انجام داد؟خیلی کارها! مثلا می تونید برای اینکه سرعت اپلیکیشنتون حتی با وجود لاگ‌های زیاد افت محسوسی نکنه، به جای اینکه توی تابع write مستقیم لاگ رو توی دیتابیس ذخیره کنید، از طریق یه Job این کار رو بکنید. یا مثلا می تونید به جای ذخیره توی دیتابیس، از طریق یه درخواست HTTP لاگ ها رو توی سرور دیگه ای ذخیره کنید (اگه این کار رو می کنید یادتون نره که درخواست HTTP رو حتما به صورت async بفرستید وگرنه تاثیر بدی روی Performance اپلیکیشنتون می ذاره). یا مثلا اپلیکیشنتون رو به سیستم های لاگ‌گیری حرفه ای و یا حتی سیستم مدیریت پروژه یا پیام‌رسان ها متصل کنید! ایده ها ته ندارن :)اما چیزی که به نظرم اهمیت بیشتری داره، اینه که ذهنمون باز بشه برای توسعه درایورهای مختلف برای لاراول، نه فقط برای لاگ، بلکه برای هرجای دیگه‌ای که اجازه می ده درایور بنویسیم.