El tema de las abstracciones y todo tipo de patrones encantadores es un buen terreno para el desarrollo de holivars y disputas eternas: por un lado, seguimos la corriente principal, todo tipo de palabras de moda y código limpio, por otro lado, tenemos práctica y realidad que siempre dictan sus propias reglas.
Qué hacer si las abstracciones comienzan a “filtrarse”, cómo usar chips de lenguaje y qué puede extraer del patrón de “especificación” - vea debajo del corte.
Entonces, vamos al grano. El artículo contendrá las siguientes secciones: para empezar, examinaremos cuál es el patrón de "especificación" y por qué su aplicación a muestras de bases de datos puras causa dificultades.
A continuación, pasamos a los árboles de expresión, que son una herramienta muy poderosa, y vemos cómo nos pueden ayudar.
al final, demostraré mi implementación de la "especificación" en esteroides.
Comencemos con las cosas básicas. Creo que todos han escuchado sobre el patrón de "especificación", pero para aquellos que no lo han escuchado, aquí está su definición de
Wikipedia :
Una "especificación" en programación es un patrón de diseño mediante el cual la representación de las reglas de lógica de negocios se puede transformar en una cadena de objetos conectados por operaciones lógicas booleanas.
Esta plantilla resalta tales especificaciones (reglas) en la lógica de negocios que son adecuadas para "acoplarse" con otros. Un objeto de lógica de negocios hereda su funcionalidad de la clase agregada abstracta CompositeSpecification, que contiene solo un método IsSatisfiedBy que devuelve un valor booleano. Después de la instanciación, el objeto se encadena junto con otros objetos. Como resultado, sin perder flexibilidad en la configuración de la lógica empresarial, podemos agregar fácilmente nuevas reglas.
En otras palabras, una especificación es un objeto que implementa la siguiente interfaz (descartando métodos para construir cadenas):
public interface ISpecification { bool IsSatisfiedBy(object candidate); }
Aquí todo es simple y claro. Pero ahora veamos un ejemplo del mundo real en el que, además del dominio, hay una infraestructura que también es una persona despiadada: pasemos al caso del uso de ORM, un DBMS y especificaciones para filtrar datos en una base de datos.
Para no ser infundado y no señalar con el dedo, tomamos como ejemplo el siguiente tema: supongamos que estamos desarrollando MMORPG, tenemos usuarios, cada usuario tiene 1 o más caracteres, y cada personaje tiene un conjunto de elementos ( asumimos que los ítems son únicos para cada usuario), y para cada uno de los ítems, a su vez, se pueden aplicar runas de mejora. En total, en forma de diagrama (consideraremos la clase ReadCharacter un poco más tarde cuando hablemos de consultas anidadas):

Este modelo está conectado libremente con el mundo real, y también contiene campos que reflejan cierta conexión con el ORM utilizado, pero esto será suficiente para que podamos demostrar el trabajo.
Supongamos que queremos filtrar todos los caracteres creados después de la fecha especificada.
Para hacer esto, escribimos una especificación de la siguiente forma:
public class CreatedAfter: ISpecification { private readonly DateTime _target; public CreatedAfter(DateTime target) { _target = target; } bool IsSatisfiedBy(object candidate) { var character = candidate as Character; if(character == null) return false; return character.CreatedAt > target; } }
Bueno, entonces, para aplicar esta especificación, hacemos lo siguiente (en adelante consideraré el código basado en NHibernate):
var characters = await session.Query<Character>().ToListAsync(); var filter = new CreatedAfter(new DateTime(2020, 1, 1)); var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray();
Mientras nuestra base sea pequeña, todo funcionará de manera hermosa y rápida, pero si nuestro juego se vuelve más o menos popular y gana un par de decenas de miles de usuarios, todo este encanto consumirá memoria, tiempo y dinero, y es mejor disparar a esta bestia de inmediato. porque No es un inquilino. En esta triste nota, pospondremos la especificación y volveremos un poco a mi práctica.
Érase una vez, en un proyecto muy, muy distante, tenía clases en mi código que contenían lógica para recuperar datos de la base de datos. Se veían algo así:
public class ICharacterDal { IEnumerable<Character> GetCharactersCreatedAfter(DateTime date); IEnumerable<Character> GetCharactersCreatedBefore(DateTime date); IEnumerable<Character> GetCharactersCreatedBetween(DateTime from, DateTime to); ... }
y su uso:
var dal = new CharacterDal(); var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1));
Dentro de las clases estaba la lógica para trabajar con el DBMS (en ese momento era ADO.NET).
Todo parecía estar bien, pero con la expansión del proyecto, estas clases también crecieron, convirtiéndose en objetos difíciles de mantener. Además, hubo un regusto desagradable: parece ser una regla comercial, pero se almacenaron en el nivel de infraestructura, porque estaban vinculados a una implementación específica.
Este enfoque fue reemplazado por el repositorio
IQueryable <T> , que permitió llevar todas las reglas directamente a la capa de dominio.
public interface IRepository<T> { T Get(object id); IQueryable<T> List(); void Delete(T obj); void Save(T obj); }
que se usó algo como esto:
var repository = new Repository(); var targetDate = new DateTime(2020, 1, 1); var createdUsers = await repository.List().Where(x => x.CreatedAd > targetDate).ToListAsync();
Un poco mejor, pero el problema es que las reglas se arrastran a lo largo del código, y la misma verificación puede ocurrir en cientos de lugares, y es fácil imaginar lo que puede resultar en cambios en los requisitos.
Este enfoque oculta otro problema: si no materializa la consulta, es decir, la posibilidad de completar varias consultas a la base de datos, en lugar de una, que, por supuesto, afecta negativamente el rendimiento del sistema.
Y aquí, en uno de los proyectos, un colega sugirió usar una
biblioteca que sugiriera la implementación del patrón de "especificación" basado en árboles de expresión.
En resumen, sobre la base de esta biblioteca, filmamos especificaciones que nos permitieron crear filtros para entidades y construir filtros más complejos basados en concatenaciones de reglas simples. Por ejemplo, tenemos una especificación para los caracteres creados después del año nuevo y hay una especificación para elegir caracteres con un determinado elemento; luego, combinando estas reglas, podemos crear una solicitud de una lista de caracteres creados después del nuevo año y tener el elemento especificado. Y si en el futuro cambiaremos la regla para determinar nuevos caracteres (por ejemplo, usaremos la fecha del año nuevo chino), entonces lo corregiremos solo en la especificación misma y no hay necesidad de buscar todos los usos de esta lógica por código.
Este proyecto se completó con éxito, y la experiencia de utilizar este enfoque ha sido muy exitosa. Pero no quería quedarme quieto, y hubo algunos problemas en la implementación, a saber:
- el operador de pegado OR no funcionó;
- la unión solo funciona para consultas que contienen filtros de tipo Where, pero quería reglas más completas (consultas anidadas, omitir / tomar, obtener proyecciones);
- el código de especificación dependía del ORM seleccionado;
- no fue posible utilizar las funciones ORM, ya que Esto condujo a la inclusión de dependencias en la capa de lógica de negocios (por ejemplo, era imposible hacer la recuperación).
El resultado de resolver estos problemas fue el mini-marco
Singularis.Secification , que consta de varios ensamblajes:
- Singularis.Specification.Definition: define el objeto de especificación y también contiene la interfaz IQuery con la que se forma la regla.
- Singularis.Specification.Executor. *: Implementa un repositorio y un objeto para ejecutar especificaciones para ORM específicos (actualmente soportado por ef.core y NHibernate, como parte de los experimentos también hice una implementación para mongodb, pero este código no entró en producción).
Echemos un vistazo más de cerca a la implementación.
La interfaz de especificación define la propiedad pública que contiene la regla de especificación:
public interface ISpecification { IQuery Query { get; } Type ResultType { get; } } public interface ISpefication<T>: ISpecification { }
Además, la interfaz contiene la propiedad
ResultType , que devuelve el tipo de entidad obtenida como resultado de la consulta.
Su implementación está contenida en la clase
Especificación <T> , que implementa la propiedad
ResultType , calculando en función de la regla almacenada en la consulta, así como dos métodos:
Source () y
Source <TSource> () . Estos métodos sirven para formar la fuente de la regla.
Source () crea una regla con un tipo que coincide con el argumento de la clase de especificación, y
Source <TSource> () le permite crear una regla para una clase arbitraria (utilizada al generar consultas anidadas).
Además, también existe la clase
SpecificationExtension , que contiene métodos de extensión para encadenar solicitudes.
Se admiten dos tipos de unión: concatenación (puede considerarse como unión por la condición "Y") y unión por la condición "O".
Volvamos a nuestro ejemplo e implementemos nuestras dos reglas:
public class CreatedAfter: Specification<Character> { public CreatedAfter(DateTime target) { Query = Source().Where(x => x.CreatedAt > target); } } public class CreatedBefore: Specification<Character> { public CreatedBefore(DateTime target) { Query = Source().Where(x => x.CreatedAt < target); } }
y encuentre todos los usuarios que cumplan ambas reglas:
var specification = new CreatedAfter(new DateTime(2019, 1, 1).Combine(new CreatedBefore(new DateTime(2020, 1, 1)); var users = repository.List(specification);
La combinación con el método
Combinar admite reglas arbitrarias. Lo principal es que el tipo resultante del lado izquierdo coincide con el tipo de entrada del lado derecho. Por lo tanto, puede crear reglas que contengan proyecciones, omitir / tomar para paginación, ordenar reglas, buscar, etc.
La regla Or es más restrictiva: solo admite cadenas que contienen condiciones de filtrado Where. Considere el uso de un ejemplo: encontramos todos los caracteres creados antes de 2000 o después de 2020:
var specification = new CreatedAfter(new DateTime(2020, 1, 1).Or(new CreatedBefore(new DateTime(2000, 1, 1)); var users = repository.List(specification );
La interfaz
IQuery repite en gran medida la interfaz
IQueryable , por lo que no debería haber preguntas especiales. Detengámonos solo en métodos específicos:
Fetch / ThenFetch : le permite incluir datos relacionados en la consulta generada para fines de optimización. Por supuesto, esto es un poco torcido cuando tenemos características de implementación de infraestructura que afectan las reglas de negocios, pero, como dije, la realidad es una abstracción dura y pura: esto es algo bastante teórico.
Donde :
IQuery declara dos sobrecargas de este método, una toma solo una expresión lambda para filtrar en forma de
Expression <Func <T, bool >> , y la segunda también toma parámetros adicionales
IQueryContext , que le permite ejecutar subconsultas anidadas. Veamos un ejemplo.
Tenemos la clase ReadCharacter en el modelo: suponga que nuestro modelo se presenta como una parte de lectura que contiene datos desnormalizados y sirve para comentarios rápidos, y una parte de escritura que contiene enlaces, datos normalizados, etc. Queremos mostrar todos los caracteres para los que el usuario tiene correo en un dominio específico.
public class CharactersForUserWithEmailDomain: Specification<ReadCharacter> { public CharactersForUserWithEmailDomain(string domain) { var usersQuery = Source<User>(x => x.Email.Contains(domain)).Projection(x => x.Id); Query = Source().Where((x, ctx) => ctx.GetQueryResult<int>(usersQuery).Contains(x.Id)); } }
Como resultado de la ejecución, se generará la siguiente consulta SQL:
select readcharac0_.id as id1_3_, readcharac0_.UserId as userid2_3_, readcharac0_.Name as name3_3_ from ReadCharacters readcharac0_ where readcharac0_.UserId in ( select user1_.Id from Users user1_ where user1_.Email like ('%'+@p0+'%') ); @p0 = '@inmagna.ca' [Type: String (4000:0:0)]
Para cumplir con todas estas maravillosas reglas, se
define la interfaz
IRepository , que le permite recibir artículos por identificador, recibir uno (el primero adecuado) o una lista de objetos de acuerdo con la especificación, y también guardar y eliminar artículos de la tienda.
Con la definición de consultas, descubrimos, ahora queda por enseñar a nuestro ORM a entender esto.
Para hacer esto, analizaremos el ensamblaje de
Singularis.Infrastructure.NHibernate (para ef.core todo se ve igual, solo con los detalles de ef.core).
El punto de acceso a datos es el objeto Repository, que implementa la interfaz
IRepository . En caso de recibir un objeto por identificador, así como para modificar el almacenamiento (guardar / eliminar), esta clase concluye una sesión y oculta una implementación específica de la capa empresarial. En el caso de trabajar con especificaciones, forma un objeto
IQueryable que refleja nuestra consulta en términos de
IQuery y luego lo ejecuta en el objeto de sesión.
La magia principal y el código más feo reside en la clase responsable de convertir
IQuery a
IQueryable - SpecificationExecutor. Esta clase contiene mucha reflexión, que llama métodos Queryable o métodos de extensión de un ORM específico (EagerFetchingExtensionsMethods para NHiberante).
Esta biblioteca se usa activamente en nuestros proyectos (para ser honesto, una biblioteca ya actualizada se usa para nuestros proyectos, pero gradualmente todos estos cambios se establecerán en el dominio público) está en constante cambio. Hace solo un par de semanas, se lanzó la próxima versión, que cambió a métodos asincrónicos, se corrigieron errores en el ejecutor para ef.core, se agregaron pruebas y muestras. Es probable que la biblioteca contenga errores y un centenar de lugares para la optimización: nació como un proyecto paralelo en el marco del trabajo en los proyectos principales, por lo que me complacerá sugerir sugerencias para mejorar. Además, no debe apresurarse a usarlo; es probable que en su caso particular esto sea innecesario o inaplicable.
¿Cuándo vale la pena usar la solución descrita? Probablemente sea más fácil comenzar con la pregunta "cuándo no debería":
- Highload: si necesita un alto rendimiento, el uso de ORM en sí mismo plantea una pregunta. Aunque, por supuesto, nadie prohíbe implementar un ejecutor que traduzca las consultas a SQL y las ejecute ...
- proyectos muy pequeños: esto es muy subjetivo, pero debes admitir que tirar del ORM y todo el zoológico que lo acompaña al proyecto de la "lista de tareas pendientes" parece disparar gorriones desde un cañón.
En cualquier caso, quién dominó la lectura hasta el final, gracias por su tiempo. ¡Espero comentarios para el desarrollo futuro!
Casi se me olvida: el código del proyecto está disponible en GitHub'e:
https://github.com/SingularisLab/singularis.specificationLos ensamblajes están disponibles para descargar a través de nuget