Meilleure approche pour les performances lors du filtrage des autorisations dans Laravel

9

Je travaille sur une application où un utilisateur peut avoir accès à de nombreux formulaires à travers de nombreux scénarios différents. J'essaie de construire l'approche avec les meilleures performances lors du retour d'un index de formulaires à l'utilisateur.

Un utilisateur peut avoir accès aux formulaires via les scénarios suivants:

  • Formulaire propriétaire
  • L'équipe possède le formulaire
  • Possède des autorisations sur un groupe propriétaire d'un formulaire
  • A des autorisations pour une équipe qui possède un formulaire
  • A l'autorisation d'un formulaire

Comme vous pouvez le voir, l'utilisateur peut accéder à un formulaire de 5 manières différentes. Mon problème est de savoir comment renvoyer le plus efficacement possible un tableau des formulaires accessibles à l'utilisateur.

Politique de formulaire:

J'ai essayé d'obtenir tous les formulaires du modèle, puis de filtrer les formulaires par la stratégie de formulaire. Cela semble être un problème de performances car à chaque itération de filtre, le formulaire est passé 5 fois par une méthode éloquente contains () comme indiqué ci-dessous. Plus il y a de formulaires dans la base de données, cela ralentit.

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);
}

Bien que la méthode ci-dessus fonctionne, il s'agit d'un col de bouteille performant.

D'après ce que je peux voir, mes options suivantes sont:

  • Filtre FormPolicy (approche actuelle)
  • Recherchez toutes les autorisations (5) et fusionnez en une seule collection
  • Recherchez tous les identifiants pour toutes les autorisations (5), puis interrogez le modèle de formulaire à l'aide des identifiants dans une instruction IN ()

Ma question:

Quelle méthode fournirait les meilleures performances et existe-t-il une autre option qui fournirait de meilleures performances?

Tim
la source
vous pouvez également créer une approche Many To Many pour créer un lien si l'utilisateur peut accéder au formulaire
code for money
Qu'en est-il de la création d'une table spécifiquement pour interroger les autorisations du formulaire utilisateur? Le user_form_permissiontableau contenant uniquement le user_idet le form_id. Cela rendra les autorisations de lecture un jeu d'enfant, mais la mise à jour des autorisations sera plus difficile.
PtrTon
Le problème avec la table user_form_permissions est que nous voulons étendre les autorisations à d'autres entités qui nécessiteraient alors une table distincte pour chaque entité.
Tim
1
@Tim mais c'est toujours 5 requêtes. Si c'est juste à l'intérieur de la zone d'un membre protégé, ce n'est peut-être pas un problème. Mais si c'est sur une URL accessible au public qui peut recevoir beaucoup de demandes par seconde, je reconnais que vous voudriez optimiser un peu cela. Pour des raisons de performances, je maintiens une table distincte (que je peux mettre en cache) chaque fois qu'un formulaire ou un membre de l'équipe est ajouté ou supprimé via des observateurs de modèle. Ensuite, à chaque demande, je l'obtiendrais du cache. Je trouve cette question et ce problème très intéressants et j'aimerais savoir ce que les autres pensent aussi. Cette question mérite plus de votes et de réponses, a commencé une prime :)
Raul
1
Vous pouvez envisager d'avoir une vue matérialisée que vous pouvez actualiser en tant que travail planifié. De cette façon, vous pouvez toujours obtenir rapidement des résultats relativement à jour.
apokryfos

Réponses:

2

Je chercherais à faire une requête SQL car cela va fonctionner beaucoup mieux que php

Quelque chose comme ça:

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();

Du haut de ma tête et non testé, cela devrait vous procurer tous les formulaires qui appartiennent à l'utilisateur, à ses groupes et à ces équipes.

Il ne regarde cependant pas les autorisations des formulaires de vue utilisateur dans les groupes et les équipes.

Je ne sais pas comment vous avez configuré votre authentification pour cela et vous devrez donc modifier la requête pour cela et toute différence dans votre structure de base de données.

Josh
la source
Merci d'avoir répondu. Cependant, le problème n'était pas la question de savoir comment obtenir les données de la base de données. Le problème est de savoir comment l'obtenir efficacement à chaque fois, à chaque demande, lorsque l'application a des centaines de milliers de formulaires et beaucoup d'équipes et de membres. Vos jointures contiennent des ORclauses qui, je suppose, vont être lentes. Donc, frapper cela à chaque demande sera fou, je crois.
Raul
Vous pourrez peut-être obtenir une meilleure vitesse avec une requête MySQL brute ou en utilisant quelque chose comme des vues ou des procédures, mais vous devrez faire des appels comme celui-ci chaque fois que vous voulez les données. La mise en cache des résultats peut également être utile ici.
Josh
Bien que je pense que la seule façon de rendre cette performance est la mise en cache, cela se fait au prix de toujours maintenir cette carte chaque fois qu'un changement est effectué. Imaginez que je crée un nouveau formulaire qui, si une équipe est affectée à mon compte, signifie que des milliers d'utilisateurs pourraient y accéder. Et après? Remettre en cache la politique de quelques milliers de membres?
Raul
Il existe des solutions de cache à vie (comme les abstractions de cache de laravel), et vous pouvez également supprimer les index de cache affectés juste après avoir effectué une modification. Le cache change vraiment la donne si vous l'utilisez correctement. La configuration du cache dépend des lectures et des mises à jour des données.
Gonzalo
2

Réponse courte

La troisième option: 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();

Longue réponse

D'une part, (presque) tout ce que vous pouvez faire dans le code est meilleur en termes de performances que dans les requêtes.

D'un autre côté, obtenir plus de données de la base de données que nécessaire serait déjà trop de données (utilisation de la RAM, etc.).

De mon point de vue, vous avez besoin de quelque chose entre les deux, et vous seul saurez où serait l'équilibre, selon les chiffres.

Je suggérerais d'exécuter plusieurs requêtes, la dernière option que vous avez proposée ( Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement):

  1. Recherchez tous les identifiants, pour toutes les autorisations (5 requêtes)
  2. Fusionner tous les résultats des formulaires en mémoire et obtenir des valeurs uniques array_unique($ids)
  3. Recherchez le modèle de formulaire à l'aide des identificateurs dans une instruction IN ().

Vous pouvez essayer les trois options que vous avez proposées et surveiller les performances, en utilisant un outil pour exécuter la requête plusieurs fois, mais je suis sûr à 99% que la dernière vous donnera les meilleures performances.

Cela peut également changer beaucoup, selon la base de données que vous utilisez, mais si nous parlons de MySQL, par exemple; Dans Une très grande requête utiliserait plus de ressources de base de données, ce qui non seulement passera plus de temps que les requêtes simples, mais verrouillera également la table des écritures, ce qui peut produire des erreurs de blocage (sauf si vous utilisez un serveur esclave).

D'un autre côté, si le nombre d'identifiants de formulaires est très grand, vous pouvez avoir des erreurs pour trop d'espaces réservés, vous pouvez donc vouloir découper les requêtes en groupes de, disons, 500 identifiants (cela dépend beaucoup, car la limite est en taille, pas en nombre de liaisons) et fusionner les résultats en mémoire. Même si vous n'obtenez pas d'erreur de base de données, vous pouvez également voir une grande différence de performances (je parle toujours de MySQL).


la mise en oeuvre

Je suppose que c'est le schéma de base de données:

users
  - id
  - team_id

forms
  - id
  - user_id
  - team_id
  - group_id

permissible
  - user_id
  - permissible_id
  - permissible_type

Une relation polymorphe déjà configurée serait donc admissible .

Par conséquent, les relations seraient:

  • Forme propriétaire: users.id <-> form.user_id
  • L'équipe possède le formulaire: users.team_id <-> form.team_id
  • A des autorisations sur un groupe propriétaire d'un formulaire: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
  • A des autorisations pour une équipe qui possède un formulaire: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
  • A l'autorisation d'un formulaire: permissible.user_id <-> users.id && permissible.permissible_type = 'App\From'

Version simplifiée:

$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();

Version détaillée:

// 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();

Ressources utilisées:

Performances de la base de données:

  • Requêtes à la base de données (à l'exclusion de l'utilisateur): 2 ; un pour obtenir le permis et un autre pour obtenir les formulaires.
  • Pas de jointures !!
  • Les OR minimums possibles ( user_id = ? OR id IN (?..) OR team_id IN (?...) OR group_id IN (?...).

PHP, en mémoire, performances:

  • foreach boucle le admissible avec un interrupteur à l' intérieur.
  • array_values(array_unique()) pour éviter de répéter les identifiants.
  • Dans la mémoire, 3 rangées de ids ( $teamIds, $groupIds, $formIds)
  • En mémoire, les autorisations pertinentes collection éloquente (cela peut être optimisé, si nécessaire).

Avantages et inconvénients

AVANTAGES:

  • Heure : la somme des heures des requêtes uniques est inférieure à l'heure d'une grande requête avec jointures et OU.
  • Ressources DB : Les ressources MySQL utilisées par une requête avec jointure et ou instructions sont plus grandes que celles utilisées par la somme de ses requêtes distinctes.
  • Argent : moins de ressources de base de données (processeur, RAM, lecture de disque, etc.), plus chères que les ressources PHP.
  • Verrous : Au cas où vous n'interrogez pas un serveur esclave en lecture seule, vos requêtes feront moins de lignes verrous de lecture (le verrou de lecture est partagé dans MySQL, donc il ne verrouillera pas une autre lecture, mais il bloquera toute écriture).
  • Évolutif : cette approche vous permet de faire plus d'optimisations de performances telles que la segmentation des requêtes.

LES INCONVÉNIENTS:

  • Ressources de code : effectuer des calculs dans le code, plutôt que dans la base de données, consommera évidemment plus de ressources dans l'instance de code, mais surtout dans la RAM, stockant les informations intermédiaires. Dans notre cas, ce ne serait qu'un tableau d'identifiants, ce qui ne devrait pas vraiment être un problème.
  • Maintenance : si vous utilisez les propriétés et les méthodes de Laravel et que vous modifiez la base de données, il sera plus facile de mettre à jour le code que si vous effectuez des requêtes et un traitement plus explicites.
  • Trop de travail? : Dans certains cas, si les données ne sont pas si grandes, l'optimisation des performances peut être exagérée.

Comment mesurer les performances

Quelques indices sur la façon de mesurer la performance?

  1. Journaux de requête lents
  2. TABLE D'ANALYSE
  3. AFFICHER L'ÉTAT DE LA TABLE COMME
  4. EXPLIQUER ; Format de sortie EXPLAIN étendu ; utiliser expliquer ; expliquer la sortie
  5. AFFICHER LES AVERTISSEMENTS

Quelques outils de profilage intéressants:

Gonzalo
la source
Quelle est cette première ligne? Il est presque toujours préférable d'améliorer les performances pour utiliser une requête, car l'exécution de diverses boucles ou manipulation de tableaux en PHP est plus lente.
Flamme
Si vous avez une petite base de données ou que votre machine de base de données est bien plus puissante que votre instance de code, ou si la latence de la base de données est très mauvaise, alors oui, MySQL est plus rapide, mais ce n'est généralement pas le cas.
Gonzalo
Lorsque vous optimisez une requête de base de données, vous devez tenir compte du temps d'exécution, du nombre de lignes renvoyées et, surtout, du nombre de lignes examinées. Si Tim dit que les requêtes deviennent lentes, je suppose que les données augmentent et donc le nombre de lignes examinées. De plus, la base de données n'est pas optimisée pour le traitement comme l'est un langage de programmation.
Gonzalo
Mais vous n'avez pas besoin de me faire confiance, vous pouvez exécuter EXPLAIN , pour votre solution, puis vous pouvez l'exécuter pour ma solution de requêtes simples, et voir la différence, puis penser si un simple array_merge()et array_unique()un tas d'identifiants, serait vraiment ralentir votre processus.
Gonzalo
Dans 9 cas sur 10, la base de données mysql s'exécute sur la même machine qui exécute le code. La couche de données est destinée à être utilisée pour la récupération de données et elle est optimisée pour sélectionner des éléments de données à partir de grands ensembles. Je n'ai pas encore vu de situation où a array_unique()est plus rapide qu'une instruction GROUP BY/ SELECT DISTINCT.
Flamme
0

Pourquoi ne pouvez-vous pas simplement interroger les formulaires dont vous avez besoin, au lieu de le faire Form::all(), puis d'enchaîner une filter()fonction après?

Ainsi:

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

Alors oui, cela fait quelques requêtes:

  • Une requête pour $user
  • Un pour $user->team
  • Un pour $user->team->forms
  • Un pour $user->permissible
  • Un pour $user->permissible->groups
  • Un pour $user->permissible->groups->forms

Cependant, le côté pro est que vous n'avez plus besoin d'utiliser la stratégie , car vous savez que tous les formulaires du $formsparamètre sont autorisés pour l'utilisateur.

Cette solution fonctionnera donc pour n'importe quelle quantité de formulaires que vous avez dans la base de données.

Une note sur l'utilisation merge()

merge()fusionne les collections et supprime les ID de formulaire en double qu'il a déjà trouvés. Donc, si pour une raison quelconque, une forme de la teamrelation est également une relation directe avec la user, elle n'apparaîtra qu'une seule fois dans la collection fusionnée.

C'est parce que c'est en fait un Illuminate\Database\Eloquent\Collectionqui a sa propre merge()fonction qui vérifie les identifiants du modèle Eloquent. Donc, vous ne pouvez pas réellement utiliser cette astuce lors de la fusion de 2 contenus de collection différents comme Postset Users, car un utilisateur avec id 3et un message avec id 3seront en conflit dans ce cas, et seul ce dernier (le message) sera trouvé dans la collection fusionnée.


Si vous voulez que ce soit encore plus rapide, vous devez créer une requête personnalisée en utilisant la façade DB, quelque chose comme:

// 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();

Votre requête réelle est beaucoup plus importante car vous avez tant de relations.

La principale amélioration des performances vient du fait que le travail lourd (la sous-requête) contourne complètement la logique du modèle Eloquent. Il ne reste alors plus qu'à passer la liste des identifiants dans la whereInfonction pour récupérer votre liste d' Formobjets.

Flamme
la source
0

Je crois que vous pouvez utiliser Lazy Collections pour cela (Laravel 6.x) et charger les relations avant qu'elles ne soient accessibles.

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
la source