lunes, 12 de enero de 2009

Un ejemplo práctico y III

En esta tercera y última entrega del ejemplo práctico pretendo:

  • Teorizar sobre los diferentes enfoques al problema de carga de entidades en un diseño orientado a objetos.
  • Profundizar en el diseño de la clase DataLoader y en sus mecanismos de expansión.

En el día a día de un desarrollador es habitual la utilización de tablas de Código-Descripción. Con ellas se ahorra espacio ya que no se duplica la información, se normalizan los datos, etc. Lo que todos ya sabemos. Los problemas con el manejo de estas entidades comienzan cuando en nuestro sistema existen entidades con muchos "códigos" de "tablas maestras", claves externas vamos.

¿Cómo obtenemos estos valores relativos a estas claves?. ¿Utilizamos sentencias INNER JOIN?. ¿Realmente y vamos a necesitar cargar todos estos valores en todos los escenarios?. Abordar estas preguntas, a mi modo de ver, es crucial a la hora de diseñar un sistema para que sea AGIL y TOLERANTE a los cambios. Esto último es importantísimo en proyectos grandes en los que, como todos sabemos, los cambios son constantes y no hay que romper lo que ya funciona.

Voy a intentar poner un ejemplo sencillo: supongamos que tenemos la típica entidad Empleado, que tiene, además de los campos habituales, un código de departamento, un código de categoría, un código de Responsable y una lista de Proyectos en los que participa. A la hora de cargar un objeto Empleado la implementación más habitual es la de lanzar una sentencia SQL con un INNER JOIN de las diferentes tablas. Es obvio, que si un caso de uso concreto dice que solo tengo que listar el Nombre y el Departamento, estaremos solicitando y transmitiendo más datos de los que realmente necesitamos.

Con este modelo "INNER JOIN" si posteriormente se añade otra clave externa, por ejemplo TipoDeHorario, habrá que modificar la sentencia SQL para que el método Carga() llene TODAS las propiedades. Este cambio en el modelo implicará una penalización en el rendimiento de otros métodos ya implementados que funcionaban correctamente.

Si cada cambio que se realice en el modelo afecta al rendimiento de lo ya existente nuestro sistema NO ES AGIL ni TOLERANTE a cambios. Con unos cuantos cambios como estos, a posteriori, y con este enfoque, el sistema dejará rápidamente de comportarse como venía haciéndolo y lo que es peor: de como el usuario ya está acostumbrado.

Por lo tanto y como ya he escrito en otra entrada:

“Siempre que se pueda, y aquí entran cuestiones de rendimiento, el primer enfoque que doy a la carga de entidades relacionada es la Lazy Load o Carga Perezosa con algún mecanismo de cacheo. De esta manera los objetos no pierden “su integridad” y siguen funcionando como se espera tras implementar diferentes modos de carga: en un escenario haciendo un INNER JOIN con la tabla Provincias, en otro escenario con la tabla Facturas donde no interesa en absoluto la información de las anteriores.”

No estoy en contra de la utilización del método INNER JOIN, al contrario, muchas veces es el que hay que utilizar. Lo que planteo es que, antes de nada, es necesario un análisis exhaustivo de donde se  donde se va a utilizar y de como afectará  esto a a posibles futuras modificaciones.

Otra decisión importante que tenemos que tomar a la hora de cargar un objeto, es "Cuando" se cargarán las entidades relacionadas y las descripciones de las propiedades de las que solo tenemos su clave externa. En nuestro ejemplo, simple el, solamente existe una:  La lista de Proyectos. En una aplicación del mundo real podría haber, además, una lista de direcciones, una lista de teléfonos, etc. ¿Cómo abordamos este problema? ¿Cargamos todas las entidades en el método carga()?. De este modo estaríamos en disposición de obtener cualquier valor inmediatamente después de su invocación, pero de nuevo en la implementación del caso de uso Listar Nombre y Departamento estaríamos haciendo más cosas de las estrictamente necesarias.

Nota importante: Existen escenarios en que este enfoque es el correcto ya que SIEMPRE que se carga una entidad se van a utilizar/consultar sus entidades relacionadas. De no ser así es mas aconsejable utilizar mecanismos de Carga Perezosa (Lazy Load). De esta forma las cosas se cargan cuando realmente se necesitan.

