Desmitificando el Patrón de diseño Singleton en JavaScript , Anti-patrón?
El patrón de diseño singleton permite conservar una única instancia de una clase a lo largo de toda la aplicación. Algunas veces considerado como anti-patrón, es bastante polémico y a su vez bastante utilizado ya que es sencillo de implementar. En este post, veremos todo acerca de este patrón.
Polémica
Este patrón busca que una clase solo tenga una instancia, y aquí empieza la polémica. Por qué una clase necesitaría una sola instancia ?. Bueno, tal vez a primera vista no parezca tan necesario pero te aseguro que en algún momento de tu vida como desarrollador se presentará un caso en donde esto sea posible. Más adelante en este post te mostraremos un ejemplo.
Siendo así, cada vez que tu intentes crear una nueva instancia de la clase recibirás la misma aún sin saberlo.
Vamos ahora con la implementación. Si este no es el primer artículo que lees sobre la implementación de este patrón, seguro habrás visto la red está inundada de malos ejemplos y esto alienta aún más la polémica sobre el mismo. Para qué necesitaría crear un singleton en una clase User
si tengo muchos usuarios en mis base de datos?. Bueno!. Te mostraré un ejemplo de la vida real en donde creo que se puede aplicar este patrón.
Implementación
Supongamos que tienes un sitio web y tienes las preferencias del usuario en base de datos. Estas preferencias son del tipo tema, zona horaria, idioma, etc, y puedes consultarlas mediante una llamada api definida así:
POST http://somewebsite.com/config/{userId}
{
"theme": "dark",
"locale": "en-US",
"timezone": "America/Bogota"
}
Bien, pues imagina que hemos creado la siguiente clase para obtener estas preferencias por api y guardarlas como caché en una clase Config
.
class Config
{
endpoint = 'https://somewebsite.com/config'
constructor(userId) {
this.userId = userId;
}
async getValueFor(key) {
if (this.data) {
// getting from cache
return this.data[key]
}
return await fetch(`${this.endpoint}/${this.userId}`)
.then(response => response.json())
.then(data => {
this.data = data
return this.data[key]
})
}
}
Si quisieramos obtener el tema actual de un usuario podríamos hacer algo como lo siguiente:
const config = new Config(55); // userId 55
await config.getValueFor('theme'); // dark
Lo bueno de esta clase es que la segunda vez que intentes acceder a cualquier propiedad de las preferencias no harás la llamada al servidor si no que lo obtendrás de caché.
await config.getValueFor('locale'); // en-US
Ahora supongamos que necesitas acceder a esta configuración en varias clases y módulos a lo largo de tu aplicación. Eso significa que muy probablemente repetirás el código de la instanciación algunas veces y eso hará que la llamada a la api del servidor se realice una y otra vez.
En este punto tiene mucho sentido que solo tengamos una instancia de la clase en toda nuestra aplicación, o lo que es lo mismo que apliquemos el patrón singleton para ahorrarnos ese par de llamadas y de memoria. Siendo así, debemos verificar si la instancia ya fue creada o no. La implementación cambiaría así:
class Config
{
static instance
endpoint = 'https://somewebsite.com/config'
constructor(userId) {
this.userId = userId;
if (Config.instance) {
return Config.instance
} else {
Config.instance = this
}
}
...
}
Así cuando intentes crear otra instancia en otra parte del código
const config2 = new Config(55);
Podrás verificar que en realidad se trata de la misma instancia ya que el objeto data está configurado!.
typeof config2.data; // object
Mejora del patrón singleton
Aún cuando nuestro singleton funciona (y esto lo he visto en un millón de implementaciones), cuando intentamos llamar a la config de un usuario diferente aún así obtendríamos la configuración del primer usuario solicitado.
const config3 = new Config(134);
const3.userId; // 55
En primera instancia uno pensaría que no hay problema ya que solamente necesitamos consultar la config del usuario en sesión, pero personamente pienso que esto es un error de lógica en la interfaz ya que puede inducir errores en el futuro. Esta es otra de las razones polémicas por las cuales se dice que es un anti-patrón, ya que se hace muy difícil hacer pruebas unitarias de esta clase ya que mantiene el estado inicial siempre.
La forma de resolver parcialmente el problema planteado es manejar varias instancias singleton dependiendo de un criterio como en este caso el userId
.
class Config
{
static instance = {}
endpoint = 'https://somewebsite.com/config'
constructor(userId) {
this.userId = userId;
if (Config.instance[userId] !== undefined) {
return Config.instance[userId]
} else {
Config.instance[userId] = this
}
}
...
}
Así, cuando intenemos llamar la segunda vez a la config de un usuario ya consultado traeremos la misma instancia inicial, para usuario no consultados traeremos una nueva y así..
const config = new Config(55); // userId 55
await config.getValueFor('theme'); // dark
const config2 = new Config(55)
typeof config2.data; // object (existing data, same object)
const config3 = new Config(1)
config2.data; // undefined (new instance for userId = 1)
Ventajas del patrón singleton
( 1 ) Fácil de implementar, incluso manejando múltiples instancias.
( 2 ) Puedes estar seguro de que una clase solo tiene una instancia con lo cual reduces el uso de memoria, llamados a apis, etc.
( 3 ) El objeto singleton es solamente instanciado cuando se llama por primera vez.
Desventajas del patrón singleton
( 1 ) Viola el principio de responsabilidad única, ya que se ocupa de dos cosas al misma tiempo (el objetivo de la clase en sí y el manejo de las instancias).
( 2 ) Puede enmascarar un mal diseño ya que los componentes conocen mucho de los otros, o lo que es lo mismo, está fuertemente acoplado.
( 3 ) En entornos multihilo se hace complejo manejar la misma instancia de la clase.
( 4 ) Puede traer problemas en las pruebas unitarias ya que conserva el estado.
Uso de service containers
Para mitigar la mayoría de desventajas de utilizar singletons de la forma vista anteriormente se puede utilizar un service container en lugar de agregar la lógica de resulución de la instancia en la misma clase. Si ya conoces cómo funcionan los service containers te resultará un poco más intuitivo este ejemplo. Tomemos nuestra clase original y registremosla en un service container.
const container = new Container();
container.singleton(Config, (container) => {
const userId = Math.round(Math.random()*100); // get from session
return new Config(userId);
});
Después al intetar utilizar el service container por primera vez se creará la instancia.
const config = container.make(Config);
await config.getValueFor('theme'); // dark
Al realizar el segundo llamado se obtendrá la instancia original.
const config2 = container.make(Config);
console.log(config === config2); // true
Observa el siguiente ejemplo interactivo. Abre la terminal con la pestaña en el lado de abajo del plugin y ejecuta el comando node container.js
Si ejecutaste el comando obtendrás una salida similar a la siguiente:
app@service-container-in-javascript:~ 01:32
$ node container.js
first user id: 4
second user id: 4
are same? true
Esto indica que las dos instancias son exactamente la misma con lo cual el service container funciona.