Custom PSR-3 Logger

PSR-3インターフェースを実装したカスタムロガー。特定の要件に合わせた独自実装が可能で、既存ライブラリでは対応できない特殊な出力先や処理ロジックに対応。フレームワーク開発や特殊用途で使用される。

ロギングPHPPSR-3カスタム実装フレームワーク開発

ライブラリ

Custom PSR-3 Logger

概要

Custom PSR-3 Loggerは、PSR-3インターフェースを実装したカスタムロガーです。特定の要件に合わせた独自実装が可能で、既存ライブラリでは対応できない特殊な出力先や処理ロジックに対応します。フレームワーク開発や特殊用途で使用される柔軟なロギングソリューションです。PSR-3準拠により他のライブラリとの互換性を確保しながら、独自機能を実装できる設計が特徴です。

詳細

Custom PSR-3 Logger 2025年版は、フレームワーク開発者や特殊要件を持つプロジェクトでの需要が継続しています。PSR-3インターフェースに準拠することで、他のPHPライブラリとの互換性を確保しながら、独自のロギング機能を実装できる柔軟性を提供します。Monologなどの汎用ライブラリでは実現できない特殊な出力先(カスタムAPI、独自プロトコル、暗号化ログなど)や処理ロジック(ログフィルタリング、変換処理、集計機能など)に対応可能な設計となっています。

主な特徴

  • PSR-3準拠: 標準インターフェースによる互換性保証
  • 完全カスタマイズ: 要件に応じた独自実装が可能
  • 柔軟な出力先: 任意の出力先やプロトコルに対応
  • 拡張性: 独自の機能やフィルタリングロジックを追加可能
  • フレームワーク統合: 他のPHPライブラリとシームレスに統合
  • テスト可能: インターフェース準拠によりモックやテストが容易

メリット・デメリット

メリット

  • PSR-3準拠により他のライブラリとの完全互換性を確保
  • 特殊な要件や出力先に完全に対応できる柔軟性
  • 既存のログ処理システムとの統合が容易
  • フレームワーク開発時の独自ロギング戦略を実装可能
  • パフォーマンス要件に応じた最適化が可能
  • 企業固有のセキュリティ要件やコンプライアンス対応

デメリット

  • 実装とメンテナンスのコストが高い
  • 既存の成熟したライブラリと比較してテスト不足のリスク
  • 新しい開発者の学習コストが高い
  • バグやセキュリティ問題の責任が開発チームにある
  • 機能追加や改善に時間とリソースが必要
  • コミュニティサポートや文書化の負担

参考ページ

書き方の例

インストールと基本セットアップ

# PSR-3インターフェースのインストール
composer require psr/log

# プロジェクト構造の作成
mkdir src/Logger
touch src/Logger/CustomLogger.php

# オートローダーの設定(composer.json)
# "autoload": {
#     "psr-4": {
#         "MyApp\\Logger\\": "src/Logger/"
#     }
# }

composer dump-autoload

基本的なカスタムロガー実装

<?php

namespace MyApp\Logger;

use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;

class CustomLogger extends AbstractLogger
{
    private $logFile;
    private $minLevel;
    
    // ログレベルの優先度マッピング
    private const LEVEL_PRIORITY = [
        LogLevel::EMERGENCY => 700,
        LogLevel::ALERT     => 600,
        LogLevel::CRITICAL  => 500,
        LogLevel::ERROR     => 400,
        LogLevel::WARNING   => 300,
        LogLevel::NOTICE    => 250,
        LogLevel::INFO      => 200,
        LogLevel::DEBUG     => 100,
    ];
    
    public function __construct(string $logFile = null, string $minLevel = LogLevel::DEBUG)
    {
        $this->logFile = $logFile ?: sys_get_temp_dir() . '/custom.log';
        $this->minLevel = $minLevel;
        
        // ログディレクトリの作成
        $logDir = dirname($this->logFile);
        if (!is_dir($logDir)) {
            mkdir($logDir, 0755, true);
        }
    }
    
    /**
     * PSR-3の核となるlogメソッドの実装
     */
    public function log($level, $message, array $context = []): void
    {
        // ログレベルのフィルタリング
        if (!$this->shouldLog($level)) {
            return;
        }
        
        // メッセージの補間処理
        $interpolatedMessage = $this->interpolate($message, $context);
        
        // ログエントリの構築
        $logEntry = $this->buildLogEntry($level, $interpolatedMessage, $context);
        
        // ログの出力
        $this->writeLog($logEntry);
    }
    
    /**
     * ログレベルによるフィルタリング
     */
    private function shouldLog(string $level): bool
    {
        $currentPriority = self::LEVEL_PRIORITY[$level] ?? 0;
        $minPriority = self::LEVEL_PRIORITY[$this->minLevel] ?? 0;
        
        return $currentPriority >= $minPriority;
    }
    
    /**
     * PSR-3準拠のメッセージ補間
     */
    private function interpolate(string $message, array $context): string
    {
        $replacements = [];
        
        foreach ($context as $key => $val) {
            // stringにキャスト可能な値のみ処理
            if (is_null($val) || is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) {
                $replacements['{' . $key . '}'] = $val;
            }
        }
        
        return strtr($message, $replacements);
    }
    
    /**
     * ログエントリの構築
     */
    private function buildLogEntry(string $level, string $message, array $context): string
    {
        $timestamp = date('Y-m-d H:i:s');
        $levelUpper = strtoupper($level);
        
        // JSON形式でのコンテキスト情報
        $contextJson = empty($context) ? '' : ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
        
        return "[{$timestamp}] {$levelUpper}: {$message}{$contextJson}" . PHP_EOL;
    }
    
    /**
     * ログの書き込み
     */
    private function writeLog(string $logEntry): void
    {
        file_put_contents($this->logFile, $logEntry, FILE_APPEND | LOCK_EX);
    }
}

// 使用例
use MyApp\Logger\CustomLogger;
use Psr\Log\LogLevel;

$logger = new CustomLogger('/var/log/myapp.log', LogLevel::INFO);

$logger->info('ユーザーがログインしました', ['user_id' => 123, 'ip' => '192.168.1.1']);
$logger->error('データベース接続エラー', ['error' => 'Connection timeout', 'host' => 'db.example.com']);
$logger->debug('デバッグ情報', ['query' => 'SELECT * FROM users']); // 出力されない(INFOレベルより下のため)

高度なカスタムロガー(複数出力先対応)

<?php

namespace MyApp\Logger;

use Psr\Log\AbstractLogger;
use Psr\Log\LogLevel;

class AdvancedCustomLogger extends AbstractLogger
{
    private $handlers = [];
    
    public function addHandler(LogHandlerInterface $handler): void
    {
        $this->handlers[] = $handler;
    }
    
    public function log($level, $message, array $context = []): void
    {
        $logRecord = new LogRecord($level, $message, $context);
        
        foreach ($this->handlers as $handler) {
            if ($handler->handles($logRecord)) {
                $handler->write($logRecord);
            }
        }
    }
}

// ログハンドラーのインターフェース
interface LogHandlerInterface
{
    public function handles(LogRecord $record): bool;
    public function write(LogRecord $record): void;
}

// ログレコードクラス
class LogRecord
{
    public $level;
    public $message;
    public $context;
    public $timestamp;
    
    public function __construct(string $level, string $message, array $context = [])
    {
        $this->level = $level;
        $this->message = $message;
        $this->context = $context;
        $this->timestamp = new \DateTime();
    }
}

// ファイルハンドラー
class FileHandler implements LogHandlerInterface
{
    private $filePath;
    private $minLevel;
    
    public function __construct(string $filePath, string $minLevel = LogLevel::DEBUG)
    {
        $this->filePath = $filePath;
        $this->minLevel = $minLevel;
    }
    