A continuación intentaré mostrar como realizar la carga de objetos para evitar, de algún modo, los problemas expuestos anteriormente.

Carga de un valor desde una tabla maestra.

En ocasiones es necesario mostrar la descripción de una propiedad en lugar del código o clave externa. Por ejemplo, supongamos que ahora necesitamos especificar un Tipo para la entidad Envio que ya conocemos de otras entradas del blog. Los tipos pueden ser: Local = 0, Nacional = 1 e Internacional = 2. Podríamos implementarlo de la siguiente forma:

Private _idTipo As Integer 
Public Property IdTipo() As Integer 
  Get 
    Return _idTipo 
  End Get 
  Set(ByVal value As Integer) 
    _idTipo = value 
  End Set 
End Property 

Private _TipoDeEnvio As String = "" 
Public ReadOnly Property TipoDeEnvio() As String 
  Get 
    Return _TipoDeEnvio 
  End Get 
End Property 

Con un enfoque tradicional del problema primero realizaríamos un INNER JOIN en la sentencia SQL, cambiaríamos el cargador del objeto y lo tendríamos. Lo MALO de este enfoque es que penalizamos el código ya existente que no necesita el tipo de envio. Por lo tanto es necesario hacernos las siguientes preguntas: ¿Es en todos los escenarios estrictamente necesaria la carga del Tipo de envio? ¿Lo será en futuros escenarios o casos de uso?. Lo más normal es dar un NO como respuesta estas dos preguntas.

Tendremos, por tanto, que mejorar el mecanismo de carga de este tipo de propiedades. Una primera aproximación puede ser la de cachear el valor. Lo vemos con el siguiente código:

Public ReadOnly Property TipoDeEnvio() As String 
  Get 
    If _TipoDeEnvio.Length = 0 Then 
      _TipoDeEnvio = New TipoDeEnvio().Carga(_idTipo).Descripcion 
    End If 
    Return _TipoDeEnvio 
  End Get 
End Property 

Si observamos con detenimiento esta nueva implementación nos daremos cuenta de que no interfiere con la anterior y de que en el caso de seguir cargando el objeto con INNER JOIN la creación y carga de un objeto TipoDeEnvio no será necesaria. Es muy importante, repito, que las modificaciones no rompan ni incidan en el rendimiento de lo que ya funciona.

Esta patrón puede ser interesante para la implementación de algunos casos de uso sencillos. Por ejemplo, si queremos abrir un solo envio y conocer su tipo. Necesitaremos, entonces, 2 accesos a la capa de Datos.

En el caso de que queramos, por el contrario, listar todos los envios de un mes cualquiera y mostrar su tipo, el patrón anterior no será el más adecuado, realizará una consulta a la base de datos por cada envio. No es del todo malo, ya que en una etapa inicial del desarrollo puede interesar más mostrar la futura funcionalidad, que el RENDIMIENTO.

Una versión mejorada puede hacer uso de mecanismos de cacheo globales o incrementales.

Public ReadOnly Property TipoDeEnvio() As String 
  Get 
    If _TipoDeEnvio.Length = 0 Then 
      If Helper.CacheDeTipos Is Nothing Then 
        _TipoDeEnvio = New TipoDeEnvio().Carga(_idTipo).Descripcion 
      Else 
        _TipoDeEnvio = Helper.CacheDeTipos.Item(_idTipo).Descripcion 
      End If 
    End If 
    Return _TipoDeEnvio 
  End Get 
End Property 

Se puede apreciar como esta última implementación “permitirá” el correcto funcionamiento del objeto independientemente del contexto en el que se utilice y de como se haya inicializado.

Carga de Entidades relacionadas

En ocasiones me he encontrado con código heredado en el que los objetos del tipo Cliente se cargaban en el constructor junto con una colección de TODAS las facturas del mismo, para finalmente mostrar por pantalla sencillamente la Localidad y la Provincia. Inadmisible.

Hemos de preguntarnos, como hicimos anteriormente, si es estrictamente necesario cargar todas, algunas o ninguna de las entidades relacionadas. Existirán casos en los que no haya más remedio que cargar todas las entidades y otros en los que habrá que dotar al objeto de mecanismos que le permitan ser usado de una forma Flexible.

Como norma general en el diseño de los objetos se establece que los métodos de carga recuperarán solamente los valores comunes a todos los casos de uso, es decir, los de la Tabla de la Base de Datos correspondiente a la entidad. Los demás valores o entidades relacionadas deberían ser cargadas bajo petición.

Un patrón de implementación de la propiedad Documentos es la siguiente:

Private _Documentos As Anexos = Nothing 
Public ReadOnly Property Documentos() As Anexos 
  Get 
    If _Documentos Is Nothing Then 
      _Documentos = New Anexos().Carga(Me.IdEnvio) 
    End If 
    Return _Documentos 
  End Get 
End Property

podremos utilizar el objeto de la siguiente forma:

. 
. 
With New Envio().Carga(350) 
  Debug.WriteLine(.this.IdEnvio) 
  For Each a As Anexo In .this.Documentos 
    Debug.WriteLine(a.FileName) 
  Next 
End With 
. 
.

De esta forma no existirá penalización en el rendimiento cuando solamente queramos cambiar, por ejemplo la fecha de un envio ya  que los Documentos SOLO son cargados cuando se solicitan.

Si requisitos posteriores indican, por ejemplo, la necesidad de incluir una lista de Destinatarios del envio, bastará con realizar la implementación de la propiedad Destinatarios de la misma forma que antes:

Private _Destinatarios As Destinatarios 
Public ReadOnly Property Destinatarios() As Destinatarios 
  Get 
    If _Destinatarios Is Nothing Then 
      _Destinatarios = New Destinatarios().Carga(Me.IdEnvio) 
    End If 
    Return _Destinatarios 
  End Get 
End Property

La ejecución de la nueva funcionalidad no interfiere en absoluto con la funcionalidad anterior consistente en listar los nombres de los documentos anexos.

With New Envio().Carga(350) 
  Debug.WriteLine(.this.IdEnvio)        
  For Each a As Destinatario In .this.Destinatario 
    Debug.WriteLine(a.Nombre) 
  Next 
End With 

Lo que perseguimos con patrones como estos es que los objetos se comporten como se espera que lo hagan y sobre todo, mantener está coherencia y efectividad aun cuando los requisitos cambien (Que cambiarán).

Al principio y cuando estamos acostumbrados a la carga INNER JOIN todo lo anterior nos parece que es complicar las cosas, porque, si me puedo traer toda la información de una sola vez, ¿Para qué montar tanto lio?. La respuesta es: Porque no sabemos cuales serán los requisitos en el en futuro, porque no queremos "Que el diseño inicial nos hipoteque el sistema", porque queremos desarrollos ágiles y porque queremos a toda costa evitar sentencias SLQ de más de 30 líneas que difícilmente entenderemos pasados cinco minutos.

Pensemos en que los grandes sistemas existentes, incluso los vivos, están compuestos por la agregación de pequeñas partes: bloques, ladrillos, células ... Lo complejo aquí es el sistema no sus componentes. Diseñemos, púes, componentes pequeños, sencillos, pensados para su expansión futura y, sobre todo, fácilmente comprensibles tanto en su funcionamiento individual como cuando interactúan con otros para formar el TODO.

El DataLoader y sus mecanismos de expansión.

Como hemos visto anteriormente los objetos de un sistema deben ser cargados de forma efectiva y coherente en sus todos y cada uno de los escenarios en donde son utilizados. Para ello debemos disponer de unos mecanismos de carga lo suficientemente flexibles para realizar con éxito esta tarea.

En el Modelo que propongo esta responsabilidad se le ha asignado a los propios objetos. No obstante se podría, por ejemplo, crear una clase  “BindingManager” que realizara la tarea de gestionar los “cargadores” de los objetos.

Los objeto de la capa de negocio, heredando y sobrescribiendo, implementan el siguiente interfaz:

Public Interface ILoader
  Function GetObjectLoader() As ObjectLoader
  Property ConnectionProvider() As Dal.ConnectionProviderDelegate
  Property transactionProvider() As Dal.TransactionProviderDelegate
End Interface

