Ayuda a crear un método base flexible ‘buscar’ en una clase de servicio utilizando el principio DRY

Durante años, he estado volviendo a implementar el mismo código una y otra vez (con la evolución) sin encontrar ningún método de manera clara y eficiente, para resúmenes.

El patrón es un método base ‘find [Type] s’ en mis capas de servicio que abstrae la creación de consultas de consulta a un único punto en el servicio, pero admite la capacidad de crear rápidamente métodos proxy más fáciles de usar (ver el ejemplo PostServivce :: getPostById () método de más abajo).

Lamentablemente, hasta ahora, no he podido cumplir estos objectives:

  1. Reducir la posibilidad de errores introducidos por distintas implementaciones
  2. Exponer opciones de parámetros válidos / no válidos en IDE para autocompletar
  3. Sigue el principio DRY

Mi implementación más reciente generalmente se parece al siguiente ejemplo. El método toma una serie de condiciones y una variedad de opciones, y crea y ejecuta Doctrine_Query (en su mayoría reescribí esto aquí hoy, por lo que puede haber algunos errores tipográficos / de syntax, no es un corte y pegado directo).

class PostService { /* ... */ /** * Return a set of Posts * * @param Array $conditions Optional. An array of conditions in the format * array('condition1' => 'value', ...) * @param Array $options Optional. An array of options * @return Array An array of post objects or false if no matches for conditions */ public function getPosts($conditions = array(), $options = array()) { $defaultOptions = = array( 'orderBy' => array('date_created' => 'DESC'), 'paginate' => true, 'hydrate' => 'array', 'includeAuthor' => false, 'includeCategories' => false, ); $q = Doctrine_Query::create() ->select('p.*') ->from('Posts p'); foreach($conditions as $condition => $value) { $not = false; $in = is_array($value); $null = is_null($value); $operator = '='; // This part is particularly nasty :( // allow for conditions operator specification like // 'slug LIKE' => 'foo%', // 'comment_count >=' => 1, // 'approved NOT' => null, // 'id NOT IN' => array(...), if(false !== ($spacePos = strpos($conditions, ' '))) { $operator = substr($condition, $spacePost+1); $conditionStr = substr($condition, 0, $spacePos); /* ... snip validate matched condition, throw exception ... */ if(substr($operatorStr, 0, 4) == 'NOT ') { $not = true; $operatorStr = substr($operatorStr, 4); } if($operatorStr == 'IN') { $in = true; } elseif($operatorStr == 'NOT') { $not = true; } else { /* ... snip validate matched condition, throw exception ... */ $operator = $operatorStr; } } switch($condition) { // Joined table conditions case 'Author.role': case 'Author.id': // hard set the inclusion of the author table $options['includeAuthor'] = true; // break; intentionally omitted /* ... snip other similar cases with omitted breaks ... */ // allow the condition to fall through to logic below // Model specific condition fields case 'id': case 'title': case 'body': /* ... snip various valid conditions ... */ if($in) { if($not) { $q->andWhereNotIn("p.{$condition}", $value); } else { $q->andWhereIn("p.{$condition}", $value); } } elseif ($null) { $q->andWhere("p.{$condition} IS " . ($not ? 'NOT ' : '') . " NULL"); } else { $q->andWhere( "p.{condition} {$operator} ?" . ($operator == 'BETWEEN' ? ' AND ?' : ''), $value ); } break; default: throw new Exception("Unknown condition '$condition'"); } } // Process options // init some later processing flags $includeAuthor = $includeCategories = $paginate = false; foreach(array_merge_recursivce($detaultOptions, $options) as $option => $value) { switch($option) { case 'includeAuthor': case 'includeCategories': case 'paginate': /* ... snip ... */ $$option = (bool)$value; break; case 'limit': case 'offset': case 'orderBy': $q->$option($value); break; case 'hydrate': /* ... set a doctrine hydration mode into $hydration */ break; default: throw new Exception("Invalid option '$option'"); } } // Manage some flags... if($includeAuthor) { $q->leftJoin('p.Authors a') ->addSelect('a.*'); } if($paginate) { /* ... wrap query in some custom Doctrine Zend_Paginator class ... */ return $paginator; } return $q->execute(array(), $hydration); } /* ... snip ... */ } 

Phewf

Los beneficios de esta función base son:

  1. me permite soportar rápidamente nuevas condiciones y opciones a medida que el esquema evoluciona
  2. me permite un medio para implementar rápidamente condiciones globales en la consulta (por ejemplo, agregar una opción ‘excludeDisabled’ con un valor predeterminado de verdadero y filtrar todos los modelos disabled = 0, a menos que una persona que llama diga explícitamente de manera diferente).
  3. me permite crear rápidamente métodos nuevos y más sencillos de usar que las llamadas proxy vuelvan al método findPosts. Por ejemplo:
 class PostService { /* ... snip ... */ // A proxy to getPosts that limits results to 1 and returns just that element public function getPost($conditions = array(), $options()) { $conditions['id'] = $id; $options['limit'] = 1; $options['paginate'] = false; $results = $this->getPosts($conditions, $options); if(!empty($results) AND is_array($results)) { return array_shift($results); } return false; } /* ... docblock ...*/ public function getPostById(int $id, $conditions = array(), $options()) { $conditions['id'] = $id; return $this->getPost($conditions, $options); } /* ... docblock ...*/ public function getPostsByAuthorId(int $id, $conditions = array(), $options()) { $conditions['Author.id'] = $id; return $this->getPosts($conditions, $options); } /* ... snip ... */ } 

Los principales inconvenientes con este enfoque son:

  • El mismo método monolítico de ‘encontrar [Modelo] s’ se crea en todos los servicios de acceso a modelos, y en su mayoría solo cambian la construcción del interruptor de condición y los nombres de la tabla base.
  • No hay una forma simple de realizar Y / O operaciones de condición. Todas las condiciones expresamente ANDed.
  • Presenta muchas oportunidades para errores tipográficos
  • Presenta muchas oportunidades para las pausas en la API basada en convenciones (por ejemplo, un servicio posterior puede requerir la implementación de una convención de syntax diferente para especificar la opción orderBy, que se vuelve tedioso para realizar un back-port a todos los servicios anteriores).
  • Viola los principios DRY.
  • Las condiciones y opciones válidas están ocultas para los analizadores de autocompletado de IDE y los parámetros de opciones y condiciones requieren una extensa explicación de bloque de documentos para seguir las opciones permitidas.

En los últimos días, he intentado desarrollar una solución más OO para este problema, pero he sentido que estoy desarrollando TOO una solución compleja que será demasiado rígida y restrictiva para usar.

La idea por la que estaba trabajando era algo similar a lo siguiente (el proyecto actual será Doctrine2 fyi, por lo que habrá un ligero cambio) …

 namespace Foo\Service; use Foo\Service\PostService\FindConditions; // extends a common \Foo\FindConditions abstract use Foo\FindConditions\Mapper\Dql as DqlConditionsMapper; use Foo\Service\PostService\FindOptions; // extends a common \Foo\FindOptions abstract use Foo\FindOptions\Mapper\Dql as DqlOptionsMapper; use \Doctrine\ORM\QueryBuilder; class PostService { /* ... snip ... */ public function findUsers(FindConditions $conditions = null, FindOptions $options = null) { /* ... snip instantiate $q as a Doctrine\ORM\QueryBuilder ... */ // Verbose $mapper = new DqlConditionsMapper(); $q = $mapper ->setQuery($q) ->setConditions($conditions) ->map(); // Concise $optionsMapper = new DqlOptionsMapper($q); $q = $optionsMapper->map($options); if($conditionsMapper->hasUnmappedConditions()) { /* .. very specific condition handling ... */ } if($optionsMapper->hasUnmappedConditions()) { /* .. very specific condition handling ... */ } if($conditions->paginate) { return new Some_Doctrine2_Zend_Paginator_Adapter($q); } else { return $q->execute(); } } /* ... snip ... */ } 

Y, por último, una muestra de la clase Foo \ Service \ PostService \ FindConditions:

 namespace Foo\Service\PostService; use Foo\Options\FindConditions as FindConditionsAbstract; class FindConditions extends FindConditionsAbstract { protected $_allowedOptions = array( 'user_id', 'status', 'Credentials.credential', ); /* ... snip explicit get/sets for allowed options to provide ide autocompletion help */ } 

Foo \ Options \ FindConditions y Foo \ Options \ FindOptions son realmente bastante similares, por lo que, por ahora, al menos, ambos amplían una clase principal común de Foo \ Options. Esta clase principal maneja las variables permitidas de inicialización y los valores predeterminados, accediendo a las opciones establecidas, restringiendo el acceso a solo las opciones definidas, y proporcionando una interfaz de iterador para DqlOptionsMapper para recorrer las opciones.

Desafortunadamente, después de hackear esto durante unos días, me siento frustrado con la complejidad de este sistema. Como está, todavía no hay soporte en esto para grupos de condiciones y condiciones OR, y la capacidad de especificar operadores de comparación de condición alternativa ha sido un completo atolladero para crear una clase Foo \ Options \ FindConditions \ Comparison alrededor de un valor al especificar un FindConditions value ( $conditions->setCondition('Foo', new Comparison('NOT LIKE', 'bar')); ).

Preferiría usar la solución de otra persona si existiera, pero todavía tengo que encontrarme con algo que haga lo que estoy buscando.

Me gustaría ir más allá de este proceso y volver a construir el proyecto en el que estoy trabajando, pero ni siquiera veo un final a la vista.

Entonces, Stack Overflowers: ¿Hay alguna forma mejor que proporcione los beneficios que he identificado sin incluir los inconvenientes?

Creo que estás complicando las cosas.

He trabajado en un proyecto que usa Doctrine 2 que tiene muchas entidades, diferentes usos para ellos, varios servicios, repositorys personalizados, etc. y he encontrado que algo así funciona bastante bien (para mí de todos modos).

1. Repositorios para consultas

En primer lugar, generalmente no realizo consultas en servicios. Doctrine 2 proporciona el EntityRepository y la opción de crear subclases para cada entidad para este propósito exacto.

  • Siempre que sea posible, utilizo los métodos de magia estándar de FindOneBy … y FindBy … Esto me ahorra tener que escribir DQL y funciona bastante bien de la caja.
  • Si necesito una lógica de consulta más complicada, suelo crear buscadores específicos de casos de uso en los repositorys. Estas son cosas como UserRepository.findByNameStartsWith y cosas por el estilo.
  • Por lo general, no creo un súper elegante “¡Puedo tomar cualquier argumento que me des!” tipo de buscadores de magia. Si necesito una consulta específica, agrego un método específico. Si bien esto puede parecer que requiere la escritura de más código, creo que es una manera mucho más simple y fácil de entender. (Intenté revisar el código de su buscador y era bastante complicado buscarlo en algunos lugares)

Entonces en otras palabras …

  • Intenta usar lo que la doctrine ya te da (métodos de búsqueda mágica)
  • Use clases de repository personalizadas si necesita una lógica de consulta personalizada
  • Crear un método por tipo de consulta

2. Servicios para combinar lógica no-entidad

Use los servicios para combinar “transacciones” detrás de una interfaz simple que puede usar de sus controladores o probar fácilmente con pruebas unitarias.

Por ejemplo, supongamos que tus usuarios pueden agregar amigos. Cuando un usuario hace amigos a otra persona, se envía un correo electrónico a la otra persona para que lo notifique. Esto es algo que tendrías a tu servicio.

Su servicio incluiría (por ejemplo) un método addNewFriend que toma dos usuarios. Luego, podría usar un repository para consultar algunos datos, actualizar las matrices de amigos de los usuarios y llamar a otra clase que luego envíe el correo electrónico.

Puede usar el entitymanager en sus servicios para obtener clases de repository o entidades persistentes.

3. Entidades para la lógica específica de la entidad

Por último, debe tratar de poner su lógica comercial que sea específica de una entidad directamente en la clase de entidad.

Un ejemplo simple para este caso podría ser que tal vez el envío de correo electrónico en el escenario anterior utilice algún tipo de saludo. “Hola, señor Anderson”, o “Hola, señorita Anderson”.

Entonces, por ejemplo, necesitaría un poco de lógica para determinar el saludo apropiado. Esto es algo que podría tener en la clase de entidad: por ejemplo, getGreeting o algo así, que luego podría tener en cuenta el sexo y la nacionalidad del usuario y devolver algo basado en eso. (suponiendo que el género y la nacionalidad se almacenen en la base de datos, pero no el saludo en sí; el saludo se calcularía mediante la lógica de la función)

Probablemente también debería señalar que las entidades generalmente no deberían saber ni del administrador de la entidad ni de los repositorys. Si la lógica requiere cualquiera de estos, probablemente no pertenezca a la clase de entidad misma.

Beneficios de este enfoque

He encontrado que el enfoque que he detallado aquí funciona bastante bien. Es mantenible porque generalmente es bastante “obvio” para lo que hacen las cosas, no depende de un comportamiento de consulta complicado, y debido a que las cosas se dividen claramente en diferentes “áreas” (repositorys, servicios, entidades) es bastante sencillo probar la unidad como bien.