¿Diseño de patrón de repositorio adecuado en PHP?


291

Prefacio: Estoy intentando usar el patrón de repositorio en una arquitectura MVC con bases de datos relacionales.

Recientemente comencé a aprender TDD en PHP, y me estoy dando cuenta de que mi base de datos está demasiado unida al resto de mi aplicación. He leído sobre repositorios y el uso de un contenedor IoC para "inyectarlo" en mis controladores. Cosas muy chulas. Pero ahora tengo algunas preguntas prácticas sobre el diseño del repositorio. Considere el siguiente ejemplo.

<?php

class DbUserRepository implements UserRepositoryInterface
{
    protected $db;

    public function __construct($db)
    {
        $this->db = $db;
    }

    public function findAll()
    {
    }

    public function findById($id)
    {
    }

    public function findByName($name)
    {
    }

    public function create($user)
    {
    }

    public function remove($user)
    {
    }

    public function update($user)
    {
    }
}

Problema 1: demasiados campos

Todos estos métodos de búsqueda utilizan un SELECT *enfoque de seleccionar todos los campos ( ). Sin embargo, en mis aplicaciones, siempre estoy tratando de limitar la cantidad de campos que obtengo, ya que esto a menudo agrega gastos generales y ralentiza las cosas. Para aquellos que usan este patrón, ¿cómo manejas esto?

Problema # 2: demasiados métodos

Si bien esta clase se ve bien en este momento, sé que en una aplicación del mundo real necesito muchos más métodos. Por ejemplo:

  • findAllByNameAndStatus
  • findAllInCountry
  • findAllWithEmailAddressSet
  • findAllByAgeAndGender
  • findAllByAgeAndGenderOrderByAge
  • Etc.

Como puede ver, podría haber una lista muy larga de posibles métodos. Y luego, si agrega el problema de selección de campo anterior, el problema empeora. En el pasado, normalmente ponía toda esta lógica en mi controlador:

<?php

class MyController
{
    public function users()
    {
        $users = User::select('name, email, status')
            ->byCountry('Canada')->orderBy('name')->rows();

        return View::make('users', array('users' => $users));
    }
}

Con mi enfoque de repositorio, no quiero terminar con esto:

<?php

class MyController
{
    public function users()
    {
        $users = $this->repo->get_first_name_last_name_email_username_status_by_country_order_by_name('Canada');

        return View::make('users', array('users' => $users))
    }

}

Problema # 3: Imposible hacer coincidir una interfaz

Veo el beneficio de usar interfaces para repositorios, por lo que puedo cambiar mi implementación (para fines de prueba u otros). Entiendo que las interfaces definen un contrato que debe seguir una implementación. Esto es genial hasta que comience a agregar métodos adicionales a sus repositorios como findAllInCountry(). Ahora necesito actualizar mi interfaz para que también tenga este método, de lo contrario, otras implementaciones pueden no tenerlo, y eso podría dañar mi aplicación. Por esto se siente loco ... un caso de la cola moviendo al perro.

Patrón de especificación?

Esto me lleva a creer que el repositorio sólo debe tener un número fijo de métodos (como save(), remove(), find(), findAll(), etc). Pero entonces, ¿cómo ejecuto búsquedas específicas? He oído hablar del patrón de especificación , pero me parece que esto solo reduce un conjunto completo de registros (vía IsSatisfiedBy()), lo que claramente tiene problemas de rendimiento importantes si está extrayendo de una base de datos.

¿Ayuda?

Claramente, necesito repensar un poco las cosas cuando trabajo con repositorios. ¿Alguien puede aclarar cómo se maneja esto mejor?

Respuestas:


208

Pensé que me tomaría un buen rato responder mi propia pregunta. Lo que sigue es solo una forma de resolver los problemas 1-3 en mi pregunta original.

Descargo de responsabilidad: no siempre puedo usar los términos correctos al describir patrones o técnicas. Lo siento por eso.

Los objetivos:

  • Cree un ejemplo completo de un controlador básico para ver y editar Users.
  • Todo el código debe ser totalmente comprobable y simulable.
  • El controlador no debe tener idea de dónde se almacenan los datos (lo que significa que se pueden cambiar).
  • Ejemplo para mostrar una implementación de SQL (más común).
  • Para obtener el máximo rendimiento, los controladores solo deben recibir los datos que necesitan, sin campos adicionales.
  • La implementación debería aprovechar algún tipo de mapeador de datos para facilitar el desarrollo.
  • La implementación debe tener la capacidad de realizar búsquedas complejas de datos.

La solución

