Diferentes estratégias de log utilizando Strategy Pattern com PHP

Se você já desenvolveu qualquer tipo de aplicação já deve ter sentido a necessidade de registrar logs, seja para rastrear algum tipo de erro ou para saber se a aplicação está realizando os processos corretamente, principalmente se a aplicação rodar como um serviço ou em plano de fundo.

Existem diferentes estratégias para registrar logs, acredito que o método mais comum seja registrar em um arquivo de texto, porém você pode querer registrar no console, em um banco de dados ou até mesmo utilizar algum serviço externo como o Sentry. Você pode inclusive utilizar várias estratégias ao mesmo tempo em uma mesma aplicação.

Uma das soluções nessa situação é desenvolver uma biblioteca de log utilizando o padrão de projeto Strategy Pattern para lidar com essas diferentes estratégias. Neste artigo vou apresentar desde um exemplo mais simples até um exemplo utilizando Strategy Pattern para registro de logs.

Código fonte disponível em: https://github.com/rafaelcouto/diferentes-estrategias-de-log-utilizando-strategy-pattern-com-php

O Strategy Pattern

O Strategy Pattern é um padrão de projeto (design pattern) comportamental e seu principal objetivo é definir uma família de algoritmos que realizam uma operação semelhante, cada um com uma implementação diferente, permitindo a mudança do algoritmo durante a execução da aplicação.

Imagem 1 – Estrutura do Strategy Pattern. Fonte: https://refactoring.guru/pt-br/design-patterns/strategy

É necessário então uma Interface, as Classes Concretas de cada estratégia e uma Classe de Contexto. A Interface vai garantir que todas as estratégias sigam as mesmas regras. As Classes Concretas terão o algoritmo de cada estratégia. E a Classe de Contexto irá permitir trocar de estratégia durante a execução da aplicação.

Mas chega de teoria e vamos à pratica, pois acredito que vai ficar muito mais fácil de entender!

Preparando o ambiente

Primeiramente, é necessário montar um ambiente para executar os exemplos deste artigo. Para isso vou utilizar o Composer para gerenciar as dependências e também gerar o autoload das classes.

{
    "name": "rafaelcouto/logging-strategy-pattern",
    "require": {
        "php": ">=8.0",
        "joshtronic/php-loremipsum": "dev-master"
    },
    "autoload": {
        "psr-4": {
            "LoggingStrategyPattern\\": "src/"
        }
    }
}

Defino um nome para o pacote, exijo que tenha o PHP 8 ou superior e declaro como dependência o php-loremipsum, que é uma biblioteca para gerar textos aleatórios, apenas para os exemplos ficarem mais interessantes. Será também aplicado o autoload no padrão PSR-4 na pasta src. Basta então rodar composer update e estamos prontos para começar.

Como esse projeto é apenas para fins didáticos, todos os exemplos vão ser montados para serem rodados no terminal.

Exemplo sem orientação a objetos

Vamos supor que a aplicação é muito simples e quero apenas registrar os logs no console, então poderia pensar apenas em criar uma função para isso.

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use joshtronic\LoremIpsum;

function console_log(int $level, string $text): void
{
    $dateTime = date('Y-m-d H:i:s');
    $levelNames = [1 => 'INFO', 2 => 'WARNING', 3 => 'ERROR'];

    $message = "[{$dateTime}] {$levelNames[$level]}: {$text}";

    switch ($level) {
        case 1:
            $message = "\033[36m{$message}\033[0m";
            break;
        case 2:
            $message = "\033[33m{$message}\033[0m";
            break;
        case 3:
            $message = "\033[31m{$message}\033[0m";
            break;
    }

    echo $message . PHP_EOL;
}

$lipsum = new LoremIpsum();
for ($i = 1; $i <= 5; $i++) {
    console_log(rand(1, 3), $lipsum->words(5));
    sleep(1);
}

Declaro a função console_log() que irá receber o nível de log (info, warning ou error) e o texto a ser exibido, e ela irá dar saída no formato [DATA] NIVEL: TEXTO com a coloração de acordo com o nível. Por fim, faço um laço para chamar essa função passando um nível aleatório, através da função rand(), e um texto aleatório gerado pela biblioteca php-loremipsum.

Animação 1 – Resultado do exemplo sem orientação a objetos