Como ya he dicho anteriormente los objetos inicialmente son cargados con sus valores mas “usados” y a la vez los mas “fácilmente” accesibles. Por lo tanto para este escenario el objeto proporciona a través de la interfaz ILoader su “cargador genérico”. Este cargador, normalmente, traspasa los valores de la base de datos (DataReader) a las variables privadas de las propiedades del objeto. El siguiente código muestra el cargador generico de la clase Envio:

Public Class Envio : Inherits ObjetoBase
  .
  .
  Private Shared Sub InitDataBinder()
    If _DataBinder Is Nothing Then
      _DataBinder = New Dal.ObjectLoader()
      With _DataBinder 
        .Add(New Dal.BindItem("id", "Id", 0, GetType(Integer)))
        .Add(New Dal.BindItem("idEnvio", "IdEnvio", 1, GetType(String)))
        .Add(New Dal.BindItem("observaciones", "Observaciones", 2, GetType(String)))
        .Add(New Dal.BindItem("numeroDeRegistro", "NumeroDeRegistro", 3, GetType(String)))
        .Add(New Dal.BindItem("fechaDeRegistro", "FechaDeRegistro", 4, GetType(Date)))
        .Add(New Dal.BindItem("fecha", "Fecha", 5, GetType(Date)))          
        .Add(New Dal.BindItem("usuario", "Usuario", 6, GetType(String)))          
      End with
    End If
  End Sub

  Shared _DataBinder As Dal.ObjectLoader = Nothing
  Public Overrides Function GetBinder() As Dal.ObjectLoader
    InitDataBinder()
    Return _DataBinder
  End Function
  .
  .
End Class
 

Este cargador está diseñado para trabajar con un DataReader con 6 campos que son los que actualmente devuelve el siguiente método de la capa de Datos:

Public Function GetItem(ByVal id As Integer) As IDataReader
  Using cmd As IDbCommand = CreateCommand()   
    cmd.CommandText = String.Format("Select * from [Envio] where Id={0}", id)
    Return DataServer.ExecuteReader(cmd)
  End Using
End Function

El responsable de la carga es el método LoadObject de la clase DataLoader que devuelve el objeto cargado con los valores de un DataReader:

Public Class Envio : Inherits ObjetoBase
  .
  .
  Public Function Carga(ByVal id As Integer) As Envio
    Using D__ As New Dal.Envios(ConnectionProvider, TransactionProvider)
      Dal.Loader.LoadObject(Me, D__.GetItem(id))
    End Using
    Return Me
  End Function
  .
  .

Hasta aquí hemos visto cual es la forma, genérica, en la que los objetos son cargados. Creo que es el momento de aclarar que en la actual implementación de los métodos de carga se generan dinámicamente funciones de carga de acuerdo a la información especificada en los ObjectLoader de los objetos. Esta generación dinámica de código intermedio se realiza una sola vez por tipo de cargador y almacenada como un delegado en el mismo ObjectLoader. El mecanismo de generación supone que existe una variable privada por cada bindItem cuyo nombre va precedido de “_”. Esta variable recibirá el valor del campo del DataReader cuyo índice se indica en el BindItem. Veamos una ejemplo de lo que digo:

en el cargador del objeto

.Add(New Dal.BindItem("id", "Id", 0, GetType(Integer)))

en la propiedad

.
.
Private _id As Integer
Public Property Id() As Integer
.
.

Nota: El segundo parámetro del constructor de BindItem actualmente no se usa. Es el nombre del campo en la tabla de la base de datos. Por rendimiento el acceso es por el indice de columna, el tercer parámetro.

A partir de ahora veremos como el Modelo nos permite especificar que valores cargar.

Supongamos que en un determinado caso de uso necesitamos conocer el Id, el Id del envio y el usuario que lo realizó. A la vista del código que ya tenemos utilizamos el método genérico de carga para implementar dicho caso de uso. Bueno, funciona, y en un primer estadío del desarrollo nos sirve para mostrar la funcionalidad del sistema al cliente pero sin entrar en cuestiones de rendimiento. El cliente, sin que sirva de precedente, nos indica que “Que si, que le gusta la funcionalidad” pero que estima que “el tiempo de respuesta debe ser bastante menor”. En este momento, o una vez que detectamos un punto en el que el rendimiento es importante, pasaríamos a la optimización del código de carga para este caso particular.

