> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 套件的日誌通道擴充與重新解析

> 區分使用 Laravel 13 的 custom、via 與 LogManager::extend()，並在考量通道保留、forgetChannel() 與 fallback 的前提下測試日誌功能。

想在套件中提供專屬的日誌輸出時，請將**建立 logger 的機制**與**使用端應用程式所選擇的通道設定**分開。單純註冊驅動並不會重新建立既有的通道，即使變更設定後，仍可能繼續使用舊的 Handler。

本頁確認了 Laravel Framework `v13.39.0` 的實作。基本操作請參閱[日誌](https://kawax.biz/zh-TW/logging.md)，提供者的準備請參閱[套件開發](https://kawax.biz/zh-TW/advanced/package-development.md)。

## 選擇需要的擴充點

若既有驅動就能實現，就不需要再新增自訂驅動。

| 想做的事 | 選項 | 擴充的位置 |
| - | - | - |
| 調整既有通道的 formatter 等 | `tap` | 建立後的 `Illuminate\Log\Logger` |
| 透過設定選擇 Monolog 的 Handler | `driver: monolog` | `handler`、`handler_with` 等設定 |
| 以套件的工廠組裝 logger | `driver: custom` 與 `via` | 具有 `__invoke(array $config)` 的類別 |
| 公開套件專屬的驅動名稱 | `LogManager::extend()` | 接收應用程式與設定陣列的閉包 |

`via` 的類別與 `tap` 的類別會由容器解析，因此也可以使用建構子注入。`tap` 是在建立通道時進行的調整，並不是每寫入一筆日誌就執行的 hook。

> [!NOTE]
>
> `logging.channels.audit` 中的 `audit` 是**通道名稱**，其中的 `driver` 則是**建立方式的名稱**。`Log::channel('audit')` 選擇的是通道名稱，因此即使之後變更驅動，呼叫端仍可維持相同的名稱。

## 先以 custom 與 via 公開工廠

以虛構的 `Acme\Audit` 套件為例，提供用於檔案輸出的工廠。將其放在可透過 PSR-4 載入的 `src/Logging/CreateAuditLogger.php`。

```php theme={null}
<?php

namespace Acme\Audit\Logging;

use Monolog\Handler\StreamHandler;
use Monolog\Logger;

final class CreateAuditLogger
{
    public function __invoke(array $config): Logger
    {
        return new Logger(
            $config['name'] ?? 'acme-audit',
            [new StreamHandler($config['path'], $config['level'] ?? 'info')],
        );
    }
}
```

在使用端應用程式中，於 `config/logging.php` 既有的 `channels` 陣列加入下列元素。既有的 `default` 與 `stack` 設定請保留。

```php theme={null}
'audit' => [
    'driver' => 'custom',
    'via' => \Acme\Audit\Logging\CreateAuditLogger::class,
    'path' => storage_path('logs/audit.log'),
    'level' => 'info',
],
```

`LogManager::createCustomDriver()` 會從容器取得 `via` 的類別，並以通道的設定陣列作為唯一引數呼叫它。若只使用這種方式，就不需要用於註冊驅動的提供者。

```php theme={null}
use Illuminate\Support\Facades\Log;

Log::channel('audit')->info('訂單已確認。', ['order_id' => 42]);
```

這個工廠會明確地將 `level` 傳給 StreamHandler。**LogManager 並不會將設定中的 `level` 一律套用到 `custom` 工廠所回傳的 logger**。新增自訂設定項目時，也請在工廠端自行反映。

> [!TIP]
>
> 若只是要變更檔案的輸出位置，使用 `single` 驅動就足夠了。這個範例是用來嵌入套件專屬 Handler 或初始化處理的最小設定。`via` 請使用可儲存在設定快取中的類別名稱，不要在設定陣列中存放閉包或 logger 的實例。

## 提供自訂驅動名稱時使用 extend

若想對使用端公開 `driver: acme-audit` 這樣的設定，請註冊使用同一個工廠的驅動。在 `src/AuditServiceProvider.php` 的 `boot()` 中擴充 Laravel 的 `log` 服務。

```php theme={null}
<?php

namespace Acme\Audit;

use Acme\Audit\Logging\CreateAuditLogger;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Log\LogManager;
use Illuminate\Support\ServiceProvider;
use Monolog\Logger as Monolog;

class AuditServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->callAfterResolving('log', static function (LogManager $manager): void {
            $manager->extend('acme-audit', static function (Application $app, array $config): Monolog {
                return $app->make(CreateAuditLogger::class)($config);
            });
        });
    }
}
```

提供者可透過[套件自動探索](https://kawax.biz/zh-TW/advanced/package-discovery.md)等方式載入。使用端的 `channels.audit` 請從先前的設定替換為下列設定，不再需要 `via`。

```php theme={null}
'audit' => [
    'driver' => 'acme-audit',
    'path' => storage_path('logs/audit.log'),
    'level' => 'info',
],
```

* Laravel 的 `LogServiceProvider` 會將 `log` 註冊為 singleton。`callAfterResolving()` 在尚未解析時會等待，若已解析則也會將註冊反映到該實例。詳情請參閱[服務解析後的 hook](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)。
* `extend()` 只會註冊用於建立的閉包。註冊這個回呼時，並不會建立通道或寫入日誌。
* `callCustomCreator()` 會將 `$app` 與通道設定陣列傳給閉包，這與傳給 `via` 工廠的引數不同。
* 以相同驅動名稱重新註冊時，會覆寫用於建立的閉包。請勿使用 `single` 等內建名稱，或可能與其他套件衝突的名稱。

> [!WARNING]
>
> 在確認的實作中，傳給 `extend()` 的匿名閉包會被重新綁定到 LogManager 的作用域。非 static 閉包中的 `$this` 並不是服務提供者。請如範例般使用 static 閉包並透過引數的 `$app` 實作，不要依賴提供者的 `$this`。

套件負責提供驅動，要在哪個通道或 stack 中使用則由使用端選擇。請設計成不會在提供者中改寫 `logging.default` 或整個既有的 `logging.channels`。

## 通道會依名稱保留

`LogManager::get()` 會將解析完成的通道儲存在 `$channels` 中。取得同名的通道時，不會重新讀取設定陣列，而是回傳已保留的物件。

```mermaid theme={null}
flowchart TD
    A["Log::channel('audit')"] --> B{"audit 是否已解析？"}
    B -->|Yes| C["回傳已保留的 Logger"]
    B -->|No| D["選擇 channels.audit 的 driver"]
    D --> E["via 或 extend 的工廠"]
    E --> F["以 Illuminate Logger 包裝<br>套用 tap 與共用 context"]
    F --> G["以 audit 名稱保留"]
```

這與儲存在檔案中的設定快取不同，是**目前 LogManager 內部的物件保留**。僅變更 `config()` 或重新註冊 `extend()`，並不會替換已建立的 Handler。

```php theme={null}
use Illuminate\Support\Facades\Log;

$before = Log::channel('audit');

config(['logging.channels.audit.level' => 'error']);

$same = Log::channel('audit');

Log::forgetChannel('audit');

$after = Log::channel('audit');
```

此範例中的 `$same` 與 `$before` 是同一個物件。`forgetChannel('audit')` 解除 Manager 的保留後，下一次取得時會依新設定建立 `$after`。

### forgetChannel 不會更新既有的參照

`forgetChannel()` 所做的是刪除 Manager 內的項目。

* 持有 `$before` 的服務之後仍可繼續使用舊的 logger。這並不是將既有參照替換為新物件的操作。
* 透過 `Log::channel('stack')` 等方式建立的 stack，擁有彙整子通道 Handler 與 Processor 的獨立 Monolog。即使只對子通道執行 `forgetChannel()`，既有 stack 的組成也不會改變。必要時，請也解除該 stack 通道名稱的保留並重新取得。
* `forgetChannel()` 本身不會呼叫 Handler 的 `close()`，並不是保證連線或緩衝區結束處理的 API。

在一般的部署中，請將[設定快取的更新](https://kawax.biz/zh-TW/advanced/package-config-merging.md)與所使用之長時間執行 worker 的重新啟動納入維運流程。不要依賴每次請求都改寫設定並重新解析的機制，也請考量[服務的生命週期](https://kawax.biz/zh-TW/advanced/package-service-lifetimes.md)。

### 加入 stack 時也需要相容於 Monolog 的操作

自訂驅動可以回傳 `Psr\Log\LoggerInterface` 的實作，但 Laravel 的 `createStackDriver()` 會對子通道呼叫 `getHandlers()` 與 `getProcessors()`。這些方法並不包含在 PSR 的介面中。

回傳任意 PSR logger 不一定能在 stack 中運作，因此若套件也要支援加入 stack，請如範例般回傳 Monolog，或明確說明其使用範圍並加以測試。

## 不只使用 mock，也要驗證實際的通道解析

僅使用 `Log::shouldReceive('channel')`，無法確認提供者的註冊、工廠的選擇與設定的反映。以下是在[套件的測試環境](https://kawax.biz/zh-TW/advanced/package-testing.md)中，實際經過 Manager 與工廠的範例。

測試用的輸出位置指定為 `php://memory`。這裡驗證通道的建構與 Handler 的設定，並不實際寫入日誌。

```php theme={null}
<?php

namespace Acme\Audit\Tests;

use Acme\Audit\AuditServiceProvider;
use Acme\Audit\Logging\CreateAuditLogger;
use Illuminate\Log\Logger;
use Illuminate\Support\Facades\Log;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger as Monolog;
use Orchestra\Testbench\TestCase;

class AuditLoggingTest extends TestCase
{
    protected function getPackageProviders($app): array
    {
        return [AuditServiceProvider::class];
    }

    protected function defineEnvironment($app): void
    {
        $app['config']->set('logging.channels.audit', [
            'driver' => 'acme-audit',
            'path' => 'php://memory',
            'level' => 'warning',
        ]);

        $app['config']->set('logging.channels.emergency.path', 'php://memory');
    }

    public function test_the_package_driver_creates_the_configured_logger(): void
    {
        $this->assertAuditChannel(Log::channel('audit'), Level::Warning);
    }

    public function test_configuration_changes_require_a_new_channel(): void
    {
        $before = Log::channel('audit');

        config(['logging.channels.audit.level' => 'error']);

        $this->assertSame($before, Log::channel('audit'));
        $this->assertAuditChannel($before, Level::Warning);

        Log::forgetChannel('audit');

        $after = Log::channel('audit');

        $this->assertNotSame($before, $after);
        $this->assertAuditChannel($after, Level::Error);
        $this->assertAuditChannel($before, Level::Warning);
    }

    public function test_the_same_factory_can_be_used_with_via(): void
    {
        config([
            'logging.channels.audit.driver' => 'custom',
            'logging.channels.audit.via' => CreateAuditLogger::class,
        ]);

        Log::forgetChannel('audit');

        $this->assertAuditChannel(Log::channel('audit'), Level::Warning);
    }

    private function assertAuditChannel(Logger $channel, Level $level): void
    {
        $logger = $channel->getLogger();

        $this->assertInstanceOf(Monolog::class, $logger);
        $this->assertSame('acme-audit', $logger->getName());
        $this->assertCount(1, $logger->getHandlers());

        $handler = $logger->getHandlers()[0];

        $this->assertInstanceOf(StreamHandler::class, $handler);
        $this->assertSame($level, $handler->getLevel());
    }
}
```

> [!WARNING]
>
> `LogManager::get()` 可能會捕捉建立通道時發生的例外，並回傳 emergency logger。僅確認「沒有拋出例外」或「是 LoggerInterface」，並不能證明目標通道已成功建構。上面的測試也確認了 Monolog 的名稱、Handler 與日誌等級，避免將 fallback 誤認為成功。建立後發生的寫入錯誤，不一定都會被同一機制捕捉。

## 長期維護時應追加的驗證

| 變更或使用方式 | 確認事項 |
| - | - |
| 新增 `tap` | 預期的調整會套用到實際工廠所回傳的 logger |
| 加入 stack | 子通道的 Handler 與 Processor 已被納入，重新解析時的組成也正確 |
| 變更輸出位置或格式 | 在另外的驗證環境中確認實際寫入內容、權限、輪替等 |
| 變更註冊時機 | 即使 `log` 已解析，驅動仍會被註冊並能建立通道 |
| 更新 Laravel | 持續確認回呼的引數與作用域、保留的解除，以及 fallback 到 emergency logger 的行為 |

## 參考的第一手資料

* [Laravel 官方文件：Logging](https://github.com/laravel/docs/blob/13.x/logging.md)
* [Framework v13.39.0：LogManager 的建立、擴充與保留解除](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Log/LogManager.php)
* [Framework v13.39.0：Logger 的包裝與方法轉送](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Log/Logger.php)
* [Framework v13.39.0：LogServiceProvider 的 singleton 註冊](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Log/LogServiceProvider.php)
* [Framework v13.39.0：ServiceProvider::callAfterResolving](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Framework v13.39.0：RebindsCallbacksToSelf](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/RebindsCallbacksToSelf.php)
* [Laravel 官方測試：LogManagerTest](https://github.com/laravel/framework/blob/v13.39.0/tests/Integration/Log/LogManagerTest.php)


## Related topics

- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [日誌](https://kawax.biz/zh-TW/logging.md)
- [套件的 Storage 驅動程式註冊與重新解析](https://kawax.biz/zh-TW/advanced/package-filesystem-drivers.md)
- [套件的 Blade 擴充與編譯快取](https://kawax.biz/zh-TW/advanced/package-blade-extensions.md)
- [套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.