Aprender a dominar ASP.NET: presentación de las clases de entidad personalizada

8 de Julio de 2005

Publicado: Marzo de 2005

Karl Seguin

Microsoft Corporation

Resumen: hay situaciones en las que los DataSets sin tipo tal vez no sean la mejor solución para manipular datos. El objetivo de esta guía es explorar una alternativa a los DataSets: las colecciones y entidades personalizadas. (32 páginas impresas.) (Este artículo contiene vínculos a páginas en inglés.)

En esta página

Introducción Introducción
Problemas de los Datasets Problemas de los Datasets
Clases de entidad personalizada Clases de entidad personalizada
Asignación de datos relacionales a objetos Asignación de datos relacionales a objetos
Colecciones personalizadas Colecciones personalizadas
Administración de relaciones Administración de relaciones
Conceptos avanzados Conceptos avanzados
Conclusión Conclusión

Introducción

Los días de ADODB.RecordSet y de MoveNext han pasado al olvido, reemplazados por las capacidades más avanzadas y flexibles de Microsoft ADO.NET. El nuevo arsenal es el espacio de nombres System.Data, que incluye DataReaders increíblemente rápidos y DataSets con funciones avanzadas, todo ello empaquetado en un útil modelo orientado a objetos. No sorprende que haya disponibles tales herramientas. Las arquitecturas de 3 niveles se basan en una capa de acceso a datos (DAL) sólida para conectar con elegancia la capa de datos con la capa empresarial. Una DAL de calidad facilita la reutilización de código, es clave para lograr un buen rendimiento y resulta totalmente transparente.

De la misma manera que han evolucionado las herramientas, lo han hecho los patrones de desarrollo. Al despedirnos de MoveNext, también hemos dicho adiós a una sintaxis bastante incómoda y nuestras mentes se han abierto a los datos desconectados, lo que a su vez ha supuesto un profundo cambio en la manera de crear aplicaciones.

Como los DataReaders resultaban familiares (se comportan de forma parecida a los RecordSets), en seguida pasamos a explorar DataAdapters, DataSets, DataTables y DataViews. La experiencia obtenida al utilizar estos nuevos objetos cambió la manera de desarrollar aplicaciones. Los datos desconectados permitieron utilizar nuevas técnicas de almacenamiento en caché, lo que mejoró enormemente el rendimiento de las aplicaciones. Las capacidades de estas clases permitieron escribir funciones más inteligentes y eficaces, al tiempo que reducían, a veces de manera considerable, la cantidad de código necesario para realizar actividades rutinarias.

Hay varias situaciones en las que los DataSets son especialmente adecuados, como la creación de prototipos, sistemas pequeños y utilidades de asistencia. Sin embargo, si se utilizan en sistemas empresariales, donde la facilidad de mantenimiento es más importante que el tiempo de implantación en el mercado, es posible que no sean la mejor solución. Esta guía pretende explorar una alternativa a los DataSets que se orienta hacia este tipo de trabajo: las colecciones y entidades personalizadas. Hay otras alternativas, pero ninguna ofrece las mismas capacidades ni cuenta con mayor respaldo. Lo primero que haremos será ver las limitaciones de los DataSets para comprender el problema que queremos solucionar.

No hay que olvidar que toda solución tiene sus ventajas e inconvenientes, por lo que es posible que los inconvenientes de los DataSets le resulten más aceptables que los de las entidades personalizadas (algo que también trataremos). Cada usuario deberá decidir cuál es la solución que mejor se adapta a su proyecto. Se debe tener en cuenta el costo total de una solución, incluida la naturaleza de los requisitos que se deben cambiar y la probabilidad de que se invierta más tiempo en posproducción que en el propio desarrollo del código. Por último, hay que señalar que cuando hablo de DataSets, no me refiero a los DataSets con tipo, que realmente solucionan algunas limitaciones asociadas con los DataSets sin tipo.

Problemas de los Datasets

Falta de abstracción

El primer y más obvio motivo para considerar alternativas es la incapacidad de los DataSet de desacoplar el código de la estructura de base de datos. Los DataAdapters realizan un gran trabajo a la hora de independizar el código del proveedor de base de datos subyacente (Microsoft, Oracle, IBM, etc.), pero no logran abstraer el componente básico de una base de datos: tablas, columnas y relaciones. Estos componentes básicos de las bases de datos también lo son del DataSet. Los DataSets y las bases de datos comparten algo más que componentes comunes, lamentablemente, también comparten el esquema. Consideremos la siguiente instrucción select:

SELECT UserId, FirstName, LastName
   FROM Users

Sabemos que los valores estarán disponibles a partir de las DataColumns UserId, FirstName y LastName de nuestro DataSet.

¿Qué problema plantea esto? He aquí un ejemplo básico habitual. Para empezar, tenemos una función DAL sencilla:

'Visual Basic .NET
Public Function GetAllUsers() As DataSet
 Dim connection As New SqlConnection(CONNECTION_STRING)
 Dim command As SqlCommand = New SqlCommand("GetUsers", connection)
 command.CommandType = CommandType.StoredProcedure
 Dim da As SqlDataAdapter = New SqlDataAdapter(command)
 Try
  Dim ds As DataSet = New DataSet
  da.Fill(ds)
  Return ds
 Finally
  connection.Dispose()
  command.Dispose()
  da.Dispose()
 End Try
End Function

//C#
public DataSet GetAllUsers() {
 SqlConnection connection = new SqlConnection(CONNECTION_STRING);
 SqlCommand command = new SqlCommand("GetUsers", connection);
 command.CommandType = CommandType.StoredProcedure;
 SqlDataAdapter da = new SqlDataAdapter(command);
 try {
  DataSet ds = new DataSet();
  da.Fill(ds);
  return ds;
 }finally {
  connection.Dispose();
  command.Dispose();
  da.Dispose();
 }            
}

A continuación, tenemos una página con un repetidor que muestra todos los usuarios:

<HTML>
 <body>
   <form id="Form1" method="post" runat="server">
     <asp:Repeater ID="users" Runat="server">
        <ItemTemplate>
           <%# DataBinder.Eval(Container.DataItem, "FirstName") %>
           <br />
        </ItemTemplate>
     </asp:Repeater>
   </form>
 </body>
</HTML>
<script runat="server">
  public sub page_load
     users.DataSource = GetAllUsers()
     users.DataBind()
  end sub
</script>


Como se puede ver, la página ASPX utiliza la función DAL GetAllUsers como DataSource del repetidor. Si por algún motivo cambia el esquema de la base de datos (desmoralización por rendimiento, normalización por claridad, cambio de los requisitos), el cambio se propagará hasta el ASPX, es decir la línea Databinder.Eval, que utiliza el nombre de columna "FirstName". La pregunta que surge es: ¿un cambio en el esquema de la base de datos que se propaga lentamente hasta el código ASPX? No parece que esta sea una arquitectura de n niveles.

Si el único problema fuera cambiar el nombre de una columna, no resultaría complicado realizar cambios a este ejemplo. Pero, ¿qué ocurre si GetAllUsers se utiliza en varios sitios o, peor aún, se expone como un servicio Web que se ofrece a innumerables consumidores? ¿Con qué facilidad o seguridad se podría propagar este cambio? En este ejemplo básico, el procedimiento almacenado sirve como capa de abstracción, lo cual probablemente sería suficiente. Sin embargo, depender de procedimientos almacenados para algo que no sea la protección más básica contra esto, acabaría acarreando mayores problemas en el futuro. Puede considerarse que esto es una forma de incrustación en el código. Básicamente, al utilizar DataSets, creará una conexión rígida entre el esquema de la base de datos (independientemente de si utiliza nombres de columna o posiciones ordinales) y las capas empresariales o de las aplicaciones. Afortunadamente, la experiencia (o la lógica) nos ha enseñado el impacto que la incrustación en el código tiene sobre el mantenimiento y el futuro desarrollo.

Otro aspecto en el que los DataSets no proporcionan una abstracción adecuada es en que requieren que los programadores conozcan el esquema subyacente. No estamos hablando de un conocimiento básico, sino de un completo conocimiento de los nombres de las columnas, los tipos y las relaciones. Al eliminar este requisito no sólo es más difícil que aparezcan problemas en el código, como acabamos de ver, sino que también resulta más fácil de escribir y mantener. Sólo será necesario escribir:

Convert.ToInt32(ds.Tables[0].Rows[i]["userId"]);

es difícil de leer y requiere un conocimiento perfecto de los nombres de columna y sus tipos. Lo ideal sería que la capa empresarial no supiera nada acerca de la base de datos subyacente, el esquema de base de datos o SQL. Si se utilizan DataSets, como se expresa en la cadena de código anterior (utilizar CodeBehind no mejora la situación), probablemente se obtendrá una capa empresarial muy delgada.

Tipos flexibles

Los DataSets tienen tipos flexibles, lo que hace que sean más susceptibles a errores y probablemente requieran un mayor esfuerzo de desarrollo. Por tanto, cada vez que se quiera recuperar un valor de un DataSet, tendrá el formato de un System.Object, que será necesario convertir. Existe el peligro de que esta conversión no se realice correctamente. Desgraciadamente, este error no se producirá durante la compilación, sino durante la ejecución. Además, herramientas como Microsoft Visual Studio.NET (VS.NET) no son muy buenas a la hora de ayudar a los desarrolladores cuando se trata de objetos con tipos flexibles. Cuando antes hemos mencionado que era necesario disponer de un conocimiento profundo del esquema, lo decíamos en serio. Nuevamente, consideraremos un ejemplo muy habitual:

'Visual Basic.NET
Dim userId As Integer = 
?      Convert.ToInt32(ds.Tables(0).Rows(0)("UserId"))
Dim userId As Integer = CInt(ds.Tables(0).Rows(0)("UserId"))
Dim userId As Integer = CInt(ds.Tables(0).Rows(0)(0))

//C#
int userId = Convert.ToInt32(ds.Tables[0].Rows[0]("UserId"));

Este código representa maneras posibles de recuperar un valor del DataSet. Lo más probable es que el código lo tenga en varios sitios (si no se realiza la conversión y se utiliza Visual Basic .NET, es posible que la opción Option Strict esté desactivada, en cuyo caso tendrá un problema aún mayor).

Desgraciadamente, en cada una de estas líneas hay un potencial para generar numerosos errores durante la ejecución:

1. La conversión puede generar errores porque:

  • El valor puede ser nulo.

    El programador puede estar equivocado acerca del tipo de datos subyacente (nuevamente, es necesario tener un conocimiento perfecto del esquema de la base de datos).

    Si utiliza valores ordinales, es imposible saber con precisión la columna que se encuentra en la posición X.

2. ds.Tables(0) podría devolver una referencia nula (si alguna parte del método DAL o del procedimiento almacenado genera errores).

3. "UserId" puede que no sea un nombre de columna válido porque:

  • Los nombres hayan cambiado.

  • No lo devuelva el procedimiento almacenado.

  • Puede estar mal escrito.

Se puede modificar este código de manera que se defienda mejor, es decir mediante la incorporación de comprobaciones de null/nothing y try/catch en torno a la conversión, pero esto no ayudará al programador.

Y lo peor de todo es que, como hemos dicho, esto no se abstraerá, lo que significa que cada vez que se quiera obtener el userId del DataSet, se correrán los riesgos anteriormente mencionados o será necesario volver a programar los mismos pasos defensivos (por supuesto, una función auxiliar puede facilitarlo). En los objetos con tipos flexibles los errores no aparecen durante el diseño o compilación, donde es posible detectarlos y solucionarlos con facilidad, sino durante la ejecución, donde existe el peligro de que lleguen a producción y resultan más difíciles de detectar.

No orientado a objetos

No podemos pensar que el uso de los DataSets esté orientado a objetos tan sólo porque sean objetos y C# y Visual Basic .NET sean lenguajes orientados a objetos (OO). El "hola mundo" de la programación OO suele ser una subclase Person con una subclase Employee. Sin embargo, los DataSets no permiten este tipo de herencia, ni la mayoría de las demás técnicas de la OO, al menos de una manera natural o intuitiva. Scott Hanselman, un claro defensor de las entidades de clase, lo explica mejor:

"Un DataSet es un objeto, ¿no? Pero no es un objeto de dominio, no es una 'manzana' o una 'naranja', sino un objeto de tipo 'DataSet'. Un DataSet es un recipiente (con información acerca del almacén de datos que hay detrás). Un DataSet es un objeto que sabe cómo CONTENER filas y columnas y que sabe MUCHO sobre la base de datos. Pero no quiero devolver recipientes. Quiero devolver objetos de dominio, como 'manzanas'."1

Los DataSets mantienen los datos en un formato relacional, lo que los convierte en útiles y fáciles de utilizar con bases de datos relacionales. Desgraciadamente, también se pierden todas las ventajas de la OO.

Al ser imposible que los DataSets actúen como objetos de dominio, no se puede agregar funcionalidad a ellos. Normalmente, los objetos tienen campos, propiedades y métodos, que actúan respecto a una instancia de la clase. Por ejemplo, es posible tener funciones Promote o CalculateOvertimePay asociadas con un objeto User, a las que es posible llamar limpiamente mediante someUser.Promote() o someUser.CalculateOverTimePay(). Como no se pueden agregar métodos a un DataSet, será necesario utilizar funciones de utilidad, tratar con objetos de tipos flexibles y disponer de más instancias de valores incrustados en el código. Básicamente, el código terminará por ser de procedimiento y tendrá que extraer datos continuamente del DataSet o almacenarlos torpemente en variables locales y pasarlas cuando sea necesario. Ambos métodos presentan inconvenientes y ninguno de ellos ventajas.

En contra de los DataSets

Si la idea que se tiene de una capa de acceso a datos consiste en devolver un DataSet, es probable que se estén perdiendo algunas ventajas importantes. Un motivo es que es posible que se esté utilizando una capa empresarial delgada o inexistente que, entre otras cosas, limita su capacidad de abstracción. Además, es difícil sacar partido de las técnicas de la OO, ya que se está utilizando una solución predefinida genérica. Por último, algunas herramientas como Visual Studio.NET no pueden ayudar demasiado a los programadores con objetos con tipos flexibles, como los DataSets, lo que reduce la productividad y aumenta la probabilidad de que aparezcan errores.

Todos estos factores afectan directamente a la facilidad de mantenimiento del código de alguna manera. La falta de abstracción hace más difícil y peligroso cambiar las características y solucionar errores. No se podrá aprovechar completamente la reutilización del código ni el aumento de la legibilidad que ofrece la OO. Además, por supuesto, los programadores, independientemente de si trabajan en la lógica de negocios o en la lógica de presentación, deberán conocer a la perfección la estructura de datos subyacente.

Clases de entidad personalizada

La mayoría de los problemas asociados a los DataSets se pueden solucionar si se aprovechan las avanzadas capacidades de la programación OO dentro de una capa empresarial bien definida. Básicamente, queremos tomar datos organizados de manera relacional (base de datos) y tenerlos disponibles como objetos (código). La idea es que en vez de tener una DataTable con información sobre coches, se tengan en realidad objetos coche (denominados entidades personalizadas u objetos de dominio).

Antes de ver las entidades personalizadas, examinaremos los retos a los que nos enfrentaremos. El más obvio es la cantidad de código necesaria. En vez de obtener sencillamente los datos y rellenar automáticamente un DataSet, tendremos que obtener los datos y asignarlos manualmente a las entidades personalizadas que será necesario crear previamente. Si consideramos que se trata de una tarea repetitiva, es posible mitigarla mediante herramientas de generación de código o asignadores O/R (volveremos más adelante sobre este punto). El mayor problema es el proceso de asignar los datos del mundo relacional al mundo de los objetos. Para sistemas simples la asignación es bastante sencilla, pero a medida que la complejidad aumenta, las diferencias entre los dos mundos pueden resultar problemáticas. Por ejemplo, una técnica clave del mundo de los objetos para facilitar la reutilización del código, así como el mantenimiento, es la herencia. Desgraciadamente, la herencia es un concepto ajeno a las bases de datos relacionales. Otro ejemplo es la diferencia en el tratamiento de las relaciones, ya que el mundo de los objetos mantiene una referencia a un objeto independiente y el mundo relacional utiliza claves externas.

Puede parecer que este enfoque no se adapta bien a sistemas más complejos a medida que crece tanto la cantidad de código como la disparidad entre los datos relacionales y los objetos, pero lo cierto es justo lo contrario. Los sistemas complejos se benefician de este enfoque ya que sus dificultades se aíslan en una única capa: el proceso de asignación (que, una vez más, es posible automatizar). Además, este enfoque ya es bastante conocido, lo que implica que ya hay un cierto número de patrones de diseño que se enfrentan limpiamente a una complejidad adicional. Si se multiplican las deficiencias de los DataSets anteriormente consideradas por las de un sistema complejo, obtendremos como resultado un sistema cuya dificultad de creación sólo se verá superada por la imposibilidad de cambiarlo.

¿Qué son entidades personalizadas?

Las entidades personalizadas son objetos que representan un dominio empresarial. Por tanto, son la base de la capa empresarial. Si dispone de un componente de autenticación de usuario (el ejemplo que utilizaremos en esta guía), probablemente tenga objetos User y Role. Un sistema de comercio electrónico probablemente utilice los objetos Supplier y Merchandise, mientras que una inmobiliaria puede tener Houses, Rooms y Addresses. En el código, las entidades personalizadas son sencillamente clases (existe una correlación bastante estrecha entre una entidad y una clase, tal como se utiliza en programación OO). Una clase User habitual suele presentar el siguiente aspecto:

 
'Visual Basic .NET
Public Class User
#Region "Fields and Properties"
 Private _userId As Integer
 Private _userName As String
 Private _password As String
 Public Property UserId() As Integer
  Get
   Return _userId
  End Get
  Set(ByVal Value As Integer)
    _userId = Value
  End Set
 End Property
 Public Property UserName() As String
  Get
   Return _userName
  End Get
  Set(ByVal Value As String)
   _userName = Value
  End Set
 End Property
 Public Property Password() As String
  Get
   Return _password
  End Get
  Set(ByVal Value As String)
   _password = Value
  End Set
 End Property
