miércoles, 31 de julio de 2024

El Patron Observador(Eventos y Suscriptores) y su Relación con Redux

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

El resto del post habla únicamente de Redux, les aviso a quienes no estén familiarizados o no estén interesados en Redux, que probablemente ya leyeron todo lo que les puede interesar de esta publicación.

En la guía de estilo de Redux podemos leer esto:
De manera que idealmente cada acción de redux debería de describir algo que ocurrió en la aplicación, por ejemplo: el usuario hizo click en un botón, el usuario presionó cierta tecla, o incluso cosas más abstractas como que se pidió que una ventana se cerrara, o cambió el nombre de un objeto.

... en lugar de que las acciones de redux sean peticiones para cambiar el estado global (esto comparte los mismos problemas graves que dan las variables globales), las acciones simplemente deben de describir algo que ya ocurrió y dejar que sean los reducers los que se encarguen de cambiar el estado interno y los selectores los que se encarguen de leer el estado interno (el resto del proyecto no debería de necesitar siquiera saber cómo está estructurado el estado de Redux).

Todo muy bonito, pero hay un problema...

No le dedicaría un espacio tan grande en este post a simplemente regurgitar lo que ya dice la guía de estilo de Redux, y es que hace un tiempo me percaté de que Redux Toolkit parece tener construidos los slices de manera que hace imposible poder usar las acciones como eventos.

Redux Toolkit posee la función createSlice , a la cual se le pasa un estado inicial y una lista de reducers, y para cada reducer crea una acción para llamarlo. ¡Prácticamente crea un montón de variables globales con funciones para cambiarlas!, y para hacer las cosas peor, cada slice aisla su estado interno del resto del estado, de manera que no puede ser cambiado desde afuera(esto esto es malo porque pareciera que es imposible que una acción cambie el contenido de varios slices, impidiendo su funcionamiento como eventos).

Así que parece todo perdido pero...

¡Esto tiene una solución!

Naturalmente, los creadores de Redux Toolkit no diseñaron el framework pensando en romper la guía de estilos de Redux, sin embargo, me sorprendió lo mucho que me costó encontrar la solución para esto.

Resulta que createSlice tiene un parámetro opcional llamado extraReducers. Utilizando ése parámetro se pueden crear reducers que en lugar de que reaccionen a su propia acción creada por el slice, reaccionen a una acción definida en otro lado.

Su syntaxis es un poco rara, pero no es dificil de usar: extraReducers recibe un callback, y el callback recibe como parámetro un objeto que llamaremos armador, este armador tiene un método llamado addCase, el cual recibe como parámetro una acción ya existente y el reducer con el cual debería de reaccionar.

Suena muy enrevesado, pero viéndolo en acción no lo es tanto:

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.

No hay comentarios.:

Publicar un comentario

De StackOverflow a ChatGPT: El Arte de Pegar sin Pensar

 Es curioso cómo apenas en Agosto del año pasado critiqué duramente la práctica de copiar código de páginas de internet para pegarlo dentro ...