Leo

Sobre tecnologia...

Laravel, Ordenação de catálogo por score

6 years ago · 9 MIN READ
#PHP  #Laravel  #Tricks 

Esse artigo apresenta uma técnica, independente de linguagem ou framework, que tem como objetivo auxiliar a ordenação de elementos em uma listagem, alimentada por um banco de dados relacional. Apesar da agnosticidade do artigo, vou demonstrar alguns exemplos utilizando laravel, pois foi o que utilizamos para desenvolver o projeto em questão.

A necessidade principal surgiu quando começamos a desenvolver um portal de fazendas, e a medida que o portal era desenvolvido implementavamos regras de ordenação cada vez mais difíceis de serem avaliadas em conjunto, em uma única consulta SQL. Essas regras são importantes porque estão diretamente relacionadas a métricas de conversão, a regra clássica mais forte que podemos citar são as imagens, de acordo com o OLX um anúncio sem imagens recebe 7 vezes menos visualizações do que um anúncio com imagens, não queremos um anúncio sem imagem no topo das nossas listagens, entregar um lugar privilegiado para um anúncio sem imagem é um pecado pois além de desperdiçar a posição, força o usuário a navegar para as próximas páginas.

Regras

A página de busca completa com dados fictícios deve ser familiar com a página retratada abaixo:

boom-tela-cheia.png

Quando o anúncio está incompleto ele deixa de ser bonificado, de acordo com as seguintes regras:

  1. presença de detalhes(atributos da fazenda).

atributos.png

Esses ícones ao lado da imagem representam atributos da fazenda, um atributo desse pode ser diferencial para o anúncio, por esse motivo bonificamos os anúncios que contém atributos.

  1. presença de video demonstrativo da fazenda.

video-fazenda.png

Esse vídeo aparece na página interna do anúncio tem como objetivo apresentar a fazenda.

  1. presença de valor.

consulte.png

Quando o anúnciante cadastra um anúncio sem valor, ele tem um anúncio "sob consulta", anúncios com valor são bonificados para incentivar a prática.

  1. e presença de imagens.

no-image.png

De longe esse é o pior descuido no cadastro do anúncio, as regras de bonificação foram apresentadas em ordem crescente de pontuação, isso significa que o anúncio sem imagem vai aparecer depois de todos os outros anúncios.

dentre os anúncios que empatarem nessas regras, devemos dar preferência para os mais baratos,seguidos dos mais atuais, e se um anúncio for marcado como "destaque" ele simplesmente vai para o topo, ignorando as regras de ordenação.

O que foi apresentado são regras de ordenação, temos outras regras de exibição do anúncio, se o anúncio for particular por exemplo, ele precisa ser pago, anunciante do tipo imobiliária não paga anúncio, mas têm um plano, que pode vencer, entre outras regras.

Pontuando o anúncio

A técnica utilizada para pontuar o anúncio consiste em mapear os bônus para cada regra acima em forma de flags, dentro da classe Property::class, sempre que o anúncio for salvo, calculamos a potuação total e salvamos em um campo score, que pertence ao próprio anúncio, mapeamos os bônus das regras dentro da Model da fazenda, em forma de constantes:

class Property extends Model implements GalleriableInterface, LikeableInterface
{
    use SoftDeletes, Sluggable, RevisionableTrait, GalleriableTrait, LikeableTrait;

    const WITH_DETAILS = 1;
    const WITH_VIDEO = 2;
    const WITH_PRICE = 4;
    const WITH_IMAGES = 8;

Acima é mostrado somente a declaração da classe com a definição das constantes, a classe é bem complexa, vamos manter o foco nas constantes, note que o valor das flags são múltiplos de 2, isso nos trará uma grande vantagem mais pra frente princialmente nas views, na hora de verificar se o anúncio tem imagem, note também que quanto mais significativa é a regra, maior o valor da sua flag.

Já definimos o bônus de cada flag, agora precisamos calcular esse score apartir desses bônus, o laravel tem um esquema de eventos que nos auxilia nisso, o próprio ORM dispara eventos para podermos "sentir" a aplicação, os listeners para esses eventos são registrados em um EventServiceProvider como mostrado:

<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Boomrural\Frontend\Listeners\PWAVersionForget;
use Mixdinternet\Properties\Listeners\GenerateScore;
use Mixdinternet\Properties\Listeners\GenerateScoreFromImage;
use Mixdinternet\Properties\Property;
use Mixdinternet\Galleries\Image;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        'eloquent.saved: *' => [PWAVersionForget::class],
        'eloquent.saving: ' . Property::class => [GenerateScore::class],
        'eloquent.deleted: ' . Image::class => [GenerateScoreFromImage::class]
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        parent::boot();
    }
}

