martes, 26 de agosto de 2008

La capa de acceso a datos.

A partir de los comentarios de la entrada "Jaque" me quedé dando vueltas alrededor de la arquitectura de la capa de datos en una aplicación.

El primer punto a definir es qué entendemos por capa de datos. Me gusta tomar Wikipedia porque siento que representa el mínimo común denominador entre todas las acepciones posibles. Una base sobre la que (creo) estaríamos todos de acuerdo. En la correspondiente entrada (Programación por capas) encontramos:

[...] Capa de datos: es donde residen los datos y es la encargada de acceder a los mismos. Está formada por uno o más gestores de bases de datos que realizan todo el almacenamiento de datos, reciben solicitudes de almacenamiento o recuperación de información desde la capa de negocio.

Lo que sigue a continuación es toda la secuencia de experiencia previa, análisis, pruebas y errores que nos llevó a la capa de acceso a datos que tenemos armada ahora, con la que estoy bastante conforme. Está claro que dependiendo del proyecto las atribuciones y responsabilidades de esta capa podrán variar sutilmente. Para el caso, necesito agregar una responsabilidad más, que puede o no asignarse a esta capa, pero que en todo caso quiero incluir en la discusión:

Transformar objetos que representan la estructura de la base de datos en otros que representan la lógica del negocio y viceversa.

Como decía, puede que se lo asignemos o no a la capa de datos, pero en lo que estaremos todos de acuerdo es en que los objetos que maneje la capa de negocios no deberían estar absolutamente condicionados por la estructura de los datos en la base, por más que tengan alguna relación con ellos o que utilicemos las tablas como punto de partida. Por ejemplo:

  • El módulo de facturación puede definir un objeto "Cliente" de acuerdo a sus necesidades, por ejemplo con una propiedad "Facturación" (una lista de facturas). Digamos que se llama "Facturacion.Cliente".
  • El módulo de CRM podría definir un objeto "Cliente" diferente ("CRM.Cliente"), más acorde a sus propias necesidades. Por ejemplo con una propiedad "Incidentes" que contiene una lista de las comunicaciones que tenemos con él.

Traducido a la capa de acceso a datos, estamos haciendo un JOIN entre la tabla de clientes y la de facturas por un lado y la de incidentes por el otro, más todas las tablas satélite que se puedan requerir en cada caso.

En el framework de trabajo existente hasta ese momento, cada objeto de negocio utilizaba una capa de datos para acceder a la base, pero definía su propia consulta utilizando el lenguaje específico, SQL, que se iba armando en el mismo objeto de negocio. La capa de datos devolvía objetos genéricos (DataTable, DataReader, etc.) que eran utilizados directamente. En este caso provee la persistencia y la conexión, pero se desentiende de la consulta en sí misma.

Los problemas más notorios son que el código de consulta ensucia la clase de negocio, no permite reutilización alguna y se producen infinitos puntos de acceso a datos. Por otro lado la utilización de objetos de datos genéricos como el DataTable hacen que el código sea difícil de seguir, sobre todo si se utilizan subíndices para acceder a los datos en particular (ej.: saldo=dataRow[1] + dataRow[3]).

Con este punto de partida, los primeros esfuerzos se orientaron a optimizar y ordenar el armado de esas consultas. En principio se adoptó una filosofía estricta de utilizar siempre procedimientos almacenados, para separar el código SQL del resto de la lógica.

Eso emprolija un poco el código, pero no resuelve los demás problemas. Era una tarea necesaria, pero que no atacaba el centro de la cuestión, representado por el ejemplo anterior: necesitamos manejar objetos de negocio específicos, no genéricos ("Cliente" en vez de "DataTable") y necesitamos acceder a propiedades evitando el uso de subíndices, entre otros.

Entonces comenzamos a armar una capa de acceso a datos que asumiera esa tarea de transformación de datos en conceptos de negocio, montado sobre el "proveedor de persistencia y conexión" (por llamarlo de alguna manera) existente.

Otro problema era lograr cierta reutilización. Estaba claro que si se creaba un procedimiento almacenado para cada necesidad estaríamos simplemente poniendo el muerto bajo otra alfombra. Todas la consultas que antes ensuciaban los objetos de negocio pasarían a formar un inmenso (e inmanejable) repositorio de procedimientos almacenados.

Aquí es donde entra en juego el problema de los JOIN en los procedimientos almacenados que se menciona al pasar en los comentarios de Jaque. En nuestro ejemplo, si hago un JOIN en la base de datos necesito dos consultas o procedimientos almacenados: una para Cliente-Facturas y otra para Cliente-Incidentes.

