[APORTE] C++ y bases de datos

Iniciado por eferion, 9 Septiembre 2014, 16:42 PM

0 Miembros y 1 Visitante están viendo este tema.

eferion

Introducción

En este documento intento plasmar un poco mi experiencia a la hora de tratar con bases de datos. Se que está muy de moda usar herramientas que automatizan la conexión y hacen gran parte del trabajo por nosotros. Sin embargo ésta es una de las cosas que prefiero hacer por mi cuenta, ya que he visto fracasar bastantes proyectos debido a una mala gestión de la base de datos.

Ojo, no me considero un experto sobre el tema, de hecho, mi preparación es ingeniero de telecomunicaciones y en la carrera he visto tanta teoría sobre bases de datos como la que pueden dar unos niños en la guardería.

Se aceptan críticas constructivas, comentarios, anotaciones, mejoras... en fín, lo de siempre.

Como también se pone de moda esto de las licencias, este código se presenta tal cual, sin garantía de ningún tipo. Sois libres de copiar y/o modificar el código a vuestro antojo, sin embargo declino toda responsabilidad que su mal uso pueda conllevar. También sois libres de coger el código y guardarlo en algún rincón oscuro como vuestro tesoro o usarlo para hechizos y brujería, eso si, siempre bajo vuestra responsabilidad.

Nota adicional: No puedo garantizar que el código esté 100% libre de errores, ya que la mayor parte de los ejemplos no son ejecutables y los he tenido que ir retocando (con cuidado, eso sí) para adaptarlos a la guía. Aún así, me parece importante recalcar que el código está escrito bajo el estándar C++11, así que cuidado con usar compiladores no compatibles.

Bueno, dicha la parte aburrida vamos al tema. Antes o después, todo el que se dedica al mundo de la programación, ya sea por hobby o profesionalmente, acaba haciendo uso de bases de datos. Esto suele ser debido a que tenemos la mala costumbre de querer guardar todo tipo de información... con lo divertido que es reescribir los documentos una y otra vez.

Debido a que no suele ser demasiado eficiente eso de resetear la base de datos y rellenarla de nuevo cada vez que queremos almacenar algún cambio, lo lógico es que nos veamos obligados a guardar determinada información sobre los objetos que se encuentran en la base de datos:


  • Necesitamos saber si el objeto es nuevo o no (no es lo mismo CREATE que UPDATE).
  • Necesitamos saber si el objeto ha sido modificado. Si no lo hacemos tendremos que actualizar TODOS, con el tiempo que ello pueda conllevar.
  • En el caso de tablas con campo autoincremental, necesitamos almacenar dicho OID para futuras consultas.

La primera aproximación que yo creo que hemos hecho todos es meter toda esta información a piñón en el propio objeto. Algo del tipo:

Código (cpp) [Seleccionar]

class User
{
 public:

   // Interfaz para la base de datos

   // Devuelve el OID ( en el caso de objetos nuevos vale 0 )
   int OID( ) const;

   // Permite modificar el OID
   void SetOID( int oid );

   // Indica si el objeto es nuevo (no esta aun en la BD )
   bool IsNew( ) const;

   // Indica si el objeto ha sido modificado
   bool IsModified( ) const;

   // Reinicia los flags 'IsNew' e 'IsModified'
   void ObjectSaved( );

   // Interfaz propia del objeto

   std::string Name( ) const;

   std::string Surname( ) const;

   // ...
};


Como queda patente de un primer vistazo, esta solución no parece demasiado elegante. Estamos introduciendo acoplamiento al introducir en la interfaz elementos propios de la gestión de la base de datos. Además, este diseño plantea otros problemas:


  • Si se usan plugins, exponemos información sensible de la base de datos a terceros.
  • Si nuestra aplicación trabaja en red nos obliga a enviar información innecesaria en el lado del cliente.

El diseño, al fin y al cabo, es feo, ya puestos, también podemos poner en 'User' los métodos para leer y escribir en la base de datos, un par de métodos también para poder almacenar la información en un fichero y, si nos quedan más ganas de marcha, también podemos meter en la clase el código que gestione la interfaz gráfica...(espero que se entienda la ironía).

Después de un rato pensando, a alguien podría decir... "bueno, creo una clase base que tenga los datos de la BD y soluciono el problema con herencia:

Código (cpp) [Seleccionar]

class DBItem
{
 public:

   // Interfaz para la base de datos

   // Devuelve el OID ( en el caso de objetos nuevos vale 0 )
   int OID( ) const;

   // Permite modificar el OID
   void SetOID( int oid );

   // Indica si el objeto es nuevo (no esta aun en la BD )
   bool IsNew( ) const;

   // Indica si el objeto ha sido modificado
   bool IsModified( ) const;

   // Reinicia los flags 'IsNew' e 'IsModified'
   void ObjectSaved( );
};

class User : public DBItem
{
 public:

   // Interfaz propia del objeto

   std::string Name( ) const;

   std::string Surname( ) const;

   // ...
};


No voy a negarlo, es una posible solución, aunque este diseño sigue teniendo los mismos problemas de acoplamiento que el diseño anterior. La ventaja, eso sí, es que podremos reutilizar código de 'DBItem', no iban a ser todo malas noticias. Sin embargo, como comentaba, este diseño sigue sin convencer.