Estoy dividiendo mi interacción de almacenamiento persistente (base de datos) en dos categorías: R (Leer) y CUD (Crear, Actualizar, Eliminar). Mi experiencia ha sido que las lecturas son realmente lo que hace que una aplicación se ralentice. Y aunque la manipulación de datos (CUD) es en realidad más lenta, ocurre con mucha menos frecuencia y, por lo tanto, es mucho menos preocupante.

CUD (Crear, Actualizar, Eliminar) es fácil. Esto implicará trabajar con modelos reales , que luego se pasan a mi Repositoriespara persistencia. Tenga en cuenta que mis repositorios seguirán proporcionando un método de lectura, pero simplemente para la creación de objetos, no para mostrar. Más sobre eso más tarde.

R (Leer) no es tan fácil. No hay modelos aquí, solo objetos de valor . Use matrices si lo prefiere . Estos objetos pueden representar un modelo único o una combinación de muchos modelos, cualquier cosa realmente. No son muy interesantes por sí mismos, pero sí cómo se generan. Estoy usando lo que estoy llamando Query Objects.

El código:

Modelo de usuario

Comencemos de manera simple con nuestro modelo de usuario básico. Tenga en cuenta que no hay extensión de ORM o material de base de datos en absoluto. Solo pura gloria modelo. Agregue sus captadores, establecedores, validación, lo que sea.

class User
{
    public $id;
    public $first_name;
    public $last_name;
    public $gender;
    public $email;
    public $password;
}

Interfaz de repositorio

Antes de crear mi repositorio de usuario, quiero crear mi interfaz de repositorio. Esto definirá el "contrato" que los repositorios deben seguir para que mi controlador pueda utilizarlos. Recuerde, mi controlador no sabrá dónde se almacenan realmente los datos.

Tenga en cuenta que mis repositorios solo contendrán estos tres métodos. El save()método es responsable de crear y actualizar usuarios, simplemente dependiendo de si el objeto de usuario tiene o no un conjunto de identificación.

interface UserRepositoryInterface
{
    public function find($id);
    public function save(User $user);
    public function remove(User $user);
}

Implementación del repositorio de SQL

Ahora para crear mi implementación de la interfaz. Como se mencionó, mi ejemplo iba a ser con una base de datos SQL. Tenga en cuenta el uso de un mapeador de datos para evitar tener que escribir consultas SQL repetitivas.

class SQLUserRepository implements UserRepositoryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function find($id)
    {
        // Find a record with the id = $id
        // from the 'users' table
        // and return it as a User object
        return $this->db->find($id, 'users', 'User');
    }

    public function save(User $user)
    {
        // Insert or update the $user
        // in the 'users' table
        $this->db->save($user, 'users');
    }

    public function remove(User $user)
    {
        // Remove the $user
        // from the 'users' table
        $this->db->remove($user, 'users');
    }
}

Consultar interfaz de objeto

Ahora con CUD (Crear, Actualizar, Eliminar) atendido por nuestro repositorio, podemos centrarnos en la R (Leer). Los objetos de consulta son simplemente una encapsulación de algún tipo de lógica de búsqueda de datos. Son no generadores de consultas. Al abstraerlo como nuestro repositorio podemos cambiar su implementación y probarlo más fácilmente. Un ejemplo de un objeto de consulta podría ser un AllUsersQueryo AllActiveUsersQuery, o incluso MostCommonUserFirstNames.

Puede estar pensando "¿no puedo simplemente crear métodos en mis repositorios para esas consultas?" Sí, pero aquí es por qué no estoy haciendo esto:

  • Mis repositorios están diseñados para trabajar con objetos modelo. En una aplicación del mundo real, ¿por qué necesitaría obtener el passwordcampo si estoy buscando una lista de todos mis usuarios?
  • Los repositorios a menudo son específicos del modelo, pero las consultas a menudo involucran más de un modelo. Entonces, ¿en qué repositorio pones tu método?
  • Esto mantiene mis repositorios muy simples, no una clase hinchada de métodos.
  • Todas las consultas ahora están organizadas en sus propias clases.
  • Realmente, en este punto, existen repositorios simplemente para abstraer mi capa de base de datos.

Para mi ejemplo, crearé un objeto de consulta para buscar "AllUsers". Aquí está la interfaz:

interface AllUsersQueryInterface
{
    public function fetch($fields);
}

Implementación de objeto de consulta

Aquí es donde podemos usar un mapeador de datos nuevamente para ayudar a acelerar el desarrollo. Tenga en cuenta que estoy permitiendo un ajuste al conjunto de datos devuelto: los campos. Esto es lo más lejos que quiero llegar manipulando la consulta realizada. Recuerde, mis objetos de consulta no son constructores de consultas. Simplemente realizan una consulta específica. Sin embargo, como sé que probablemente usaré este mucho, en varias situaciones diferentes, me estoy dando la capacidad de especificar los campos. ¡Nunca quiero devolver campos que no necesito!