Solução simples e funcional, basta eu chamar a função de qualquer lugar da minha aplicação e vou conseguir registrar os logs no console. Porém, conforme minha biblioteca for crescendo e eu for adicionando novas opções a tendência é a complexidade dessa função ir sempre aumentando.

Além disso, e se eu quiser começar a registrar os logs em um arquivo e/ou em um banco de dados? E no Sentry que envolve integração com uma API? Provavelmente terminaria com um amontoado de funções e ficaria cada vez mais difícil estender ou dar manutenção no código.

Exemplo com orientação a objetos

Com a orientação a objetos e os princípios do SOLID é possível resolver as questões do exemplo anterior e deixar um código muito mais legível, organizado e extensível.

Primeiramente, para não haver números mágicos no código, vou criar um Enum para representar o nível de log.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern;

final class LogLevelEnum
{
    use EnumHelper;

    public const INFO = 1;
    public const WARNING = 2;
    public const ERROR = 3;
}

Agora, ao invés de utilizar 1, posso utilizar LogLevelEnum::INFO. Bem mais legível, não?

Essa classe utiliza a Trait EnumHelper, que possui o método getName() para obter o nome do nível de acordo com o seu valor (através de Reflection), portanto, se eu passar o nível 1, irá me retornar INFO. Isso será necessário apenas para facilitar a exibição do nível no registro do log.

O próximo passo é criar a Interface.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern;

interface LoggerContract
{
    public function log(int $level, string $text): void;
}

Com a interface eu defino um contrato, ou seja, independente se eu for registrar o log no console, em um arquivo ou no banco de dados, a classe concreta de cada uma dessas estratégias vai precisar implementar o método log() e então executar as ações específicas da estratégia.

Vou criar também uma Classe Abstrata apenas para definir métodos comuns para todas as estratégias.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern;

abstract class AbstractLogger
{
    protected function buildMessage(int $level, string $text): string
    {
        $dateTime = date('Y-m-d H:i:s');
        $levelName = LogLevelEnum::getLevelName($level);

        return "[{$dateTime}] {$levelName}: {$text}" . PHP_EOL;
    }
}

No caso, declaro apenas o método buildMessage() que vai construir e retornar a mensagem de log de acordo com o nível e o texto, já que esse método pode ser usado por qualquer estratégia.

Vou então criar a classe concreta da mesma estratégia utilizada no exemplo anterior.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern\Logger;

use LoggingStrategyPattern\AbstractLogger;
use LoggingStrategyPattern\LoggerContract;
use LoggingStrategyPattern\LogLevelEnum;

class ConsoleLogger extends AbstractLogger implements LoggerContract
{
    public function log(int $level, string $text): void
    {
        $message = $this->buildMessage($level, $text);
        echo $this->getColoredMessage($level, $message);
    }

    private function getColoredMessage(int $level, string $message): string
    {
        switch ($level) {
            case LogLevelEnum::INFO:
                return "\033[36m{$message}\033[0m";
            case LogLevelEnum::WARNING:
                return "\033[33m{$message}\033[0m";
            case LogLevelEnum::ERROR:
                return "\033[31m{$message}\033[0m";
        }

        return $message;
    }
}

Implementei método log() da interface, onde construo a mensagem através do método buildMessage() da classe abstrata e então exibo em tela a mensagem colorida através do método getColoredMessage().

Basicamente, faz a mesma coisa que a função console_log() do primeiro exemplo, porém a tendência é código ficar muito mais legível. Por exemplo, extraí a parte de colorir no método getColoredMessage(), seguindo o princípio do Clean Code de manter métodos específicos e pequenos.

Como é uma classe, você pode ir criando métodos e propriedades conforme a complexidade for aumentando, desde que a classe mantenha o propósito dela, que é registrar um log no console, respeitando o SRP (Single Responsibility Principle).

Agora vou adaptar o exemplo anterior para utilizar a classe ConsoleLogger.

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use joshtronic\LoremIpsum;
use LoggingStrategyPattern\Logger\ConsoleLogger;

$consoleLogger = new ConsoleLogger();

$lipsum = new LoremIpsum();
for ($i = 1; $i <= 5; $i++) {
    $consoleLogger->log(rand(1, 3), $lipsum->words(5));
    sleep(1);
}

Não ficou muito diferente do primeiro exemplo, apenas mudei o paradigma para orientação a objetos. Portanto, ao invés de utilizar a função console_log(), preciso criar um objeto ConsoleLogger para registrar os logs no console.

Porém, agora ficou mais fácil implementar outras estratégias de log, basta criar a classe concreta para cada estratégia implementando a interface LoggerContract.

Registrando log em um arquivo texto

A segunda estratégia que vou utilizar é registrar os logs em um arquivo texto.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern\Logger;

use LoggingStrategyPattern\AbstractLogger;
use LoggingStrategyPattern\LoggerContract;

class FileLogger extends AbstractLogger implements LoggerContract
{
    /** @var resource */
    private $fileResource;

    public function __construct(string $filePath)
    {
        $this->fileResource = fopen($filePath, 'a');
    }

    public function log(int $level, string $text): void
    {
        fwrite($this->fileResource, $this->buildMessage($level, $text));
    }

    public function __destruct()
    {
        if ($this->fileResource) {
            fclose($this->fileResource);
        }
    }
}

Nessa estratégia é necessário o caminho do arquivo texto em que os logs serão registrados, portanto recebo o caminho do arquivo ($filePath) no construtor e já abro o arquivo para escrita através do fopen().

No método log() construo a mensagem da mesma forma que na estratégia de console (através do método buildMessage() da classe abstrata), e então escrevo utilizando o fwrite().

Por fim, quando o objeto for descartado, fecho o arquivo utilizando o fclose().

Registrando log no banco de dados

A terceira e última estratégia que vou utilizar é registrar os logs em um banco de dados. Para isso, vou criar uma tabela de logs no banco de dados MySQL.

create table logs
(
	id int not null primary key auto_increment,
	datetime datetime not null,
	level enum('1', '2', '3') not null,
	text text not null
);

Super simples, apenas uma tabela com id, data, nível e texto do log. Agora basta criar a classe concreta.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern\Logger;

use LoggingStrategyPattern\AbstractLogger;
use LoggingStrategyPattern\LoggerContract;
use PDO;

class DatabaseLogger extends AbstractLogger implements LoggerContract
{
    public function __construct(private PDO $connection)
    {
    }

    public function log(int $level, string $text): void
    {
        $stmt = $this->connection->prepare("INSERT INTO logs(datetime, level, text) VALUES(CURRENT_TIMESTAMP(), :level, :text)");
        $stmt->bindParam(':level', $level);
        $stmt->bindParam(':text', $text);
        $stmt->execute();
    }
}

Nessa estratégia é necessário uma conexão com o banco de dados para registrar os logs na tabela, portanto recebo uma conexão PDO no construtor já declarando uma propriedade privada para ela, através do recurso de Constructor Property Promotion do PHP 8. E no método log() faço apenas a inclusão do log na tabela através dessa conexão.

Exemplo utilizando o Strategy Pattern

Agora que tenho as classes concretas de cada estratégia implementando a interface LoggerContract, preciso criar a Classe de Contexto que me permita definir as estratégias em tempo de execução.

<?php

declare(strict_types=1);

namespace LoggingStrategyPattern;

class LoggerContext implements LoggerContract
{
    /** @var LoggerContract[] */
    public $loggers = [];

    public function addLogger(LoggerContract $logger): void
    {
        $this->loggers[] = $logger;
    }

    public function log(int $level, string $text): void
    {
        foreach ($this->loggers as $logger) {
            $logger->log($level, $text);
        }
    }
}

A classe de contexto normalmente utiliza apenas uma estratégia por vez. Porém, neste caso em específico, como posso registrar logs usando mais de uma estratégia ao mesmo tempo, eu crio um array de LoggerContract e então tenho um método addLogger() para ir adicionando as estratégias.

Essa classe também implementa o LoggerContract e então no método log() eu passo por cada uma das estratégias e chamo o método log() dela.

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/config/database.php';

use joshtronic\LoremIpsum;
use LoggingStrategyPattern\LoggerContext;
use LoggingStrategyPattern\Logger\ConsoleLogger;
use LoggingStrategyPattern\Logger\FileLogger;
use LoggingStrategyPattern\Logger\DatabaseLogger;

$loggerContext = new LoggerContext();
$loggerContext->addLogger(new ConsoleLogger());
$loggerContext->addLogger(new FileLogger(__DIR__ . '/logs/log.txt'));
$loggerContext->addLogger(new DatabaseLogger(new PDO(sprintf('mysql:host=%s;dbname=%s', DB_HOST, DB_NAME), DB_USER, DB_PASSWORD)));

$lipsum = new LoremIpsum();
for ($i = 1; $i <= 5; $i++) {
    $loggerContext->log(rand(1, 3), $lipsum->words(5));
    sleep(1);
}

Como podemos ver no exemplo, para registrar os logs preciso criar um objeto LoggerContext e então adicionar as estratégias que vou utilizar na aplicação. Neste caso vou utilizar todas as estratégias, porém em alguma outra aplicação eu poderia utilizar apenas a estratégia de registrar no console, por exemplo. Basta então utilizar o método log() do contexto para registrar o log em cada uma das estratégias adicionadas.

Animação 2 – Resultado do exemplo com Strategy Pattern

Perceba na Animação 2 que todas as estratégias de log foram executadas. Na primeira aba do terminal temos os logs no console, na segunda aba temos os mesmos logs no arquivo texto e no terceira aba os mesmos logs no banco de dados.

Exemplo utilizando o Strategy Pattern com Singleton

Para simplificar a utilização da biblioteca, posso criar um Singleton na minha aplicação.

<?php

declare(strict_types=1);

use LoggingStrategyPattern\Logger\ConsoleLogger;
use LoggingStrategyPattern\Logger\DatabaseLogger;
use LoggingStrategyPattern\Logger\FileLogger;
use LoggingStrategyPattern\LoggerContext;

final class Logger
{
    public static ?Logger $instance = null;

    private LoggerContext $loggerContext;

    public static function getInstance(): Logger
    {
        if (static::$instance === null) {
            static::$instance = new static();
        }
        return self::$instance;
    }

    private function __construct()
    {
        $this->setUp();
    }

    private function setUp(): void
    {
        $this->loggerContext = new LoggerContext();
        $this->loggerContext->addLogger(new ConsoleLogger());
        $this->loggerContext->addLogger(new FileLogger(__DIR__ . '/../logs/log.txt'));
        $this->loggerContext->addLogger(new DatabaseLogger(new PDO(sprintf('mysql:host=%s;dbname=%s', DB_HOST, DB_NAME), DB_USER, DB_PASSWORD)));
    }

    public function log(int $level, string $text)
    {
        $this->loggerContext->log($level, $text);
    }

}

Dessa forma, eu garanto que sempre haverá apenas uma instância da classe de contexto na minha aplicação, ou seja, no método setUp() eu configuro as estratégias de log que vou utilizar na minha aplicação, e sempre que eu quiser registrar um log, basta eu pegar a instância do singleton através do Logger::getInstance() e então chamar o método log().

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/config/database.php';
require_once __DIR__ . '/class/Logger.php';

use joshtronic\LoremIpsum;

$logger = Logger::getInstance();

$lipsum = new LoremIpsum();
for ($i = 1; $i <= 5; $i++) {
    $logger->log(rand(1, 3), $lipsum->words(5));
    sleep(1);
}

E pronto! Ficou tão simples quanto chamar a função console_log() do primeiro exemplo, porém com a biblioteca, o código ficou muito mais flexível e totalmente desacoplado da minha aplicação, ou seja, posso reutilizá-la em outras aplicações.

Além disso, ficou muito fácil estender a biblioteca. Se você quiser começar a registrar os logs no Sentry por exemplo, basta criar a classe SentryLogger, implementar o LoggerContract integrando com a API deles e então posso adicionar essa estratégia no contexto e não preciso mexer em nenhum outro ponto da aplicação.

Conclusão

Neste artigo fiz uma abordagem prática do Strategy Pattern, pois acredito que é muito mais fácil entender um padrão de projeto quando aplicamos ele em um problema do mundo real.

Acredito que a parte mais difícil dos padrões de projeto em geral seja identificar quando utilizá-lo. No caso do Strategy Pattern, se você perceber trechos de código que fazem a mesma coisa, porém de jeitos diferentes, talvez seja uma situação para aplicá-lo.

Tentei mostrar também as vantagens que temos ao utilizar a programação orientada a objetos, pois mais cedo ou mais tarde o código que você fez vai precisar de manutenção, e com os recursos deste paradigma é possível fazer um código muito mais legível e extensível.

Enfim, espero que este artigo tenha sido útil de alguma forma. Até a próxima.

Código fonte disponível em: https://github.com/rafaelcouto/diferentes-estrategias-de-log-utilizando-strategy-pattern-com-php

Referências