Etiqueta: Arquitectura De Software

  • 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?