class AllUsersQuery implements AllUsersQueryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function fetch($fields)
    {
        return $this->db->select($fields)->from('users')->orderBy('last_name, first_name')->rows();
    }
}

Antes de pasar al controlador, quiero mostrar otro ejemplo para ilustrar cuán poderoso es esto. Tal vez tengo un motor de informes y necesito crear un informe para AllOverdueAccounts. Esto podría ser complicado con mi mapeador de datos, y es posible que desee escribir algo real SQLen esta situación. No hay problema, así es como podría verse este objeto de consulta:

class AllOverdueAccountsQuery implements AllOverdueAccountsQueryInterface
{
    protected $db;

    public function __construct(Database $db)
    {
        $this->db = $db;
    }

    public function fetch()
    {
        return $this->db->query($this->sql())->rows();
    }

    public function sql()
    {
        return "SELECT...";
    }
}

Esto mantiene muy bien toda mi lógica para este informe en una clase, y es fácil de probar. Puedo burlarme del contenido de mi corazón, o incluso usar una implementación completamente diferente.

El controlador

Ahora la parte divertida: unir todas las piezas. Tenga en cuenta que estoy usando inyección de dependencia. Por lo general, las dependencias se inyectan en el constructor, pero en realidad prefiero inyectarlas directamente en mis métodos de controlador (rutas). Esto minimiza el gráfico de objetos del controlador, y en realidad lo encuentro más legible. Tenga en cuenta que si no le gusta este enfoque, simplemente use el método de constructor tradicional.

class UsersController
{
    public function index(AllUsersQueryInterface $query)
    {
        // Fetch user data
        $users = $query->fetch(['first_name', 'last_name', 'email']);

        // Return view
        return Response::view('all_users.php', ['users' => $users]);
    }

    public function add()
    {
        return Response::view('add_user.php');
    }

    public function insert(UserRepositoryInterface $repository)
    {
        // Create new user model
        $user = new User;
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the new user
        $repository->save($user);

        // Return the id
        return Response::json(['id' => $user->id]);
    }

    public function view(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('view_user.php', ['user' => $user]);
    }

    public function edit(SpecificUserQueryInterface $query, $id)
    {
        // Load user data
        if (!$user = $query->fetch($id, ['first_name', 'last_name', 'gender', 'email'])) {
            return Response::notFound();
        }

        // Return view
        return Response::view('edit_user.php', ['user' => $user]);
    }

    public function update(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Update the user
        $user->first_name = $_POST['first_name'];
        $user->last_name = $_POST['last_name'];
        $user->gender = $_POST['gender'];
        $user->email = $_POST['email'];

        // Save the user
        $repository->save($user);

        // Return success
        return true;
    }

    public function delete(UserRepositoryInterface $repository)
    {
        // Load user model
        if (!$user = $repository->find($id)) {
            return Response::notFound();
        }

        // Delete the user
        $repository->delete($user);

        // Return success
        return true;
    }
}

Pensamientos finales:

Lo importante a tener en cuenta aquí es que cuando estoy modificando (creando, actualizando o eliminando) entidades, estoy trabajando con objetos de modelos reales y realizando la persistencia a través de mis repositorios.

Sin embargo, cuando estoy visualizando (seleccionando datos y enviándolos a las vistas) no estoy trabajando con objetos modelo, sino más bien con objetos de valor antiguo. Solo selecciono los campos que necesito y está diseñado para que pueda maximizar el rendimiento de mi búsqueda de datos.

Mis repositorios se mantienen muy limpios y, en cambio, este "desorden" se organiza en mis consultas modelo.

Utilizo un mapeador de datos para ayudar con el desarrollo, ya que es ridículo escribir SQL repetitivo para tareas comunes. Sin embargo, absolutamente puede escribir SQL donde sea necesario (consultas complicadas, informes, etc.). Y cuando lo haces, está bien escondido en una clase con el nombre adecuado.

¡Me encantaría escuchar tu opinión sobre mi enfoque!


Actualización de julio de 2015:

Me han preguntado en los comentarios dónde terminé con todo esto. Bueno, no tan lejos en realidad. A decir verdad, todavía no me gustan los repositorios. Los encuentro excesivos para búsquedas básicas (especialmente si ya está usando un ORM), y desordenados al trabajar con consultas más complicadas.

Por lo general, trabajo con un ORM de estilo ActiveRecord, por lo que a menudo solo haré referencia a esos modelos directamente en toda mi aplicación. Sin embargo, en situaciones donde tengo consultas más complejas, usaré objetos de consulta para hacerlos más reutilizables. También debo tener en cuenta que siempre inyecto mis modelos en mis métodos, lo que hace que sean más fáciles de burlar en mis pruebas.


