Laravel 使用 Slack 配合 Proxy 設定方法

延續之前 Laravel Log 的小筆記,後來有歷經兩次實作,今天來談談當時的設計方法。

這會分作兩段來討論,一個是 Slack + Proxy 怎麼實作在 Monolog 上,以及新實作的 Logger 怎麼套用在 Laravel Log 上。

Slack + Proxy

官方的 SlackWebhookHandler 是沒有 proxy 設定的。雖然我覺得這是個很基本的需求,也有發過 PR,但最終作者還是 close 了。不過這問題還好,可以自己額外寫套件繼承後再處理,程式碼大概會長這樣:

關鍵在 write() 方法,其他必要的參數等就不看了。

/**
* Overload for custom option of sending request
*
* @param array $record
*/
protected function write(array $record)
{
$postData = $this->getSlackRecord()->getSlackData($record);
$postString = json_encode($postData);

$ch = curl_init();

$options = [
CURLOPT_URL => $this->getWebhookUrl(),
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-type: application/json'],
CURLOPT_POSTFIELDS => $postString,
];

if (defined('CURLOPT_SAFE_UPLOAD')) {
$options[CURLOPT_SAFE_UPLOAD] = true;
}

if (null !== $this->proxy) {
$options[CURLOPT_PROXY] = $this->proxy;
}

curl_setopt_array($ch, $options);

Util::execute($ch);
}

write() 都是從官方複製出來的,僅僅只為了加入 proxy 設定判斷,滿滿地違反 DRY 原則,哭哭。

後來改寫成 Psr18SlackWebhookHandler:簡單來說,是把 HTTP client 換成 PSR18 的實作,要不要 proxy 就由該物件決定,這樣寫就變得很單純:

/**
* Overload for custom option of sending request
*
* @param array $record
*/
protected function write(array $record): void
{
$postData = $this->getSlackRecord()->getSlackData($record);
$postString = json_encode($postData);

$request = $this->httpRequestFactory->createRequest('POST', $this->getWebhookUrl())
->withHeader('Content-type', 'application/json')
->withBody($this->httpStreamFactory->createStream($postString));

$this->httpClient->sendRequest($request);
}

這裡面有很多物件是過去 curl 所沒有的,要套用在 Laravel 上,就會有初始化問題,如 $this->httpClient 的實例該如何產生。

以下討論 Laravel 6+ 之後的版本。

直接 pushHandler() 加入自定 Handler

簡單、直接、明暸!

使用 Monolog 的 pushHandler(),將新的 Handler 加入即可。使用時機點除了之前文章提到的 boot() 外,也可以使用 register() + Container::extend() 達成目的,範例程式如下:

public function register()
{
$this->app->extend('log', function (LogManager $log) {
$log->pushHandler($this->createSlackWebhookHandler());

return $log;
});
}

private function createSlackWebhookHandler(): HandlerInterface
{
$handler = new Psr18SlackWebhookHandler(config('webhook'));

$handler->setDriver(
$this->app->make(ClientInterface::class),
$this->app->make(RequestFactoryInterface::class),
$this->app->make(StreamFactoryInterface::class)
);

return $handler;
}

夠直接,缺點也很明確:不容易套件化,因為這個方法只有處理到 new Handler() 行為,而沒有考慮 Laravel Log 設計整個生命週期,因此不管到哪,還是需要重寫這段 ServiceProvider 與設定的整合。

應用 LogManager::extend()

最好的結果就是能結合 logging.php 設定檔,讓使用這個 Handler 只要改改設定就解決了。

首先來看單一 logger 的設定寫法如下:

'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => env('LOG_LEVEL', 'critical'),
]

這個設定檔會交由 LogManager 產生對應的 logger 實例。Laravel 四處都存在著擴充實作的可能,如 LogManager::extend()

public function extend($driver, Closure $callback)
{
$this->customCreators[$driver] = $callback->bindTo($this, $this);

return $this;
}

這裡只有單純把 $callback 保存起來,因此還要追 $this->customCreators[$driver] 在哪裡被呼叫,實際上是在 LogManager::callCustomCreator() 呼叫的:

protected function callCustomCreator(array $config)
{
return $this->customCreators[$config['driver']]($this->app, $config);
}

從這段程式可以知道,Closure 會收到的參數有 Container 與該 logger 的 config array(從 $config['driver'] 可以看得出來)。參考其他 Driver,這裡要回傳的可以是 Monolog instance。

實作套件

接著以我實作的 monoex 為例,我取名為 psr18slack

$logManager->extend('psr18slack', function (Container $app, array $config) {
return (new Par18SlackFactory($app))->__invoke($config);
});

Par18SlackFactory 任務很單純,為產生 Monolog:

public function __invoke(array $config)
{
return new Monolog($this->parseChannel($config), [
$this->prepareHandler($this->createHandler($config), $config),
]);
}

private function createHandler(array $config): HandlerInterface
{
$handler = new Psr18SlackWebhookHandler(
$config['url'],
$config['channel'] ?? null,
$config['username'] ?? 'Laravel',
$config['attachment'] ?? true,
$config['emoji'] ?? ':boom:',
$config['short'] ?? false,
$config['context'] ?? true,
$this->level($config),
$config['bubble'] ?? true,
$config['exclude_fields'] ?? []
);

$handler->setDriver(
$this->app->make(ClientInterface::class),
$this->app->make(RequestFactoryInterface::class),
$this->app->make(StreamFactoryInterface::class)
);

return $handler;
}

$config 是從 LogManager 那邊拿到的 logger 完整設定,所以可以直接用來 createHandler()

最後這是可以把 auto discovery 加上的套件--project 頂多就沒使用 psr18slack 的 driver 而已,不會對整體流程造成影響:

{
"extra": {
"laravel": {
"providers": [
"MilesChou\\Monoex\\ServiceProvider"
]
}
}
}

最後,當開發者安裝完 monoex 後,只要在 logging.php 設定以下的資訊,就能正常使用 PSR-18 專用的 slack 了。

'stack' => [
'driver' => 'psr18slack',
// same as slack driver
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
]

當然 ServiceProvider 必須先準備好 PSR-17 Factory / PSR-18 Client,讓 container 能正常取用下面這三個物件:

// Example, use laminas/laminas-diactoros and symfony/http-client:
$app->singleton(RequestFactoryInterface::class, new \Laminas\Diactoros\RequestFactory());
$app->singleton(ResponseFactoryInterface::class, new \Laminas\Diactoros\ResponseFactory());
$app->singleton(StreamFactoryInterface::class, new \Laminas\Diactoros\StreamFactory());

$app->singleton(ClientInterface::class, function($app) {
return new \Symfony\Component\HttpClient\Psr18Client(
null,
$app->make(ResponseFactoryInterface::class),
$app->make(StreamFactoryInterface::class)
);
});