La idea entonces parece que pasa por separar físicamente la información relativa a la base de datos de los datos propios del objeto. El problema que aparece entonces es cómo conseguir esto de la forma más limpia posible, intentando además que sea reutilizable. Los requisitos que ha de cumplir este sistema son los siguientes:


  • Debe ser posible separar la lógica de negocio de la capa de acceso a datos.
  • Debe permitir la gestión de elementos eliminados.
  • Debe ser capaz de detectar elementos nuevos y elementos modificados.
  • La solución debe ser reutilizable.




Separar la lógica de negocio de la capa de acceso a datos.

Una posible solución, la que trataré en este tema, pasa por crear un contenedor a modo de caché.

El contenedor va a estar dividido en 2 niveles:


  • Nivel 1: El contenedor. Representa la interfaz accesible por la aplicación. Debe permitir realizar las operaciones habituales sobre los objetos que gestiona: creación, borrado, edición, etc.
  • Nivel 2: Información de base de datos. El contenedor almacenará instancias de un objeto que almacenará la información relevante de la base de datos. Este objeto únicamente será accesible por el contenedor.

De acuerdo con lo expuesto, la implementación de la clase interna del contenedor debería tener un aspecto similar al siguiente:

Código (cpp) [Seleccionar]

/*
* Gestiona información sobre un elemento almacenado en una caché.
* Los objetos se guardan indizados por clave única.
*/
class UserCacheItem final
{
   UserCacheItem( ) = delete;

   UserCacheItem(
             const UserCacheItem& ar_other ) = delete;

   UserCacheItem& operator=(
             const UserCacheItem& ar_other ) = delete;

 public:

   /*
    * Constructor de la clase.
    */
   UserCacheItem(
             User* ap_item,
             int a_key )
     : _item{ ap_item },
       _key{ a_key }
   {
   }

   /*!
    * Destructor de la clase.
    */
   virtual ~UserCacheItem( )
   {
   }

   /*!
    * Método llamado por la caché cuando se han almacando los cambios en la base de datos.
    */
   void Commit( )
   {
     if ( _tempOid )
     {
       _key = *_tempOid;
       _tempOid.reset( );
     }
   }
   
   /*!
    * Indica si el objeto gestionado es nuevo o no.
    */
   inline bool IsNew( ) const
   {
     return 0 == _key;
   }
   
   /*!
    * Indica si el objeto gestionado ha sufrido cambios desde el último commit.
    */
   inline bool IsModified( ) const
   {
     return !IsNew( ) && !_tempOid && _hash != _item->Hash( );
   }

   /*!
    * Expone el puntero al objeto gestionado por la instancia.
    */
   inline User* Item( ) const;
   {
     return _item.get( );
   }
   
   /*!
    * Recupera la clave única utilizada para identificar el objeto gestionado.
    */
   int Key( ) const
   {
     if ( _tempOid )
       return *_tempOid;
     else
       return _key;
   }
   
   /*!
    * Recupera la clave original.
    */
   inline int OriginalKey( ) const
   {
     return _key;
   }

   /*!
    * Permite modificar la clave del objeto.
    */
   void ChangeKey(
             int a_key )
   {
     if ( ar_key == _key )
     {
       _tempOid.reset( );
     }
     else if ( ( !_tempOid ) || ( a_key != *_tempOid ) )
     {
       _tempOid.reset( new int{ a_key } );
     }
   }
   
 private:

   std::unique_ptr< User* > _item;
   int _key;
   std::unique_ptr< int > _tempKey
};


Como se ve, internamente se usan punteros inteligentes. Desde mi punto de vista el uso de punteros inteligentes tiene dos ventajas básicas:


  • Evitar lagunas de memoria.
  • Queda claro que esta clase se encarga de controlar el ciclo de vida de los objetos de tipo 'User', lo cual evita malentendidos con los 'delete'.
  • Facilita el mantenimiento del código.

Además, ya se cómo funciona la memoria dinámica, no necesito practicarlo cada vez que tenga que crear objetos... y más cuando su ciclo de vida está tan delimitado. Prefiero centrar mis esfuerzos en problemas más serios.

En cuanto al control del oid, se puede apreciar que se usan dos claves: _key y _tempKey. Para almacenar el oid se ha optado por un mecanismo en dos pasos:


  • El valor inicial de _key se establece al crear el objeto.
  • Al llamar a ChangeKey se modifica _tempKey.
  • Al llamar a 'Commit' el valor de _key se modifica por _tempKey, suponiendo que tenga valor.

Este mecanismo en dos pasos permite "recordar" la clave que tenía inicialmente el objeto de cara a poder localizarlo en la base de datos en base a dicha clave. Recordemos que las actualizaciones a la base de datos suelen ser del tipo: "UPDATE users SET key=30 WHERE key=20". Si no guardamos ese '20', dificilmente podremos localizar el registro en la base de datos.

Entiendo que en este caso cambiar el oid puede no tener demasiado sentido, pero no siempre es así.

Hablando ahora del contenedor, su interfaz se detalla a continuacion:

Código (cpp) [Seleccionar]

/*!
* Contenedor de BD para objetos de tipo "User"
*/
class UserCache
{
   UserCache(
             const UserCache& ar_other ) = delete;

   UserCache& operator=(
             const UserCache& ar_other ) = delete;

 public:

   /*!
    * Constructor por defecto.
    */
   UserCache( )
   {
   }

   /*!
    * Destructor.
    */
   virtual ~UserCache( )
   {
   }