Algo similar sucede con las transacciones. Necesito grabar Clientes-Facturas en una única transacción. Si la implemento en un procedimiento almacenado, ese procedimiento sólo podrá grabar esa combinación de datos.

Era claro que por ese camino y sólo tomando las dos o tres tablas principales del sistema tendríamos una miríada de consultas diferentes, cada una respondiendo a una combinación cabecera-detalle diferente. Se descartó por ser a todas luces poco práctica la posibilidad de una súper-consulta que recupere (por ejemplo) un cliente y todos sus datos devolviendo una especie de súper-entidad. Está claro que la tabla cliente podría tener más de 20 tablas de detalle independientes (ej.: facturas, remitos, incidentes, pedidos, contactos, pagos, cheques, movimientos, y todo lo que se nos ocurra), y que rara vez se utilice más de un par en cada módulo de negocio (es claro que el módulo que maneje la chequera poco tiene que hacer con los incidentes). Por otro lado también se descartó la posibilidad de cargar esa súper-entidad "on demand" por presentarse demasiado compleja.

Es cuando surge como una posibilidad más sencilla el tener un conjunto de consultas estándar, las clásicas SELECT, INSERT, UPDATE y DELETE para cada tabla y con ellas llenar las estructuras de datos más complejas en el código. En la gran mayoría de los casos esto no representa problemas de rendimiento. Pero de todas maneras nada impide tener una consulta especial sólo para aquellas situaciones que sí lo justifiquen.

Surgieron así las clases de acceso a datos creadas automáticamente a partir de la estructura de cada tabla. Para nuestro ejemplo, tendríamos tres clases ya predefinidas: ClientesDatos, FacturasDatos, IncidentesDatos. El objeto de negocio las utiliza para llenar Facturación.Cliente.Facturas y CRM.Cliente.Incidentes en cada caso. Estas clases devuelven objetos tipados, también creados a partir de la estructura de las tablas.

Con las transacciones sucede lo mismo. Al momento de grabar un cliente y sus incidentes la clase de negocio abre una transacción, llama a las clases de datos correspondientes y la cierra. No puede abrirse una transacción en la misma clase de datos, ya que esto impediría la reutilización. Conceptualmente tiene sentido. Una transacción queda definida en la operación de negocio que se realice. No es lo mismo facturar que insertar un incidente, pero a la capa de datos no tiene por qué interesarle esto.

Pero estoy haciendo trampa, porque en mi ejemplo omití una situación común. Se puede argumentar, con razón, que IncidentesDatos devuelve, según mi ejemplo, incidentes para un cliente, y que probablemente otro módulo requiera, por ejemplo, "incidentes por responsable interno" o "incidentes sin responsable" o "incidentes no resueltos", etc. Para cada uno de estos casos deberíamos armar un procedimiento almacenado y estaríamos de vuelta con el problema anterior.

Esto era justamente lo que nos estaba comenzando a suceder. Si bien era fácil tener clasificados los procedimientos almacenados (ya que siempre se referían a una única tabla), la realidad era que por ese camino iban a ser demasiados.

Hubo que romper el mito de "todo procedimientos almacenados" (con cierta reticencia inicial por mi parte, pero por suerte la mejora se notó rápidamente, despejando las dudas). De vuelta a generar código automático, esta vez para crear objetos que encapsularan un criterio WHERE generado dinámicamente. Un especie de filtro extremadamente configurable para cada tabla.

La clave fue el encapsulamiento. Si la lógica de armar consultas "on the fly" se hubiese expandido sin control por todo el sistema estaríamos de vuelta en la situación inicial. Estos objetos (uno específico para cada tabla) se volvieron internos de la capa de datos.

Para resumir, en el cuerpo de un método de la capa de acceso a datos se crea uno de estos objetos, se lo configura con un par de líneas de código y al ejecutarlo nos devuelve un objeto tipado que representa los datos de una tabla de la base. Un objeto de la capa de negocio puede llamar a varios métodos de la capa de acceso a datos para crear objetos complejos según su necesidad.

Cambiamos procedimientos almacenados por métodos, que resultaron más fáciles de clasificar, mantener y controlar. Un ejemplo en este sentido: si cambio la estructura de una tabla y genero automáticamente el código de la clase de filtro para esa tabla, se provoca automáticamente un error de compilación en todos los métodos de datos que se vean afectados por el cambio (y sólo en ellos). Una vez arreglados estos errores de compilación puedo estar seguro de fue implementado sin mayores consecuencias. Es decir, si compila funciona, si no funciona no compila.

¿Y dónde quedaron los JOIN y los procedimientos almacenados en T-SQL? Reservados sólo para algunos casos especiales, donde la consulta se realiza sobre tablas muy grandes o donde es necesario alguna lógica adicional u optimización relacionada con los datos. Pero resultaron ser apenas un par de casos excepcionales.

6 comentarios:

Cerebrado dijo...

Jejeje. Es muy bueno el post, sobre todo porque refleja exactamente lo que pensaba con respecto a los comentarios de otro de tus posts.
(Es muy bueno tener a alguien dedicado a escribir lo que otros pensamos. ¿Pensaste en dedicarte exclusivamente a la documentación?)

AcP dijo...

Si hay algo que no me sobra son ideas, así que cuando te golpees la cabeza y se te caiga alguna idea pasámela que la desarrollo (y esto va para todos).

Tanks.

Improbable dijo...

Me gustó el post.

Un par de comentarios.

" Al momento de grabar un cliente y sus incidentes la clase de negocio abre una transacción, no puede abrirse una transacción en la misma clase de datos, ya que esto impediría la reutilización."

Este problema para mí se resolvió con AOP y transacción declarativa en particular Spring o Spring.net (obvio, con AOP podés construirlo vos, sería divertido y un poco inútil hacerlo).

Con respecto a las consultas, sigo pensando en un entorno de uso intensivo (diría que eso descarta Sql Server, pero no quiero exponer mis fobias tan facilmente): una query de menos es una query de menos, y vale la pena ahorrarsela. En tal caso, me gusta la idea del HQL o del CriteriaQuery de Hibernate, o incluso un SQL con un callback para devolver los objetos del modelo de dominio a partir del a consulta.

De memoria, sería escribir algo así " from Cliente c join c.Incidencias as inci with inci.estado = ? " (nótese la variable de bind :-) )

Esto devuelve una lista de objetos Cliente que tengan incidencias en un determinado estado. Cualquier forma que se me ocurre ahora de hacerlo a mano, sería muchísimo menos performante y trabajoso.

AcP dijo...

Gracias por el aporte... me dejaste pensando, así que voy a tener que responder en un par de días.

Esteban dijo...

Yo soy un fanatico de los SP.
Los defiendo a capa y espada. Creo que en la enorme mayoria de los sistemas, son la solucion mas prolija, performante y segura.
Con una sola excepcion: El caso de filtros dinamicos. Donde ahi, un SELECT armado on the fly, es mucho mas elegante y eficiente.
Odio con toda mi alma hibernate y ese tipo de soluciones. Creo que quienes crearon semejante enjendro deberian ser torturados y quemados en una hoguera publica...

En mi laburo, generamos una arquitectura que nos ha servido.
Via MyGeneration (herramienta de generacion de codigo), generamos el SP, una la clase de Entidad, la clase que accede al los datos y una clase de servicio, que no es un simple pasamanos, sino que contempla el cacheo a Memcached, un metodo Validate() y en caso de que corresponda, la transaccion que contempla la llamada a una o mas entidades (obviamente esto ya no es tarea del generador de codigo).
Me gusta atar la logica de negocio a la base de datos en la medida de lo posible.
Soy programador, pero tengo alma de DBA...

Alejandro G. dijo...

hola, entiendo tu dilema, yo tambien encuentro un poco lioso eso de separar en capas algo que tecnicamente es inseparable (la capa de presentacion siempre necesitara datos, porque de otra manera no tiene sentido su existencia)

lo que he leido es que no es 100% separable la capa de datos y la capa de negocio (por no incluir la capa de presentacion que tambien es inseparable como comente) lo que se debe de buscar es que sea escalable mas que separable, que el mejoramiento de una capa no irrumpa en la funcionalidad de las otras (pseudo) capas.

el servicio de datos, persigue la seguridad y la integridad de los datos, es decir que sean correctos y que cuando se requiera algun dato se entregue datos mas o menos digeridos. las consultas SQL existiran, pero lo que se pretende es que existan mas consultas estilo 'vistas' que te relacionen los datos sin necesidad de que tu aplicacion sepa de antemano cual es la relacion entre tablas.

los procedimientos almacenados deben de ayudarte para generar tablas secundarias que de otra manera serian muy costosas desde el cliente (p.e. tablas para generar personas para encuestar, procesadas en horas nocturnas, datos estadisticos de encuestas telefonicas, etc.) tambien puedes hacer uso de los triggers para crear nuevos registros en tablas (p.e. cuando un producto sea < 3 hacer un pedido).

la capa de datos no pretende hacer nada mas que como comente antes: seguridad, integridad y datos (puros y duros) a la espera de ser procesados por alguien que le interese (sea o no una aplicacion, una persona con conocimientos de SQL debe de obtener los datos que necesite sin necesidad de tener la aplicacion corporativa )

espero que ayude en algo mi reflexion. saludos