Cómo integrar Freeze en aplicaciones existentes para obtener persistencia en los sirvientes.

Ingredientes

  • ZeroC-Ice instalado y funcionando.
  • La aplicación que queremos que persista.

Empezando

Nuestro objetivo es simple: añadir persistencia a los sirvientes de nuestras aplicaciones. Con ello, es posible restaurar el estado de un sirviente, aunque la aplicación haya terminado. Además, puesto que usamos el Evictor de Freeze, tenemos gestión de recursos: reactivación de sirvientes bajo demanda y desactivación de objetos que no se usan. De esta forma, optimizamos los recursos de la máquina.

El Evictor mantiene una lista de los sirvientes más recientemente usados, y que están disponibles directamente. El resto de sirvientes están guardados en una base de datos. En caso de que sea necesario acceder a alguno de ellos, el Evictor se encarga de todo: crea un nuevo objeto de ese mismo tipo y actualiza su estado con información de la base de datos, para que sirva las peticiones convenientemente.

Es la implementación del patrón evictor. Para más información sobre este patrón, consultad 1.

Ejemplo sin Freeze

Supongamos que tenemos un sencillo sistema de mensajes. Consta de una aplicación que instancia tantos sirvientes como mensajes se deseen guardar. Estos sirvientes mantienen el contenido del mensaje, y aportan la interfaz para leerlo o modificarlo. La definición de dicho slice es:

Nota: en 3 está disponible el código fuente completo de estos ejemplos. Te lo puedes descargar con subversion: ‘svn co 3

Message.ice

// -*- mode: C++; coding: utf-8 -*-
// Message service.
module UCLM {
  interface Message {
    void write(string msg);
    string read();
  };
};

Los sirvientes que implementan Message, sólo permiten leer y escribir el contenido del mensaje. Donde se guardan los datos es un detalle de implementación. En este caso, se almacenan en una variable llamada message de tipo string, pero podría ser cualquier sitio: un fichero en disco, una base de datos, /dev/null…

La declaración de los sirvientes podría ser como sigue:

MessageI.h

// -*- mode: C++; coding: utf-8 -*-

#ifndef __MessageI_h__
#define __MessageI_h__

#include <Message.h>

namespace UCLM {

  class MessageI : virtual public Message {
  public:

    MessageI(::std::string);

    virtual void write(const ::std::string&,
                       const Ice::Current&);

    virtual ::std::string read(const Ice::Current&);

  private:
    ::std::string message;
  };
}

#endif // __MessageI_h__

Tiene un constructor para inicializar el objeto desde el servidor. La implementación es trivial: leer message o escribirlo. Este ejemplo es bastante sencillo, pero podría ser una aplicación cualquiera. Para instanciar estos sirvientes, tenemos un servidor normal y corriente:

Server.cpp

// -*- mode: C++; coding: utf-8 -*-

// Messase service.

#include <MessageI.h>
#include <Ice/Ice.h>

using namespace std;
using namespace UCLM;

#define MAX_SERVS 100000

class MessageServer : virtual public Ice::Application {
public:

  virtual int run(int argc, char* argv[]){

    _ic = communicator();

    // Create the object adapter
    _adapter = _ic->createObjectAdapterWithEndpoints("MServer", "tcp -h 127.0.0.1 -p 11111");
    _adapter->activate();

    // Create MAX_SERVS servants
    UCLM::MessagePtr serv;
    Ice::ObjectPrx prx;

    cout << "Creating posts from 0 to " << MAX_SERVS << " ...";
    cout.flush();

    for (int i=1; i<MAX_SERVS; i++){
      ostringstream sid;
      sid << "Post" << i;
      serv = new UCLM::MessageI("Message from " + sid.str());
      prx = _adapter->add(serv, _ic->stringToIdentity(sid.str()));
    }

    cout << " done." << endl;
    cout << "Proxies at 'PostX -t:tcp -h 127.0.0.1 -p 11111' where X is post number." << endl;
    cout << "Waiting events..." << endl;

    shutdownOnInterrupt();
    _ic->waitForShutdown();
    return 0;
  }


private:

  Ice::ObjectAdapterPtr _adapter;
  Ice::CommunicatorPtr _ic;

};

Este servidor no tiene nada especial: hereda de Ice.Application, crea un adaptador de objetos al que añade 100.000 sirvientes de tipo Message y espera eventos. El cliente simplemente accede a estos objetos usando el proxy adecuado. En principio, un uso muy común y sencillo de Ice.

Si ejecutamos el servidor, los 100.000 sirvientes (100.000 instancias de la clase MessageI) se quedan residentes en memoria. Generalmente, no los usaremos todos al mismo tiempo, sólo una parte de ellos, por lo que mantenerlos en memoria es caro e ineficiente. Por otro lado, si modificamos el estado de alguna de estas instancias y el servidor termina, las modificaciones se habrán perdido. Por ello, vamos a usar Freeze.

Ejemplo con Freeze

Por supuesto, es incómodo tener que rehacer todo nuestro software para añadir el Evictor de Freeze. Suerte que se lo han pensado bien para que no tengamos que sufrir mucho.

Intentaremos modificar lo menos posible. Así pues, lo primero que hacemos es un estudio de impacto en nuestras aplicaciones. Si mantenemos las interfaces, los clientes no varían en absoluto, y es perfectamente posible hacerlo.

Un aspecto a tener en cuenta: si sólo tenemos definidas las interfaces en el slice (es decir, no usamos clases, por lo que no tenemos atributos), Ice no sabe qué es lo que debe persistir, ya que la serialización de los datos se hace de la misma forma que si se enviaran por la red. Es decir, si queremos que un atributo sea persistente, debe estar definido en el slice, por lo que en vez de interfaces, debemos usar clases.

También, puesto que el Evictor debe saber cuando actualizar la base de datos y cuando no, hemos de especificar si los métodos modifican el contenido de los objetos. Esto se consigue añadiendo metainformación en el slice: [“freeze:read”] para los métodos que no modifican el estado y [“freeze:write”] para aquellos que sí lo hacen.

Otro requisito a tener en cuenta es la necesidad de usar factorías para la instanciación de nuestros sirvientes: un requisito de Freeze. De nuevo, tenemos ayudas en Ice para ello.

Veamos las modificaciones. En primer lugar, vamos a cambiar el slice original para incluir la metainformación de Freeze:

Message.ice

// -*- mode: C++; coding: utf-8 -*-
// Message service.

module UCLM {

  interface Message {
    ["freeze:write"] void write(string msg);
    string read();
  };
};

Puesto que, por defecto, Freeze considera que los métodos no modifican el estado, no es necesario especificarlo explícitamente para read(). Estas modificaciones no afectan en absoluto a compilaciones posteriores de la versión original del software, por lo que son inocuas.

Lo próximo es añadir los atributos que serán persistentes. En la implementación de nuestro ejemplo, guardamos el mensaje en una variable de tipo string llamada message. Ese será el atributo que añadiremos a una nueva clase, que implementará nuestra interfaz Message. Para mantener las cosas sencillas, aquí lo hago en un nuevo fichero (pero esto no es obligatorio):

PersistentMessage.ice

// -*- mode: C++; coding: utf-8 -*-
// Message service with persistence.

#include "Message.ice"

module UCLM {

  class PersistentMessage implements Message {
    string message;
  };
};

Perfecto. Ahora, el código de los sirvientes persistentes. Puesto que hemos hecho coincidir los nombres de las variables, la implementación es idéntica. Lo único que añadiremos será:

  • Un constructor vacío (que lo usaremos en la factoría, pero que es particular para este caso, y por tanto, puede que no sea necesario en otros)
  • Un inicializador, al que llamará el Evictor una vez que haya restaurado un sirviente y antes de hacerlo disponible. Se usa en caso de que sea necesario modificar algo del sirviente (aquí no lo necesitamos)
  • Una factoría que usará el Evictor para instanciar nuestros sirvientes (esto si que es necesario).

Con estos cambios, la cabecera del sirviente viene siendo:

PersistentMessageI.h

// -*- mode: C++; coding: utf-8 -*-

#ifndef __PersistentMessageI_h__
#define __PersistentMessageI_h__

#include <Freeze/Freeze.h>
#include <IceUtil/IceUtil.h>

#include <PersistentMessage.h>

namespace UCLM {

  class PersistentMessageI : virtual public PersistentMessage,
			     public IceUtil::AbstractMutexI<IceUtil::Mutex> {
  public:
    PersistentMessageI();
    PersistentMessageI(::std::string);
    virtual void write(const ::std::string&,
		       const Ice::Current&);
    virtual ::std::string read(const Ice::Current&);

  };


  class MessageFactory :  virtual public Ice::ObjectFactory {
  public:
    virtual Ice::ObjectPtr create(const ::std::string&);
    virtual void destroy();
  };


  class MessageInitializer : virtual public Freeze::ServantInitializer {
  public:
    virtual void initialize(const Ice::ObjectAdapterPtr&,
                            const Ice::Identity&,
                            const std::string&,
                            const Ice::ObjectPtr&);
  };
}

#endif // __PersistentMessageI_h__

Vemos un par de cosillas interesantes. En primer lugar, ya no incluye Ice.h sino Freeze.h, algo obvio por todos lados. También se incluye IceUtil.h, que necesitamos para poder heredar de la clase AbstractMutexI, algo que por cierto es otro requisito de los sirvientes. Para profundizar sobre la causa y demás, el capítulo dedicado a Freeze del Ice.Book tiene mucha información.

En cuanto a los métodos del sirviente, añadimos un constructor vacío, que será el que use la factoría. El resto es igual, excepte que en este caso, no es necesario añadir el atributo message puesto que lo heredamos de la clase de Ice.

Lo siguiente que vemos es la factoría que necesita el Evictor. Es sencilla, porque Ice nos provee los mecanismos para hacerla. Hereda de Ice::ObjectFactory e implementa dos métodos: create(), que será el que usemos, y destroy() que en este caso no es necesario.

Por último el inicializador, que como he dicho, no hace nada.

La parte de implementación:

PersistentMessageI.cpp

// -*- mode: C++; coding: utf-8 -*-

#include <PersistentMessageI.h>

using namespace std;

UCLM::PersistentMessageI::PersistentMessageI(){
}

UCLM::PersistentMessageI::PersistentMessageI(string msg){

  message = msg;
}

void
UCLM::PersistentMessageI::write(const string& msg,
                      const Ice::Current& current) {

  cout << "Event: ++ write" << endl;
  message = msg;
}

::std::string
UCLM::PersistentMessageI::read(const Ice::Current& current) {

  cout << "Event: -- read" << endl;
  return message;
}

Ice::ObjectPtr
UCLM::MessageFactory::create(const ::std::string& type){
  cout << "MessageFactory::create(" << type << ")" << endl;
  if (type == "::UCLM::PersistentMessage") {
    return new PersistentMessageI();
  }
  else {
    assert(false);
    return 0;
  }
}

void
UCLM::MessageFactory::destroy() {}

void
UCLM::MessageInitializer::initialize(const Ice::ObjectAdapterPtr&,
				     const Ice::Identity&,
				     const std::string&,
				     const Ice::ObjectPtr&){
}

Como vimos, el código es muy similar. Sólo la factoría merece un poco de atención (por supuesto, esto son implementaciones sencillas, ad-hoc y pueden variar todo lo necesario, según las circunstancias). En nuestro caso, solo se comprueba el tipo y si coincide, se instancia un nuevo sirviente, usando el contructor vacío.

En lo que respecta al sirviente, no hay más. Los cambiós más interesantes están en el servidor.

PersistentServer.cpp

// -*- mode: C++; coding: utf-8 -*-
// Messase service with persistency

#include <PersistentMessageI.h>
#include <Freeze/Freeze.h>

using namespace std;
using namespace UCLM;

#define MAX_SERVS 100000

class PersistentMessageServer : virtual public Ice::Application {
public:
  PersistentMessageServer(const string& dbName) :
    _dbName(dbName) {
  }

  virtual int run(int argc, char* argv[]){

    _ic = communicator();

    // The object factories

    Ice::ObjectFactoryPtr factory = new MessageFactory;
    _ic->addObjectFactory(factory, PersistentMessage::ice_staticId());

    // Create the object adapter
    _adapter = _ic->createObjectAdapterWithEndpoints("MServer", "tcp -h 127.0.0.1 -p 11112");
    _adapter->activate();

    // Create the Freeze evictor
    Freeze::ServantInitializerPtr init = new MessageInitializer;
    _evictor = Freeze::createEvictor(_adapter, _dbName, "dbfile", init);
    _adapter->addServantLocator(_evictor, "");

    // Create MAX_SERVS servants
    UCLM::PersistentMessagePtr serv;
    Ice::ObjectPrx prx;

    cout << "Creating posts from 0 to " << MAX_SERVS << " ...";
    cout.flush();

    for (int i=0; i<MAX_SERVS; i++){
      ostringstream sid;
      sid << "Post" << i;
      Ice::Identity id = _ic->stringToIdentity(sid.str());

      if (!_evictor->hasObject(id)){
	serv = new UCLM::PersistentMessageI("Message from " + sid.str());
	prx = _evictor->add(serv, id);
      }
    }

    cout << " done." << endl;
    cout << "Proxies at 'PostX -t:tcp -h 127.0.0.1 -p 11112' where X is post number." << endl;
    cout << "Waiting events..." << endl;

    shutdownOnInterrupt();
    _ic->waitForShutdown();

    return 0;
  }

private:
  string _dbName;
  Ice::ObjectAdapterPtr _adapter;
  Freeze::EvictorPtr _evictor;
  Ice::CommunicatorPtr _ic;
};

int
main(int argc, char* argv[]){

  PersistentMessageServer srv("storage");
  return srv.main(argc, argv);
}

De nuevo, se incluye Freeze.h en vez de Ice.h. Además, al constructor del servidor se pasa el nombre del directorio que usará la base de datos. Este debe estar creado, o fallará. Lo siguiente que se hace es instanciar las factorías necesarias: en este caso sólo MessageFactory, pero serían necesarias tantas como clases de sirvientes persistentes quisiéramos.

Una vez creado el adaptador como de costumbre, creamos el Evictor, que es el que realmente se encarga de toda la faena. A él le añadiremos los sirvientes y a él le preguntaremos si los tiene ya. Al crearlo, lo añadimos al adaptador como el Servant Locator por defecto.

ACTUALIZACIÓN En la versión 3.3 de Ice, han metido algunos cambios al respecto. Además de este Evictor, tenemos disponible otro transaccional. Si quieres usar este mismo, pero en la versión 3.3, el método para crearlo es createBackgroundSaveEvictor. Hay más info en la documentación de Ice, versión 3.3 obviamente.

Lo siguiente es crear los sirvientes. Si todavía no se han añadido a la base de datos, es necesario añadir el sirviente al Evictor para que lo haga. Pero, si el sirviente ya ha sido instanciado, no es necesario hacer nada. Por ello, lo primero que se hace es conseguir la identidad del objeto. Con esta, se pregunta al Evictor si tiene el objeto. Sólo en caso de que retorne falso instanciamos el sirviente y lo añadimos.

El resto es conocido. Compilamos enlazando con Freeze (-lFreeze con Gcc) y listo.

Resultados

Si lanzas ambos servidores, puedes apreciar la diferencia. El arranque del servidor sin Freeze es igual de rápido todas las veces, y ocupa mucha memoria RAM. El servidor persistente tarda más en iniciar la primera vez (puesto que tiene que añadir los sirvientes en la base de datos) pero es más ágil el resto de ocasiones. Además, ocupa bastante menos memoria.

En cuanto a la persistencia, puedes hacer una prueba. Lanza el servidor persistente, modifica alguno de sus mensajes, reinicialo y comprobarás que el mensaje mantiene las modificaciones que hiciste:

$ ./PersistentServer &     ## Lanzado el servidor persistente
[1] 6369
$ Creating posts from 0 to 100000 ... done.
Proxies at 'PostX -t:tcp -h 127.0.0.1 -p 11112' where X is post number.
Waiting events...

$ ./Client 'Post100 -t:tcp -h 127.0.0.1 -p 11112' write "hola"
$ fg
./PersistentServer         ## Ctr+c para terminar el servidor
$ ./PersistentServer &     ## Lo arrancamos de nuevo
[1] 6375
$ Creating posts from 0 to 100000 ... done.
Proxies at 'PostX -t:tcp -h 127.0.0.1 -p 11112' where X is post number.

Waiting events...

$ ./Client 'Post100 -t:tcp -h 127.0.0.1 -p 11112' read
COMMAND: read
VALUE: 'hola'
$

Y si no te lo crees, pruébalo tu mismo :-p.

Puedes jugar con las propiedades de Freeze para afinar mucho el resultado. En concreto, puedes modificar el número máximo de sirvientes activos. Una cosa a tener en cuenta es la penalización que se obtiene al tener que reactivar el sirviente cuando llega una petición, por ello, también puedes tener en un momento dado, algún sirviente que nunca sea puesto a dormir. Para estos y más detalles, te remito al Ice.Book.

Referencias

  1. Patrón Evictor
  2. Manual de Ice: Ice.Book
  3. Código de los ejemplos


blog comments powered by Disqus