    public function handles(LogRecord $record): bool
    {
        $levelPriority = [
            LogLevel::DEBUG => 100,
            LogLevel::INFO => 200,
            LogLevel::NOTICE => 250,
            LogLevel::WARNING => 300,
            LogLevel::ERROR => 400,
            LogLevel::CRITICAL => 500,
            LogLevel::ALERT => 600,
            LogLevel::EMERGENCY => 700,
        ];
        
        return ($levelPriority[$record->level] ?? 0) >= ($levelPriority[$this->minLevel] ?? 0);
    }
    
    public function write(LogRecord $record): void
    {
        $formatted = sprintf(
            "[%s] %s: %s %s\n",
            $record->timestamp->format('Y-m-d H:i:s'),
            strtoupper($record->level),
            $record->message,
            empty($record->context) ? '' : json_encode($record->context)
        );
        
        file_put_contents($this->filePath, $formatted, FILE_APPEND | LOCK_EX);
    }
}

// メールハンドラー
class EmailHandler implements LogHandlerInterface
{
    private $to;
    private $subject;
    private $triggerLevel;
    
    public function __construct(string $to, string $subject = 'Application Error', string $triggerLevel = LogLevel::ERROR)
    {
        $this->to = $to;
        $this->subject = $subject;
        $this->triggerLevel = $triggerLevel;
    }
    
    public function handles(LogRecord $record): bool
    {
        $levelPriority = [
            LogLevel::ERROR => 400,
            LogLevel::CRITICAL => 500,
            LogLevel::ALERT => 600,
            LogLevel::EMERGENCY => 700,
        ];
        
        return isset($levelPriority[$record->level]) && 
               $levelPriority[$record->level] >= ($levelPriority[$this->triggerLevel] ?? 400);
    }
    
    public function write(LogRecord $record): void
    {
        $body = sprintf(
            "レベル: %s\n時刻: %s\nメッセージ: %s\nコンテキスト: %s",
            strtoupper($record->level),
            $record->timestamp->format('Y-m-d H:i:s'),
            $record->message,
            json_encode($record->context, JSON_PRETTY_PRINT)
        );
        
        mail($this->to, $this->subject, $body);
    }
}

// 使用例
$logger = new AdvancedCustomLogger();
$logger->addHandler(new FileHandler('/var/log/app.log', LogLevel::INFO));
$logger->addHandler(new EmailHandler('[email protected]', 'Critical Error', LogLevel::CRITICAL));

$logger->info('アプリケーション開始');
$logger->error('処理エラーが発生しました', ['error_code' => 500]);
$logger->critical('データベースが応答しません', ['host' => 'db.example.com']); // メール送信される

データベースロガーの実装

<?php

namespace MyApp\Logger;

use Psr\Log\AbstractLogger;
use PDO;

class DatabaseLogger extends AbstractLogger
{
    private $pdo;
    private $tableName;
    
    public function __construct(PDO $pdo, string $tableName = 'application_logs')
    {
        $this->pdo = $pdo;
        $this->tableName = $tableName;
        $this->createTableIfNotExists();
    }
    
    public function log($level, $message, array $context = []): void
    {
        $sql = "INSERT INTO {$this->tableName} (level, message, context, created_at) VALUES (?, ?, ?, ?)";
        $stmt = $this->pdo->prepare($sql);
        
        $stmt->execute([
            $level,
            $message,
            json_encode($context),
            date('Y-m-d H:i:s')
        ]);
    }
    
    private function createTableIfNotExists(): void
    {
        $sql = "
            CREATE TABLE IF NOT EXISTS {$this->tableName} (
                id INT AUTO_INCREMENT PRIMARY KEY,
                level VARCHAR(20) NOT NULL,
                message TEXT NOT NULL,
                context JSON,
                created_at DATETIME NOT NULL,
                INDEX idx_level (level),
                INDEX idx_created_at (created_at)
            )
        ";
        
        $this->pdo->exec($sql);
    }
    
