El patrón observador es otro patrón de diseño que nos encontramos muy a menudo, y para bien o para mal, muchas veces tenemos que usarlo de manera forzosa en muchos APIs (sobre todo en aquellos que utilizan funciones de red).
¿De qué se trata este patrón?, pues en resumen consta de 3 elementos principales:
- Eventos. Se trata de objetos que contienen una etiqueta (generalmente por medio de una cadena de caracteres) y además de éso pueden contener cualquier información (la etiqueta suele utilizarse para diferenciar unos eventos de otros, casi como el tipo de una estructura lo hace).
Algunos sistemas de eventos requieren que la información dentro del evento sea serializable, pero no siempre es el caso. - Emisores.Los emisores son objetos que pueden emitir eventos. Es decir, tienen una función para emitir que recibe un evento.
- Suscriptores. Los suscriptores son callbacks que se añaden a los emisores, y son llamados cada que un emisor emite un evento con una etiqueta específica.
Por ejemplo, en un servidor HTTP se podría recibir un evento cuando un cliente intenta visitar una URL dada; en una aplicación de escritorio se podría emitir un evento cuando el usuario presiona un botón; o en un videojuego se puede recibir un evento una vez que se terminaron de cargar las texturas.
Por si no quedó del todo claro, la idea es que cada evento represente un suceso del cual alguna parte de la aplicación debe de encargarse. Se utiliza un emisor para notificar cuando algún evento sucede mientras que los suscriptores son los encargados de tomar acción una vez que el evento ya sucedió.
Un uso no tan obvio, pero que me parece bastante útil (valga la redundancia) es evitar el antipatrón de efectos secundarios. Ya que es bastante común que parte de los requerimientos del proyecto incluyan realizar una acción B, cada que una acción A se realiza.
Por ejemplo, en un juego RPG, podría ser inmovilizar al personaje cada que se muestra un cuadro de dialogo.
MAL ❌
export function showDialog(text : string, sytle : DialogueStyle) : void {
drawMessageBackground(style.backgroundStyle);
drawText(text, style.textStyle);
freezeCharacter();
}
...
showDialog("It's dangerous to go alone, take this");
Este código tiene el problema de que si alguien mira el lugar donde se manda llamar showDialog, no hay indicio alguno de que el presonaje se vaya a inmovilizar.Por otro lado, si se optara por mandar llamar a freezeCharacter cada que vez que se manda llamar la función showDialog, rompería con el principio DRY y en consecuencia haría el código más propenso a errores(a alguien se le podría olvidar mandar llamar a freezeCharacter, o borrarlo por accidente o por un malentendido) y más difícil de mantener.
Por el lado de las pruebas, podría complicar las cosas ya que toda prueba unitaria que involucre mostrar un diálogo debería de configurar las dependencias para incluir al personaje también.
Por último, si se creara una función llamada showDialogAndFreezeCharacter , se rompería el principio de que una función debe hacer una y sólo una cosa.
MEJOR✅
export function showDialog(text : string, sytle : DialogueStyle) : void {
drawMessageBackground(style.backgroundStyle);
drawText(text, style.textStyle);
events.emit(DIALOG_DISPLAYED);
}
...
events.on(DIALOG_DISPLAYED, freezeCharacter);
...
showDialog("It's dangerous to go alone, take this");
Esta implementación es más coherente porque la función showDialog, en sí misma no está haciendo otra cosa distinta a mostar el dialogo, mientras que la acción de "cada vez que se muestre un diálogo congela al personaje", está claramente descrita en un lugar a parte y el bajo nivel de acoplamiento permite escribir pruebas unitarias más fácilmente.Ventajas
Un bajo nivel de acoplamiento. Dado que para utilizar un emisor no es necesario conocer información alguna sobre quienes van a ser los sucriptores y los suscriptores tampoco necesitan saber la información de quien emitió los eventos, el acoplamiento es extremadamente bajo.
Lo cual se traduce en facilidad para crear pruebas automáticas (una prueba puede emitir eventos sin necesidad de crear un fake o un mock) y facilidad de modificar la implementación de una clase sin romper otras clases.Facilidad para crear bitácoras. Es posible poner en una bitácora cada que se emite un evento y de ésa manera tener una idea de qué está sucediendo.
Desventajas
Cambiar el esquema de un evento puede ser muy costoso.
El stack pierde su utilidad. Normalmente basta con ver la pila de llamadas para poder saber cual fue la secuencia de pasos que llevaron a suceder una situación en particular (sobre todo cuando hay una excepción no cachada). El problema con esta arquitectura de eventos es que es bastante común que el stack muestre una clase encargada de desencolar los eventos del emisor, sin dejar pista alguna de cómo fue que se llegó a emitir ése evento.
Las llamadas asíncronas se vuelven más problemáticas en las pruebas. Cuando se quiere probar un procedimiento asíncrono que inicia con la emisión de un evento, no es trivial saber cuándo termina para poder verificar que el resultado fue correcto (a veces hay que crear funciones específicamente para que le notifiquen a la prueba cuando el resultado ya está listo).
Eventos y Redux
Todo muy bonito, pero hay un problema...
¡Esto tiene una solución!
createSlice({
name : "fanboy",
initialState: {
name : "Juanito",
surname : "Perez",
emotion: "indiferent",
usd: 100
},
reducers: {
changedHisName : (state, action: PayloadAction<{name: string, surname: string}>) => {
state.name = action.name;
state.surname = action.surname;
},
earnedMoney : (state, action: PayloadAction<number>) => {
state.usd += action.payload;
state.emotion = "happy";
}
},
extraReducers: (builder) => {
builder.addCase(NINTENDO_RELEASED_GAME, (state, action: Payload<{metacritic_score : number}>) => {
if (action.payload.metacritic_score < 80) {
state.emotion = "angry";
return;
}
if (action.payload.metacritic_score < 90) {
state.emotion = "indiferent";
return;
}
if (state.usd < 60) {
state.emotion = "sad";
return;
}
state.usd -= 60;
state.emotion = "happy";
}).addCase(SONY_RELEASED_GAME, (state, action: Payload<{metacritic_score : number}>) => {
if (action.payload.metacritic_score > 90) {
state.emotion = "angry";
}
});
}
});
Por si no se entiende por el contexto, en este caso NINTENDO_RELEASED_GAME y SONY_RELEASED_GAME son acciones definidas fuera del slice, pero que una vez despachadas cambian el contenido del slice.