La primera optimización que podemos hacer es la creación en la capa de datos de un método que nos devuelva un DataReader únicamente con los columnas necesarias:

Public Function GetItemsExt() As IDataReader
  Using cmd As IDbCommand = CreateCommand()
    cmd.CommandText = "Select Id,IdEnvio,Usuario from [Envio]"
    Return DataServer.ExecuteReader(cmd)
  End Using
End Funtion

A continuación deberemos crear un método que pueda ser usado para implementar el exigente Caso de Uso. Este método debe especificar cuales son las propiedades que se van a cargar. Hasta ahora hemos visto que el cargador de objetos necesita un objeto y un DataReader con los valores de las propiedades para realizar su trabajo. Lo que no vemos tan fácilmente es que necesita también un cargador del objeto ya que este es solicitado al objeto si no se proporciona. De todas formas, y este es un típico mecanismo de expansión del diseño, existen versiones sobrecargadas de los métodos de carga: LoadObject y LoadObjects que aceptan un Cargador Personalizado:

Public Shared Sub LoadObject(Of T As {Class, New, ILoader})(ByVal Target As T, ByVal dr As IDataReader, ByVal Loader_ As ObjectLoader) Public Shared Sub LoadObject(Of T As {Class, New, ILoader})(ByVal Target As T, ByVal dr As IDataReader) Public Shared Function LoadObjects(Of T As {Class, New, ILoader})(ByVal Target As Generic.List(Of T), ByVal dr As IDataReader) As Generic.List(Of T) Public Shared Function LoadObjects(Of T As {Class, New, ILoader})(ByVal Target As Generic.List(Of T), ByVal dr As IDataReader, ByVal Loader_ As ObjectLoader) As Generic.List(Of T)

En el siguiente ejemplo el cargador se crearía en cada llamada, no obstante vemos claramente que este es un punto en el que se podrían aplicar mecanismos de cacheo de cargadores específicos. No lo hago aquí por simplicidad.

Public Function CargaExt() As Envios
  Using D__ As New Dal.Envios(ConnectionProvider, TransactionProvider)
    Dim customLoader As New Dal.ObjectLoader
    With customLoader
      .Add(New Dal.BindItem("id", "Id", 0, GetType(Integer)))
      .Add(New Dal.BindItem("idEnvio", "IdEnvio", 1, GetType(String)))
      .Add(New Dal.BindItem("usuario", "Usuario", 2, GetType(String)))
    End With
    Return Dal.Loader.LoadObjects(Of Envio)(Me, D__.GetItemsExt(), customLoader)
  End Using
End Function

Bueno pues esto es todo por hoy. Hemos visto la forma generica de cargar los objetos y como, en casos específicos, es necesaria una carga especifica  y optimizada de valores para obtener unos rendimientos altos.

Espero que todo esto sirva, al menos, para daros algunas ideas.

Saludos y hasta la próxima.

3 comentarios:

Anónimo dijo...

Impresionante, aclara muchas cosas, y generaría un interesante debate con otras opciones que he visto, pero así queda muy práctico. Gracias por compartirlo, me dará otra visión.

Espero impaciente futuros posts.

Saludos.

Rafael Castro dijo...

Anónimo, muchas gracias por tus comentarios.

Precisamente lo que pretendo es proporcionar este nuevo o no tan nuevo enfoque de la carga de objetos.

Creo que es más conveniente la utilización de buenas practicas y abogar por la calidad en nuestro código que la adopción de la ultima tecnología que aparece.

No digo que no haya que reciclarse, si no que hay que ser CRITICOS ante lo que se nos ofrece.

La experiencia tambien ayuda en esto.

Al final, lo que más controlas es lo que tu escribes, esforcemonos en escribir codigo y artilugios que funcione y que sean sencillos, o complicados, pero sobre todo que sean FACILMENTE entendibles y explicables.


Saludos

Anónimo dijo...

Gracias a ti por la aportación, estos ejemplos reales dan una visión global y se aprende mucho más que con la teoría.

Espero que sigan más posts :-)

Saludos.