Categoría: Ejemplos

En estos artículos podrás ver ejemplos de problemas reales y cómo fueron resueltos

  • Cómo hacer un CRUD con Symfony e EasyAdmin

    Cómo hacer un CRUD con Symfony e EasyAdmin

    Me proponía escribir un artículo sobre lo bueno que es EasyAdmin, pero se me ocurró que una imagen vale más que 1000 palabras… Y un video más aún :). Así que armé este:

    Si lo disfrutaste y te quedaste con ganas de aprender más sobre Symfony el curso Introducción a Symfony Framework te puede ayudar.

  • Un redimensionador de imágenes eficiente hecho con PHP

    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?

     

  • Cómo pasar un sistema monolingüe a multilingüe

    Cómo pasar un sistema monolingüe a multilingüe

    Hace un tiempo, trabajando para una red social de viajes, me topé con un desafío sumamente interesante: adaptar un sistema hecho para trabajar exclusivamente en Español para dar soporte a varios idiomas (En particular, en nuestro caso se trataba de la versión en Portugués, pero se preveía que podríamos necesitar más idiomas en el futuro).

    Si el sistema se arrancara desde cero no sería un gran problema. Se podría usar Symfony o algún otro framework similar que ya tienen incorporada la funcionalidad multi-idioma y listo.

    Desafortunadamente, en aquel momento el sistema estaba desarrollado sobre un framework propietario y la re-escritura estaba fuera de discusión.

    Así que, junto a mi equipo de desarrollo, comenzamos a pensar cómo resolver este problema.

    Lo primero que entendimos fue que teníamos que resolver dos desafíos complementarios:

    1. La internacionalización del contenido estático (Contenido generado por nosotros mismos, botones, menúes, etc…)
    2. La internacionalización del contenido dinámico (Contenido subido por los propios usuarios)

    Cómo internacionalizar el contenido estático de un sitio

    En nuestro caso, dado que la aplicación era grande, había mucho contenido con textos hardcodeados.

    Decidimos utilizar gettext como motor de traducción (Tenía la ventaja de usar archivos .po y .mo, lo cual hacía muy fácil el proceso de traducción).

    El siguiente paso era el más pesado: generar los archivos con los textos a traducir.

    En aquel momento usábamos el motor de templates Smarty (Hoy no dudaría un segundo en usar Twig), así que el camino por delante (Si bien sumamente tedioso) era claro: había que crear una pequeña extensión para Smarty que permitiera tomar un texto y devolverlo traducido.

    Y así lo hicimos (bueno, contratamos a alguien para que lo haga :p).

    Cómo internacionalizar el dinámico estático de un sitio

    Claro que la traducción del contenido estático era sólo el comienzo… el verdadero desafío era traducir el contenido dinámico (o lo más parecido que se pudiera).

    Decidimos utilizar un sistema de tags donde cada pieza de contenido (artículo, opinión, comentario, etc…) estuviera taggeado con el idioma en el que había sido escrito.

    Cada usuario tenía un idioma asociado (lo poníamos por default según el idioma de su país y el usuario después podía modificarlo) y, cada vez que generaba un contenido se asumía que lo hacía en su idioma (Después los administradores del sitio podían re-taggearlo para corregirlo si lo consideraban necesario).

    En definitiva era simple: una tabla idioma con un id y un nombre y en cada tabla de contenido una nueva columna para la clave foránea.

    Y funcionó bien… hasta que dejó de hacerlo :p

    ¡Ojo! ¡No fue un problema técnico! De hecho, casi diría que el sistema funcionó demasiado bien.

    ¿Qué pasó?

    La definición original desde el negocio era que cada visitante viera sólo contenido en su idioma, pero, cuando se iba a lanzar el sitio en portugués rápidamente nos dimos cuenta de que iba a arrancar vacío (Obvio, ¡no había ningún contenido taggeado en portugués!) y eso no iba a ser muy atractivo para los nuevos visitantes (ni para el SEO).

    Así que cambió la directiva por algo mucho más sensato: no filtrar el contenido según el idioma del usuario si no priorizarlo (Es decir: para un usuario de habla portuguesa, primero se debía ver contenido en Portugués pero, una vez finalizado, se debía comenzar a ver contenido en Español).

    Claro que eso estaba muy bien desde el punto de vista de los usuarios, pero… ¿quién piensa en los pobres programadores que tienen que implementar esa locura?

    Pues bien, la solución que encontramos fue incorporar una noción de distancia entre idiomas.

    La idea era que cada idioma (cultura en realidad porque también manejábamos variaciones regionales pero eso no cambia mucho así que no voy a complicar más de lo necesario por ahora).

    De modo que la base de datos se veía más o menos así:

    Esta estructura permite generar efectivamente una matriz donde cada cultura se relaciona con todas las demás:

    eses_ARpt_BR
    es012
    es_AR102
    pt_BR220

    Los números que están en el cruce de la matriz constituyen la distancia entre un idioma y el otro.

    Esto sirve como criterio de ordenamiento, de modo que, cuando queremos mostrar los comentarios haríamos algo así como:

    SELECT * FROM comments co INNER JOIN cultures_distances cd ON co.culture_id = cd.target_culture_id
    WHERE cd.source_culture_id = ??
    ORDER BY cd.distance

    El placeholder se reemplaza por el id de la cultura del visitante actual, de modo que los contenidos están ordenados en función de la distancia existente entre el idioma del visitante y aquel en el que están escritos… no está mal, ¿cierto? 🙂

    Este sistema a su vez, permite siempre meter más variaciones regionales entre medio (Basta con que la distancia sea un número real, o sea un float).

    Otro punto que vale la pena explicar es que la distancia no tiene por qué ser simétrica (No necesariamente debe valer lo mismo la distancia entre «es» y «es_AR» que viceversa).

    Siendo que no estamos hablando de temas de geometría si no de idiomas, podemos hacer lo que queramos (al fin de cuentas, ¡es nuestro sistema!).

    Y bueno, así fue como funcionó nuestro sitio multilingüe.

    Insisto en lo que decía al comienzo, si tenés la posibilidad, partí de un framework que soporte i18n… si no es el caso, espero este artículo te haya sido inspirador 🙂

  • Un motor de sugerencias en PHP

    Un motor de sugerencias en PHP

    Otro desafío interesante que me tocó encarar junto a mi equipo en el desarrollo de una red social de viajes fue el Sugeridor de Opinables.

    Una de las características que tenía el sitio en que estaba trabajando era la posibilidad de que los usuarios dejaran opiniones (o reseñas mejor dicho) sobre lugares que habían visitado (en general hoteles, restaurantes, atractivos, etc…).

    En un determinado momento, para el negocio se volvió sumamente importante incrementar la cantidad de reseñas (obviamente debían ser auténticas, no inventadas por robots ni plagiadas de otros sitios).

    Una idea que tuvimos junto con el equipo de producto fue la de intentar «adivinar» sobre qué otros lugares podría un usuario dejar su reseña, sabiendo qué lugares había visitado.

    Lo que debíamos lograr era algo similar a esto:

    Pero había una serie de restricciones que debíamos cumplir:

    • Siempre debía haber una próxima sugerencia para el usuario que acababa de opinar
    • Sólo debían sugerirse opinables de ciertos rubros (Por ejemplo, sólo hoteles y restaurantes y esta regla debía poder alterarse para acompañar las necesidades del negocio)
    • La cantidad de opinables por destino y rubro (en el conjunto inicial) debía poder ser modificable en forma sencilla (es decir configurable)
    • En el caso de que no se tuviera nada de historia (el sitio permitía las opiniones de usuarios no registrados), debía existir un conjunto de opinables general
    • No debían sugerirse empresas sobre las que el usuario ya hubiese opinado (Se buscaba ampliar la cantidad de empresas con al menos 1 opinión)

    Todo esto dio lugar a una arquitectura bastante compleja pero sumamente eficaz, basada en estas clases:

    SugeridorOpinables
    • CalculadorDestinosSugeridos
      CalculadorOpinablesParticulares
      CalculadorOpinablesGenerales

       

    • SaneadorOpinables
    • Con algunas subclases también para implementar diferentes estrategias de selección de destinos y empresas (Por ejemplo, si el usuario era anónimo o ya tenía algo de historia en el sitio).
    • Una vez que se habían instanciado las clases «auxiliares» según el caso particular, todas se inyectaban como dependencias de la clase principal:
    • $oSugeridor->setCalculadorDestinos( $oCalculadorDestinos );
      $oSugeridor->setCalculadorOpinablesParticulares( $oCalculadorOpinables );
      $oSugeridor->setRubrosParticipantes( $aRubrosSugeribles );
      $oSugeridor->setCalculadorOpinablesGenerales( $oCalculadorOpinablesGenerales );
      $oSugeridor->setSaneador( new SaneadorOpinablesDuplicados() );

      Y la parte más interesante era cómo se encadenaban las funciones de cada una de las clases en algo como esto:

    • /**
       * Obtiene el conjunto inicial del flow y genera los opinables para ser enviados posteriormente por @see self::getProximosNElementos
       * 
       * @return array<empresas> Conjunto inicial de sugerencias generado en base a las estrategias elegidas
       */
      public function getConjuntoInicial()
      {
         $aDestinosSugeridos = $this->getCalculadorDestinos()->getDestinos();
         $oCalculadorOpinablesParticulares = $this->getCalculadorOpinablesParticulares();
         $aRubrosParticipantes = $this->getRubrosParticipantes();
               
         /** En el caso de partir de una review la primera condición debería ser siempre true, la segunda depende de la configuración */
         if ( !empty($aDestinosSugeridos) && !empty($aRubrosParticipantes) && array_sum( $aDestinosSugeridos ) != array_sum( $aRubrosParticipantes ) ) {
            throw new ExcepcionDatoInvalido( 'Las expectativas de proporción de rubros no son compatibles con las de proporción de destinos' );
         }
         
         $oCalculadorOpinablesParticulares->setDestinosSugeridos( $aDestinosSugeridos );
         $oCalculadorOpinablesParticulares->setRubrosParticipantes( $aRubrosParticipantes );
         
         $preOpinablesParticulares = $oCalculadorOpinablesParticulares->getOpinables();
         $opinablesParticularesCalificacionAlta = array();
         $opinablesParticularesCalificacionBaja = array();
         
         foreach($preOpinablesParticulares as $opinablesParticulares)
         {
            $oCalificacionEditorial = $opinablesParticulares->getCalificacionEditorial();
            if( $oCalificacionEditorial && $oCalificacionEditorial['valor'] >= empresas::CALIFICACION_MEDIA ){
               $opinablesParticularesCalificacionAlta[$opinablesParticulares->getKeyValue()] = $opinablesParticulares;
            } else {
               $opinablesParticularesCalificacionBaja[$opinablesParticulares->getKeyValue()] = $opinablesParticulares;
            }
         }
         
         $aOpinablesParticulares = $opinablesParticularesCalificacionAlta + $this->shuffleArray($opinablesParticularesCalificacionBaja);
         
         // Se sanean los opinables según la estrategia enviada desde el llamador
         $oSaneador = $this->getSaneador();
         $oSaneador->setOpinablesGenerales($this->getCalculadorOpinablesGenerales()->getOpinables());
         $oSaneador->setOpinablesParticulares($aOpinablesParticulares);
         $oSaneador->sanear();
         
         $aOpinablesGenerales = $this->shuffleArray( $oSaneador->getOpinablesGenerales() );
         
         $aConjuntoInicial = $this->buildSetInicial(
               $this->getLongitudInicial(), 
               $aDestinosSugeridos, 
               $aRubrosParticipantes, 
               $aOpinablesParticulares, 
               $aOpinablesGenerales
         );
         
         // Se genera un conjunto con la union de los opinables particulares y generales
         $aConjuntoTotal = $aOpinablesParticulares + $aOpinablesGenerales;
      
         // Se eliminan del conjunto total los opinables que entraron en el conjunto inicial
         foreach ( $aConjuntoInicial as $oOpinableInicial ) {
            unset( $aConjuntoTotal[ $oOpinableInicial->getKeyValue() ] );
         }
      
         // Se guarda en el storage el conjunto total para ser accedido posteriormente
         // Solo se guardan los IDs de los opinables
         $this->getStorage()->store( array_map(function($a) { return $a->getKeyValue(); }, $aConjuntoTotal) );
      
         return $aConjuntoInicial;
      }

      (Es difícil entender todo el código sin tener una idea del framework que estábamos usando… desafortunadamente hecho en casa, pero lo importante son las líneas resaltadas).

    • Lo que se ve acá es cómo al tener todo tan desacoplado es muy simple alterar la lógica de una parte del motor sin afectar al resto (Por ejemplo, si cambiara la forma de seleccionar los destinos, la de los rubros o los opinables).

    Este es otro ejemplo de aplicación del patrón strategy, en este caso, para una red social de viajes.

  • Un ejemplo de uso del patrón strategy en PHP

    Un ejemplo de uso del patrón strategy en PHP

    Hace poco, trabajando en una mejora para un sistema que desarrollé para un cliente me pasó lo siguiente:

    Una parte del trabajo de la aplicación era obtener información financiera de diferentes fuentes, básicamente se trataba de obtener precios históricos de bonos.

    Existían diferentes fuentes de consulta debido a que la información no siempre estaba disponible en todos los sitios (más allá de no disponer de APIs, pero esa es otra historia).

    El punto es que, en la primera versión de la aplicación, que obviamente estaba desarrollada sobre el framework Symfony, simplemente creamos un método dentro del Controlador:

    private function fetchBondPrice($symbol, \DateTime $date)
    {
        try {
            if ($price = $this->fetchBondPriceFromQuoteNet($symbol, $date)) {
    
                return $price;
            }
    
            if ($price = $this->fetchBondPriceFromMorningStar($symbol, $date)) {
    
                return $price;
    
            }
        } catch (Exception $e) {
    
        }
    
        return null;
    }

    Y las cosas funcionaron… pero desde el comienzo quedó pendiente este comentario en el código:

    * @todo Refactor into a Strategy Pattern

    El momento oportuno llegó cuando debimos alterar algunos aspectos de las búsquedas específicas y, para asegurarnos de que todo saliera bien, decidimos usar PHPUnit y ahí se hizo casi obvia la necesidad de refactorizar el código.

    La primera vuelta del refactor nos trajo consigo una clase sencilla, un Service de Symfony llamado BondPriceFetcherManager.

    Que, a priori no era mucho más que una forma de deslindar responsabilidad desde el controlador hacia una clase más específica… pero seguíamos teniendo un método que había que modificar si queríamos incorporar una nueva fuente de datos (Algo que muy probablemente se venía).

    Por otro lado, fue evidente que algo andaba mal cuando tuvimos que cambiar los métodos fetchBondPriceFromQuoteNet y fetchBondPriceFromMorningStar de privados a públicos para poder hacer los tests…Había llegado el momento de implementar el patrón Strategy.

    Muy brevemente, lo que hicimos fue crear una clase para cada fuente de datos: QuoteNetBondPriceFetcher y MorningStarBondPriceFetcher.

    Ambas derivadas del nuevo BondPriceFetcher que simplemente se ve así:

    <?php
    abstract class BondPriceFetcher
    {
        /**
         * @param $symbol
         * @param \DateTime $date
         * @return mixed
         */
        abstract public function fetchPrice($symbol, \DateTime $date);
    }

    Ahora sí las cosas se ponen interesantes 🙂

    Después queda una nueva clase genérica BondPriceFetcherManager que tiene un método:

    public function fetchBondPrice($symbol, \DateTime $date)
    {
        $price = null;
    
        foreach ($this->getFetchers() as $fetcher ) {
            try {
                if ( $price = $fetcher->fetchPrice( $symbol, $date) ) {
    
                    break;
                }
            } catch ( Exception $e ) {
                $this->logger->addDebug( 'Error buscando precio del bono: '.$symbol.'-'.$date->format('Y-m-d').' usando '.get_class($fetcher).': '.$e->getMessage() );
            }
        }
    
        return $price;
    }

    Obviamente, también tiene su método

    public function addFetcher( BondPriceFetcher $fetcher )
    {
        $this->fetchers[] = $fetcher;
    
        return $this;
    }

    Y

    /**
     * @param Logger $logger
     * @return BondPriceFetcherManager
     */
    public function setLogger(Logger $logger)
    {
        $this->logger = $logger;
        
        return $this;
    }

    Y listo! Ya nos quedó una muy linda implementación que permite:

    1. Extender la cantidad de fuentes de datos (Basta con crear un nuevo BondPriceFetcher y agregar una nueva llamada a addFetcher)
    2. Alterar la prioridad que se le da a cada fuente (Basta con cambiar el orden de las llamadas a addFetcher) y sin alterar en absoluto el código propio del FetcherManager
    3. Testear automáticamente toda esta funcionalidad
    4. Reutilizar esta funcionalidad (Si se hiciera en un Bundle por ejemplo)

    Debo reconocer que, si bien no soy un gran fanático de los patrones de diseño, Strategy es definitivamente mi favorito 🙂

    El mayor aporte del patrón Strategy es el desacoplamiento (además de generar un código más elegante a fuerza de un poco más de texto)

    ¿Cómo fue tu experiencia con la aplicación de patrones de diseño?

  • Un sistema de caché distribuido en PHP

    Un sistema de caché distribuido en PHP

    Hace un tiempo, cuando trabajaba para una gran .com del rubro turismo, surgió un escenario muy interesante que teníamos que resolver junto con mi equipo:

    Teníamos un servidor de bases de datos que daba soporte a siete servidores que hacían de FrontEnd. Nuestra aplicación era php puro (con un «framework» desarrollado in-house… muy mala idea) y todos los frontends corrían el mismo código (Todo detrás de un balanceador de carga, obvio):

    El problema que teníamos era que el sitio tenía bastante tráfico y la base de datos se nos convertía en un cuello de botella bastante a menudo.

    La solución que implementamos consistía en tener ciertas partes de las respuestas pre-calculadas.

    Ahora, como te imaginarás, mantener una experiencia de navegación consistente teniendo varios servidores diferentes tiene sus complicaciones, en nuestro caso, el desafío más importante era cómo mantener copias sincronizadas de la información pre-calculada (Para evitar, por ejemplo, que un simple F5 mostrara algo diferente de una página supuestamente estática).

    Por otro lado, los servidores de FrontEnd que usábamos eran simples instancias de máquinas virtuales que (no estoy muy seguro de por qué), tenían una tendencia a romperse intempestivamente (Bueno… un poco por eso teníamos tanta redundancia :)), con lo cual, no era viable tener un único responsable de la generación del caché…

    Lo que necesitábamos básicamente era una arquitectura que permitiera:

    1. Que cualquier FrontEnd fuera capaz de generar la versión estática de la información
    2. Que no hubiese dos FrontEnds generando la versión estática a la vez
    3. Que todos los FrontEnds sirvieran el mismo contenido (Si estaba disponible)

    Sin entrar en detalles sobre cómo logramos el objetivo 2 (Lo dejo para otro post en todo caso, pero involucra un sistema de semáforos), lo que hicimos fue crear una función (Método de una clase en realidad) que recibiera dos funciones:

    • Una para verificar si la copia local de la información estaba vigente aún
    • Otra para generar la información en caso de ser necesario

    Lo interesante de este mecanismo es que, gracias al uso de los callbacks fue bastante sencillo separar (¡y reutilizar!) el mecanismo de exclusión mutua y todo lo que hacía al andamiaje de lo que tenía que ver con la generación de la información propiamente dicha.

    Una versión simplificada del código a modo de ilustración es esta:

    public function get( $sKey, $iTtl, Closure $fGeneration, array $aGenerationParams = [], Closure $fValidation = null ) {   
       $oRemoteStorage = $this->getRemoteStorageFactory()->build($sKey);
       if ( ( $oCache = $this->getFromStorage( $oRemoteStorage ) ) && !$oCache->isExpired() && $fValidation( $mContents = $oCache->getContents() ) ) {
           $this->putInStorage( $oCache, $oLocalStorage );
           return $mContents;
       }
       $mContents = call_user_func_array( $fGeneration, $aGenerationParams );   
       $oCache = new CacheObject( $mContents, time() + $iTtl );
       $this->putInStorage( $oCache, $oLocalStorage );
       $this->putInStorage( $oCache, $oRemoteStorage );
       return $mContents;
    }
    

    Lo interesante de esta función son los parámetros $fGeneration y  $fValidation, ambos Closures, esta es la clave para que el mecanismo de caché se mantenga agnóstico respecto de qué es exactamente lo que se está cacheando… cómo se genera ese caché y cómo se verifica su vigencia son problemas del usuario del mecanismo de caché.

    De esta forma queda un sistema altamente reutilizable :).

    Si te interesa ver el código completo (Está un poco viejo, pero la idea sirve), acá está el repo en GitHub.

     

  • Una máquina virtual lista para PHP+Symfony2+XDebug

    Charlando con algunos amigos desarrolladores php surgió un tema que les estaba resultando complicado, así que decidí poner mi pequeño granito de arena (para ellos y para otros que tal vez estén pasando por lo mismo).

    Ya habíamos hablado del por qué usar una máquina virtual para proyectos PHP. Ya estaba claro que usar un framework es más conveniente que no usarlo (independientemente de cuál fuera) y les había comentado sobre mis herramientas favoritas de automatización (Ansible y Vagrant).

    Todos estábamos de acuerdo «en la teoría», pero a la hora de pasar a la práctica se veían algo frustrados por no poder lograr tener una máquina virtual que fuese fácil de usar y que soportara, entre otras cosas, el uso de XDebug.

    Lo que te voy a mostrar a continuación son los archivos de configuración que yo usé en el último proyecto que hice en php:

    El archivo playbook.yml:

    ---
    - hosts: all
     sudo: true
     tasks:
    
    - name: create /var/www
     file: path=/var/www state=directory
    
    - name: create site symlink
     file: src=/vagrant dest=/var/www/site state=link
     notify: restart apache
    
    - name: install misc packages
     apt: name={{ item }} state=latest update_cache=true
     with_items:
     - ruby2.0
     - ruby2.0-dev
     - git
     - curl
     - unzip
     - vim
    
    - name: Symlink exists for Ruby 2.0
     file: src=/usr/bin/ruby2.0 dest=/usr/local/bin/ruby state=link
    
    - name: Symlink exists for Ruby Gems 2.0
     file: src=/usr/bin/gem2.0 dest=/usr/local/bin/gem state=link
    
    - name: install language packs for locale support
     apt: name={{ item }} state=latest
     with_items:
     - language-pack-de-base
     - language-pack-es-base
    
    # Apache2
    
    - name: ensure apache is installed
     apt: name=apache2 state=present
    
    - name: make sure apache is running
     action: service name=apache2 state=started enabled=true
    
    - file: src=/etc/apache2/mods-available/rewrite.load dest=/etc/apache2/mods-enabled/rewrite.load state=link
     notify: restart apache
    
    - file: src=/etc/apache2/mods-available/headers.load dest=/etc/apache2/mods-enabled/headers.load state=link
     notify: restart apache
    
    - copy: src=/vagrant/ansible/templates/site.conf dest=/etc/apache2/sites-available/site.conf remote_src=true
     notify: restart apache
    
    - file: src=/etc/apache2/sites-available/site.conf dest=/etc/apache2/sites-enabled/site.conf state=link
     notify: restart apache
    
    - file: path=/etc/apache2/sites-enabled/000-default.conf state=absent
     notify: restart apache
    
    - file: path=/etc/apache2/conf.d state=directory
    
    - copy: src=/vagrant/ansible/templates/fqdn.conf dest=/etc/apache2/conf.d/fqdn.conf remote_src=true
     notify: restart apache
    
    - copy: src=/vagrant/ansible/templates/nosendfile.conf dest=/etc/apache2/conf.d/nosendfile.conf remote_src=true
     notify: restart apache
    
    # MySQL
    
    - name: install MySQL
     apt: name={{ item }} state=latest
     with_items:
     - mysql-server
     - mysql-client
     - python-mysqldb
    
    - name: add mysql user
     mysql_user: name=vagrant
     host={{ item }}
     password=vagrant priv=*.*:ALL,GRANT
     login_user=root
     login_password=
     with_items:
     - '%'
     - localhost
    
    - name: create mysql databases
     mysql_db: name={{ item }}
     state=present
     with_items:
     - site_development
     - site_development_stats
     - site_testing
     - site_testing_stats
    
    - file: path=/etc/mysql/conf.d state=directory
     - name: Set MySQL number of connections
     copy: src=/vagrant/ansible/templates/max_connections.cnf dest=/etc/mysql/conf.d/max_connections.cnf remote_src=true
     notify: restart mysql
    
    - name: Install mysql command line client configuration file
     copy: src=/vagrant/ansible/templates/my.cnf dest=/home/vagrant/.my.cnf owner=vagrant group=vagrant remote_src=true
    
    # PHP
    
    - name: add php5.6 ppa
     apt_repository: repo='ppa:ondrej/php'
    
    - name: install PHP5.6 packages
     apt: name={{ item }} state=latest
     with_items:
     - php5.6
     - libapache2-mod-php5.6
     - php5.6-cli
     - php5.6-dev
     - php5.6-mysql
     - php-pear
     - php5.6-mcrypt
     - php5.6-gd
     - php5.6-curl
     - php5.6-xdebug
     - php5.6-memcache
     - php5.6-memcached
     - php5.6-readline
     - php5.6-xml
     - php5.6-mbstring
     - php5.6-zip
    
    - file: path=/etc/php5.6/conf.d state=directory
     - file: path=/etc/php5.6/cli/conf.d state=directory
     - file: path=/etc/php5.6/apache2/conf.d state=directory
    
    - copy: src=/vagrant/ansible/templates/php-site.ini dest=/etc/php5.6/conf.d/php-site.ini remote_src=true
     notify: restart apache
    
    - name: configure xdebug
     copy: src=templates/xdebug.ini dest=/etc/php/5.6/mods-available/xdebug.ini
     notify: restart apache
    
    - name: symlink common php configuration for cli handler
     file: src=/etc/php5.6/conf.d/php-site.ini dest=/etc/php5.6/cli/conf.d/php-site.ini state=link
     notify: restart apache
    
    - name: symlink common php configuration for apache2 handler
     file: src=/etc/php5.6/conf.d/php-site.ini dest=/etc/php5.6/apache2/conf.d/php-site.ini state=link
     notify: restart apache
    
    # phpmyadmin
    
    - name: install phpmyadmin
     apt: name=phpmyadmin state=latest
    
    # Assets compilation
    
    - name: add nodejs ppa
     apt_repository: repo='ppa:chris-lea/node.js'
    
    - name: install nodejs
     apt: name=nodejs state=latest
    
    # Set up site
    
    - file: src=/vagrant dest=/var/www/site state=link
     - file: path={{ item }} owner=vagrant group=vagrant mode=0777 state=directory
     with_items:
     - /var/cache/site
     - /var/cache/site/cache
     - /var/cache/site/clockwork
     - /var/cache/site/logs
     - /var/cache/site/meta
     - /var/cache/site/sessions
     - /var/cache/site/views
    
    - name: ensure once more that 000-default.conf is deleted
     file: path=/etc/apache2/sites-enabled/000-default.conf state=absent
     notify: restart apache
    
    - name: ensure that phpmyadmin's stock config is deleted
     file: path=/etc/apache2/conf.d/phpmyadmin.conf state=absent
    
    - name: set proper permissions for app/reports directory
     file: path=/vagrant/app/reports group=www-data owner=vagrant mode=0775 state=directory
    
    # Common stuff
    
    - name: Install Composer
     shell: curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer creates=/usr/local/bin/composer
    
    handlers:
     - name: restart apache
     action: service name=apache2 state=restarted
     - name: restart mysql
     action: service name=mysql state=restarted
     - name: restart beanstalkd
     action: service name=beanstalkd state=restarted

    Los archivos templates (deben estar en el directorio ansible/templates dentro de tu proyecto):

    site.conf:

    <VirtualHost *:80>
     ServerName myApp
     DocumentRoot /var/www/site/web
    
    <Directory />
     Options FollowSymLinks
     AllowOverride None
     </Directory>
    
    <Directory /var/www/site/web>
     DirectoryIndex app_dev.php
     Options Indexes FollowSymLinks MultiViews
     AllowOverride All
     Order allow,deny
     allow from all
     </Directory>
    
    ErrorLog /var/log/apache2/error.log
     LogLevel warn
     CustomLog /var/log/apache2/access.log combined
    
    ## enable phpmyadmin
    
    Alias /phpmyadmin /usr/share/phpmyadmin
    
    <Directory /usr/share/phpmyadmin>
     Options FollowSymLinks
     DirectoryIndex index.php
    
    <IfModule mod_php5.c>
     AddType application/x-httpd-php .php
    
    php_flag magic_quotes_gpc Off
     php_flag track_vars On
     php_flag register_globals Off
     php_admin_flag allow_url_fopen Off
     php_value include_path .
     php_admin_value upload_tmp_dir /var/lib/phpmyadmin/tmp
     php_admin_value open_basedir /usr/share/phpmyadmin/:/etc/phpmyadmin/:/var/lib/phpmyadmin/
     </IfModule>
    
    </Directory>
    </VirtualHost>

    fqdn.conf

    ServerName Localhost

    nosendfile.conf

    EnableSendfile off

    max_connections.cnf

    [mysqld]
    max_connections = 400

    my.cnf

    [client]
    user=vagrant
    password=vagrant

    php-site.ini

    max_execution_time = 30000
    memory_limit = 512M
    display_errors = On
    disable_functions =

    xdebug.ini

    zend_extension=xdebug.so
    xdebug.remote_enable=On
    xdebug.remote_connect_back=On

    Para iniciar el proyecto, basta con copiar y pegar esto en un archivo llamado Vagrantfile (en la raíz de tu proyecto):

    # -*- mode: ruby -*-
     # vi: set ft=ruby :
    
    # All Vagrant configuration is done below. The "2" in Vagrant.configure
     # configures the configuration version (we support older styles for
     # backwards compatibility). Please don't change it unless you know what
     # you're doing.
     Vagrant.configure("2") do |config|
     # The most common configuration options are documented and commented below.
     # For a complete reference, please see the online documentation at
     # https://docs.vagrantup.com.
    
    # Every Vagrant development environment requires a box. You can search for
     # boxes at https://atlas.hashicorp.com/search.
     config.vm.box = "ubuntu/trusty64"
    
    # Disable automatic box update checking. If you disable this, then
     # boxes will only be checked for updates when the user runs
     # `vagrant box outdated`. This is not recommended.
     # config.vm.box_check_update = false
    
    # Create a forwarded port mapping which allows access to a specific port
     # within the machine from a port on the host machine. In the example below,
     # accessing "localhost:8080" will access port 80 on the guest machine.
     config.vm.network "forwarded_port", guest: 80, host: 8080
    
    # Create a private network, which allows host-only access to the machine
     # using a specific IP.
     config.vm.network "private_network", ip: "192.168.33.10"
    
    # Create a public network, which generally matched to bridged network.
     # Bridged networks make the machine appear as another physical device on
     # your network.
     # config.vm.network "public_network"
    
    # Share an additional folder to the guest VM. The first argument is
     # the path on the host to the actual folder. The second argument is
     # the path on the guest to mount the folder. And the optional third
     # argument is a set of non-required options.
     # config.vm.synced_folder "../data", "/vagrant_data"
    
    # Provider-specific configuration so you can fine-tune various
    
    # backing providers for Vagrant. These expose provider-specific options.
     # Example for VirtualBox:
     #
     config.vm.provider "virtualbox" do |vb|
     # # Display the VirtualBox GUI when booting the machine
     # vb.gui = true
     #
     # # Customize the amount of memory on the VM:
     vb.memory = "2048"
     end
     #
     # View the documentation for the provider you are using for more
     # information on available options.
    
    # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies
     # such as FTP and Heroku are also available. See the documentation at
     # https://docs.vagrantup.com/v2/push/atlas.html for more information.
     # config.push.define "atlas" do |push|
     # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME"
     # end
    
    # Enable provisioning with a shell script. Additional provisioners such as
     # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
     # documentation for more information about their specific syntax and use.
     # config.vm.provision "shell", inline: <<-SHELL
     # apt-get update
     # apt-get install -y apache2
     # SHELL
    
    #
     # Run Ansible from the Vagrant Host
     #
     config.vm.provision "ansible" do |ansible|
       ansible.playbook = "ansible/playbook.yml"
       ansible.verbose = "vv"
     end
    
    # Symfony needs to be able to write to it's cache, logs and sessions directory in var/
     config.vm.synced_folder "./var", "/vagrant/var",
       :owner => 'vagrant',
       :group => 'www-data',
       :mount_options => ["dmode=777","fmode=777"]
     end

    y ejecutar el comando (Obviamente, habiendo instalado Vagrant previamente…):

     vagrant up

    Y comienza la magia 🙂

    Un ratito después (Dependiendo de la conexión que tengas) vas a tener una máquina lista para desarrollar tu aplicación basada en Symfony2.

    Y ahora… ¡a codear!

    ¿Me olvidé de algo? ¡Avisame en los comentarios!

  • Cómo obtener la cotización del día de una acción con PHP

    Todo lo que voy a mostrarte acá se basa en la API de Yahoo Finance.

    Lo primero que tenés que hacer es instalar composer.

    Segundo, inicializar el proyecto:

    php composer.phar init

    Tercero: agregar la dependencia del paquete https://github.com/scheb/yahoo-finance-api:

    php composer.phar require scheb/yahoo-finance-api

    Y después podés usar un código como este:

    #!/usr/bin/php
    <?php
    
    require __DIR__ . '/vendor/autoload.php';
    
    $client = new \Scheb\YahooFinanceApi\ApiClient();
    
    $d = new DateTime($argv[2]);
    
    echo "Buscando ".$argv[1]." en fecha: ".$d->format('d/m/y').PHP_EOL;
    try {
      $data = $client->getHistoricalData($argv[1], $d, $d);
    
      echo $data['query']['results']['quote']['Close'].PHP_EOL;
    } catch ( Exception $e ) {
      echo $e->getMessage().PHP_EOL;
    }
    

    En este ejemplo, lo que tenés es una utilidad de línea de comandos que recibe dos parámetros:

    1. El ticker (Símbolo del papel en cuestión, por ejemplo TS para la acción de Tenaris)
    2. La fecha.

    La llamada sería así:

    php get_stock_price.php TS Yesterday

    y el resultado será algo como:

    Buscando TS en fecha: 15/12/16
    34.220001