    /**
     * ログ検索機能
     */
    public function searchLogs(array $criteria = []): array
    {
        $where = [];
        $params = [];
        
        if (isset($criteria['level'])) {
            $where[] = 'level = ?';
            $params[] = $criteria['level'];
        }
        
        if (isset($criteria['from_date'])) {
            $where[] = 'created_at >= ?';
            $params[] = $criteria['from_date'];
        }
        
        if (isset($criteria['to_date'])) {
            $where[] = 'created_at <= ?';
            $params[] = $criteria['to_date'];
        }
        
        if (isset($criteria['message_contains'])) {
            $where[] = 'message LIKE ?';
            $params[] = '%' . $criteria['message_contains'] . '%';
        }
        
        $whereClause = empty($where) ? '' : 'WHERE ' . implode(' AND ', $where);
        $sql = "SELECT * FROM {$this->tableName} {$whereClause} ORDER BY created_at DESC LIMIT 1000";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        
        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

// 使用例
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'username', 'password');
$logger = new DatabaseLogger($pdo);

$logger->info('ユーザー登録', ['user_id' => 456, 'email' => '[email protected]']);
$logger->error('支払い処理失敗', ['order_id' => 789, 'amount' => 1000]);

// ログ検索
$errorLogs = $logger->searchLogs([
    'level' => 'error',
    'from_date' => '2025-01-01',
    'to_date' => '2025-01-31'
]);

API連携ロガーの実装

<?php

namespace MyApp\Logger;

use Psr\Log\AbstractLogger;

class ApiLogger extends AbstractLogger
{
    private $apiEndpoint;
    private $apiKey;
    private $batchSize;
    private $logBuffer = [];
    
    public function __construct(string $apiEndpoint, string $apiKey, int $batchSize = 10)
    {
        $this->apiEndpoint = $apiEndpoint;
        $this->apiKey = $apiKey;
        $this->batchSize = $batchSize;
        
        // デストラクタでバッファをフラッシュ
        register_shutdown_function([$this, 'flush']);
    }
    
    public function log($level, $message, array $context = []): void
    {
        $this->logBuffer[] = [
            'level' => $level,
            'message' => $message,
            'context' => $context,
            'timestamp' => date('c'), // ISO 8601フォーマット
            'server' => $_SERVER['SERVER_NAME'] ?? 'unknown',
            'request_id' => uniqid()
        ];
        
        if (count($this->logBuffer) >= $this->batchSize) {
            $this->flush();
        }
    }
    
    public function flush(): void
    {
        if (empty($this->logBuffer)) {
            return;
        }
        
        $payload = [
            'logs' => $this->logBuffer,
            'application' => 'MyApp',
            'version' => '1.0.0'
        ];
        
        $this->sendToApi($payload);
        $this->logBuffer = [];
    }
    
    private function sendToApi(array $payload): void
    {
        $ch = curl_init();
        
        curl_setopt_array($ch, [
            CURLOPT_URL => $this->apiEndpoint,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload),
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $this->apiKey,
                'User-Agent: CustomLogger/1.0'
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
            CURLOPT_CONNECTTIMEOUT => 3
        ]);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        
        if ($httpCode !== 200) {
            // API送信失敗時の緊急時ログ
            error_log("Failed to send logs to API: HTTP {$httpCode}");
        }
        
        curl_close($ch);
    }
}

// 暗号化ロガーの実装
class EncryptedFileLogger extends AbstractLogger
{
    private $filePath;
    private $encryptionKey;
    
    public function __construct(string $filePath, string $encryptionKey)
    {
        $this->filePath = $filePath;
        $this->encryptionKey = $encryptionKey;
    }
    
    public function log($level, $message, array $context = []): void
    {
        $logData = [
            'level' => $level,
            'message' => $message,
            'context' => $context,
            'timestamp' => time()
        ];
        
        $encrypted = $this->encrypt(json_encode($logData));
        file_put_contents($this->filePath, $encrypted . "\n", FILE_APPEND | LOCK_EX);
    }
    