   /*!
    * Inserta un nuevo elemento en la cache
    */
   bool Add(
             User* ap_element,
             int a_key )
   {
     if ( ap_element )
     {
       auto it = _items.find( a_key );
       if ( it == _items.end( ) )
       {
         UserCacheItem* newItem = new UserCacheItem{ ap_element, a_key };
         _items.insert( std::make_pair( a_key, std::unique_ptr< UserCacheItem >{ newItem } ) );
         return true;
       }
     }

     return false;
   }

   /*!
    * Obtiene la clave para un usuario.
    */
   int Key(
             const User* ap_element ) const
   {
     auto it = FindItem( *ap_element, _items );
     if ( it != _items.end( ) )
       return it->first;

     return 0;
   }

   /*!
    * Recupera la lista de usuarios.
    */
   std::vector< User* > Items( ) const
   {
     std::vector< User* > to_return;

     for ( auto it = _items.begin( ); it != _items.end( ); ++it )
       to_return.push_back( it->second->Item( ) );

     return to_return;
   }

   std::map< int, User* > MappedItems( ) const
   {
     std::map< int, User* > to_return;

     for ( auto it = _items.begin( ); it != _items.end( ); ++it )
       to_return.insert( std::make_pair( it->first, it->second->Item( ) ) );

     return to_return;
   }

   /*!
    * Recupera un usuario en base a su oid.
    */
   User* Item(
             int a_key ) const
   {
     auto it = _items.find( a_key );
     if ( it != _items.end( ) )
       return it->second->Item( );

     return nullptr;
   }

   /*!
    * Permite cambiar el oid de un usuario.
    */
   bool ChangeKey(
             const User* ap_element,
             int a_key );
   {
     if ( ap_element )
     {
       auto it = _items.find( a_key );
       if ( it != _items.end( ) )
         return ( it->second->Item( ) == ap_element );

       it = FindItem( *ap_element, _items );
       if ( it != _items.end( ) )
       {
         // Si cambia la clave tenemos que actualizar el mapa de elementos
         it->second->SetKey( ar_key );
         _items.insert( std::make_pair( ar_key, std::move( it->second ) ) );
         _items.erase( it );
         return true;
       }
     }

     return false;
   }
   
   /*!
    * Marca todos los usuarios como "no modificados", además se encarga de
    * descartar todos los elementos eliminados.
    */
   void Commit( )
   {
     for ( auto& item : _items )
       item.second->Commit( );
   }

   /*!
    * Reinicia la caché. Elimina todo su contenido.
    */
   void Clear( )
   {
     _items.clear( );
   }

   /*!
    * Indica si hay cambios pendientes de llevar a la base de datos.
    */
   bool ChangesPending( ) const;
   {
     for ( auto& item : _items )
     {
       if ( item.second->IsModified( ) )
         return true;
     }

     return false;
   }
   
 private:

   std::map< int, std::unique_ptr< UserCacheItem > > _items;
   
   std::map< int, User* >::iterator FindItem(
             const User& ar_element ) const
   {
     return std::find_if( _items.begin( ),
                          _items.end( ),
                          [&ar_element]( const std::pair< int, User* >& pair )
                          { return pair.second->Item( ) == &ar_element; } );
   }

};


Bueno, con este diseño parece que hemos conseguido desacoplar el uso de la base de datos del objeto 'User'. No está mal para empezar. Sin embargo aún queda camino por recorrer.




Borrar usuarios

Normalmente, cuando en una aplicación se decide eliminar información, éste borrado no suele ser inmediato. En ocasiones hay que esperar a que el usuario confirme la operación dando la orden de "guardar cambios", mientras que en otras ocasiones interesa retrasar las eliminaciones con la intención de intentar agruparlas para optimizar las consultas a la base de datos.

Para satisfacer esta necesidad, nuestro contenedor va a encargarse de la gestión de los elementos borrados de la siguiente forma:


  • Cuando le indicamos que deseamos borrar un elemento que ya se encuentra gestionado por el contenedor lo introduce en un listado de elementos a eliminar. Los elementos no se eliminarán de forma efectiva hasta que no hagamos "commit".
  • Cuando le indicamos que deseamos borrar un elemento que NO se encuentra gestionado por el contenedor lo elimina directamente. Esto es así porque al no estar gestionado por el contenedor no es posible que otras partes del código conozcan este objeto.

Un ejemplo del código que permite cumplir con esta función lo encontramos a continuación:

Código (cpp) [Seleccionar]

class UserCache
{
 public:
 
   bool Delete(
       int a_key )
   {
     auto it = _items.find( a_key );
     if ( it != _items.end( ) )
     {
       _deletedItems.push_back( std::move( it->second ) );
       _items.erase( it );
       return true;
     }

     return false;
   }
   
   bool Delete(
       User* ap_element )
   {
     if ( ap_element )
     {
       auto it = FindItem( *ap_element );

       if ( it != _items.end( ) )
       {
         _deletedItems.push_back( std::move( it->second ) );
         _items.erase( it );
         return true;
       }
     }

     return false;
   }
       
   std::map< int, User* > ToDelete( ) const
   {
     std::map< int, User* > to_return;

     for ( auto& pair : _deletedItems )
       to_return.insert( std::make_pair( pair.first, pair.second->Item( ) ) );

     return to_return;
   }
   
   void Commit( )
   {
     _deletedItems.clear( );

     for ( auto& item : _items )
       item.second->Commit( );
   }

   void Clear( )
   {
     _items.clear( );
     _deletedItems.clear( );
   }

   bool ChangesPending( ) const
   {
     if ( !_deletedItems.empty( ) )
       return true;

     for ( auto& item : _items )
     {
       if ( item.second->IsModified( ) )
         return true;
     }

     return false;
  }
   
 private:
 
   std::vector< std::unique_ptr< User > > _deletedItems;
};


Como se puede apreciar, el contenedor nos va a ofrecer en un cómodo listado todos los objetos que se han marcado como borrados. Si recogemos este listado podemos




Modificar usuarios

Cuando recurrimos al método 'feo' para interaccionar con bases de datos, a menudo caemos en la tentación de registrar las modificaciones con un código similar al siguiente:

Código (cpp) [Seleccionar]

class User
{
 public:
 
   User( )
     : _modified{ false }
   { }
   
   std::string Name( ) const
   { return _name; }
   
   void SetName( const std::string& name )
   {
     if ( name != _name )
     {
       _name = name;
       _modified = true;
     }
   }
   
   bool IsModified( ) const
   { return _modified; }
   
 private:
 
   std::string _name;
   bool _modified;
};


Esta solución ya no nos sirve, ya que intentamos que la clase 'User' sera totalmente independiente de la base de datos.

Para poder marcar los objetos como modificados se puede optar por varias soluciones diferentes, os enumero tres de ellas:


  • Hacer una llamada del tipo 'UserCache::SetModified( usuario )' cada vez que modifiquemos un objeto. Este mecanismo tiene el inconveniente de ser complicado de gestionar, ya que obliga a meter esta instrucción en todas las funciones que modifiquen el objeto. Si el objeto se modifica en zonas muy concretas de la aplicación puede ser una solución bastante factible. PD.: no es recomendado si la clase puede ser modificada por plugins.
Código (cpp) [Seleccionar]

class UserCache
{
 public:
   void SetModified( User* ap_element );
};

class Something
{
 public:
 
   void UpdateUser( User* user );
   {
     user->SetName( anotherName );
     user->SetSurname( anotherSurname );
     
     // El método realmente no es estático, lo pongo asi por legibilidad
     UserCache::SetModified( ap_element );      
   }
};



  • Utilizar un hash para identificar los elementos que han cambiado. Este hash debe estar mapeado en el contenedor para poder detectar cambios. Este mecanismo puede implementarse en el contenedor o en la clase 'User'. Esta solución, aparte de ser más limpia ofrece código reutilizable, ya que el hash puede usarse, por ejemplo, en los operadores de igualdad.
Código (cpp) [Seleccionar]

class User
{
 public:
   std::size_t hash( ) const
   {
     const std::size_t name{ std::hash< std::string >( )( _name ) };
     const std::size_t surname { std::hash< std::string >( )( _surname ) };
     return name ^ ( surname << 1 );
   }
};



  • Utilizar un "contador de versión". El contador de versión puede ser tan sencillo como un "unsigned int" que se va incrementando cada vez que se realiza un cambio en el objeto. Entonces, basta con que el contenedor realice un mapeo de la versión de cada objeto para poder identificar los elementos modificados. La ventaja de este sistema respecto al hash es que es más rápido, la desventaja es que requiere actualizar los setters. Este método se parece bastante al "método feo" que iniciaba este apartado, es cierto.
Código (cpp) [Seleccionar]

class User
{
 public:
 
   User( )
     : _version{ 0 }
   { }
   
   void SetName( const std::string& name )
   {
     if ( name != _name )
     {
       _name = name;
       ++_version;
     }
   }
   
   unsigned int Version( ) const
   { return _version; }
};


Una vez que ya hemos elegido el mecanismo que vamos a implementar tenemos que adaptar el funcionamiento del contenedor para que sea capaz de identificar los elementos que han sufrido cambios. En este caso, el código de ejemplo presupone que se ha tomado como buena la opción de hash. Adaptar el código para que funcione con la opción del "contador de versiones" es bastante sencillo:

Clase UserCacheItem:
Código (cpp) [Seleccionar]

class UserCacheItem final
{
 public:

   /*!
    * \brief Constructor de la clase.
    */
   UserCacheItem(
       User* ap_item,
       int a_key )
     : _item{ ap_item },
       _key{ ar_key },
       _hash{ ap_item->Hash( ) }
   {
   }

   /*
    * Método llamado por la caché cuando se han almacando los cambios en la base de datos.
    */
   void Commit( )
   {
     if ( _tempOid )
     {
       _key = *_tempOid;
       _tempOid.reset( );
     }

     _hash = ap_item->Hash( );
   }
   
   /*!
    * Indica si el objeto gestionado ha sufrido cambios desde el último commit.
    */
   inline bool IsModified( ) const
   { return !IsNew( ) && !_tempOid && _hash != _item->Hash( ); }

 private:

   std::unique_ptr< User > _item;
   int _key;
   std::unique_ptr< int > _tempOid;
   std::size_t _hash;

};


Clase UserCache:
Código (cpp) [Seleccionar]

class UserCache
{
 public:

   std::set< User* > ToCreate( ) const
   {
     std::set< User* > to_return;

     for ( auto& item : _items )
     {
       if ( item.second->IsNew( ) )
         to_return.insert( item.second->Item( ) );
     }

     return to_return;
   }

