Melhor abordagem para desempenho ao filtrar permissões no Laravel

9

Estou trabalhando em um aplicativo em que um usuário pode ter acesso a muitos formulários através de vários cenários diferentes. Estou tentando criar a abordagem com o melhor desempenho ao retornar um índice de formulários para o usuário.

Um usuário pode ter acesso aos formulários através dos seguintes cenários:

  • Possui Formulário
  • Equipe possui Formulário
  • Tem permissões para um grupo que possui um formulário
  • Tem permissões para uma equipe que possui um formulário
  • Tem permissão para um formulário

Como você pode ver, existem 5 maneiras possíveis de o usuário acessar um formulário. Meu problema é como retornar com mais eficiência uma matriz de formulários acessíveis ao usuário.

Política do formulário:

Tentei obter todos os formulários do modelo e filtrar os formulários pela política de formulários. Isso parece ser um problema de desempenho, pois em cada iteração de filtro o formulário é passado por um método eloqüente contains () 5 vezes, conforme mostrado abaixo. Quanto mais formulários no banco de dados, isso fica mais lento.

FormController@index

public function index(Request $request)
{
   $forms = Form::all()
      ->filter(function($form) use ($request) {
         return $request->user()->can('view',$form);
   });
}
FormPolicy@view

public function view(User $user, Form $form)
{
   return $user->forms->contains($form) ||
      $user->team->forms->contains($form) ||
      $user->permissible->groups->forms($contains);
}

Embora o método acima funcione, é um gargalo de desempenho.

Pelo que posso ver, minhas seguintes opções são:

  • Filtro FormPolicy (abordagem atual)
  • Consultar todas as permissões (5) e mesclar em coleção única
  • Consulte todos os identificadores para obter todas as permissões (5) e, em seguida, consulte o modelo de formulário usando os identificadores em uma instrução IN ()

Minha pergunta:

Qual método forneceria o melhor desempenho e há outra opção que proporcionaria um melhor desempenho?

Tim
fonte
você também pode fazer um muitos para muitos abordagem para a ligação se o usuário pode acessar o formulário
código para dinheiro
Que tal criar uma tabela especificamente para consultar permissões de formulário de usuário? A user_form_permissiontabela contendo apenas oe user_ido form_id. Isso facilitará bastante as permissões de leitura, mas a atualização das permissões será mais difícil.
PtrTon 18/10/19
O problema com a tabela user_form_permissions é que queremos expandir as permissões para outras entidades, que exigiriam uma tabela separada para cada entidade.
Tim
11
@ Tim, mas ainda são 5 consultas. Se isso estiver dentro da área de um membro protegido, talvez não seja um problema. Mas se isso estiver em um URL público que possa receber muitos pedidos por segundo, reconheço que você deseja otimizar um pouco isso. Por motivos de desempenho, eu manteria uma tabela separada (que eu possa armazenar em cache) toda vez que um formulário ou membro da equipe for adicionado ou removido por meio de observadores de modelo. Então, em cada solicitação, eu pegaria isso no cache. Acho essa pergunta e problema muito interessantes e gostaria de saber o que os outros pensam também. Esta questão merece mais votos e respostas, começou uma recompensa :)
Raul
11
Você pode considerar ter uma visão materializada que pode ser atualizada como um trabalho agendado. Dessa forma, você sempre pode obter resultados relativamente atualizados rapidamente.
Apokryfos 30/10/19

Respostas:

2

Eu gostaria de fazer uma consulta SQL, pois isso terá um desempenho muito melhor que o php

Algo assim:

User::where('id', $request->user()->id)
    ->join('group_users', 'user.id', 'group_users.user_id')
    ->join('team_users', 'user.id', 'team_users.user_id',)
    ->join('form_owners as user_form_owners', function ($join) {
        $join->on('users.id', 'form_owners.owner_id')
            ->where('form_owners.owner_type', User::class);
    })
    ->join('form_owners as group_form_owners', function ($join) {
        $join->on('group_users.group_id', 'form_owners.owner_id')
            ->where('form_owners.owner_type', Group::class);
    })
    ->join('form_owners as team_form_owners', function ($join) {
        $join->on('team_users.team_id', 'form_owners.owner_id')
           ->where('form_owners.owner_type', Team::class);
    })
    ->join('forms', function($join) {
        $join->on('forms.id', 'user_form_owners.form_id')
            ->orOn('forms.id', 'group_form_owners.form_id')
            ->orOn('forms.id', 'team_form_owners.form_id');
    })
    ->selectRaw('forms.*')
    ->get();

Do alto da minha cabeça e não testado, você deve obter todos os formulários que pertencem ao usuário, aos grupos dele e às equipes.

No entanto, ele não analisa as permissões dos formulários de exibição do usuário em grupos e equipes.

Não tenho certeza de como você configurou sua autenticação para isso e, portanto, seria necessário modificar a consulta para essa e qualquer diferença na sua estrutura de banco de dados.

Josh
fonte
obrigado pela resposta. No entanto, o problema não era a consulta sobre como obter os dados do banco de dados. O problema é: como obtê-lo com eficiência sempre, em todas as solicitações, quando o aplicativo tiver centenas de milhares de formulários e muitas equipes e membros. Suas junções têm ORcláusulas, as quais suspeito que serão lentas. Então, acertar isso em cada solicitação será insano, acredito.
Raul
Você pode obter uma velocidade melhor com a consulta bruta do MySQL ou usar algo como visualizações ou procedimentos, mas terá que fazer chamadas como essa sempre que desejar os dados. O armazenamento em cache de resultados também pode ajudar aqui.
Josh
Embora eu esteja pensando que a única maneira de obter esse desempenho é o cache, isso custa sempre manter esse mapa toda vez que uma alteração é feita. Imagine que eu crie um novo formulário que, se uma equipe for atribuída à minha conta, significa que milhares de usuários poderão ter acesso a ele. Qual é o próximo? Re-armazenar em cache a política de alguns milhares de membros?
Raul
Existem soluções de cache com vida útil (como as abstrações de cache do laravel), e você também pode remover os índices de cache afetados logo após fazer qualquer alteração. O cache é um verdadeiro divisor de águas se você o usar corretamente. Como configurar o cache depende das leituras e atualizações dos dados.
Gonzalo
2

Resposta curta

A terceira opção: Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement

$teamMorphType  = Relation::getMorphedModel('team');
$groupMorphType = Relation::getMorphedModel('group');
$formMorphType  = Relation::getMorphedModel('form');

$permissible = [
    $teamMorphType  => [$user->team_id],
    $groupMorphType => [],
    $formMorphType  => [],
];

foreach ($user->permissible as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
        case $groupMorphType:
        case $formMorphType:
            $permissible[$permissible->permissible_type][] = $permissible->permissible_id;
            break;
    }
}

$forms = Form::query()
             ->where('user_id', '=', $user->id)
             ->orWhereIn('id', $permissible[$fromMorphType])
             ->orWhereIn('team_id', $permissible[$teamMorphType])
             ->orWhereIn('group_id', $permissible[$groupMorphType])
             ->get();

Resposta longa

Por um lado, (quase) tudo o que você pode fazer no código é melhor em termos de desempenho do que nas consultas.

Por outro lado, obter mais dados do banco de dados do que o necessário já seria um excesso de dados (uso de RAM e assim por diante).

Na minha perspectiva, você precisa de algo intermediário, e só você saberá onde estaria o equilíbrio, dependendo dos números.

Sugiro executar várias consultas, a última opção que você propôs ( Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement):

  1. Consultar todos os identificadores, para todas as permissões (5 consultas)
  2. Mesclar todos os resultados de formulários na memória e obter valores exclusivos array_unique($ids)
  3. Consulte o modelo de formulário, usando os identificadores em uma instrução IN ().

Você pode tentar as três opções propostas e monitorar o desempenho, usando alguma ferramenta para executar a consulta várias vezes, mas tenho 99% de certeza de que a última fornecerá o melhor desempenho.

Isso também pode mudar bastante, dependendo de qual banco de dados você estiver usando, mas se estamos falando sobre MySQL, por exemplo; Em Uma consulta muito grande usaria mais recursos do banco de dados, que não apenas gastariam mais tempo do que consultas simples, mas também bloqueariam a tabela de gravações, e isso pode produzir erros de conflito (a menos que você use um servidor escravo).

Por outro lado, se o número de IDs de formulários for muito grande, você poderá ter erros para muitos espaços reservados, portanto, você pode agrupar as consultas em grupos de, digamos, 500 IDs (isso depende muito, pois o limite tem tamanho, não número de ligações) e mescla os resultados na memória. Mesmo se você não receber um erro no banco de dados, também poderá ver uma grande diferença no desempenho (ainda estou falando do MySQL).


Implementação

Vou assumir que este é o esquema do banco de dados:

users
  - id
  - team_id

forms
  - id
  - user_id
  - team_id
  - group_id

permissible
  - user_id
  - permissible_id
  - permissible_type

Tão admissível seria uma relação polimórfica já configurada .

Portanto, as relações seriam:

  • Possui o formulário: users.id <-> form.user_id
  • A equipe possui o formulário: users.team_id <-> form.team_id
  • Tem permissões para um grupo que possui um formulário: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
  • Tem permissões para uma equipe que possui um formulário: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
  • Tem permissão para um formulário: permissible.user_id <-> users.id && permissible.permissible_type = 'App\From'

Simplifique a versão:

$teamMorphType  = Relation::getMorphedModel('team');
$groupMorphType = Relation::getMorphedModel('group');
$formMorphType  = Relation::getMorphedModel('form');

$permissible = [
    $teamMorphType  => [$user->team_id],
    $groupMorphType => [],
    $formMorphType  => [],
];

foreach ($user->permissible as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
        case $groupMorphType:
        case $formMorphType:
            $permissible[$permissible->permissible_type][] = $permissible->permissible_id;
            break;
    }
}

$forms = Form::query()
             ->where('user_id', '=', $user->id)
             ->orWhereIn('id', $permissible[$fromMorphType])
             ->orWhereIn('team_id', $permissible[$teamMorphType])
             ->orWhereIn('group_id', $permissible[$groupMorphType])
             ->get();

Versão detalhada:

// Owns Form
// users.id <-> forms.user_id
$userId = $user->id;

// Team owns Form
// users.team_id <-> forms.team_id
// Initialise the array with a first value.
// The permissions polymorphic relationship will have other teams ids to look at
$teamIds = [$user->team_id];

// Groups owns Form was not mention, so I assume there is not such a relation in user.
// Just initialise the array without a first value.
$groupIds = [];

// Also initialise forms for permissions:
$formIds = [];

// Has permissions to a group that owns a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
$teamMorphType = Relation::getMorphedModel('team');
// Has permissions to a team that owns a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
$groupMorphType = Relation::getMorphedModel('group');
// Has permission to a Form
// permissible.user_id <-> users.id && permissible.permissible_type = 'App\Form'
$formMorphType = Relation::getMorphedModel('form');

// Get permissions
$permissibles = $user->permissible()->whereIn(
    'permissible_type',
    [$teamMorphType, $groupMorphType, $formMorphType]
)->get();

// If you don't have more permissible types other than those, then you can just:
// $permissibles = $user->permissible;

// Group the ids per type
foreach ($permissibles as $permissible) {
    switch ($permissible->permissible_type) {
        case $teamMorphType:
            $teamIds[] = $permissible->permissible_id;
            break;
        case $groupMorphType:
            $groupIds[] = $permissible->permissible_id;
            break;
        case $formMorphType:
            $formIds[] = $permissible->permissible_id;
            break;
    }
}

// In case the user and the team ids are repeated:
$teamIds = array_values(array_unique($teamIds));
// We assume that the rest of the values will not be repeated.

$forms = Form::query()
             ->where('user_id', '=', $userId)
             ->orWhereIn('id', $formIds)
             ->orWhereIn('team_id', $teamIds)
             ->orWhereIn('group_id', $groupIds)
             ->get();

Recursos utilizados:

Desempenho do banco de dados:

  • Consultas ao banco de dados (excluindo o usuário): 2 ; um para obter o permitido e outro para obter os formulários.
  • Sem junções !!
  • As ORs mínimas possíveis ( user_id = ? OR id IN (?..) OR team_id IN (?...) OR group_id IN (?...).

PHP, na memória, desempenho:

  • foreach looping o permissível com um interruptor dentro.
  • array_values(array_unique()) para evitar repetir os IDs.
  • Na memória, 3 matrizes de ids ( $teamIds, $groupIds,$formIds )
  • Na memória, coleta eloquente de permissões relevantes (isso pode ser otimizado, se necessário).

Prós e contras

PROS:

  • Tempo : a soma dos tempos de consultas únicas é menor que o tempo de uma grande consulta com junções e OR.
  • Recursos de banco de dados : Os recursos MySQL usados ​​por uma consulta com instruções join e ou são maiores que os usados ​​pela soma de suas consultas separadas.
  • Dinheiro : Menos recursos de banco de dados (processador, RAM, leitura de disco etc.), que são mais caros que os recursos PHP.
  • Bloqueios : Caso você não esteja consultando um servidor escravo somente leitura, suas consultas criarão menos bloqueios de leitura de linhas (o bloqueio de leitura é compartilhado no MySQL, para que não bloqueie outra leitura, mas bloqueará qualquer gravação).
  • Escalonável : essa abordagem permite fazer mais otimizações de desempenho, como agrupar as consultas.

CONTRAS:

  • Recursos de código : fazer cálculos no código, e não no banco de dados, obviamente consumirá mais recursos na instância do código, mas principalmente na RAM, armazenando as informações do meio. No nosso caso, isso seria apenas uma matriz de IDs, o que não deveria ser realmente um problema.
  • Manutenção : Se você usar as propriedades e os métodos do Laravel e fizer alguma alteração no banco de dados, será mais fácil atualizar o código do que se você fizer consultas e processamento mais explícitos.
  • Overkilling? : Em alguns casos, se os dados não forem tão grandes, otimizar o desempenho pode ser um exagero.

Como medir o desempenho

Algumas dicas sobre como medir o desempenho?

  1. Logs de consulta lentos
  2. ANALISAR A TABELA
  3. MOSTRAR O STATUS DA MESA COMO
  4. EXPLIQUE ; Formato de saída EXPLAIN estendido ; usando explicar ; explicar saída
  5. MOSTRAR AVISOS

Algumas ferramentas interessantes de criação de perfil:

Gonzalo
fonte
Qual é a primeira linha? É quase sempre com melhor desempenho o uso de uma consulta, porque a execução de vários loops ou manipulação de matriz no PHP é mais lenta.
Chama
Se você possui um banco de dados pequeno ou sua máquina de banco de dados é muito mais poderosa que a sua instância de código, ou a latência do banco de dados é muito ruim, sim, o MySQL é mais rápido, mas esse não é geralmente o caso.
Gonzalo
Ao otimizar uma consulta ao banco de dados, é necessário considerar o tempo de execução, o número de linhas retornadas e, o mais importante, o número de linhas examinadas. Se Tim está dizendo que as consultas estão ficando lentas, presumo que os dados estejam aumentando e, portanto, o número de linhas examinadas. Além disso, o banco de dados não é otimizado para processamento como uma linguagem de programação.
Gonzalo
Mas você não precisa confiar em mim, você pode executar EXPLAIN , para sua solução, então você pode executá-lo para a minha solução de consultas simples, ver a diferença e depois pensar se um simples array_merge()e array_unique()um monte de ids realmente desacelere seu processo.
Gonzalo
Em 9 de 10 casos, o banco de dados mysql é executado na mesma máquina que executa o código. A camada de dados deve ser usada para recuperação de dados e é otimizada para selecionar partes de dados de grandes conjuntos. Ainda estou para ver uma situação em que a array_unique()é mais rápida que uma declaração GROUP BY/ SELECT DISTINCT.
Chama
0

Por que você não pode simplesmente consultar os formulários necessários, em vez de fazer Form::all()e encadear umfilter() função após ela?

Igual a:

public function index() {
    $forms = $user->forms->merge($user->team->forms)->merge($user->permissible->groups->forms);
}

Então, sim, isso faz algumas consultas:

  • Uma consulta para $user
  • Um para $user->team
  • Um para $user->team->forms
  • Um para $user->permissible
  • Um para $user->permissible->groups
  • Um para $user->permissible->groups->forms

No entanto, o lado profissional é que você não precisa mais usar a política , pois conhece todos os formulários no$forms parâmetro são permitidos para o usuário.

Portanto, esta solução funcionará para qualquer quantidade de formulários que você tiver no banco de dados.

Uma observação sobre o uso merge()

merge()mescla as coleções e descartará os IDs de formulário duplicados que ele já encontrou. Portanto, se por algum motivo um formulário da teamrelação também é uma relação direta com ouser , ele será exibido apenas uma vez na coleção mesclada.

Isso ocorre porque na verdade é um Illuminate\Database\Eloquent\Collectionque tem sua própria merge()função que verifica os IDs do modelo Eloquent. Portanto, você não pode realmente usar esse truque ao mesclar 2 conteúdos diferentes de coleções como Postse Users, porque um usuário com id 3e uma postagem com id 3entrarão em conflito nesse caso, e somente este último (o Post) será encontrado na coleção mesclada.


Se você quiser que seja ainda mais rápido, crie uma consulta personalizada usando a fachada do banco de dados, algo como:

// Select forms based on a subquery that returns a list of id's.
$forms = Form::whereIn(
    'id',
    DB::select('id')->from('users')->where('users.id', $user->id)
        ->join('teams', 'users.id', '=', 'teams.user_id')
        ...
)->get();

Sua consulta real é muito maior, pois você tem muitas relações.

A principal melhoria de desempenho aqui vem do fato de que o trabalho pesado (a subconsulta) ignora completamente a lógica do modelo Eloquent. Tudo o que resta fazer é passar a lista de IDs para a whereInfunção para recuperar sua lista de Formobjetos.

Chama
fonte
0

Acredito que você possa usar o Lazy Collections para isso (Laravel 6.x) e carregar ansiosamente os relacionamentos antes que eles sejam acessados.

public function index(Request $request)
{
   // Eager Load relationships
   $request->user()->load(['forms', 'team.forms', 'permissible.group']);
   // Use cursor instead of all to return a LazyCollection instance
   $forms = Form::cursor()->filter(function($form) use ($request) {
         return $request->user()->can('view', $form);
   });
}
public function view(User $user, Form $form)
{
   return $user->forms->contains($form) ||
      $user->team->forms->contains($form) ||
      // $user->permissible->groups->forms($contains); // Assuming this line is a typo
      $user->permissible->groups->contains($form);
}
IGP
fonte