Refactorizando el cliente de “Cat API” – Parte 1

Esta es una traducción libre de Refactoring the Cat API client – Part I

Mattias Noback tenía la idea de hacer una serie de videos sobre “Principles of Package Design book” pero finalmente en vez de videos hizo una serie de post con el material que tenía pensado presentar en los videos.

Para empezar esta es una pequeña pieza de código:

class CatApi
{
    public function getRandomImage()
    {
        if (!file_exists(__DIR__ . '/../../cache/random') 
            || time() - filemtime(__DIR__ . '/../../cache/random') > 3) {
            $responseXml = @file_get_contents(
                'http://thecatapi.com/api/images/get?format=xml&type=jpg'
            );
            if (!$responseXml) {
                // the cat API is down or something
                return 'http://cdn.my-cool-website.com/default.jpg';
            }

            $responseElement = new \SimpleXMLElement($responseXml);

            file_put_contents(
                __DIR__ . '/../../cache/random',
                (string)$responseElement->data->images[0]->image->url
            );

            return (string)$responseElement->data->images[0]->image->url;
        } else {
            return file_get_contents(__DIR__ . '/../../cache/random');
        }
    }
}

Como se puede adivinar, la función getRandomImage devuelve una URL de la imagen aleatoria (y que existe) de Cat API. Cuando llamamos a la función en dentro de una ventana de tiempo de 3 segundos, se devuelve la misma URL para que el servicio sea más rápido, así como para no abusar de Cat API.

Si este código estuviese en un script para usarlo una vez, para un fin específico,… no pasaría nada. Pero si este código termina en una aplicación en producción, yo no estaría orgulloso.

Estos son algunos de los problemas que hay por aquí: Se están intentando solucionar varios problemas en la misma función, es decir, intentamos traer una nueva URL y almacenar en la caché. Existen gran cantidad de detalles de bajo nivel, como rutas de archivos, URL, tiempo de vida de la caché, nombres de elementos XML,… que no nos dejan ver lo que está pasando realmente. Estos son grandes problemas, porque si intentamos ver los test unitarios tenemos:

class CatApiTest extends \PHPUnit_Framework_TestCase
{
    protected function tearDown()
    {
        @unlink(__DIR__ . '/../../cache/random');
    }

    /** @test */
    public function it_fetches_a_random_url_of_a_cat_gif()
    {
        $catApi = new CatApi();

        $url = $catApi->getRandomImage();

        $this->assertTrue(filter_var($url, FILTER_VALIDATE_URL) !== false);
    }

    /** @test */
    public function it_caches_a_random_cat_gif_url_for_3_seconds()
    {
        $catApi = new CatApi();

        $firstUrl = $catApi->getRandomImage();
        sleep(2);
        $secondUrl = $catApi->getRandomImage();
        sleep(2);
        $thirdUrl = $catApi->getRandomImage();

        $this->assertSame($firstUrl, $secondUrl);
        $this->assertNotSame($secondUrl, $thirdUrl);
    }
}

¡Necesitamos tener sleep()en nuestra pruebas para testear la caché! De la misma manera, verificamos únicamente que la URL es válida(no nos aseguramos que sean devueltas por Car API)

Vamos a empezar ha hacer esto un poco mejor.

Separando las preocupaciones – “Cacheado” vs “lo” real

Mirando el código anterior, vemos que las preocupaciones de “cachear cosas” están entrelazadas con las preocupaciones de “traer cosas nuevas/frescas”. En realidad la situación no es tan mala como podría parecer. Básicamente la estructura del código podría ser algo como esto:

if (!isCachedRandomImageFresh()) {
    $url = fetchFreshRandomImage();

    putRandomImageInCache($url);

    return $url;
}

return cachedRandomImage();

En cualquier lugar donde tengamos cosas “cargadas con pereza” (lazy load), o cosas nuevas generadas solo cada x segundos, veremos este patrón

Al tener separadas las preocupaciones podemos probar “traer cosas nuevas” separadamente de “cachear cosas”. De esta manera, no nos distraeríamos con los detalles de la caché cuando estamos focalizados en verificar si el código trae imágenes aleatorias cada vez que sea necesario.

Por ello podemos mover todo el código relacionado con “cachear” a una nueva clase CachedCatApi la cual tendrá la misma interfaz pública que teníamos.

class CachedCatApi
{
    public function getRandomImage()
    {
        $cacheFilePath = __DIR__ . '/../../cache/random';
        if (!file_exists($cacheFilePath) 
            || time() - filemtime($cacheFilePath) > 3) {

            // not in cache, so do the real thing
            $realCatApi = new CatApi();
            $url = $realCatApi->getRandomImage();

            file_put_contents($cacheFilePath, $url);

            return $url;
        }

        return file_get_contents($cacheFilePath);
    }
}