   std::map< int, User* > ToUpdate( ) const
   {
     std::map< int, User* > to_return;

     for ( auto& item : _items )
     {
       if ( item.second->IsModified( ) )
         to_return.insert( std::make_pair( item.first, item.second->Item( ) ) );
     }

     return to_return;
   }
   
   bool ChangesPending( ) const
   {
     for ( auto& item : _items )
     {
       if ( item.second->IsModified( ) )
         return true;
     }

     return false;
   }
};





Conectar el contenedor con la base de datos

Una de las primeras motivaciones que nos ha llevado a este punto ha sido la necesidad de reducir el nivel de acoplamiento en nuestras clases. Por este motivo vamos a delegar la responsabilidad de la comunicación con la base de datos en una nueva clase.

A mí se me ha ocurrido llamarla 'DBUsers', para gustos, los nombres de las clases. Bueno, al tema, el caso es que esta clase debería proporcionar métodos para leer y escribir en las cachés de datos. Personalmente, en esta clase no me preocupa demasiado que haya acoplamiento ya que, como norma general, cada aplicación tiene su propio diseño de base de datos, por lo que el código de esta clase dificilmente será reutilizable.

Código (cpp) [Seleccionar]

class DBUsers
{
   DBUsers( ) = delete;
   
   DBUsers(
         const DBUsers& ar_other ) = delete;
         
   const DBUsers& operator=(
         const DBUsers& ar_other ) = delete;
         
 public:
 
   DBUsers(
          const UniqueKeyCache< User, int >& ar_cache )
     : _cache{ &ar_cache }
   {
   }
   
   virtual ~DBUsers( )
   {
   }
   
   bool SaveData( )
   {
     bool ok = true;
     
     // Este primer chequeo no sería necesario, pero me gusta la simetría
     // y además así aseguro que se elimina deletedItems
     if ( ok )
     {
       // Primero es recomendable realizar la operación de borrado
       auto deletedItems = _cache.ItemsToDelete( );
       for ( auto& pair : deletedItems )
       {
         // Generación y ejecución de las consultas de eliminación.
         // Si algúna consulta da error, actualizamos ok a false
       }
     }
     
     if ( ok )
     {
       // Después actualizamos los elementos modificados
       auto updatedItems = _cache.ItemsToUpdate( );
       for ( auto& pair : updatedItems )
       {
         // Generación y ejecución de las consultas de actualización
         // Si algúna consulta da error, actualizamos ok a false
       }
     }
     
     // Finalmente damos de alta los nuevos registros
     if ( ok )
     {
       auto newItems = _cache.ItemsToCreate( );
       for ( auto& item : newItems )
       {
         // Generación y ejecución de las consultas de creación
         // Si algúna consulta da error, actualizamos ok a false
         
         // No hay que olvidarse de recuperar el OID de cada elemento
         // y actualizar la cache.
         _cache->ChangeKey( &item, newOid );
       }
     }
     
     if ( ok )
       _cache->Commit( );
     else
     {
       // Si se produce algún error tenemos que decidir entre resetear la cache
       // o permitir al usuario intentarlo más veces.
     }
   }
   
   void LoadUsers( )
   {
     // Consulta de selección sobre la tabla de usuarios
     // ...
     while ( row.next( ) ) // Para cada registro leído...
     {
       User* user = new User{ };
       user->SetName( row.data( "name" ).toString( ) );
       user->SetSurname( row.data( "surname" ).toString( ) );
       
       _cache->Add( user, row.data( "id" ).toInt( ) );
     }
   }
   
   // No hace falta leer toda la tabla... se puede leer bajo demanda para
   // reducir los tiempos de acceso.
   void LoadUsers( const std::string& name );
   
 private:
 
   UniqueKeyCache< User, int >* _cache;
   
};


Como se ve, la caché la almaceno por referencia. Si no tengo necesidad de eliminar un objeto en un contexto dado, y además necesito que ese objeto exista, prefiero usar una referencia que un puntero, así queda claro que no es competencia de ese contexto hacer un delete. Además, la idea de que la caché pueda utilizarse de forma independiente permite aislar la capa de acceso a base de datos, piensa que no es obligatorio que los datos también se pueden guardar en un fichero o enviar por red... este diseño permite tener una clase específica para cada uso y sin necesidad de que se conozcan entre ellas.




Reutilización del código

El código ya nos funciona para la clase 'User', pero la gracia es que todo este esfuerzo pueda ser reutilizado con otras clases. La mejor forma para hacer esto es convertir la caché en un template... el código puede que se ensucie un poco con esta conversión, pero creo que es un sacrificio razonable que se va a compensar con creces:

Template CacheItem (sustituye a UserCacheItem):
Código (cpp) [Seleccionar]

/*!
* Gestiona información sobre un elemento almacenado en una caché.
* Los objetos se guardan indizados por clave única.
*/
template< class _CLASS_, class _KEY_ >
class CacheItem final
{
   CacheItem( ) = delete;

   CacheItem(
       const CacheItem& ar_other ) = delete;

   CacheItem& operator=(
       const CacheItem& ar_other ) = delete;

 public:

   /*!
    * Constructor de la clase.
    */
   CacheItem(
       _CLASS_* ap_item,
       const _KEY_& ar_key );

   /*!
    * Destructor de la clase.
    */
   virtual ~CacheItem( );

   /*!
    * Método llamado por la caché cuando se han almacando los cambios en la base de datos.
    */
   void Commit( );

   /*!
    * Indica si el objeto gestionado es nuevo o no.
    */
   inline bool IsNew( ) const
   { return _KEY_{ } == _key; }

   /*!
    * Indica si el objeto gestionado ha sufrido cambios desde el último commit.
    */
   inline bool IsModified( ) const
   { return !IsNew( ) && !_tempOid && _hash != _item->Hash( ); }

   /*!
    * Expone el puntero al objeto gestionado por la instancia.
    */
   inline _CLASS_* Item( ) const
   { return _item.get( ); }

   /*!
    * Recupera la clave única utilizada para identificar el objeto gestionado.
    */
   _KEY_ Key( ) const;

   /*!
    * Recupera la clave original.
    */
   inline _KEY_ OriginalKey( ) const
   { return _key; }

   /*!
    * Permite modificar la clave del objeto.
    */
   void ChangeKey(
       const _KEY_& ar_key )
   {
     if ( ar_key == _key )
     {
       _tempOid.reset( );
     }
     else if ( ( !_tempOid ) || ( ar_key != *_tempOid ) )
     {
       _tempOid.reset( new _KEY_{ ar_key } );
     }
   }

 private:

   std::unique_ptr< _CLASS_ > _item;
   _KEY_ _key;
   std::unique_ptr< _KEY_ > _tempOid;
   std::size_t _hash;

};

template< class _CLASS_, class _KEY_ >
CacheItem< _CLASS_,_KEY_ >::CacheItem(
         _CLASS_* ap_item,
         const _KEY_& ar_key )
 : _item{ ap_item },
   _key{ ar_key },
   _hash{ ap_item->Hash( ) }
{
}

template< class _CLASS_, class _KEY_ >
CacheItem< _CLASS_,_KEY_ >::~CacheItem( )
{
}

template< class _CLASS_, class _KEY_ >
void
CacheItem< _CLASS_,_KEY_ >::Commit( )
{
 if ( _tempOid )
 {
   _key = *_tempOid;
   _tempOid.reset( );
 }

 _hash = _item->Hash( );
}

template< class _CLASS_, class _KEY_ >
_KEY_
CacheItem< _CLASS_,_KEY_ >::Key( ) const
{
 if ( _tempOid )
   return *_tempOid;
 else
   return _key;
}


Se pueden apreciar algúnos cambios en lo relativo al tratamiento de la clave primaria. Dado que ahora esta clave puede ser de cualquier tipo (un string por ejemplo), los métodos ahora gestionan la clave por referencia.

Clase UniqueKeyCache (sustituye a UserCache):
Código (cpp) [Seleccionar]

template< class _CLASS_, class _KEY_ >
class UniqueKeyCache
{
   UniqueKeyCache(
       const UniqueKeyCache& ar_other ) = delete;

   UniqueKeyCache& operator=(
       const UniqueKeyCache& ar_other ) = delete;

 public:

   /*!
    * Constructor por defecto.
    */
   UniqueKeyCache( )
   { }

   /*!
    * Destructor.
    */
   virtual ~UniqueKeyCache( )
   { }

   /*!
    * Inserta un nuevo elemento en la cache.
    */
   bool Add(
       _CLASS_* ap_element,
       const _KEY_& ar_key );

   /*!
    * Devuelve la clave asociada a un elemento.
    */
   _KEY_ Key(
       const _CLASS_* ap_element ) const;

   /*!
    * Marca un elemento para ser eliminado.
    */
   bool Delete(
       const _KEY_& ar_key );

   /*!
    * Marca un elemento para ser eliminado.
    */
   bool Delete(
       _CLASS_* ap_element );

   /*!
    * Devuelve el listado de elementos.
    */
   std::set< _CLASS_* > Items( ) const;

   /*!
    * Devuelve el listado de elementos mapeados por la clave primaria.
    */
   std::map< _KEY_, _CLASS_* > MappedItems( ) const;

   /*!
    * Devuelve un elemento dada su clave primaria.
    */
   _CLASS_* Item(
       const _KEY_& ar_key ) const;

   /*!
    * Devuelve la lista de elementos que aún no están dados de alta en la base
    * de datos.
    */
   std::set< _CLASS_* > ToCreate( ) const;

   /*!
    * Devuelve la lista de elementos a actualizar en la base de datos.
    */
   std::map< _KEY_, _CLASS_* > ToUpdate( ) const;

   /*!
    * Devuelve la lista de elementos a eliminar de la base de datos.
    */
   std::map< _KEY_, _CLASS_* > ToDelete( ) const;

   /*!
    * Permite modificar la clave primaria de un elemento.
    */
   bool ChangeKey(
       const _CLASS_* ap_element,
       const _KEY_& ar_key );

   /*!
    * Limpia la lista de elementos nuevos y/o modificados y elimina los
    * elementos marcados para borrar.
    */    
   void Commit( );

   /*!
    * Limpia la caché, eliminando todo su contenido.
    */
   void Clear( );

   /*!
    * Indica si hay o no cambios pendientes de llevar a la base de datos.
    */
   bool ChangesPending( ) const;

 private:

   using _Item_              = CacheItem< _CLASS_, _KEY_ >;
   using _ItemMap_           = std::map< _KEY_, std::unique_ptr< _Item_ > >;
   using _ItemPair_          = std::pair< _KEY_, std::unique_ptr< _Item_ > >;
   using _ItemMapIterator_   = typename _ItemMap_::iterator;
   using _ItemList_          = std::vector< std::unique_ptr< _Item_ > >;

   _ItemMap_ _items;
   _ItemList_ _deletedItems;

   /*!
    * Utilidad para buscar en la lista de elementos.
    */
   _ItemMapIterator_ FindItem(
       _CLASS_* ap_element ) const
   {
     return std::find_if( _items.begin( ),
                          _items.end( ),
                          [ap_element]( const _ItemPair_& pair )
                          { return pair.second->Item( ) == ap_element; } );
   }
};

template< class _CLASS_, class _KEY_ >
bool
UniqueKeyCache< _CLASS_, _KEY_ >::Add(
         _CLASS_* ap_element,
         const _KEY_& ar_key )
{
 if ( ap_element )
 {
   auto it = _items.find( ar_key );
   if ( it == _items.end( ) )
   {
     _Item_* newItem = new _Item_{ ap_element, ar_key };
     _items.insert( std::make_pair( ar_key, std::unique_ptr< _Item_ >( newItem ) ) );
     return true;
   }
 }

 return false;
}

template< class _CLASS_, class _KEY_ >
_KEY_
UniqueKeyCache< _CLASS_, _KEY_ >::Key(
         const _CLASS_* ap_element ) const
{
 auto it = FindItem( *ap_element );
 if ( it != _items.end( ) )
   return it->first;

 return _KEY_{ };
}

template< class _CLASS_, class _KEY_ >
bool
UniqueKeyCache< _CLASS_, _KEY_ >::Delete(
         const _KEY_& ar_key )
{
 auto it = _items.find( ar_key );
 if ( it != _items.end( ) )
 {
   _deletedItems.push_back( std::move( it->second ) );
   _items.erase( it );
   return true;
 }

 return false;
}

template< class _CLASS_, class _KEY_ >
bool
UniqueKeyCache< _CLASS_, _KEY_ >::Delete(
         _CLASS_* ap_element )
{
 if ( ap_element )
 {
   auto it = FindItem( *ap_element );
   if ( it != _items.end( ) )
   {
     _deletedItems.push_back( std::move( it->second ) );
     _items.erase( it );
     return true;
   }
 }

 return false;
}

template< class _CLASS_, class _KEY_ >
std::set< _CLASS_* >
UniqueKeyCache< _CLASS_, _KEY_ >::Items( ) const
{
 std::set< _CLASS_* > to_return;

 for ( auto& pair : _items )
   to_return.insert( pair.second->Item( ) );

 return to_return;
}

template< class _CLASS_, class _KEY_ >
std::map< _KEY_, _CLASS_* >
UniqueKeyCache< _CLASS_, _KEY_ >::MappedItems( ) const
{
 std::map< _KEY_, _CLASS_* > to_return;

 for ( auto& pair : _items )
   to_return.insert( std::make_pair( pair.first, pair.second->Item( ) ) );

 return to_return;
}

template< class _CLASS_, class _KEY_ >
_CLASS_*
UniqueKeyCache< _CLASS_, _KEY_ >::Item(
         const _KEY_& ar_key ) const
{
 auto it = _items.find( ar_key );
 if ( it != _items.end( ) )
   return it->second->Item( );

 return nullptr;
}

template< class _CLASS_, class _KEY_ >
std::set< _CLASS_* >
UniqueKeyCache< _CLASS_, _KEY_ >::ToCreate( ) const
{
 std::set< _CLASS_* > to_return;

 for ( auto& pair : _items )
 {
   if ( pair.second->IsNew( ) )
     to_return.insert( pair.second->Item( ) );
 }

 return to_return;
}

template< class _CLASS_, class _KEY_ >
std::map< _KEY_, _CLASS_* >
UniqueKeyCache< _CLASS_, _KEY_ >::ToUpdate( ) const
{
 std::map< _KEY_, _CLASS_* > to_return;

 for ( auto& item : _items )
 {
   if ( item.second->IsModified( ) )
     to_return.insert( std::make_pair( item.first, item.second->Item( ) ) );
 }

 return to_return;
}

template< class _CLASS_, class _KEY_ >
std::map< _KEY_, _CLASS_* >
UniqueKeyCache< _CLASS_, _KEY_ >::ToDelete( ) const
{
 std::map< _KEY_, _CLASS_* > to_return;

 for ( auto& pair : _deletedItems )
   to_return.insert( std::make_pair( pair.first, pair.second->Item( ) ) );

 return to_return;
}

template< class _CLASS_, class _KEY_ >
bool
UniqueKeyCache< _CLASS_, _KEY_ >::ChangeKey(
         const _CLASS_* ap_element,
         const _KEY_& ar_key )
{
 if ( ap_element )
 {
   auto it = _items.find( ar_key );
   if ( it != _items.end( ) )
     return ( it->second->Item( ) == ap_element );

   it = FindItem( *ap_element );
   if ( it != _items.end( ) )
   {
     it->second->SetKey( ar_key );
     _items.insert( std::make_pair( ar_key, std::move( it->second ) ) );
     _items.erase( it );
     return true;
   }
 }

 return false;
}

template< class _CLASS_, class _KEY_ >
void
UniqueKeyCache< _CLASS_, _KEY_ >::Commit( )
{
 _deletedItems.clear( );

 for ( auto& item : _items )
   item.second->Commit( );
}

template< class _CLASS_, class _KEY_ >
void
UniqueKeyCache< _CLASS_, _KEY_ >::Clear( )
{
 _items.clear( );
 _deletedItems.clear( );
}

template< class _CLASS_, class _KEY_ >
bool
UniqueKeyCache< _CLASS_, _KEY_ >::ChangesPending( ) const
{
 if ( !_deletedItems.empty( ) )
   return true;

 for ( auto& item : _items )
 {
   if ( item.second->IsModified( ) )
     return true;
 }

 return false;
}





Posibles mejoras

La cache se puede heredar para añadir funcionalidad específica en función del tipo de objeto que estemos cacheando. Por ejemplo, en el caso de los usuarios podría ser util obtener un listado de los usuarios con la cuenta bloqueada. Básicamente lo único que tendríamos que modificar en la clase 'UniqueKeyCache' es poner el listado de elementos en la parte protegida y heredar:

Código (cpp) [Seleccionar]

class UsersCache : public UniqueKeyCache< User, int >
{
 public:
 
   std::set< User* > LockedAccounts( ) const
   {
     std::set< User* > to_return;
     
     for ( auto& pair : _items )
     {
       if ( pair->second->Item( )->IsLocked( ) )
         to_return.insert( pair->second->Item( ) );
     }
     
     return to_return;
   }
};


Y bueno, dado que un template no es exactamente lo mismo que una clase base, lo mejor es también sustituir 'DBUsers' para que acepte un objeto de tipo 'UserCache':

Código (cpp) [Seleccionar]

class DBUsers
{
   DBUsers( ) = delete;
   
   DBUsers(
         const DBUsers& ar_other ) = delete;
         
   const DBUsers& operator=(
         const DBUsers& ar_other ) = delete;
 public:
 
   DBUsers(
          const UsersCache& ar_cache )
     : _cache{ ar_cache }
   {
   }
   
   virtual ~DBUsers( )
   {
   }
   
   bool SaveData( )
   {
     bool ok = true;
     
     // Este primer chequeo no sería necesario, pero me gusta la simetría
     // y además así aseguro que se elimina deletedItems cuando deja de ser necesario
     if ( ok )
     {
       // Primero es recomendable realizar la operación de borrado
       auto deletedItems = _cache.ItemsToDelete( );
       for ( auto& pair : deletedItems )
       {
         // Generación y ejecución de las consultas de eliminación.
         // Si algúna consulta da error, actualizamos ok a false
       }
     }
     
     if ( ok )
     {
       // Después actualizamos los elementos modificados
       auto updatedItems = _cache.ItemsToUpdate( );
       for ( auto& pair : updatedItems )
       {
         // Generación y ejecución de las consultas de actualización
         // Si algúna consulta da error, actualizamos ok a false
       }
     }
     
     // Finalmente damos de alta los nuevos registros
     if ( ok )
     {
       auto newItems = _cache.ItemsToCreate( );
       for ( auto& item : newItems )
       {
         // Generación y ejecución de las consultas de creación
         // Si algúna consulta da error, actualizamos ok a false
         
         // No hay que olvidarse de recuperar el OID de cada elemento
         // y actualizar la cache.
         _cache.ChangeKey( &item, newOid );
       }
     }
     
     if ( ok )
       _cache.Commit( );
     else
     {
       // Si se produce algún error tenemos que decidir entre resetear la cache
       // o permitir al usuario intentarlo más veces.
     }
   }
   
   void LoadUsers( )
   {
     // Consulta de selección sobre la tabla de usuarios
     // ...
     while ( row.next( ) ) // Para cada registro leído...
     {
       User* user = new User{ };
       user->SetName( row.data( "name" ).toString( ) );
       user->SetSurname( row.data( "surname" ).toString( ) );
       
       _cache.Add( user, row.data( "id" ).toInt( ) );
     }
   }
   
   // No hace falta leer toda la tabla... se puede leer bajo demanda para
   // reducir los tiempos de acceso.
   void LoadUsers( const std::string& name );
   
 private:
 
   UsersCache& _cache;
   
};


Y esto es todo. Creo que el diseño final es elegante, ya que reduce enormemente la dependencia entre clases y permite crear rápidamente enlaces con bases de datos, ficheros XML, ficheros binarios, sockets, etc.

También hay que tener en cuenta que el diseño que he planteado se puede adaptar de forma más o menos sencilla para que funcione bajo diferentes escenarios: Registros sin clave primaria, Registros con clave primaria compuesta, Clave primaria repetida, etc. Yo, personalmente, diseñaría un template de cache específico para cada caso... no soy especialmente partidario de hacer un template que herede de otro.

Y poco más que contar. Espero que os haya gustado, que hayáis aprendido algo o, en el peor de los casos, me conformo con que no penséis que habéis perdido el tiempo al leer este artículo.

Un saludo.

flony

pucha que habia sido largo, ahora estoy con parciales (para ser contador nada que ver con programar) pero termino y lo leo bien
si un problema no tiene solucion entonces no es un problema...es algo inevitable

vangodp

 :o gracias!
Me va costar un rato echar una mirada a esto...que fiera ere  ;-)

eferion

Bueno, largo o corto la idea es que sea útil, ya me contaréis.

Un saludo a ambos.