Os dois últimos itens da propriedade $listen são responsáveis por calcular o score quando a classe Property::class é salva ou quando a classe Image::class é deletada, essa última é importanto porque ao deletar a imagem pode ser que o imóvel fique sem imagem, logo sua pontuação estará errada.

Abaixo está o código do listener GenerateScore:

<?php

namespace Mixdinternet\Properties\Listeners;

class GenerateScore
{
    public function handle($model)
    {
        $this->generateScore($model);
    }

    protected function generateScore($model)
    {
        $score = 0;

        $imageCount = $model->images;

        $model->details()->count() && ($score += $model::WITH_DETAILS);
        $model->video && ($score += $model::WITH_VIDEO);
        $model->value && ($score += $model::WITH_PRICE);
        count($imageCount) && ($score += $model::WITH_IMAGES);

        $model->score = $score;
        return $model;
    }
}

Esse listener tem a função de evidenciar as regras de ordenação e caso ele constate que a regra foi seguida ele agrega o campo score com o bonus respectivo definido na flag, dentro de Property::class O listener GenerateScoreFromImage::class extende GenerateScore::class, e utiliza o método definido na "classe pai" para gerar o score do anúncio

<?php

namespace Mixdinternet\Properties\Listeners;

class GenerateScoreFromImage extends GenerateScore
{
    public function handle($model)
    {
        $property = $this->generateScore($model->gallery->galleriable);
        $property->save();
    }
}

Ambos os listeners recebem como parametro a Model que disparou o evento, isso torna possível fazermos suposições em cima da operação de inserção ou até mesmo modifica-la, por isso conseguimos gerar esse score com facilidade.

Ordenando

Para manter todas as listagens do portal sobre a mesma política de ordenação, criamos um "local scope" chamado front(), em todos os lugares em que precisamos mostrar anúncios utilizamos ele, isso é importante porque ele agrega também as regras que permitem que a fazenda seja exibida:

public function scopeFront($query)
{
    $query->orderByRequestParam();
    $query->scored();
    $query->orderBy('star', 'desc');
    $query->orderBy('value', 'asc');
    $query->tagged();
    $query->where(function ($subQuery) {
        $subQuery->where('advertisementable_type', Company::class);
        $subQuery->orWhere('vigency_at', '>=', Carbon::now());
    });
}

O scope front agrega outros scopes, cada um com uma função bem definida:

O scope scopeOrderByRequestParam definido abaixo nos permite sobrescrever a relevância de alguns fatores por parametros passados por query string, isso é necessário porque se o usuário decidir ver as fazendas mais baratas, temos que mostrar até as que não têm imagem, caso contrário seria uma falta de respeito com o usuário.

public function scopeOrderByRequestParam($query)
{
    if (request()->ordenar == 'menorValor') {
        $query->orderBy('value', 'asc');
    }

    if (request()->ordenar == 'maiorValor') {
        $query->orderBy('value', 'desc');
    }

    if (request()->ordenar == 'recentes') {
        $query->orderBy('updated_at', 'desc');
    }
}

Interessante notar a chamada "request()" dentro do sope, não é uma boa prática consumir interfaces de camadas "superiores" ou mesmo consumir serviços intimamente ligados à camada HTTP, em uma camada de persistência, isso cria um acoplamento entre os componentes, no nosso caso esse acoplamento não faz tanta diferença e a implementação desse scope facilita de mais a ordenação.

Abaixo está retratado o select de ordenação, cada opção desse select corresponde à um if dentro do scope scopeOrderByRequestParam.

select-order.png

O scope scored é o mais simples, ele simplesmente ordena os anúncios pelo campo que geramos, sem o campo score esse scope seria o mais complexo, precisariamos fazer vários joins para avaliar a presença de imagens e detalhes, esses joins são necessários porque as imagens e os detalhes ficam em tabelas separadas, de fato o campo score torna nossas consultas mais performáticas.

public function scopeScored($query)
{
    $query->orderBy('score', 'DESC');
}

Importante notar a chamada para orderBy('star', 'desc'), esse é o campo de destaque, ele poderia ser representado como uma flag, dentro de Property::class, mas eu somente percebi isso no momento em que estava escrevendo esse artigo.

Note também a chamada orderBy('value', 'asc'); isso ordena pelos anúncios mais baratos após aplicar as regras de ordenação.

O scope tagged() nos impede de exibir um anúncio que esteja sem tipo ou finalidade, as mecânicas de busca do site funcionam sobre essas tags, um anúncio sem essas tags ficaria perdido no portal e isso poderia ocasionar sérios erros.

//esconde propriedade quado alguma tag relevante é deletada
public function scopeTagged($query)
{
    $query->whereHas('finality', function () {
    });
    $query->whereHas('type', function () {
    });
}