De camino, me tomé la libertad de cambiar un par de cosas menores, que harán un poco más fácil la legibilidad del código:

  1. He eliminado todas las menciones duplicadas de __DIR__ . '/../../cache/random'
  2. He eliminado else – es irrelevante porque if siempre devuelve algo

En este momento la clase CatApi solo contienen la logica necesaria para llamar a Cat API:

class CatApi
{
    public function getRandomImage()
    {
        $responseXml = @file_get_contents('http://thecatapi.com/api/images/get?format=xml&type=jpg');
        if (!$responseXml) {
            // the cat API is down or something
            return 'http://cdn.my-cool-website.com/default.jpg';
        }

        $responseElement = new \SimpleXMLElement($responseXml);

        return (string)$responseElement->data->images[0]->image->url;
    }
}

Dependencia de inversión: introduciendo una abstracción

Llegados a este punto podríamos dividir nuestros tests así, ya que ahora tenemos 2 clases: De hecho, estaríamos probando CatApi 2 veces, debido a que CachedCatApi esta utilizándola directamente. Nosotros podríamos dar un gran paso e introducir una abstracción para CatApi – algo que sea reemplazable si no queremos hacer llamadas HTTP, pero en ese caso solo testearemos el comportamiento de CachedCatApi. Ambas clases tienen exactamente la misma interfaz, así que es fácil definir una interfaz para ellos. lLamaremos a esta interfaz CatApiy renombraremos la antigua clase CatApi como RealCatApi.

interface CatApi
{
    /**
     * @return string
     */
    public function getRandomImage();
}

class RealCatApi implements CatApi
{
    ...
}

class CachedCatApi implements CatApi
{
    ...
}

No creo que utilizar nombres propios sea bueno, pero de momento es suficiente.

Como siguiente paso, no deberíamos permitir que CachedCapApi crease cada vez instancias de CatApi. En su lugar deberíamos dotarla de una instancia que pueda utilizar para “traer imágenes nuevas/frescas”. Esto es llamado dependencia de inyección: Proporcionamos un objeto con todo lo que necesita.

class CachedCatApi implements CatApi
{
    private $realCatApi;

    public function __construct(CatApi $realCatApi)
    {
        $this->realCatApi = $realCatApi;
    }

    public function getRandomImage()
    {
        ...

            $url = $this->realCatApi->getRandomImage();

        ...
    }
}

Esto nos permite tener casos de prueba verdaderamente independientes para cada una de las clases.

class CachedCatApiTest extends \PHPUnit_Framework_TestCase
{
    protected function tearDown()
    {
        @unlink(__DIR__ . '/../../cache/random');
    }

    /** @test */
    public function it_caches_a_random_cat_gif_url_for_3_seconds()
    {
        $realCatApi = $this->getMock('RealCatApi');
        $realCatApi
            ->expects($this->any())
            ->will($this->returnValue(
                // the real API returns a random URl every time
                'http://cat-api/random-image/' . uniqid()
            );

        $cachedCatApi = new CachedCatApi($realCatApi);

        $firstUrl = $cachedCatApi->getRandomImage();
        sleep(2);
        $secondUrl = $cachedCatApi->getRandomImage();
        sleep(2);
        $thirdUrl = $cachedCatApi->getRandomImage();

        $this->assertSame($firstUrl, $secondUrl);
        $this->assertNotSame($secondUrl, $thirdUrl);
    }
}

class RealCatApiTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function it_fetches_a_random_url_of_a_cat_gif()
    {
        $catApi = new RealCatApi();

        $url = $catApi->getRandomImage();

        $this->assertTrue(filter_var($url, FILTER_VALIDATE_URL) !== false);
    }
}

Conclusión

¿Qué hemos ganado hasta ahora? Ahora podemos probar el comportamiento del “cacheo” de manera separada al “comportamiento real”. Esto significa que el agrupamiento puede evolucionar por separado, tal vez incluso en diferentes direcciones, siempre y cuando respeten su contrato.

¿Qué queda por hacer? Bien, mucho en realidad. Todavía no podemos probar RealCatApi sin hacer llamadas HTTP reales (lo cual hace el test poco fiable y bastante indigna del nombre “prueba unitaria”). Lo mismo pasa con CachedCatApi: intentará escribir en el sistema de archivos, algo que no queremos hacer en una “prueba unitaria”, ya que es lento e influye en el comportamiento global

Anuncios

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