Saltar al contenido

¿Diseño de patrones de repositorio adecuado en PHP?

Solución:

Pensé en intentar responder a mi propia pregunta. Lo que sigue es solo una forma de resolver los problemas 1-3 en mi pregunta original.

Descargo de responsabilidad: es posible que no siempre use 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 completamente comprobable y simulado.
  • 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 debe aprovechar algún tipo de mapeador de datos para facilitar el desarrollo.
  • La implementación debe tener la capacidad de realizar búsquedas de datos complejas.

La solución

Estoy dividiendo mi interacción de almacenamiento persistente (base de datos) en dos categorías: R (Lea y RUMIA (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.

RUMIA (Crear, Actualizar, Eliminar) es fácil. Esto implicará trabajar con modelos reales, que luego se pasan a mi Repositories por la 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 la visualización. Más sobre eso más tarde.

R (Leer) no es tan fácil. Aquí no hay modelos, solo objetos de valor. Utilice matrices si lo prefiere. Estos objetos pueden representar un solo modelo o una combinación de muchos modelos, cualquier cosa en realidad. Estos no son muy interesantes por sí mismos, pero la forma en que se generan sí lo es. 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 cosas de base de datos en absoluto. Solo pura gloria de modelo. Agregue sus getters, setters, 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 usuarios, quiero crear mi interfaz de repositorio. Esto definirá el “contrato” que deben seguir los repositorios para que puedan ser utilizados por mi controlador. Recuerde, mi responsable del tratamiento no sabrá dónde se almacenan realmente los datos.

Tenga en cuenta que mis repositorios solo contendrán estos tres métodos. los save() El método es responsable tanto de la creación como de la actualización de usuarios, simplemente dependiendo de si el objeto de usuario tiene o no un ID establecido.

interface UserRepositoryInterface

    public function find($id);
    public function save(User $user);
    public function remove(User $user);

Implementación del repositorio 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');
    

Interfaz de objeto de consulta

Ahora con RUMIA (Crear, Actualizar, Eliminar) a cargo de nuestro repositorio, podemos centrarnos en el 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 constructores 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 AllUsersQuery o AllActiveUsersQuery, o incluso MostCommonUserFirstNames.

Puede estar pensando “¿no puedo simplemente crear métodos en mis repositorios para esas consultas?” Sí, pero esta es la razón por la que no estoy haciendo esto:

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

Para mi ejemplo, crearé un objeto de consulta para buscar “Todos los usuarios”. Aquí está la interfaz:

interface AllUsersQueryInterface

    public function fetch($fields);

Implementación del objeto de consulta

Aquí es donde podemos usar un mapeador de datos nuevamente para ayudar a acelerar el desarrollo. Observe que estoy permitiendo un ajuste al conjunto de datos devuelto: los campos. Esto es todo lo que quiero llegar con la manipulación de 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é mucho este, en varias situaciones diferentes, me doy 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 lo poderoso que es. Quizás 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 algunos datos reales. SQL en 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 de él al contenido de mi corazón, o incluso usar una implementación diferente por completo.

El controlador

Ahora la parte divertida: juntar todas las piezas. Tenga en cuenta que estoy usando la inyección de dependencia. Normalmente, las dependencias se inyectan en el constructor, pero en realidad prefiero inyectarlas directamente en los métodos de mi controlador (rutas). Esto minimiza el gráfico de objetos del controlador y, de hecho, 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:

Las cosas importantes a tener en cuenta aquí son 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 mostrando (seleccionando datos y enviándolos a las vistas) no estoy trabajando con objetos de modelo, sino con objetos de valor antiguos. 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, es absolutamente posible escribir SQL donde sea necesario (consultas complicadas, informes, etc.). Y cuando lo hace, está muy bien guardado en una clase con el nombre correcto.

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


Actualización de julio de 2015:

Me preguntaron 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 las búsquedas básicas (especialmente si ya está usando un ORM) y desordenados cuando se trabaja con consultas más complicadas.

Por lo general, trabajo con un ORM de estilo ActiveRecord, por lo que la mayoría de las veces solo hago referencia a esos modelos directamente a lo largo de mi aplicación. Sin embargo, en situaciones en las que tengo consultas más complejas, usaré objetos de consulta para que sean más reutilizables. También debo tener en cuenta que siempre inyecto mis modelos en mis métodos, lo que hace que sea más fácil simularlos en mis pruebas.

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

Q: ¿Cómo nos ocupamos de traer de vuelta campos que no necesitamos?

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

Una entidad completa es algo así como un User objeto. 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, lo hacen sin contexto. Es una User? A User Con algo Order información adjunta? Realmente no lo sabemos.

Prefiero trabajar con entidades completas.

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

  1. Almacene en caché agresivamente las entidades para que solo pague el precio de lectura una vez desde la base de datos.
  2. Dedique más tiempo a modelar 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 varias versiones de entidades. Puedes tener un User para el back-end y tal vez un UserSmall para llamadas AJAX. Uno puede tener 10 propiedades y uno tiene 3 propiedades.

Las desventajas de trabajar con consultas ad-hoc:

  1. Termina con esencialmente 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 todos con una llamada que obtenga 10 de 10? La razón por la que esto es malo es que es un asesinato volver a factorizar / probar / simular.
  2. Se vuelve muy difícil razonar a un alto nivel sobre su código con el tiempo. En lugar de declaraciones como “¿Por qué User ¿Tan lento? “, terminas rastreando consultas únicas y, por lo tanto, 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.

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

A: Realmente no he visto otra forma de evitar esto que no sea la consolidación de llamadas. Las llamadas a métodos en su repositorio realmente se asignan a características en su aplicación. Cuantas más funciones, más llamadas específicas de datos. Puede retroceder en 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 insertado 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 soluciones mágicas”.

Utilizo 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 Repository es independiente de la 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, el Repository construye un Selector que se puede filtrar, LIMIT-edidos, clasificados y contados. Al final, el selector obtiene uno o más Entities de la persistencia.

Aquí hay un código de muestra:

Luego, 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 el genérico Selector usos Filter pero la implementación SqlSelector usos SqlFilter; los SqlSelectorFilterAdapter adapta un genérico Filter a un concreto SqlFilter.

El código de cliente crea Filter objetos (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 Filter para InMemoryFilter usando su especifico InMemorySelectorFilterAdapter; por tanto, cada implementación de selector viene con su propio adaptador de filtro.

Usando 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

Sección de Reseñas y Valoraciones

Si haces scroll puedes encontrar las explicaciones de otros usuarios, tú asimismo puedes insertar el tuyo si lo deseas.

¡Haz clic para puntuar esta entrada!
(Votos: 0 Promedio: 0)



Utiliza Nuestro Buscador

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *