Patrón composite en PHP

El patrón composite es un patrón de diseño que permite implementar algoritmos compuestos, pudiendo utilizar varios de ellos bajo una interface unificada. Pero veamos un ejemplo.

Una vez más, el departamento de administración de la empresa nos pide una nueva funcionalidad en la aplicación que mantenemos. Esta vez nos piden poder importar un listado de clientes de un fichero CSV.

Como ya aprendimos en el post de patrón strategy en PHP, esta vez no vamos a caer en la trampa, y en lugar de implementar en el caso de uso la importación, vamos a tratar de respetar el principio Open/Close de los principios SOLID y vamos a inyectarle al constructor una interface que defina el contrato del sistema de lectura, y le pasaremos una instancia que lo implemente, de ese modo, si mañana nos piden importar los datos desde otro tipo de fichero o medio, sólo necesitaremos implementar la lectura sin modificar el caso de uso. Es decir, haremos uso de la inyección de dependencias para mantener un bajo acoplamiento entre los componentes del sistema.

Así que nos ponemos a la obra, definiendo una interface para el lector:

interface Reader
{
    /**
     * @return Customer[]
     */
    public function read(): array;
}

Ahora ya podemos implementar el caso de uso sin preocuparnos de la implementación concreta, simplemente sabemos que ejecutando el método read nos devolverá un array de clientes, no importa cómo, esa es la gracia de la orientación a objetos.

class ImportCustomers
{
    private Reader $reader;

    public function __construct(Reader $reader)
    {
        $this->reader = $reader;
    }

    /**
     * @return Customer[]
     */
    public function __invoke(): array
    {
        return $this->reader->read();
    }
}

Ahora sólo nos quedaría implementar el lector de archivos CSV que es la especificación que nos han pasado. En esta implementación le pasaremos el nombre del archivo por constructor y el método read será el encargado de leer el fichero y devolver una lista de clientes.

class CsvReader implements Reader
{
    private string $filename;

    public function __construct(string $filename)
    {
        $this->filename = $filename;
    }

    /**
     * @return Customer[]
     */
    public function read(): array
    {
        $lines = [];

        $fp = fopen($this->filename, "r");
        while (!feof($fp)) {
            $line = fgetcsv($fp);
            if ($line) {
                $lines[] = $line;
            }
        }
        fclose($fp);

        return array_map(
            fn(array $fields) => new Customer($fields[0], $fields[1]),
            $lines
        );
    }
}

Y ahora ya podemos invocarlo.

$reader = new CsvReader("clientes.csv");
$importCustomers = new ImportCustomers($reader);
$customers = $importCustomers();

Trabajo terminado, pero como siempre, los usuarios se dan cuenta de que también tienen ficheros XML con listados de clientes, menos mal que ahora no estamos acoplados y bastará con implementar un reader para el XML y listo.

class XmlReader implements Reader
{
    private string $filename;

    public function __construct(string $filename)
    {
        $this->filename = $filename;
    }

    /**
     * @return Customer[]
     */
    public function read(): array
    {
        $customers = [];

        $xml = simplexml_load_file($this->filename);
        foreach ($xml->customer as $rawCustomer) {
            $customers[] = new Customer((string)$rawCustomer->name, (string)$rawCustomer->lastName);
        }

        return $customers;
    }
}

Ha sido fácil, pero resulta que lo que quieren es poder leer al mismo tiempo ficheros CSV y XML, la gente de administración siempre quiere más y parece que nos han vuelto a pillar, sólo hemos pensado en incluir un reader, y como no sabemos cuantos ficheros vamos a leer tampoco podemos inyectar los readers concretos…

Aquí es donde resulta muy útil el patrón de diseño composite, que nos va a permitir combinar varios readers sin modificar nuestro caso de uso. Al fin y al cabo, al tener una interface que define el contrato, debemos buscar la forma de combinar varios readers sin violar la interface, vamos a ello.

class MultiReader implements Reader
{
    private array $readers;
    private array $customers = [];

    /**
     * @param Reader[] $readers
     */
    public function __construct(array $readers)
    {
        $this->readers = $readers;
    }

    /**
     * @return Customer[]
     */
    public function read(): array
    {
        foreach ($this->readers as $reader) {
            $this->addCustomers($reader);
        }

        return $this->customers;
    }

    private function addCustomers(Reader $reader): void
    {
        foreach ($reader->read() as $customer) {
            $this->customers[] = $customer;
        }
    }
}

Lo que hemos hecho es una implementación que recibe por constructor, en lugar de el nombre del fichero, un array con todos los readers que vayamos a tener, uno por fichero, sea del tipo que sea, todos cumplen la misma interface.

Después, en el método read de esta implementación, lo que vamos a hacer es iterar cada uno de estos readers e ir acumulando los clientes en una propiedad privada. Una vez finalizados todos los readers, podemos devolver la lista de clientes completa.

Podemos invocarlo así.

$readers = [
  new CsvReader("clientes.csv"),
  new XmlReader("clientes.xml"),
];
$reader = new MultiReader($readers);
$importCustomers = new ImportCustomers($reader);
$customers = $importCustomers();

En caso de que quisiéramos crear los readers en tiempo de ejecución en base a los ficheros que se pasen como parámetro, podemos crear una factoría de readers que nos devuelva el reader correcto para cada fichero.

class ReaderFactory
{
    public static function getReader(string $filename): Reader
    {
        $filenameParts = explode('.', $filename);
        $extension = strtolower($filenameParts[count($filenameParts) - 1]);

        switch ($extension) {
            case 'xml':
                return new XmlReader($filename);
            case 'csv':
                return new CsvReader($filename);
            default:
                throw new Exception('Reader not implemented for ' . $extension . ' extension');
        }
    }
}

Quedando la invocación así.

$filenames = [
  "clientes.csv",
  "clientes.xml"
];

$readers = array_map(
  fn(string $filename) => ReaderFactory::getReader($filename),
  $filenames
);
$reader = new MultiReader($readers);
$importCustomers = new ImportCustomers($reader);
$customers = $importCustomers();

De este modo, aunque aparezcan nuevos tipos de fichero, sólo tendremos que crear la implementación del reader y agreagarlo a la factoría, y podemos combinar tantos ficheros y de tantos tipos como sea necesario sin cambiar el algoritmo del caso de uso y de la invocación.