#End Region
#Region "Constructors"
 Public Sub New()
 End Sub
 Public Sub New(id As Integer, name As String, password As String)
  Me.UserId = id
  Me.UserName = name
  Me.Password = password
 End Sub
#End Region
End Class

//C#
public class User {
#region "Fields and Properties"
 private int userId;
 private string userName;
 private string password;
 public int UserId {
  get { return userId; }
  set { userId = value; }
  }
 public string UserName {
  get { return userName; }
  set { userName = value; }
 }
 public string Password {
  get { return password; }
  set { password = value; }
 }
#endregion
#region "Constructors"
 public User() {}
 public User(int id, string name, string password) {
  this.UserId = id;
  this.UserName = name;
  this.Password = password;
 }
#endregion
}

Ventajas que aportan

La ventaja principal que ofrecen las entidades personalizadas procede del simple hecho de que son objetos que se controlan completamente. Es decir, permiten:

  • Aprovechar técnicas de la OO como la herencia y la encapsulación.

  • Agregar comportamiento personalizado.

Por ejemplo, nuestra clase User podría aprovechar la incorporación de una función UpdatePassword (es algo que se podría hacer con datasets mediante funciones externas o de utilidad, pero a cambio de un costo de mantenimiento y legibilidad). Además, tienen tipos inflexibles, por lo que se les puede aplicar IntelliSense:

Figura 1. IntelliSense con la clase User

Por último, como las entidades personalizadas tienen tipos inflexibles, requieren transformaciones menos propensas a los errores:

Dim userId As Integer = user.UserId
'versus
Dim userId As Integer = 
?         Convert.ToInt32(ds.Tables("users").Rows(0)("UserId"))

Asignación de datos relacionales a objetos

Tal como se comentó previamente, uno de los retos más importantes de este enfoque es el tratamiento de las diferencias entre datos relacionales y objetos. Como los datos se almacenan de manera persistente en una base de datos relacional, es imprescindible tender un puente entre estos dos mundos. Para el ejemplo User anterior podíamos esperar tener una tabla de usuarios en la base de datos con el siguiente aspecto:

Figura 2. Vista de datos del usuario

No obstante, la asignación de este esquema relacional a nuestra entidad personalizada resulta bastante sencilla:

'Visual Basic .NET
Public Function GetUser(ByVal userId As Integer) As User
 Dim connection As New SqlConnection(CONNECTION_STRING)
 Dim command As New SqlCommand("GetUserById", connection)
 command.Parameters.Add("@UserId", SqlDbType.Int).Value = userId
 Dim dr As SqlDataReader = Nothing
 Try
  connection.Open()
  dr = command.ExecuteReader(CommandBehavior.SingleRow)
  If dr.Read Then
   Dim user As New User
   user.UserId = Convert.ToInt32(dr("UserId"))
   user.UserName = Convert.ToString(dr("UserName"))
   user.Password = Convert.ToString(dr("Password"))
   Return user
  End If
  Return Nothing
 Finally
  If Not dr is Nothing AndAlso Not dr.IsClosed Then
   dr.Close()
  End If
  connection.Dispose()
  command.Dispose()
  End Try
End Function

//C#
public User GetUser(int userId) {
 SqlConnection connection = new SqlConnection(CONNECTION_STRING);
 SqlCommand command = new SqlCommand("GetUserById", connection);
 command.Parameters.Add("@UserId", SqlDbType.Int).Value = userId;
 SqlDataReader dr = null;
 try{
  connection.Open();
  dr = command.ExecuteReader(CommandBehavior.SingleRow);
  if (dr.Read()){
   User user = new User();
   user.UserId = Convert.ToInt32(dr["UserId"]);
   user.UserName = Convert.ToString(dr["UserName"]);
   user.Password = Convert.ToString(dr["Password"]);
   return user;            
  }
  return null;
 }finally{
if (dr != null & !dr.IsClosed){
   dr.Close();
  }
  connection.Dispose();
  command.Dispose();
 }
}

Habría que configurar la conexión los objetos de comandos de la manera habitual, pero a continuación habría que crear una nueva instancia de la clase User y rellenarla mediante el DataReader. Seguiría siendo posible utilizar un DataSet dentro de esta función y asignarlo a la entidad personalizada, pero la ventaja principal de los DataSets respecto al DataReader es que proporcionan una vista desconectada de los datos. En este caso, la instancia User proporciona una vista desconectada, lo que nos permite aprovechar la velocidad del DataReader'.

¡Un momento! Todavía no se ha resuelto nada

Los lectores observadores se habrán dado cuenta de que uno de los problemas señalados sobre los DataSets es que no tienen tipos inflexibles, lo que conduce a una pérdida de productividad y un aumento de la probabilidad de que aparezcan errores durante la ejecución. También requieren que los programadores conozcan a la perfección la estructura de datos subyacente. Al examinar el código anterior se puede observar que se tropieza con exactamente los mismos obstáculos. Hay que tener en cuenta, no obstante, que estos problemas se han encapsulado en una parte muy aislada del código, por lo que los consumidores de las entidades de clase (interfaz Web, consumidor de servicios Web, formulario de Windows) se mantienen totalmente ajenos a estos problemas. Por el contrario, si se utilizan DataSets estos problemas afectarán a todo el código.

Mejoras

El código anterior resultaba útil para mostrar la idea básica de la asignación, pero hay dos mejoras clave que se pueden llevar a cabo para mejorarla. En primer lugar, queremos extraer el código de rellenado en su propia función, ya que probablemente habrá que reutilizarlo:

 
'Visual Basic .NET
Public Function PopulateUser(ByVal dr As IDataRecord) As User
 Dim user As New User
 user.UserId = Convert.ToInt32(dr("UserId"))
 'example of checking for NULL
 If Not dr("UserName") Is DBNull.Value Then
  user.UserName = Convert.ToString(dr("UserName"))
 End If
 user.Password = Convert.ToString(dr("Password"))
 Return user
End Function

//C#
public User PopulateUser(IDataRecord dr) {
 User user = new User();
 user.UserId = Convert.ToInt32(dr["UserId"]);
 //example of checking for NULL
 if (dr["UserName"] != DBNull.Value){
  user.UserName = Convert.ToString(dr["UserName"]);   
 }
 user.Password = Convert.ToString(dr["Password"]);
 return user;
}