    private function encrypt(string $data): string
    {
        $iv = random_bytes(16);
        $encrypted = openssl_encrypt($data, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
        return base64_encode($iv . $encrypted);
    }
    
    public function decrypt(string $encryptedData): string
    {
        $data = base64_decode($encryptedData);
        $iv = substr($data, 0, 16);
        $encrypted = substr($data, 16);
        return openssl_decrypt($encrypted, 'AES-256-CBC', $this->encryptionKey, 0, $iv);
    }
    
    /**
     * ログファイルの読み込みと復号
     */
    public function readLogs(): array
    {
        if (!file_exists($this->filePath)) {
            return [];
        }
        
        $lines = file($this->filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        $logs = [];
        
        foreach ($lines as $line) {
            try {
                $decrypted = $this->decrypt($line);
                $logs[] = json_decode($decrypted, true);
            } catch (Exception $e) {
                // 復号に失敗した行はスキップ
                continue;
            }
        }
        
        return $logs;
    }
}

// 使用例
$apiLogger = new ApiLogger('https://logs.example.com/api/v1/logs', 'your-api-key');
$encryptedLogger = new EncryptedFileLogger('/secure/logs/app.enc', 'your-secret-key');

$apiLogger->info('API送信テスト');
$encryptedLogger->critical('機密情報エラー', ['sensitive_data' => 'hidden']);

// バッファの手動フラッシュ
$apiLogger->flush();

// 暗号化ログの読み込み
$decryptedLogs = $encryptedLogger->readLogs();

テスト可能なロガー設計

<?php

namespace MyApp\Logger;

use Psr\Log\AbstractLogger;

class TestableLogger extends AbstractLogger
{
    private $logEntries = [];
    private $isTestMode = false;
    
    public function __construct(bool $testMode = false)
    {
        $this->isTestMode = $testMode;
    }
    
    public function log($level, $message, array $context = []): void
    {
        $entry = [
            'level' => $level,
            'message' => $message,
            'context' => $context,
            'timestamp' => microtime(true)
        ];
        
        if ($this->isTestMode) {
            $this->logEntries[] = $entry;
        } else {
            // 本番では実際のログ処理
            $this->writeToActualLog($entry);
        }
    }
    
    private function writeToActualLog(array $entry): void
    {
        // 実際のログ出力処理
        $formatted = sprintf(
            "[%s] %s: %s\n",
            date('Y-m-d H:i:s', (int)$entry['timestamp']),
            strtoupper($entry['level']),
            $entry['message']
        );
        
        error_log($formatted);
    }
    
    // テスト用メソッド
    public function getLogEntries(): array
    {
        return $this->logEntries;
    }
    
    public function hasLogEntry(string $level, string $message): bool
    {
        foreach ($this->logEntries as $entry) {
            if ($entry['level'] === $level && $entry['message'] === $message) {
                return true;
            }
        }
        return false;
    }
    
    public function getLogEntriesByLevel(string $level): array
    {
        return array_filter($this->logEntries, function($entry) use ($level) {
            return $entry['level'] === $level;
        });
    }
    
    public function clearLogs(): void
    {
        $this->logEntries = [];
    }
}

// PHPUnitテストの例
class TestableLoggerTest extends \PHPUnit\Framework\TestCase
{
    private $logger;
    
    protected function setUp(): void
    {
        $this->logger = new TestableLogger(true); // テストモードで作成
    }
    
    public function testLogsInfoMessage()
    {
        $this->logger->info('テストメッセージ', ['user_id' => 123]);
        
        $this->assertTrue($this->logger->hasLogEntry('info', 'テストメッセージ'));
        
        $infoLogs = $this->logger->getLogEntriesByLevel('info');
        $this->assertCount(1, $infoLogs);
        $this->assertEquals(['user_id' => 123], $infoLogs[0]['context']);
    }
    
    public function testMultipleLevels()
    {
        $this->logger->debug('デバッグ');
        $this->logger->info('情報');
        $this->logger->error('エラー');
        
        $this->assertCount(3, $this->logger->getLogEntries());
        $this->assertCount(1, $this->logger->getLogEntriesByLevel('error'));
    }
}