Refactorizando controladores: Separando responsabilidades

Una de las cosas más complicadas para los programadores es tomar consciencia sobre las como modelar situaciones y objetos del mundo real y aplicarlos al mundo de la programación. De vez en cuando, es necesario dar un paso atrás y pensar en lo que se ha estado haciendo.

Así es como al final, acabamos dándonos cuenta de que llenar un controlador de lógica es como crear un monstruo. Últimamente he leído mucho sobre refactoring y más de un post hablando sobre que llenar el controlador de código es algo malo, así que hoy vamos a ver de primera mano como solucionarlo.

Pongamos como ejemplo un controlador como este:

namespace App\Http\Controllers;

use App\User;
use Dropbox\Client;
use Illuminate\Support\Facades\Auth;

class HomeController extends Controller
{
    public function index($songs = null)
    {
        if (Auth::check()) {

            $user = User::where('id', Auth::id())->first();

            if (!is_null($user->dropbox_token)) {

                $dbxClient = new Client($user->dropbox_token, 'App/1.0');
                $dbxFiles = $dbxClient->getDelta()['entries'];
                $mimeTypes = ['audio/mpeg'];

                foreach ($dbxFiles as $file) {
                    if (array_key_exists('mime_type', $file[1]) && in_array($file[1]['mime_type'], $mimeTypes)) {
                        $songs[] = $file;
                    }
                }
            }
        }

        return view('home')->with('songs', $songs);
    }
}

Este controlador funciona, pero por dónde empezar a limpiarlo. Buscando un poco por Internet encontré este post sobre como hacerlo y una respuesta de Paul M. Jones con otra visión de como hacer controladores más pequeños.

Una primera aproximación podría ser crear un repositorio para Dropbox.

namespace App\Dropbox;

use App\User;
use Dropbox\Client;
use Illuminate\Support\Facades\Auth;

class DropboxRepository
{
    private $user;

    public function __construct()
    {
        $this->user = User::where('id', Auth::id())->first();
    }

    public function getAllSongs()
    {
        $dbxClient = new Client($this->user['dropbox_token'], 'App/1.0');
        $dbxFiles = $dbxClient->getDelta()['entries'];
        $mimeTypes = ['audio/mpeg'];

        foreach ($dbxFiles as $file) {
            if (array_key_exists('mime_type', $file[1]) && in_array($file[1]['mime_type'], $mimeTypes)) {
                $songs[] = $file;
            }
        }

        return $songs;
    }
}

Con esto aligeramos toda la lógica de creación al repositorio y podemos dejar el controlador así:

namespace App\Http\Controllers;

use Exception;
use App\Dropbox\DropboxRepository;

class HomeController extends Controller
{
    public function index($songs = null)
    {
       try {
            $dbxRepo = new DropboxRepository();
            $songs = $dbxRepo->getAllSongs();
        } catch (Exception $e) {
            // swallow exception
        }
        return view('home')->with('songs', $songs);
    }
}

La razón de mover toda esa lógica a una clase propia es mantener el patrón Modelo-Vista-Controlador. El controlador solo es el responsable de interactuar entre la vista y el modelo.

Al interactuar con el modelo, el controlador debe solicitar datos y pasar datos, es lo que podríamos llamar “capa de servicios”. En esencia la capa de servicios es “algo” (middleware) entre el controlador y el modelo. El único propósito del controlador es pasar la información a la vista.

Como podemos ver con los namespaces, hay una capa de servicios(aka carpeta) llamada Dropbox que contiene la clase DropboxRepository con una variable privada que refleja una fila de la tabla de usuarios, de esa fila sacaremos el token de acceso de usuario de Dropbox.

También hemos creado un método público llamado getAllSOngs, que recupera todos los archivos de audio de la cuenta de Dropbox del usuario. Para ello instanciamos un nuevo cliente de Dropbox (con un token de acceso) y añadimos todos los archivos al array $dbxFiles. Por ultmo recorremos ese array para comprobar si el tipo de archivo es MP3. Utilizamos el bloque try catch en el controlador porque puede que el cliente de Drobox nos lance una excepción si el token es nulo.

Podemos refactorizar el repositorio aun más así:

namespace App\ServiceLayer\Dropbox;

use Dropbox\Client;
use Illuminate\Contracts\Auth\Guard as Auth;

class DropboxRepository
{
    private $user;

    public function __construct(Auth $auth)
    {
        $this->user = $auth->user();
    }

    public function getAllSongs()
    {
        $dbxClient = new Client($this->user['dropbox_token'], 'App/1.0');
        $dbxFiles = $dbxClient->getDelta()['entries'];
        $mimeTypes = ['audio/mpeg'];

        foreach ($dbxFiles as $file) {
            if (array_key_exists('mime_type', $file[1]) && in_array($file[1]['mime_type'], $mimeTypes)) {
                $songs[] = $file;
            }
        }

        return $songs;
    }
}

Como podemos ver, el namespaces ha cambiado. Ahora hemos creado una carpeta llamada “Service Layer” y la carpeta Dropbox dentro de la misma. Aunque esto es una pequeña modificación de la estructura de directorios, es un paso necesario para tener ordenado todo el proyecto. Más adelante podríamos tener más servicio, relacionados o no con Dropbox.

Además en el constructor estamos utilizando “inyección de dependencias”. Al utilizar inyección de dependencias aquí, estamos desacoplando todo la clase DropboxRepository de una de las Facades de Laravel. Con esto conseguimos hacer los test más fáciles.

Vale la pena mencionar, que hemos tratado de desvincular todo lo posible el código de Laravel, aún así queda mucho trabajo por hacer…

Ahora solo queda rediseñar el controlador como algo así:

namespace App\Http\Controllers;

use Exception;
use App\ServiceLayer\Dropbox\DropboxRepository;

class HomeController extends Controller
{
    private $dbxRepo;

    public function __construct(DropboxRepository $dbxRepo)
    {
        $this->dbxRepo = $dbxRepo;
    }

    public function index($songs = null)
    {
        try {
            $songs = $this->dbxRepo->getAllSongs();
        } catch (Exception $e) {
            // swallow exception
        }
        return view('home')->with('songs', $songs);
    }
}

Laravel tiene un servicio/contenedor de “Inversión de Control”(IoC) para resolver las dependencias, sin embargo solo lo hace automáticamente para las clases incorporadas en Laravel (Controllers, Commnads,…) Desde que mudamos toda nuestra lógica de HomeController a DropboxRepository tenemos que hacer que Laravel vuelva a resolver las dependencias automáticamente, para ello debemos utilizar la inyección de dependencias dentro del constructor en HomeController.

Ahora que ya tenemos todas las dependencias resueltas vamos a dar un paso más allá:

namespace App\ServiceLayer\Dropbox;

use Exception;
use Dropbox\Client;
use InvalidArgumentException;
use Illuminate\Contracts\Auth\Guard as Auth;

class DropboxRepository
{
    private $accessToken;

    public function __construct(Auth $auth)
    {
        $this->accessToken = $auth->user()['dropbox_token'];
    }

    private function getAllFiles()
    {
        try {
            $dbxClient = new Client($this->accessToken, 'App/1.0');
            return collect($dbxClient->getDelta()['entries']);
        } catch (InvalidArgumentException $e) {
            throw new Exception("User has not authenticated with Dropbox yet");
        }
    }

    public function retrieveAllAudioFiles()
    {
        try {
            $songs = $this->getAllFiles()->filter(function ($file) {
                $mimeTypes = ['audio/mpeg'];
                return (array_key_exists('mime_type', $file[1]) && in_array($file[1]['mime_type'], $mimeTypes));
            });
            return ($songs->isEmpty()) ? null : $songs;
        } catch (Exception $e) {
            return null;
        }
    }
}

Lo primero que hemos hecho es modificar user y utilizar accessToken. La idea que debemos tener en la cabeza es que DropboxRepository solo es responsable de la interacción con Dropbox, por lo que lo unico que tiene que conocer del usuario es el token de acceso. El resto de información acerca del usuario es irrelevante.

Lo siguiente que hemos hecho ha sido romper getAllSongs en dos métodos. Así separamos responsabilidades también para los métodos y además hacemos más sencillo el añadir nuevas funcionalidades (video por ejemplo) a DropboxRepository.

También es cierto que estamos devolviendo un array de archivos, podríamos devolver otra cosa, pero de momento esta bien.

Del mismo modo, nos abstraemos más de la lógica mediante los bloque try/catch.

Así que nuestro HomeController queda de la siguiente manera:

namespace App\Http\Controllers;

use App\ServiceLayer\Dropbox\DropboxRepository;

class HomeController extends Controller
{
    private $dbxRepo;

    public function __construct(DropboxRepository $dbxRepo)
    {
        $this->dbxRepo = $dbxRepo;
    }

    public function index()
    {
        $songs = $this->dbxRepo->retrieveAllAudioFiles();
        return view('home')->with('songs', $songs);
    }
}

Estoy seguro de que todavía se puede mejorar mucho: desacoplar DroboxRepository de Laravel, comprobar que droboxToken exista,… pero creo que estos pasos van en la dirección correcta 🙂

Referencias

Este tutorial ha sido parcialmente traducido de: http://ryanbishop.co/blog/2015/09/05/refactoring-separating-concerns-from-the-controller

Otras referencias interesantes:

Anuncios

2 comentarios sobre “Refactorizando controladores: Separando responsabilidades

  1. Hola.
    En DropboxRepository class tenemos en la function getAllFiles: $dbxClient = new Client($this->accessToken, ‘App/1.0’); . No es mejor pasar como argumento el objecto Client? Quien es responsable de creacion de DropboxRepository va crear tambien el client y asi no pasamos accessToken como argumento. Creo que DropboxRepository no tiene mucha relacion con accessToken y si se necesita OtroServicioRepository que va usar un client hay que crear otra vez el client.

    Me gusta

Comenta la entrada

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s