O whereHas é uma forma que o Eloquent nos traz para filtrarmos registros pelos relacionamentes da respectiva Model, é uma forma mais flexivel de filtrar registros, o whereHas gera uma subquery, um número muito grande de finalidades ou tipos nos traria um problema mas esperamos, pela regra de negócio conhecida, apenas algumas dezenas de registros.

Por fim o último where verifica se o anunciante é do tipo Company::class(imobiliária), caso não seja verifica se a propriedade "vigency_at", que é a data do vencimento do anúncio, é maior que a data de hoje, essa data é avaliada por um script que processa os pagamentos, o anúncio com "vigency_at" vencida tem o pagamento expirado.

$query->where(function ($subQuery) {
    $subQuery->where('advertisementable_type', Company::class);
    $subQuery->orWhere('vigency_at', '>=', Carbon::now());
});

Supondo fatos através do score.

Múltiplos de 2 da base decimal são múltiplos de 10 na base binária, como utilizamos somente múltiplos de 2 nas nossas flags, que resultaram no score, podemos converter o score em um número binário e utilizar o operador bitwise '&' para verificar se uma flag está "ativa", isso é possível porque o operador & compara bit por bit do nosso score com bit por bit da flag que desejarmos, fazendo a operaçao AND, representada na tabela abaixo.

anúncio com imagem e video flag de imagem resultado
0 (flag de detalhes) 0 0
1 (flag de video) 0 0
0 (flag de valor) 0 0
1 (flag de imagem) 1 1

Se a primeira coluna for a representação binária do nosso campo score e a segunda coluna a representação binária da nossa flag, para cada linha, o resultado seria 1, apenas se na mesma linha os bits do score e da flag tivessem ambos valor 1, como nossa flag é um multiplo de 10 na representação binária e em uma operação AND nessas circunstâncias o único bit que conta é o mais significativo(MSB) podemos concluir que se o resultado de AND com score e a flag escolhida for a propria flag, ela está ativa, ou seja, o bonus referente a essa flag foi somado ao score.

Isso nos permite escrever verificações mais simples:

public function getHasImageAttribute()
{
    if (($this->score & self::WITH_IMAGES) == self::WITH_IMAGES) {
        return true;
    }

    return false;
}

O accessor declarado acima fica dentro de Property::class, fazer essa suposição em cima do campo score é uma boa ideia porque para fazer isso da forma convencional precisariamos fazer uma consulta na tabela de imagens, se essa verificação for utilizada apenas uma vez em um item de uma listagem que contém 10 itens, através de um foreach, você acaba de fazer 10 operações desnecessárias no banco, e isso espalhado pelo portal todo pode influenciar em performance.

Novas regras:

Caso seja necessário implementar novas regras adicionando flags, isso pode ser um problema, pois o score de todas as propriedades serão recalculados, para fazer essa atualização utilizando o Eloquent, o "updated_at", que é a data de última atualização do registro, seria regerado então temos que tomar o cuidado de forçar esse campo com o valor antigo, isso é necessário porque existe uma ordenação por mais atuais no portal, e não queremos causar esse bug irreversível.

cenário ideal:

Em um cenário ideal pode ser uma boa ideia utilizar uma camada mais volátil para guardar dados mais voláteis(score), como elasticsearch por exemplo, para consumir os dados, isso aliviaria muito nossa lógica, nossos listeners ficariam responsáveis por manter o elasticsearch sincronizado, as regras de exibição não teriam que ser avaliadas na hora de exibir o anúncio, se o anúncio não pode ser exibido ele nem é enviado para o elasticsearch.

cenário real:

Utilizar esses campos com valores voláteis em uma tabela do mysql por exemplo, não é uma boa prática porque gera muitas inserções que deveriam ser supridas pela camada volátil adicional, sem contar que as operações de I/O são muito dramáticas, em um cenário ideal o I/O diminui muito, damos manutenção em um outro portal de carros somenente por trocar o driver da sessão de "database" para "redis" o I/O diminuiu mais de 20%.

O elasticsearch por exemplo, consome muita memória o que inviabiliza um projeto inicial onde os clientes podem não estar dispostos a pagar caro por uma hospedagem, trabalhando apenas com mysql você consegue fazer o deloy da aplicação até mesmo em hospedagens compartilhadas

Conclusão:

A técnica do score nos permite reduzir a complexidade da consulta e resumir consultas futuras em cima de suposições sobre o campo score, existem formas mais simples que envolvem arquitetura de hardware mais complexa, fica a seu critério decidir o que é melhor para o projeto.

Finalizando

Perdoem qualquer erro de portugues, espero que a capacidade de interpretação de vocês supra a minha incapacidade de comunicação. Comentem!

···

Leonardo Lemos


comments powered by Disqus


Proudly powered by Canvas · Sign In