44
@PeeHaa Nuevamente, fue para mantener los ejemplos simples. Es muy común dejar fragmentos de código fuera de un ejemplo si no pertenecen específicamente al tema en cuestión. En realidad, pasaría por mis dependencias.
Jonathan

44
Es interesante que divida su Crear, Actualizar y Eliminar de su Lectura. Pensé que valdría la pena mencionar la segregación de responsabilidad de consulta de comando (CQRS) que formalmente hace exactamente eso. martinfowler.com/bliki/CQRS.html
Adam

2
@ Jonathan Ha pasado un año y medio desde que respondiste tu propia pregunta. Me preguntaba si todavía está satisfecho con su respuesta y si esta es su solución principal ahora para la mayoría de sus proyectos. Las últimas semanas he estado leyendo mucho en repositorios y he visto a muchas personas tener su propia interpretación de cómo debería implementarse. Lo estás llamando objetos de consulta, pero este es un patrón existente, ¿verdad? Creo que lo he visto en otros idiomas.
Boedy

1
@ Jonathan: ¿Cómo maneja las consultas que deberían hacer que un usuario no sea "ID" sino, por ejemplo, "nombre de usuario" o incluso consultas más complicadas con más de una condición?
Gizzmo

1
@Gizzmo Usando objetos de consulta, puede pasar parámetros adicionales para ayudarlo con sus consultas más complicadas. Por ejemplo, se puede hacer esto en el constructor: new Query\ComplexUserLookup($username, $anotherCondition). O haga esto a través de métodos setter $query->setUsername($username);. Realmente puede diseñar esto, sin embargo, tiene sentido para su aplicación particular, y creo que los objetos de consulta dejan mucha flexibilidad aquí.
Jonathan

48

Según mi experiencia, aquí hay algunas respuestas a sus preguntas:

P: ¿Cómo nos ocupamos de recuperar los campos que no necesitamos?

R: Desde mi experiencia, esto realmente se reduce a tratar con entidades completas versus consultas ad-hoc.

Una entidad completa es algo así como un Userobjeto. Tiene propiedades y métodos, etc. Es un ciudadano de primera clase en su código base.

Una consulta ad-hoc devuelve algunos datos, pero no sabemos nada más allá de eso. A medida que los datos pasan por la aplicación, se hacen sin contexto. Es un User? A Usercon alguna Orderinformación adjunta? Realmente no lo sabemos.

Prefiero trabajar con entidades completas.

Tiene razón en que a menudo traerá datos que no usará, pero puede abordar esto de varias maneras:

  1. Guarda en caché las entidades de forma agresiva para que solo pagues el precio de lectura una vez desde la base de datos.
  2. Pase más tiempo modelando sus entidades para que tengan buenas distinciones entre ellas. (Considere dividir una entidad grande en dos entidades más pequeñas, etc.)
  3. Considere tener múltiples versiones de entidades. Puede tener un Userpara el back-end y quizás un UserSmallpara llamadas AJAX. Uno podría tener 10 propiedades y uno tiene 3 propiedades.

Las desventajas de trabajar con consultas ad-hoc:

  1. Termina esencialmente con los mismos datos en muchas consultas. Por ejemplo, con un User, terminarás escribiendo esencialmente lo mismo select *para muchas llamadas. Una llamada obtendrá 8 de 10 campos, una obtendrá 5 de 10, una obtendrá 7 de 10. ¿Por qué no reemplazar todas con una llamada que obtenga 10 de 10? La razón por la que esto es malo es que es un asesinato re-factorizar / probar / burlarse.
  2. Con el tiempo, es muy difícil razonar a un alto nivel sobre su código. En lugar de declaraciones como "¿Por qué es Usertan lento?" terminas rastreando consultas únicas y las correcciones de errores tienden a ser pequeñas y localizadas.
  3. Es realmente difícil reemplazar la tecnología subyacente. Si almacena todo en MySQL ahora y desea pasar a MongoDB, es mucho más difícil reemplazar 100 llamadas ad-hoc que un puñado de entidades.

P: Tendré demasiados métodos en mi repositorio.

R: Realmente no he visto otra solución que no sea consolidar llamadas. Las llamadas de método en su repositorio realmente se asignan a las características de su aplicación. Cuantas más funciones, más llamadas específicas de datos. Puede retrasar las funciones e intentar fusionar llamadas similares en una.

La complejidad al final del día tiene que existir en alguna parte. Con un patrón de repositorio lo hemos introducido en la interfaz del repositorio en lugar de tal vez hacer un montón de procedimientos almacenados.

A veces tengo que decirme a mí mismo: "¡Bueno, tenía que ceder en alguna parte! No hay balas de plata".


Gracias por la respuesta muy completa. Me tienes pensando ahora. Mi gran preocupación aquí es que todo lo que leo dice que no SELECT *, sino que solo selecciono los campos que necesita. Por ejemplo, vea esta pregunta . En cuanto a todas esas consultas publicitarias de las que hablas, ciertamente entiendo de dónde vienes. Tengo una aplicación muy grande en este momento que tiene muchas de ellas. Ese fue mi "¡Bueno, tenía que ceder en alguna parte!" Por el momento, opté por el máximo rendimiento. Sin embargo, ahora estoy lidiando con MUCHAS consultas diferentes.
Jonathan

1
Un pensamiento de seguimiento. He visto una recomendación para usar un enfoque R-CUD. Dado que a readsmenudo surgen problemas de rendimiento, podría utilizar un enfoque de consulta más personalizado para ellos, que no se traduzca en objetos comerciales reales. Entonces, para create, updatey delete, utilizar un ORM, que trabaja con objetos completos. ¿Alguna idea sobre ese enfoque?
Jonathan

1
Como una nota para usar "select *". Lo hice en el pasado y funcionó bien, hasta que llegamos a los campos varchar (max). Los que mataron nuestras consultas. Entonces, si tiene tablas con ints, pequeños campos de texto, etc., no está tan mal. Se siente antinatural, pero el software sigue ese camino. Lo que fue malo de repente es bueno y viceversa.
ryan1234

1
El enfoque R-CUD es en realidad CQRS
MikeSW

2
@ ryan1234 "La complejidad al final del día tiene que existir en alguna parte". Gracias por esto. Me hace sentir mejor.
johnny

20

Yo uso las siguientes interfaces:

  • Repository - carga, inserta, actualiza y elimina entidades
  • Selector - encuentra entidades basadas en filtros, en un repositorio
  • Filter - encapsula la lógica de filtrado

Mi Repositoryes agnóstico de base de datos; de hecho no especifica ninguna persistencia; podría ser cualquier cosa: base de datos SQL, archivo xml, servicio remoto, un extraterrestre del espacio exterior, etc. Para las capacidades de búsqueda, las Repositoryconstrucciones Selectorse pueden filtrar LIMIT, clasificar, clasificar y contar. Al final, el selector obtiene uno o más Entitiesde la persistencia.

Aquí hay un código de muestra:

<?php
interface Repository
{
    public function addEntity(Entity $entity);

    public function updateEntity(Entity $entity);

    public function removeEntity(Entity $entity);

    /**
     * @return Entity
     */
    public function loadEntity($entityId);

    public function factoryEntitySelector():Selector
}


interface Selector extends \Countable
{
    public function count();

    /**
     * @return Entity[]
     */
    public function fetchEntities();

    /**
     * @return Entity
     */
    public function fetchEntity();
    public function limit(...$limit);
    public function filter(Filter $filter);
    public function orderBy($column, $ascending = true);
    public function removeFilter($filterName);
}

interface Filter
{
    public function getFilterName();
}

Entonces, una implementación:

class SqlEntityRepository
{
    ...
    public function factoryEntitySelector()
    {
        return new SqlSelector($this);
    }
    ...
}

class SqlSelector implements Selector
{
    ...
    private function adaptFilter(Filter $filter):SqlQueryFilter
    {
         return (new SqlSelectorFilterAdapter())->adaptFilter($filter);
    }
    ...
}
class SqlSelectorFilterAdapter
{
    public function adaptFilter(Filter $filter):SqlQueryFilter
    {
        $concreteClass = (new StringRebaser(
            'Filter\\', 'SqlQueryFilter\\'))
            ->rebase(get_class($filter));

        return new $concreteClass($filter);
    }
}

La idea es que los Selectorusos genéricos Filterpero los SqlSelectorusos de implementación SqlFilter; el se SqlSelectorFilterAdapteradapta un genérico Filtera un concreto SqlFilter.

El código del cliente crea Filterobjetos (que son filtros genéricos) pero en la implementación concreta del selector esos filtros se transforman en filtros SQL.

Otras implementaciones de selector, como InMemorySelector, transformar de Filterque InMemoryFilterel uso de su específica InMemorySelectorFilterAdapter; entonces, cada implementación de selector viene con su propio adaptador de filtro.

Al usar esta estrategia, mi código de cliente (en la capa de negocios) no se preocupa por un repositorio específico o implementación de selector.

/** @var Repository $repository*/
$selector = $repository->factoryEntitySelector();
$selector->filter(new AttributeEquals('activated', 1))->limit(2)->orderBy('username');
$activatedUserCount = $selector->count(); // evaluates to 100, ignores the limit()
$activatedUsers = $selector->fetchEntities();

PD: esta es una simplificación de mi código real


"Repositorio: carga, inserta, actualiza y elimina entidades" esto es lo que puede hacer una "capa de servicio", "DAO", "BLL"
Yousha Aleayoub

5

Agregaré un poco sobre esto ya que actualmente estoy tratando de comprender todo esto yo mismo.

# 1 y 2

Este es un lugar perfecto para que su ORM haga el trabajo pesado. Si está utilizando un modelo que implementa algún tipo de ORM, puede usar sus métodos para encargarse de estas cosas. Haga su propio pedido Por funciones que implementan los métodos Eloquent si es necesario. Usando Eloquent por ejemplo:

class DbUserRepository implements UserRepositoryInterface
{
    public function findAll()
    {
        return User::all();
    }

    public function get(Array $columns)
    {
       return User::select($columns);
    }

Lo que parece estar buscando es un ORM. No hay razón para que su repositorio no pueda basarse en uno. Esto requeriría que el Usuario extienda elocuente, pero personalmente no lo veo como un problema.

Sin embargo, si desea evitar un ORM, tendrá que "rodar el suyo" para obtener lo que está buscando.

# 3

No se supone que las interfaces sean requisitos difíciles y rápidos. Algo puede implementar una interfaz y agregarle. Lo que no puede hacer es no implementar una función requerida de esa interfaz. También puede extender interfaces como clases para mantener las cosas SECAS.

Dicho esto, estoy empezando a comprender, pero estas realizaciones me han ayudado.


1
Lo que no me gusta de este método es que si tuviera un MongoUserRepository, eso y su DbUserRepository devolverían diferentes objetos. Db devuelve un Eloquent \ Model y Mongo algo propio. Sin duda, una mejor implementación es hacer que ambos repositorios devuelvan instancias / colecciones de una clase Entity \ User separada. De esta manera, no confía erróneamente en los métodos DB de Eloquent \ Model cuando cambia a usar MongoRepository
danharper

1
Definitivamente estaría de acuerdo contigo en eso. Lo que probablemente haría para evitar eso es nunca usar esos métodos fuera de la clase que requiere Eloquent. Por lo tanto, la función get probablemente debería ser privada y solo usarse dentro de la clase, ya que, como señaló, devolvería algo que otros repositorios no podrían.
Será el

3

Solo puedo comentar sobre la forma en que (en mi empresa) tratamos esto. En primer lugar, el rendimiento no es un gran problema para nosotros, pero tener un código limpio / adecuado sí lo es.

En primer lugar, definimos modelos como un UserModelque usa un ORM para crear UserEntityobjetos. Cuando a UserEntityse carga desde un modelo, se cargan todos los campos. Para los campos que hacen referencia a entidades foráneas, utilizamos el modelo foráneo apropiado para crear las entidades respectivas. Para esas entidades, los datos se cargarán a pedido. Ahora tu reacción inicial podría ser ... ??? ... !!! déjame darte un ejemplo un poco de ejemplo:

class UserEntity extends PersistentEntity
{
    public function getOrders()
    {
        $this->getField('orders'); //OrderModel creates OrderEntities with only the ID's set
    }
}

class UserModel {
    protected $orm;

