El mejor enfoque para el rendimiento al filtrar por permisos en Laravel

9

Estoy trabajando en una aplicación donde un usuario puede tener acceso a muchos formularios a través de diferentes escenarios. Estoy tratando de construir el enfoque con el mejor rendimiento al devolver un índice de formularios al usuario.

Un usuario puede tener acceso a los formularios a través de los siguientes escenarios:

  • Posee formulario
  • El equipo posee el formulario
  • Tiene permisos para un grupo que posee un formulario
  • Tiene permisos para un equipo que posee un formulario
  • Tiene permiso para un formulario

Como puede ver, hay 5 formas posibles en que el usuario puede acceder a un formulario. Mi problema es cómo puedo devolver de manera más eficiente una matriz de formularios accesibles al usuario.

Política de formulario:

He intentado obtener todos los formularios del modelo y luego filtrar los formularios por la política de formularios. Esto parece ser un problema de rendimiento ya que en cada iteración de filtro, el formulario se pasa a través de un método elocuente contiene () 5 veces como se muestra a continuación. Cuantos más formularios haya en la base de datos, esto se vuelve más 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);
}

Aunque el método anterior funciona, es un cuello de botella de alto rendimiento.

Por lo que puedo ver, mis siguientes opciones son:

  • Filtro FormPolicy (enfoque actual)
  • Consultar todos los permisos (5) y combinarlos en una sola colección
  • Consulte todos los identificadores para todos los permisos (5), luego consulte el modelo de formulario utilizando los identificadores en una instrucción IN ()

Mi pregunta:

¿Qué método proporcionaría el mejor rendimiento y hay alguna otra opción que ofrezca un mejor rendimiento?

Tim
fuente
también puede hacer un acercamiento Muchos a Muchos para vincular si el usuario puede acceder al formulario
código por dinero
¿Qué pasa con la creación de una tabla específicamente para consultar los permisos de formulario de usuario? La user_form_permissiontabla que contiene solo el user_idy el form_id. Esto hará que los permisos de lectura sean muy sencillos, sin embargo, actualizar los permisos será más difícil.
PtrTon
El problema con la tabla user_form_permissions es que queremos expandir los permisos a otras entidades que luego requerirían una tabla separada para cada entidad.
Tim
1
@Tim pero todavía son 5 consultas. Si esto se encuentra dentro del área de un miembro protegido, podría no ser un problema. Pero si esto está en una URL pública que puede recibir muchas solicitudes por segundo, reconozco que querrá optimizar esto un poco. Por razones de rendimiento, mantendría una tabla separada (que puedo almacenar en caché) cada vez que se agregue o elimine un formulario o miembro del equipo a través de observadores modelo. Entonces, en cada solicitud, obtendría eso de la caché. Encuentro esta pregunta y este problema muy interesantes y me encantaría saber lo que otros piensan también. Esta pregunta merece más votos y respuestas, comenzó una recompensa :)
Raul
1
Podría considerar tener una vista materializada que podría actualizar como un trabajo programado. De esta manera, siempre puede tener resultados relativamente actualizados rápidamente.
apokryfos

Respuestas:

2

Me gustaría hacer una consulta SQL, ya que funcionará mucho mejor que php

Algo como esto:

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

Desde lo alto de mi cabeza y sin probar esto, debería obtener todos los formularios que son propiedad del usuario, sus grupos y estos equipos.

Sin embargo, no analiza los permisos de los formularios de vista de usuario en grupos y equipos.

No estoy seguro de cómo configurar su autenticación para esto, por lo que necesitaría modificar la consulta para esto y cualquier diferencia en su estructura de base de datos.

Josh
fuente
gracias por la respuesta. Sin embargo, el problema no era la consulta sobre cómo obtener los datos de la base de datos. El problema es cómo obtenerlo de manera eficiente cada vez, en cada solicitud, cuando la aplicación tiene cientos de miles de formularios y muchos equipos y miembros. Sus uniones tienen ORcláusulas, que sospecho que van a ser lentas. Así que golpear esto en cada solicitud será una locura, creo.
Raul
Es posible que pueda obtener una mejor velocidad con una consulta MySQL sin procesar o usando algo como vistas o procedimientos, pero tendrá que hacer llamadas como esta cada vez que desee los datos. El almacenamiento en caché de los resultados también podría ayudar aquí.
Josh
Si bien estoy pensando que la única forma de hacer este rendimiento es el almacenamiento en caché, eso tiene el costo de mantener siempre este mapa cada vez que se realiza un cambio. Imagine que creo un nuevo formulario que, si se asigna un equipo a mi cuenta, significa que miles de usuarios podrían tener acceso a él. ¿Que sigue? ¿Volver a almacenar en caché la política de unos pocos miles de miembros?
Raúl
Existen soluciones de caché con duración (como las abstracciones de caché de laravel), y también puede eliminar los índices de caché afectados inmediatamente después de realizar cualquier cambio. El caché es un verdadero cambio de juego si lo usas correctamente. La forma de configurar el caché depende de las lecturas y actualizaciones de los datos.
Gonzalo
2

