Un redimensionador de imágenes eficiente hecho con PHP

Un proyecto interesante que tuve la oportunidad de realizar hace unos años fue un sistema de procesamiento de imágenes.

El desafío era lograr un servicio simple que permitiera escalar y rotar imágenes velozmente.

Lo diseñé como un componente separado de la aplicación principal (Una red social de viajes) para poder instalarlo sin inconvenientes en un servidor diferente (y eventualmente poder vincularlo a otros proyectos).

Por entonces me pareció una buena idea montarlo sobre una arquitectura RESTFul y, como era un proyecto muy simple y acotado (y tenía ganas de aprender algo nuevo de paso) decidí usar un framework especialmente diseñado para estos efectos: Tonic.

Lo más importante como siempre: ¿qué nombre ponerle a una aplicación como esta? Mucho de fotografía no sé, pero buscando un poco me pareció que Bresson podía servir 🙂

Muy bien, teniendo despejado el terreno había que empezar a trabajar.

Para la parte dura del desarrollo no había mucho que pensar: ImageMagick iba a ser el motor de procesamiento.

Las URIs tenían que ser simples y a la vez semánticas. Un caso de uso muy común era requerir una cierta imagen en un tamaño diferente al original, ¿qué mejor que usar el clasico WWxHH (Ancho por alto) como sufijo?

Para lograr la eficiencia buscada (además de tener un buen soporte de hardware) había que reducir al máximo las transformaciones de las imágenes… basta con almacenar cada imagen transformada para próximos usos… claro que si se trata de un sistema que maneja miles y miles de imágenes el almacenamiento puede volverse algo problemático (Menos mal que contábamos con Amazon S3).

Y por último, como para darle un poco más de robustez, me pareció interesante hacerlo agnóstico respecto del medio de almacenamiento y del motor de manipulación de imágenes… al fin y al cabo, uno nunca sabe lo que depara el destino y la verdad… no costaba mucho hacerlo un poco más elegante.

Y bueno… así nació este pequeño proyecto.

¿Querés ver código? Acá vamos 🙂

En este caso sólo hay disponible un tipo de recurso (Resource): la imagen. Para eso tenemos la clase ImageResource.

namespace Bresson;

use Tonic\Resource;
use Tonic\Exception;
use Tonic\Response;

/**
 * @uri /(.+)-(\d+)x(\d+)
 * @uri /(.+)-(\d+)x(\d+)-r(\d+)
 * @uri /(.+)-(th)
 * @uri /(.+)-(bg)
 * @uri /(.+)
 */

class ImageResource extends Resource
{
    const MAX_RETRIES = 20;

    /**
     * @method get
     * @provides image/jpeg
     */
    public function get($prefix, $width = null, $height = null, $degrees = null)
    {
        $dataSource = $this->app->container['DS'];
        $sOriginalRequest = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
        $this->app->container['logger']->addDebug('Buscando '.$sOriginalRequest);
        if(empty($height) && empty($width)){
            $original = true;
        } else {
            $original = false;
        }
        if ( $dataSource->elementExists($sOriginalRequest) && $contents = $dataSource->fetchElement($sOriginalRequest,$original) ) {
            $this->app->container['logger']->addInfo( $sOriginalRequest. ' encontrado en el storage');
            $this->app->container['logger']->addDebug('Contenido obtenido');

            return new Response(
                Response::OK,
                $contents,
                array(
                    'Content-Length' => strlen($contents),
                ));
        } else {
            $this->app->container['logger']->addInfo($sOriginalRequest.' NO encontrado en el storage');
            if ( !empty($width) && !$original) {
                $sOriginalName = '/'.$prefix.substr( $sOriginalRequest, strrpos( $sOriginalRequest, '.' ) );
                
                $this->app->container['logger']->addDebug('Request contiene sufijo, buscando imagen original', array( 'imagenOriginal' => $sOriginalName ) );
                if ( !$dataSource->elementExists( $sOriginalName ) ) {
                    $this->app->container['logger']->addDebug('Archivo original no encontrado', array( 'Archivo' => $sOriginalName ) );
                    $sOriginalName = '/'.$prefix.'-bg'.substr( $sOriginalRequest, strrpos( $sOriginalRequest, '.' ) );
                    if ( !$dataSource->elementExists( $sOriginalName ) ) {
                        $this->app->container['logger']->addDebug('Archivo bg no encontrado', array( 'Archivo' => $sOriginalName ) );
                        $sOriginalName = '/'.$prefix.'-th'.substr( $sOriginalRequest, strrpos( $sOriginalRequest, '.' ) );
                        if ( !$dataSource->elementExists( $sOriginalName ) ) {
                            $this->app->container['logger']->addDebug('Archivo th no encontrado', array( 'Archivo' => $sOriginalName ) );

                            throw new \Tonic\NotFoundException;                 
                        }
                    }
                }
                $this->app->container['logger']->addDebug('Imagen original encontrada en el storage', array( 'imagenOriginal' => $sOriginalName ) );
                try {
                    $sLockFileName = '/tmp/'.preg_replace( '|/|', '.', $sOriginalRequest ) .'.lck';
                    if ( @$fp = fopen( $sLockFileName, 'x' ) ) {
                        $this->app->container['logger']->addDebug('Lock obtenido', array( 'lockFile' => $sLockFileName ) );
                        $processor = $this->app->container['imageProcessor'];
                        $tmpFile = tempnam(__DIR__, 'img');
                        $this->app->container['logger']->addDebug('Descargando archivo original a temporal', array( 'tmpFile' => $tmpFile ) );
                        file_put_contents( $tmpFile, $dataSource->fetchElement($sOriginalName,true) );
                        $processor->readImageFile( $tmpFile );

                        $this->app->container['logger']->addInfo('Archivo original obtenido, procesando imagen');

                        if ($degrees) {
                            $processor->rotate($degrees);
                        }

                        if ( !is_numeric($width) ) {
                            $this->app->container['logger']->addDebug('El ancho no es numérico, se busca una regla de redimensión', array( 'width' => $width ) );
                            $aParts = explode("/", $sOriginalName);
                            $taxonomy = $aParts[1];
                            if ( !array_key_exists($taxonomy, $this->app->container['config']['process_rules'] ) ) {
                                $this->app->container['logger']->addError('Se pidio una taxonomía desconocida', array( 'taxonomy' => $taxonomy ) );

                                throw new \Tonic\Exception( 'Unknown taxonomy "'.$taxonomy.'"', 400 );
                            }

                            $rules = $this->app->container['config']['process_rules'][$taxonomy];

                            if ( !array_key_exists( $width, $rules ) ) {
                                $this->app->container['logger']->addError('Se pidio un tamaño desconocido para la taxonomia', array( 'taxonomy' => $taxonomy, 'width' => $width ) );

                                throw new \Tonic\Exception( 'Unknown size "'.$width.'", available sizes for "'.$taxonomy.'": ['.implode(', ', array_keys($rules)).']', 400 );
                            }

                            $size = $rules[$width];
                            list( $width, $height ) = getimagesize($tmpFile);

                            if($width > $height){
                                $width = $size;
                                $height = 0;
                            } else {
                                $width = 0;
                                $height = $size;
                            }

                            if ( !$processor->scale( $width, $height ) ) {
                                $this->app->container['logger']->addCritical('No se pudo hacer el resizing' );

                                throw new \Tonic\Exception( 'Image couldn\'t be scaled', 500 );
                            }
                        } else {
                            $result = $processor->scale( $width, $height );
                            if ( !$result ) {

                                $this->app->container['logger']->addCritical('No se pudo hacer el resizing' );

                                throw new \Tonic\Exception( 'Image couldn\'t be resized', 500 );
                            }

                            $processor->centerCrop( $width, $height);
                        }

                        $processor->writeImageFile( $tmpFile );
                        $contents = file_get_contents($tmpFile);
                        unlink($tmpFile);

                        $this->app->container['logger']->addDebug('Almacenando en el storage' );
                        /**
                         * @todo Agregar manejo de exception y logging (En todo caso, si no se puede guardar que devuvelva y vuelva a calcular en el próximo request
                         */
                        $dataSource->storeElement( $sOriginalRequest, $contents );

                        $this->app->container['logger']->addDebug('Liberando el lock' );
                        fclose($fp);
                        unlink($sLockFileName);
                    } else {
                        $this->app->container['logger']->addDebug('No se pudo obtener el lock, otro proceso debe estar generando' );
                        $tries = 0;
                        do {
                            usleep( 10 );
                            $tries++;
                            $elementExists = $dataSource->elementExists($sOriginalRequest);
                        } while ( !$elementExists && $tries < ImageResource::MAX_RETRIES );

                        if ( $elementExists ) {
                            $this->app->container['logger']->addDebug('Otro proceso generó el archivo buscado' );
                            $contents = $dataSource->fetchElement($sOriginalRequest);
                        } else {
                            $this->app->container['logger']->addCritical('Se agotaron los reintentos' );

                            throw new \Tonic\NotFoundException;
                        }
                    }

                    return new Response(
                        Response::OK,
                        $contents,
                        array(
                            'Content-Length' => strlen($contents),
                        ));
                } catch ( Exception $e ) {
                    $this->app->container['logger']->addCritical('Exception inesperada: ', array( 'exception' => $e->getMessage() ) );

                    throw new Exception( 500, $e->getMessage() );
                }
            } else {
                $this->app->container['logger']->addDebug('Archivo no encontrado',array('Archivo' => $sOriginalRequest) );

                throw new \Tonic\NotFoundException;
            }
        }
    }
}

Las partes interesantes:

/**
 * @uri /(.+)-(\d+)x(\d+)
 * @uri /(.+)-(\d+)x(\d+)-r(\d+)
 * @uri /(.+)-(th)
 * @uri /(.+)-(bg)
 * @uri /(.+)
 */

Esta es la forma de mapear una URI a un controlador en Tonic… no está mal, ¿cierto?

La primera regla es la más cómun: imagen-ANCHOxALTO, simplemente redimensiona.

La segunda hace lo mismo más una rotación: imagen-ANCHOxALTO-rGRADOS.

La tercera y la cuarta (th y bg) simplemente son tamaños predeterminados (Algo que tenía mucho sentido en la aplicación cliente).

Por último, si se quiere una imagen sin alteración alguna también hay que proveerla…

/**
 * @method get
 * @provides image/jpeg
 */

Una forma muy simple de definir el método HTTP aceptado y el Content-Type retornado.

$dataSource = $this->app->container['DS'];

La parte del agnosticismo respecto del medio de almacenamiento se logra a través de esta dependencia (El DataSource), más sobre esto más abajo.

$sLockFileName = '/tmp/'.preg_replace( '|/|', '.', $sOriginalRequest ) .'.lck';
if ( @$fp = fopen( $sLockFileName, 'x' ) ) {

Todas las partes del código que hacen referencia al LockFile son simplemente un mecanismo de exclusión mutua. Tené en cuenta que en este contexto era muy frecuente tener una gran cantidad de usuarios solicitando la misma imagen a la vez, y una de las premisas del proyecto era ser eficiente… no podíamos permitirnos procesar una misma imagen más de una vez, con lo cual, tuvimos que implementar este mecanismo para asegurarnos de no saturar los recursos del servidor en tareas redundantes (Ok, supongo que podríamos haber usado algo de más bajo nivel como los semáforos… en aquel momento no los conocía).

$processor = $this->app->container['imageProcessor'];

A través de esta dependencia logramos agnosticismo respecto del motor de procesamiento de imágenes.

Y lo demás se explica por sí mismo 🙂

Lo importante de notar en este código es que se trata de un thin controller, no hace mucho más que preparar el terreno para que el procesador de imágenes y el data source hagan su trabajo cómodamente.

La inyección de las dependencias se realiza desde el entry point principal de la aplicación: web/dispatch.php

$app = new Tonic\Application($config);
$app->container = new Pimple();

Apenas creada la aplicación se le agrega un simple contenedor de dependencias (Pimple) y luego se van generando las dependencias en base a una configuración:

$bressonConfig = array(
   'process_rules' => array(),
   'data_source' => array(
      'class' => 'Bresson\LocalStorage',
      'init_params' => array(
         'base_dir' => __DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'files',
      ),
   ),
   'image_processor' => array(
      'class' => 'Bresson\IMagickProcessor',
   ),
   'log' => array(
      'filename' => __DIR__.'/../log/bresson.log',
      'level' => 'DEBUG',
   ),
);
$app->container['config'] = $bressonConfig;
$app->container['DS'] = new $app->container['config']['data_source']['class']( $app->container['config']['data_source']['init_params'] );
$app->container['imageProcessor'] = new $app->container['config']['image_processor']['class']();

Por último tenemos dos interfaces que nos permiten abstraer las clases específicas que realizan las tareas complejas:

  • ImageProcessor
  • Storage

En este caso sólo tenemos un ImageProcessor: IMagickProcessor el cual en los hechos no es más que un wrapper para la clase Imagick.

En cambio, del lado de los Storage tenemos dos posibilidades:

  • LocalStorage
  • S3Storage

El primero es muy simple: utiliza como medio de almacenamiento el sistema de archivos local, el segundo es algo más interesante: usa S3.

Pero lo importante es que, dada la arquitectura de la solución, implementar nuevos soportes de almacenamiento o procesadores de imágenes se vuelve algo super sencillo:

Basta con crear una clase que implemente la interfase adecuada (Sea ImageProcessor o Storage) y modificar una pequeña configuración (Que, dicho sea de paso, debería estar en un archivo separado…).

Y listo, tenemos un pequeño pero potente procesador de imágenes muy extendible y escalable.

¿Qué lecciones te llevás de este pequeño ejemplo?

 

mchojrin

Por mchojrin

Ayudo a desarrolladores PHP a afinar sus habilidades técnicas y avanzar en sus carreras

1 comentario

¿Te quedó alguna duda? Publica aca tu pregunta

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.