    public function findUsers(IGetOptions $options = null)
    {
        return $orm->getAllEntities(/*...*/); // Orm creates a list of UserEntities
    }
}

class OrderEntity extends PersistentEntity {} // user your imagination
class OrderModel
{
    public function findOrdersById(array $ids, IGetOptions $options = null)
    {
        //...
    }
}

En nuestro caso $dbes un ORM que puede cargar entidades. El modelo indica al ORM que cargue un conjunto de entidades de un tipo específico. El ORM contiene una asignación y la usa para inyectar todos los campos de esa entidad en la entidad. Sin embargo, para campos foráneos solo se cargan los id de esos objetos. En este caso, OrderModelcrea OrderEntitys con solo los id de los pedidos referenciados. Cuando PersistentEntity::getFieldes llamado por la OrderEntityentidad, le indica a su modelo que cargue lentamente todos los campos en el OrderEntitys. Todos los OrderEntitycorreos electrónicos asociados con una UserEntity se tratan como un conjunto de resultados y se cargarán a la vez.

La magia aquí es que nuestro modelo y ORM inyectan todos los datos en las entidades y que las entidades simplemente proporcionan funciones envolventes para el getFieldmétodo genérico suministrado por PersistentEntity. Para resumir, siempre cargamos todos los campos, pero los campos que hacen referencia a una entidad extranjera se cargan cuando es necesario. Solo cargar un montón de campos no es realmente un problema de rendimiento. Cargar todas las entidades extranjeras posibles, sin embargo, sería una GRAN disminución del rendimiento.

Ahora a cargar un conjunto específico de usuarios, basado en una cláusula where. Proporcionamos un paquete de clases orientado a objetos que le permite especificar expresiones simples que se pueden pegar. En el código de ejemplo lo llamé GetOptions. Es un contenedor para todas las opciones posibles para una consulta de selección. Contiene una colección de cláusulas where, un grupo por cláusula y todo lo demás. Nuestras cláusulas where son bastante complicadas, pero obviamente podrías hacer una versión más simple fácilmente.

$objOptions->getConditionHolder()->addConditionBind(
    new ConditionBind(
        new Condition('orderProduct.product', ICondition::OPERATOR_IS, $argObjProduct)
    )
);

Una versión más simple de este sistema sería pasar la parte WHERE de la consulta como una cadena directamente al modelo.

Lamento esta respuesta bastante complicada. Traté de resumir nuestro marco lo más rápido y claro posible. Si tiene alguna pregunta adicional, no dude en hacerla y actualizaré mi respuesta.

EDITAR: además, si realmente no desea cargar algunos campos de inmediato, puede especificar una opción de carga diferida en su mapeo ORM. Debido a que todos los campos finalmente se cargan a través del getFieldmétodo, podría cargar algunos campos en el último minuto cuando se llama a ese método. Este no es un problema muy grande en PHP, pero no lo recomendaría para otros sistemas.


3

Estas son algunas soluciones diferentes que he visto. Hay pros y contras para cada uno de ellos, pero es decisión tuya.

Problema 1: demasiados campos

Este es un aspecto importante, especialmente cuando tiene en cuenta los escaneos de solo índice . Veo dos soluciones para lidiar con este problema. Puede actualizar sus funciones para incluir un parámetro de matriz opcional que contendría una lista de columnas para devolver. Si este parámetro está vacío, devolverá todas las columnas de la consulta. Esto puede ser un poco raro; basado en el parámetro, podría recuperar un objeto o una matriz. También podría duplicar todas sus funciones para tener dos funciones distintas que ejecuten la misma consulta, pero una devuelve una matriz de columnas y la otra devuelve un objeto.

public function findColumnsById($id, array $columns = array()){
    if (empty($columns)) {
        // use *
    }
}

public function findById($id) {
    $data = $this->findColumnsById($id);
}

Problema # 2: demasiados métodos

Trabajé brevemente con Propel ORM hace un año y esto se basa en lo que puedo recordar de esa experiencia. Propel tiene la opción de generar su estructura de clase basada en el esquema de base de datos existente. Crea dos objetos para cada tabla. El primer objeto es una larga lista de funciones de acceso similares a las que ha enumerado actualmente; findByAttribute($attribute_value). El siguiente objeto hereda de este primer objeto. Puede actualizar este objeto secundario para incorporar sus funciones getter más complejas.

Otra solución sería utilizar __call()para asignar funciones no definidas a algo procesable. Su __callmétodo sería capaz de analizar findById y findByName en diferentes consultas.

public function __call($function, $arguments) {
    if (strpos($function, 'findBy') === 0) {
        $parameter = substr($function, 6, strlen($function));
        // SELECT * FROM $this->table_name WHERE $parameter = $arguments[0]
    }
}

Espero que esto ayude al menos algo.



0

Estoy de acuerdo con @ ryan1234 en que debe pasar objetos completos dentro del código y usar métodos de consulta genéricos para obtener esos objetos.

Model::where(['attr1' => 'val1'])->get();

Para uso externo / punto final, me gusta mucho el método GraphQL.

POST /api/graphql
{
    query: {
        Model(attr1: 'val1') {
            attr2
            attr3
        }
    }
}

0

Problema # 3: Imposible hacer coincidir una interfaz

Veo el beneficio de usar interfaces para repositorios, por lo que puedo cambiar mi implementación (para fines de prueba u otros). Entiendo que las interfaces definen un contrato que debe seguir una implementación. Esto es genial hasta que comience a agregar métodos adicionales a sus repositorios como findAllInCountry (). Ahora necesito actualizar mi interfaz para que también tenga este método, de lo contrario, otras implementaciones pueden no tenerlo, y eso podría dañar mi aplicación. Por esto se siente loco ... un caso de la cola moviendo al perro.

Mi instinto me dice que tal vez requiera una interfaz que implemente métodos optimizados de consulta junto con métodos genéricos. Las consultas sensibles al rendimiento deben tener métodos específicos, mientras que las consultas poco frecuentes o livianas son manejadas por un controlador genérico, tal vez a costa del controlador haciendo un poco más de malabarismo.

Los métodos genéricos permitirían implementar cualquier consulta y, por lo tanto, evitarían cambios importantes durante un período de transición. Los métodos específicos le permiten optimizar una llamada cuando tiene sentido y puede aplicarse a múltiples proveedores de servicios.

Este enfoque sería similar a las implementaciones de hardware que realizan tareas optimizadas específicas, mientras que las implementaciones de software hacen el trabajo ligero o la implementación flexible.


0

Creo que GraphQL es un buen candidato en este caso para proporcionar un lenguaje de consulta a gran escala sin aumentar la complejidad de los repositorios de datos.

Sin embargo, hay otra solución si no quieres utilizar GraphQL por ahora. Al usar un DTO donde se usa un objeto para transportar los datos entre procesos, en este caso entre el servicio / controlador y el repositorio.

Ya se proporcionó una respuesta elegante anteriormente, sin embargo, intentaré dar otro ejemplo que creo que es más simple y podría servir como punto de partida para un nuevo proyecto.

Como se muestra en el código, solo necesitaríamos 4 métodos para las operaciones CRUD. el findmétodo se usaría para enumerar y leer pasando argumentos de objeto. Los servicios de back-end podrían construir el objeto de consulta definido en función de una cadena de consulta URL o en función de parámetros específicos.

El objeto de consulta ( SomeQueryDto) también podría implementar una interfaz específica si es necesario. y es fácil de extender más tarde sin agregar complejidad.

<?php

interface SomeRepositoryInterface
{
    public function create(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function update(SomeEnitityInterface $entityData): SomeEnitityInterface;
    public function delete(int $id): void;

    public function find(SomeEnitityQueryInterface $query): array;
}

class SomeRepository implements SomeRepositoryInterface
{
    public function find(SomeQueryDto $query): array
    {
        $qb = $this->getQueryBuilder();

        foreach ($query->getSearchParameters() as $attribute) {
            $qb->where($attribute['field'], $attribute['operator'], $attribute['value']);
        }

        return $qb->get();
    }
}

/**
 * Provide query data to search for tickets.
 *
 * @method SomeQueryDto userId(int $id, string $operator = null)
 * @method SomeQueryDto categoryId(int $id, string $operator = null)
 * @method SomeQueryDto completedAt(string $date, string $operator = null)
 */
class SomeQueryDto
{
    /** @var array  */
    const QUERYABLE_FIELDS = [
        'id',
        'subject',
        'user_id',
        'category_id',
        'created_at',
    ];

    /** @var array  */
    const STRING_DB_OPERATORS = [
        'eq' => '=', // Equal to
        'gt' => '>', // Greater than
        'lt' => '<', // Less than
        'gte' => '>=', // Greater than or equal to
        'lte' => '<=', // Less than or equal to
        'ne' => '<>', // Not equal to
        'like' => 'like', // Search similar text
        'in' => 'in', // one of range of values
    ];

    /**
     * @var array
     */
    private $searchParameters = [];

    const DEFAULT_OPERATOR = 'eq';

    /**
     * Build this query object out of query string.
     * ex: id=gt:10&id=lte:20&category_id=in:1,2,3
     */
    public static function buildFromString(string $queryString): SomeQueryDto
    {
        $query = new self();
        parse_str($queryString, $queryFields);

        foreach ($queryFields as $field => $operatorAndValue) {
            [$operator, $value] = explode(':', $operatorAndValue);
            $query->addParameter($field, $operator, $value);
        }

        return $query;
    }

    public function addParameter(string $field, string $operator, $value): SomeQueryDto
    {
        if (!in_array($field, self::QUERYABLE_FIELDS)) {
            throw new \Exception("$field is invalid query field.");
        }
        if (!array_key_exists($operator, self::STRING_DB_OPERATORS)) {
            throw new \Exception("$operator is invalid query operator.");
        }
        if (!is_scalar($value)) {
            throw new \Exception("$value is invalid query value.");
        }

        array_push(
            $this->searchParameters,
            [
                'field' => $field,
                'operator' => self::STRING_DB_OPERATORS[$operator],
                'value' => $value
            ]
        );

        return $this;
    }

    public function __call($name, $arguments)
    {
        // camelCase to snake_case
        $field = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $name));

        if (in_array($field, self::QUERYABLE_FIELDS)) {
            return $this->addParameter($field, $arguments[1] ?? self::DEFAULT_OPERATOR, $arguments[0]);
        }
    }

    public function getSearchParameters()
    {
        return $this->searchParameters;
    }
}

Ejemplo de uso:

$query = new SomeEnitityQuery();
$query->userId(1)->categoryId(2, 'ne')->createdAt('2020-03-03', 'lte');
$entities = $someRepository->find($query);

// Or by passing the HTTP query string
$query = SomeEnitityQuery::buildFromString('created_at=gte:2020-01-01&category_id=in:1,2,3');
$entities = $someRepository->find($query);
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.