Respuesta corta

La tercera opción: 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();

Respuesta larga

Por un lado, (casi) todo lo que puede hacer en código, es mejor en términos de rendimiento, que hacerlo en consultas.

Por otro lado, obtener más datos de la base de datos de los necesarios ya sería demasiados datos (uso de RAM, etc.).

Desde mi punto de vista, necesitas algo intermedio, y solo tú sabrás dónde estaría el saldo, dependiendo de los números.

Sugeriría ejecutar varias consultas, la última opción que propuso ( Query all identifiers for all permissions (5), then query the Form model using the identifiers in an IN() statement):

  1. Consulta todos los identificadores, para todos los permisos (5 consultas)
  2. Combina todos los resultados de los formularios en la memoria y obtén valores únicos array_unique($ids)
  3. Consulte el modelo de formulario, utilizando los identificadores en una declaración IN ().

Puede probar las tres opciones que propuso y controlar el rendimiento, utilizando alguna herramienta para ejecutar la consulta varias veces, pero estoy 99% seguro de que la última le dará el mejor rendimiento.

Esto también puede cambiar mucho, dependiendo de qué base de datos esté usando, pero si estamos hablando de MySQL, por ejemplo; En una consulta muy grande, se usarían más recursos de la base de datos, lo que no solo pasará más tiempo que las consultas simples, sino que también bloqueará la tabla de las escrituras, y esto puede producir errores de punto muerto (a menos que use un servidor esclavo).

Por otro lado, si el número de identificadores de formularios es muy grande, puede tener errores para demasiados marcadores de posición, por lo que es posible que desee agrupar las consultas en grupos de, digamos, 500 identificadores (esto depende mucho, ya que el límite está en tamaño, no en número de enlaces), y combina los resultados en la memoria. Incluso si no recibe un error en la base de datos, también puede ver una gran diferencia en el rendimiento (todavía estoy hablando de MySQL).


Implementación

Asumiré que este es el esquema de la base de datos:

users
  - id
  - team_id

forms
  - id
  - user_id
  - team_id
  - group_id

permissible
  - user_id
  - permissible_id
  - permissible_type

Tan permisible sería una relación polimórfica ya configurada .

Por lo tanto, las relaciones serían:

  • Posee el formulario: users.id <-> form.user_id
  • El equipo posee el formulario: users.team_id <-> form.team_id
  • Tiene permisos para un grupo que posee un formulario: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Team'
  • Tiene permisos para un equipo que posee un formulario: permissible.user_id <-> users.id && permissible.permissible_type = 'App\Group'
  • Tiene permiso para un formulario: permissible.user_id <-> users.id && permissible.permissible_type = 'App\From'

Versión simplificada:

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

Versión detallada:

// 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:

Rendimiento de la base de datos:

  • Consultas a la base de datos (excluyendo al usuario): 2 ; uno para obtener lo permitido y otro para obtener los formularios.
  • No se une!
  • Los OR mínimos posibles ( user_id = ? OR id IN (?..) OR team_id IN (?...) OR group_id IN (?...).

PHP, en memoria, rendimiento:

  • foreach girando lo permitido con un interruptor dentro.
  • array_values(array_unique()) para evitar repetir los identificadores.
  • En la memoria, 3 matrices de ids ( $teamIds, $groupIds, $formIds)
  • En memoria, permisos relevantes colección elocuente (esto se puede optimizar, si es necesario).

Pros y contras

PROS:

  • Tiempo : la suma de los tiempos de consultas individuales es menor que el tiempo de una consulta grande con combinaciones y OR.
  • Recursos de base de datos : los recursos de MySQL utilizados por una consulta con uniones o declaraciones, son mayores que los utilizados por la suma de sus consultas separadas.
  • Dinero : Menos recursos de base de datos (procesador, RAM, lectura de disco, etc.), que son más caros que los recursos PHP.
  • Bloqueos : en caso de que no esté consultando un servidor esclavo de solo lectura, sus consultas harán menos filas de bloqueos de lectura (el bloqueo de lectura se comparte en MySQL, por lo que no bloqueará otra lectura, pero bloqueará cualquier escritura).
  • Escalable : este enfoque le permite realizar más optimizaciones de rendimiento, como fragmentar las consultas.

CONTRAS:

  • Recursos de código : hacer cálculos en código, en lugar de en la base de datos, obviamente consumirá más recursos en la instancia de código, pero especialmente en la RAM, almacenando la información intermedia. En nuestro caso, esto sería solo una serie de identificadores, que en realidad no debería ser un problema.
  • Mantenimiento : si utiliza las propiedades y métodos de Laravel, y realiza algún cambio en la base de datos, será más fácil actualizar el código que si realiza consultas y procesos más explícitos.
  • ¿Matanza excesiva? : En algunos casos, si los datos no son tan grandes, optimizar el rendimiento puede ser exagerado.

Cómo medir el desempeño

¿Algunas pistas sobre cómo medir el rendimiento?

  1. Registros de consulta lentos
  2. TABLA DE ANÁLISIS
  3. MOSTRAR ESTADO DE LA MESA COMO
  4. EXPLICAR ; Formato de salida EXPLAIN extendido ; usando explicar ; explicar salida
  5. MOSTRAR ADVERTENCIAS

Algunas herramientas de perfilado interesantes:

Gonzalo
fuente
¿Cuál es esa primera línea? Casi siempre es mejor usar una consulta, ya que ejecutar varios bucles o la manipulación de matrices en PHP es más lento.
Llama
Si tiene una base de datos pequeña o su máquina de base de datos es mucho más poderosa que su instancia de código, o la latencia de la base de datos es muy mala, entonces sí, MySQL es más rápido, pero este no suele ser el caso.
Gonzalo
Cuando optimiza una consulta de base de datos, debe tener en cuenta el tiempo de ejecución, el número de filas devueltas y, lo más importante, el número de filas examinadas. Si Tim dice que las consultas se están volviendo lentas, entonces supongo que los datos están creciendo y, por lo tanto, el número de filas examinadas. Además, la base de datos no está optimizada para procesarse como un lenguaje de programación.
Gonzalo
Pero no necesita confiar en mí, puede ejecutar EXPLAIN , para su solución, luego puede ejecutarlo para mi solución de consultas simples, y ver la diferencia, y luego pensar si un simple array_merge()y array_unique()un montón de identificadores, Realmente ralentiza tu proceso.
Gonzalo
En 9 de cada 10 casos, la base de datos mysql se ejecuta en la misma máquina que ejecuta el código. La capa de datos debe usarse para la recuperación de datos y está optimizada para seleccionar datos de conjuntos grandes. Todavía no he visto una situación en la que a array_unique()sea ​​más rápido que una declaración GROUP BY/ SELECT DISTINCT.
Llama
0

¿Por qué no puede simplemente consultar los formularios que necesita, en lugar de hacerlo Form::all()y luego encadenar una filter()función después?

Al igual que:

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

Entonces sí, esto hace algunas consultas:

  • Una consulta para $user
  • Uno para $user->team
  • Uno para $user->team->forms
  • Uno para $user->permissible
  • Uno para $user->permissible->groups
  • Uno para $user->permissible->groups->forms

Sin embargo, el lado profesional es que ya no necesita usar la política , ya que sabe que todos los formularios en el $formsparámetro están permitidos para el usuario.

Entonces, esta solución funcionará para cualquier cantidad de formularios que tenga en la base de datos.

Una nota sobre el uso merge()

merge()combina las colecciones y descartará los identificadores de formularios duplicados que ya haya encontrado. Entonces, si por alguna razón un formulario de la teamrelación también es una relación directa con el user, solo se mostrará una vez en la colección fusionada.

Esto se debe a que en realidad es una función Illuminate\Database\Eloquent\Collectionque tiene su propia merge()función que verifica los identificadores del modelo Eloquent. Por lo tanto, no puede usar este truco al fusionar 2 contenidos de colección diferentes como Postsy Users, porque un usuario con id 3y una publicación con id 3entrarán en conflicto en este caso, y solo el último (la publicación) se encontrará en la colección fusionada.


Si desea que sea aún más rápido, debe crear una consulta personalizada utilizando la fachada DB, algo así 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();

Su consulta real es mucho más grande ya que tiene muchas relaciones.

La mejora principal del rendimiento aquí proviene del hecho de que el trabajo pesado (la subconsulta) omite por completo la lógica del modelo Eloquent. Entonces todo lo que queda por hacer es pasar la lista de identificadores a la whereInfunción para recuperar su lista de Formobjetos.

Fuego
fuente
0

Creo que puede usar Lazy Collections para eso (Laravel 6.x) y cargar las relaciones antes de acceder.

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
fuente