El segundo detalle que hay que tener en cuenta es que en vez de utilizar un SqlDataReader para nuestra función de asignación, se utiliza un IDataRecord, que es la interfaz que implementan todos los DataReaders. El uso de IDataRecord hace que el proceso de asignación sea independiente del proveedor. Es decir, podemos utilizar la función anterior para asignar un User de una base de datos de Access, incluso si utiliza un OleDbDataReader. Si se combina este enfoque concreto con el patrón de diseño de modelo de proveedores ( vínculo 1, vínculo 2), se obtendrá un código que se podrá utilizar con facilidad para varios proveedores de bases de datos.

Por último, el código anterior muestra las posibilidades que ofrece la encapsulación. Trabajar con NULL en DataSets no es lo más sencillo, ya que cada vez que se obtiene un valor hay que comprobar si es NULL. Con el método de rellenado anterior nos hemos ocupado cómodamente de esto en un único lugar y hemos evitado que los consumidores tengan que ocuparse de esto.

¿Dónde asignar?

Hay cierto debate sobre si este acceso a datos y función de asignación debe formar parte de una clase diferente o de la entidad personalizada adecuada. Hay que reconocer que resulta muy elegante conseguir que todas las tareas relacionadas con los usuarios (extracción de datos, actualización y asignación) formen parte de la entidad personalizada User, lo que tiende a funcionar bien cuando el esquema de la base de datos se parece bastante a la entidad personalizada (como ocurre en este ejemplo). A medida que la complejidad del sistema aumenta y las diferencias entre los dos mundos comienzan a aparecer, disponer de una clara diferenciación entre la capa de datos y la capa empresarial puede simplificar enormemente el mantenimiento (a la que me gusta llamar capa de acceso a datos). Un efecto secundario de tener el código del acceso y la asignación dentro de su propia carpeta, la DAL, es que nos proporciona una interesante regla para asegurarnos de que existe una clara diferenciación entre las capas:

"Nunca devuelvas una clase de System.Data ni un espacio de nombres secundario del DAL"

Colecciones personalizadas

Hasta ahora sólo hemos considerado el trabajo con entidades individuales. Sin embargo, habitualmente serán necesarios más de un objeto. Una solución sencilla sería almacenar varios valores en una colección genérica, como una Arraylist. Sin embargo, esta solución no resulta la más acertada, ya que implica que volverán a aparecer algunos de los problemas que planteaban los DataSets, es decir:

  • No tienen tipos inflexibles.

  • No se pueden agregar componentes personalizados.

La solución que mejor se adapta a nuestras necesidades es crear nuestra propia colección personalizada. Afortunadamente, Microsoft .NET Framework ofrece una clase especialmente pensada para heredarla en este caso: CollectionBase. El trabajo de CollectionBase consiste en almacenar cualquier tipo de objeto dentro de Arraylists privadas, pero exponiendo el acceso a estas colecciones privadas mediante métodos que sólo requieren un tipo específico, como un objeto User. Es decir, el código con tipos flexibles se encapsula en una API con tipos inflexibles.

Mientras que las colecciones personalizadas parecen incluir una cantidad de código, este código se puede generar automáticamente o cortar y pegar, con lo que, en muchos casos, basta con buscar y reemplazar. Ahora veamos las diferentes partes que forman una colección personalizada para nuestra clase User:

'Visual Basic .NET
Public Class UserCollection
   Inherits CollectionBase
 Default Public Property Item(ByVal index As Integer) As User
  Get
   Return CType(List(index), User)
  End Get
  Set
   List(index) = value
  End Set
 End Property
 Public Function Add(ByVal value As User) As Integer
  Return (List.Add(value))
 End Function
 Public Function IndexOf(ByVal value As User) As Integer
  Return (List.IndexOf(value))
 End Function
 Public Sub Insert(ByVal index As Integer, ByVal value As User)
  List.Insert(index, value)
 End Sub
 Public Sub Remove(ByVal value As User)
  List.Remove(value)
 End Sub
 Public Function Contains(ByVal value As User) As Boolean
  Return (List.Contains(value))
 End Function
End Class

//C#
public class UserCollection : CollectionBase {
 public User this[int index] {
  get {return (User)List[index];}
  set {List[index] = value;}
 }
 public int Add(User value) {
  return (List.Add(value));
 }
 public int IndexOf(User value) {
  return (List.IndexOf(value));
 }
 public void Insert(int index, User value) {
  List.Insert(index, value);
 }
 public void Remove(User value) {
  List.Remove(value);
 }
 public bool Contains(User value) {
  return (List.Contains(value));
 }
}

Se puede hacer más si se implementa CollectionBase, pero el código anterior representa la funcionalidad básica necesaria para una colección personalizada. Si examinamos la función Add, veremos que lo único que estamos haciendo es empaquetar la llamada a List.Add (que es una Arraylist) en una función que sólo permite un objeto User.

Asignación de colecciones personalizadas

El proceso de asignar los datos relacionales a colecciones personalizadas es muy similar al que hemos examinado para entidades personalizadas. En vez de crear una única entidad y devolverla, se agrega la entidad a la colección y se pasa a la siguiente:

 
'Visual Basic .NET
Public Function GetAllUsers() As UserCollection
 Dim connection As New SqlConnection(CONNECTION_STRING)
 Dim command As New SqlCommand("GetAllUsers", connection)
 Dim dr As SqlDataReader = Nothing
 Try
  connection.Open()
  dr = command.ExecuteReader(CommandBehavior.SingleResult)
  Dim users As New UserCollection
  While dr.Read()
   users.Add(PopulateUser(dr))
  End While
  Return users
 Finally
  If Not dr Is Nothing AndAlso Not dr.IsClosed Then
   dr.Close()
  End If
  connection.Dispose()
  command.Dispose()
 End Try
End Function

//C#
public UserCollection GetAllUsers() {
 SqlConnection connection = new SqlConnection(CONNECTION_STRING);
 SqlCommand command =new SqlCommand("GetAllUsers", connection);
 SqlDataReader dr = null;
 try{
  connection.Open();
  dr = command.ExecuteReader(CommandBehavior.SingleResult);
  UserCollection users = new UserCollection();
  while (dr.Read()){
   users.Add(PopulateUser(dr));
  }
  return users;
 }finally{
  if (dr != null & !dr.IsClosed){
   dr.Close();
  }
  connection.Dispose();
  command.Dispose();
 }
}

Obtenemos los datos de la base de datos, creamos la colección personalizada y recorremos los resultados para crear cada objeto User y agregarlo a la colección. Veamos ahora cómo se reutiliza la función de asignación PopulateUser.

Incorporación de comportamiento personalizado

Al hablar acerca de las entidades personalizadas sólo hemos mencionado de pasada la posibilidad de incorporar comportamiento personalizado a las clases. El tipo de funcionalidad que agregaremos a las entidades personalizadas dependerá mayormente del tipo de lógica de negocios que esté implementando, aunque hay cierta funcionalidad común que es interesante implementar en las colecciones personalizadas. Un ejemplo sería devolver una única entidad en función de alguna clave, por ejemplo un usuario en función de un userId:

 
'Visual Basic .NET
Public Function FindUserById(ByVal userId As Integer) As User
 For Each user As User In List
  If user.UserId = userId Then
   Return user
  End If
 Next
 Return Nothing
End Function

//C#
public User FindUserById(int userId) {
 foreach (User user in List) {
  if (user.UserId == userId){
   return user;
  }
 }
 return null;
}

Otra sería devolver un subconjunto de usuarios en función de determinados criterios, como un nombre de usuario parcial:

 
'Visual Basic .NET
Public Function FindMatchingUsers(ByVal search As String) As UserCollection
 If search Is Nothing Then
  Throw New ArgumentNullException("search cannot be null")
 End If
 Dim matchingUsers As New UserCollection
 For Each user As User In List
  Dim userName As String = user.UserName
  If Not userName Is Nothing And userName.StartsWith(search) Then
   matchingUsers.Add(user)
  End If
 Next
 Return matchingUsers
End Function

//C#
public UserCollection FindMatchingUsers(string search) {
 if (search == null){
  throw new ArgumentNullException("search cannot be null");
 }
 UserCollection matchingUsers = new UserCollection();
 foreach (User user in List) {
  string userName = user.UserName;
  if (userName != null & userName.StartsWith(search)){
   matchingUsers.Add(user);
  }
 }
 return matchingUsers;
}

Enlace de colecciones personalizadas

El primer ejemplo que consideramos fue enlazar un DataSet con un control ASP.NET. Si se tiene en cuenta lo habitual que resulta esto, le alegrará saber que las colecciones personalizadas se pueden enlazar con la misma facilidad (ya que CollectionBase implementa Ilist, que se utiliza para enlazar). Las colecciones personalizadas pueden actuar como DataSource de cualquier control que las exponga y DataBinder.Eval se puede utilizar de la misma manera que un DataSet:

 

'Visual Basic .NET
Dim users as UserCollection = DAL.GetallUsers()
repeater.DataSource = users
repeater.DataBind()

//C#
UserCollection users = DAL.GetAllUsers();
repeater.DataSource = users;
repeater.DataBind();

<!-- HTML -->
<asp:Repeater onItemDataBound="r_IDB" ID="repeater" Runat="server">
 <ItemTemplate>
  <asp:Label ID="userName" Runat="server">
   <%# DataBinder.Eval(Container.DataItem, "UserName") %><br />
  </asp:Label>
 </ItemTemplate>
</asp:Repeater>

En vez de utilizar el nombre de columna como segundo parámetro de DataBinder.Eval, puede especificar el nombre de la propiedad que desea mostrar, en este caso UserName.

Para los que se procesan en OnItemDataBound o OnItemCreated expuestos por varios controles de enlace de datos, probablemente realice la transformación de e.Item.DataItem a DataRowView. Al enlazar una colección personalizada, e.Item.DataItem se transforma por el contrario a la entidad personalizada, en nuestro ejemplo, la clase User:

 
'Visual Basic .NET
Protected Sub r_ItemDataBound (s As Object, e As RepeaterItemEventArgs)
 Dim type As ListItemType = e.Item.ItemType
 If type = ListItemType.AlternatingItem OrElse
?   type = ListItemType.Item Then
  Dim u As Label = CType(e.Item.FindControl("userName"), Label)
  Dim currentUser As User = CType(e.Item.DataItem, User)
  If Not PasswordUtility.PasswordIsSecure(currentUser.Password) Then
   ul.ForeColor = Drawing.Color.Red
  End If
 End If
End Sub

//C#
protected void r_ItemDataBound(object sender, RepeaterItemEventArgs e) {
 ListItemType type = e.Item.ItemType;
 if (type == ListItemType.AlternatingItem || 
?    type == ListItemType.Item){
  Label ul = (Label)e.Item.FindControl("userName");
  User currentUser = (User)e.Item.DataItem;
  if (!PasswordUtility.PasswordIsSecure(currentUser.Password)){
   ul.ForeColor = Color.Red;
  }
 }
}

Administración de relaciones

Hasta el sistema más sencillo incluirá relaciones entre las entidades. Las relaciones se establecen en las bases de datos relacionales mediante claves externas. En los objetos, una relación es sencillamente una referencia a otro objeto. Por ejemplo, si continuamos con los ejemplos anteriores, es razonable esperar que un objeto User tenga un Role:

 
'Visual Basic .NET
Public Class User
 Private _role As Role
 Public Property Role() As Role
  Get
   Return _role
  End Get
  Set(ByVal Value As Role)
   _role = Value
  End Set
 End Property
End Class

//C#
public class User {
 private Role role;
 public Role Role {
  get {return role;}
  set {role = value;}
 }
}

O una colección de Roles:

 
'Visual Basic .NET
Public Class User
 Private _roles As RoleCollection
 Public ReadOnly Property Roles() As RoleCollection
  Get
   If _roles Is Nothing Then
    _roles = New RoleCollection
   End If
   Return _roles
  End Get
 End Property
End Class

//C#
public class User {
 private RoleCollection roles;
 public RoleCollection Roles {
  get {
   if (roles == null){
    roles = new RoleCollection();
   }
   return roles;
  }
 }
}

En estos dos ejemplos, tenemos una clase Role o RoleCollection ficticia, que son sencillamente otra clase de colección o entidad personalizada como las clases User y UserCollection.

Asignación de relaciones

El auténtico problema consiste en asignar las relaciones. Imaginemos un ejemplo muy sencillo. Queremos recuperar un usuario a partir de su userId junto con sus funciones. En primer lugar, examinaremos el modelo relacional:

Figura 3. Relaciones entre usuarios y funciones

A continuación, vemos una tabla Users y otra Roles. Ambas podemos asignarlas de manera directa a entidades personalizadas. También tenemos una tabla UserRoleJoin, que representa la relación de varios a varios entre Users y Roles.

CREATE PROCEDURE GetUserById(
  @UserId INT
)AS
SELECT UserId, UserName, [Password]
  FROM Users
  WHERE UserId = @UserID
SELECT R.RoleId, R.[Name], R.Code
  FROM Roles R INNER JOIN
     UserRoleJoin URJ ON R.RoleId = URJ.RoleId
  WHERE  URJ.UserId = @UserId

Por último, podemos asignar el modelo relacional al modelo de objetos:

 
'Visual Basic .NET
Public Function GetUserById(ByVal userId As Integer) As User
 Dim connection As New SqlConnection(CONNECTION_STRING)
 Dim command As New SqlCommand("GetUserById", connection)
 command.Parameters.Add("@UserId", SqlDbType.Int).Value = userId
 Dim dr As SqlDataReader = Nothing
 Try
  connection.Open()
  dr = command.ExecuteReader()
  Dim user As User = Nothing
  If dr.Read() Then
   user = PopulateUser(dr)
   dr.NextResult()
   While dr.Read()
    user.Roles.Add(PopulateRole(dr))
   End While
  End If
  Return user
 Finally
  If Not dr Is Nothing AndAlso Not dr.IsClosed Then
   dr.Close()
  End If
  connection.Dispose()
  command.Dispose()
 End Try
End Function

//C#
public User GetUserById(int userId) {
 SqlConnection connection = new SqlConnection(CONNECTION_STRING);
 SqlCommand command = new SqlCommand("GetUserById", connection);
 command.Parameters.Add("@UserId", SqlDbType.Int).Value = userId;
 SqlDataReader dr = null;
 try{
  connection.Open();
  dr = command.ExecuteReader();
  User user = null;
  if (dr.Read()){
   user = PopulateUser(dr);
   dr.NextResult();
   while(dr.Read()){
    user.Roles.Add(PopulateRole(dr));
   }            
  }
  return user;
 }finally{
if (dr != null & !dr.IsClosed){
   dr.Close();
  }
  connection.Dispose();
  command.Dispose();
 }
}

Se crea y se rellena la instancia User. Se pasa al siguiente resultado/selección y se itera, con lo que se rellenan los Roles y se agregan a la propiedad RolesCollection de la clase User.

Conceptos avanzados

El objetivo de esta guía era presentar el concepto y el uso de las entidades personalizadas y las colecciones. Las entidades personalizadas se utilizan repetidamente en el sector y se han documentado numerosos patrones para tratar una gran variedad de escenarios. Los patrones de diseño son estupendos por varios motivos. En primer lugar, a la hora de enfrentarse a una situación concreta, es muy posible que no sea el primero que intente resolver un determinado problema. Los patrones de diseño permiten reutilizar una solución de un determinado problema probada y comprobada (los patrones de diseño no están pensados para que baste con cortar y pegar, pero casi siempre proporcionan unos cimientos sólidos para una solución), lo que a su vez le permite confiar en que el sistema se escalará bien respecto a la complejidad, no sólo porque se utilice un enfoque de uso habitual sino también porque está bien documentado. Los patrones de diseño también proporcionan un vocabulario común, lo que facilita enormemente la transferencia de conocimientos y el aprendizaje.

No se puede decir que los patrones de diseño sólo se aplican a las entidades personalizadas y, de hecho, muchos no lo hacen. No obstante, si les da una oportunidad le sorprenderá descubrir la cantidad de patrones bien documentados que se aplican a entidades personalizadas y el proceso de asignación.

Esta última sección está dedicada a señalar algunos escenarios más avanzados que serán útiles para sistemas de mayor tamaño o más complejos. Aunque la mayoría de los temas probablemente merecen una guía aparte, intentaré, como mínimo, indicar algunos recursos de partida.

Un excelente sitio para empezar es Patterns of Enterprise Application Architecture de Martin Fowler, que no sólo servirá de estupenda referencia (con explicaciones detalladas y una gran cantidad de código de ejemplo) para patrones de diseño habituales, sino que las primeras 100 páginas conseguirán que realmente comprenda el concepto. Además, Fowler tiene un catálogo de patrones en línea, que es estupendo para aquellos que ya están familiarizados con los conceptos y necesitan una referencia práctica.

Concurrencia

Todos los ejemplos anteriores tratan de la extracción de datos de la base de datos y la creación de objetos a partir de dichos datos. En general, la actualización, la eliminación y la inserción de datos es igualmente sencilla. La capa empresarial crea un objeto, lo pasa a la capa de acceso a datos que se ocupa de controlar la asignación con el mundo relacional. Por ejemplo:

 
'Visual Basic .NET
Public sub UpdateUser(ByVal user As User)
 Dim connection As New SqlConnection(CONNECTION_STRING)
 Dim command As New SqlCommand("UpdateUser", connection)
 'could have a reusable function to inversly map this
 command.Parameters.Add("@UserId", SqlDbType.Int)
 command.Parameters(0).Value = user.UserId
 command.Parameters.Add("@Password", SqlDbType.VarChar, 64)
 command.Parameters(1).Value = user.Password
 command.Parameters.Add("@UserName", SqlDbType.VarChar, 128)
 command.Parameters(2).Value = user.UserName
 Try
  connection.Open()
  command.ExecuteNonQuery()
 Finally
  connection.Dispose()
  command.Dispose()
 End Try
End Sub

//C#
public void UpdateUser(User user) {
 SqlConnection connection = new SqlConnection(CONNECTION_STRING);
 SqlCommand command = new SqlCommand("UpdateUser", connection);
 //could have a reusable function to inversly map this
 command.Parameters.Add("@UserId", SqlDbType.Int);
 command.Parameters[0].Value = user.UserId;
 command.Parameters.Add("@Password", SqlDbType.VarChar, 64);
 command.Parameters[1].Value = user.Password; 
 command.Parameters.Add("@UserName", SqlDbType.VarChar, 128);
 command.Parameters[2].Value = user.UserName;
 try{
  connection.Open();
  command.ExecuteNonQuery();
 }finally{
  connection.Dispose();
  command.Dispose();
 }
}

Sin embargo, trabajar con la concurrencia no resulta tan sencillo, es decir, ¿qué ocurre cuando dos usuarios intentan actualizar los mismos datos a la vez? El comportamiento predeterminado (si no se hace nada) es que la última persona que confirme los datos sobrescribirá todo el trabajo anterior, lo cual conlleva el peligro de que el trabajo de algunos usuarios se vea sobrescrito sin advertencia alguna. Una manera de evitar completamente todos los conflictos consiste en utilizar la concurrencia pesimista. Sin embargo, este método requiere un mecanismo de bloqueo, que puede ser difícil de implementar de manera escalable. La otra posibilidad es utilizar técnicas de concurrencia optimista. Una solución más cómoda y agradable para el usuario es permitir que domine la primera confirmación y que los siguientes usuarios reciban una notificación. Para ello, es necesario crear versiones de las filas, por ejemplo mediante marcas de hora.

Información adicional

Tipos anulables

Los tipos anulables son en realidad componentes genéricos que se utilizan por motivos diferentes de los indicados anteriormente. Uno de los retos a los que hay que enfrentarse al tratar con bases de datos es el manejo correcto y coherente de las columnas que admiten NULL. Al trabajar con cadenas y otras clases (denominadas tipos de referencia), basta con asignar nothing/null a una variable en el código:

 
'Visual Basic .NET
if dr("UserName") Is DBNull.Value Then
   user.UserName = nothing
End If

//C#
if (dr["UserName"] == DBNull.Value){
   user.UserName = null;
}

También es posible no hacer nada (de manera predeterminada, los tipos de referencia son nothing/null). Esto tampoco funciona demasiado bien para tipos de valores como integers, booleans, decimals, etc. Ciertamente, es posible asignar nothing/null a estos valores, pero supondrá asignarles un valor predeterminado. Si sólo declara un entero o le asigna nothing/null, la variable contendrá en realidad el valor 0, lo que hará difícil volver a asignarle el valor a la base de datos: ¿el valor es 0 o null? Los tipos anulables solucionan este problema al permitir que los tipos de los valores contengan un valor real o nulo. Por ejemplo, si quisiéramos permitir un valor null en la columna userId (lo cual no es muy realista), primero habría que declarar el campo userId y su propiedad correspondiente como un tipo anulable:

 
'Visual Basic .NET
Private _userId As Nullable(Of Integer)
Public Property UserId() As Nullable(Of Integer)
   Get
      Return _userId
   End Get
   Set(ByVal value As Nullable(Of Integer))
      _userId = value
   End Set
End Property


//C#
private Nullable<int> userId;
public Nullable<int> UserId {
   get { return userId; }
   set { userId = value; }
}

A continuación, habría que utilizar la propiedad HasValue para determinar si se había asignado nothing/null:

 
'Visual Basic .NET
If UserId.HasValue Then
   Return UserId.Value
Else
   Return DBNull.Value
End If

//C#
if (UserId.HasValue) {
   return UserId.Value;
} else {
   return DBNull.Value;
}

Información adicional

Iteradores

El ejemplo UserCollection que hemos estudiado sólo representa la funcionalidad básica que probablemente necesitará en la colección personalizada. Algo que no se podrá hacer con la implementación ofrecida es iterar a través de una colección mediante un bucle foreach. Para ello, la colección personalizada debería tener una clase que admitiese un enumerador que implementase la interfaz IEnumerable. Aunque este proceso es bastante directo y repetitivo, supone escribir todavía más código. C# 2.0 presenta la nueva palabra clave yield para trabajar con los detalles de la implementación de esta interfaz automáticamente. Actualmente, no hay ningún equivalente en Visual Basic .NET a la nueva palabra clave yield.

Información adicional

Conclusión

La decisión de cambiar a las colecciones y entidades personalizadas no debe realizarse a la ligera. Hay muchos factores que es necesario tener en cuenta como, por ejemplo, el conocimiento de los conceptos de la OO, el tiempo que se puede dedicar a este nuevo enfoque y el entorno en el que se piensa implementar. Aunque las ventajas son considerables en general, tal vez no sea así en su caso concreto. Incluso aunque sean considerables, puede haber inconvenientes que las hagan desaconsejables. No hay que olvidar que hay otras posibilidades. Jimmy Nilsson ha descrito de manera general algunas de estas posibilidades en su serie de 5 partes Choosing Data Containers for .NET (partes 1, 2, 3, 4 y 5).

Las entidades personalizadas permiten utilizar todas las posibilidades de la programación orientada a objetos, así como configurar el marco para una arquitectura robusta y de fácil mantenimiento con varios niveles. Uno de los objetivos de esta guía era hacerle pensar en su sistema en términos de las entidades empresariales que lo constituyen, en vez de en DataSets genéricos y DataTable. También hemos comentado algunos aspectos fundamentales que se deben tener en cuenta independientemente de la decisión que se tome, es decir los patrones de diseño, las diferencias entre el mundo de los datos relacionales y el de los objetos (más información) y una arquitectura con varios niveles. Hay que recordar que el tiempo inicial invertido se recupera innumerables veces durante la vida útil de un sistema.

Bibliografía relacionada

1http://www.hanselman.com/blog/PermaLink.aspx?guid=d88f7539-10d8-4697-8c6e-1badb08bb3f5http://www.hanselman.com/blog/PermaLink.aspx?guid=d88f7539-10d8-4697-8c6e-1badb08bb3f5

Mostrar: