
Compartir:
Guillaume es desarrollador senior de .Net en Vonage. Ha estado trabajando en .Net durante casi 15 años, mientras que en los últimos años se ha centrado en la defensa de Software Craftsmanship. Sus temas favoritos son la calidad del código, la automatización de pruebas, el mobbing y las katas de código. Fuera del trabajo, le gusta pasar tiempo con su mujer y su hija, hacer ejercicio o jugar.
La invasión de las mónadas - Parte 3: Programación orientada a los ferrocarriles
Tiempo de lectura: 6 minutos
Hola amigos,
Nuestro anterior post, "La invasión de las mónadas - Parte 2: ¡Mónadas en acción!", presentaba un conjunto de varias mónadas y cómo pueden encajar en escenarios del mundo real.
Hoy nos centraremos en la Programación Orientada a Ferrocarriles (ROP), un enfoque funcional de la gestión de errores.
Spoiler: Si has leído nuestros posts anteriores, has observado ROP sin ser realmente consciente de ello.
Resumen rápido
Utilizamos nuestra autenticación de dos factores (2FA) como ejemplo real. Este flujo de trabajo en dos pasos requiere iniciar una verificación primero y después verificar el código enviado al usuario.
var myPhoneNumber = ...
var result = await StartVerificationRequest.Build()
.WithBrand("Monads Inc.")
.WithWorkflow(SmsWorkflow.Parse(myPhoneNumber))
.Create() // Build the authentication request
.BindAsync(request => verifyClient.StartVerificationAsync(request)) // Process the authentication request
.BindAsync(VerifyCodeAsync) // Start the second step if it's a success
.Match(AuthenticationSuccessful, AuthenticationFailed);
private async Task<Result<Unit>> VerifyCodeAsync(StartVerificationResponse response)
{
var myCode = ... // Receive verification code based on the specified workflow
return await response
.BuildVerificationRequest(myCode) // Build the verification request
.BindAsync(request => verifyClient.VerifyCodeAsync(request)); // Process the verification request
}Como se sugirió anteriormente, este fragmento ya aplica ROP. Probablemente ahora tengas muchas preguntas. ¿Qué es la ROP? ¿Cómo funciona? ¿Por qué es útil?
Empecemos por el principio.
Se espera el fracaso
Es estrictamente imposible construir software sin gestionar errores. En el mundo real, los fallos pueden producirse en cualquier punto
durante la ejecución, y no podemos ignorarlos.
¿Y si tuviéramos el mismo flujo de trabajo, escrito en estilo imperativo?
var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
var response = verifyClient.StartVerificationAsync(request);
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
var result = verifyClient.VerifyCodeAsync(verificationRequest);
this.AuthenticationSuccessful();Parece similar, pero falta algo importante: la gestión de errores.
De hecho, este fragmento sólo muestra el camino feliz, donde todo va como se esperaba.
Por supuesto, esto no es suficiente: nuestro objetivo es que haya paridad de características entre estos fragmentos. En su estado actual, cualquier fallo provocaría la caída de nuestro sistema. Como hemos mencionado anteriormente, este flujo puede fallar en varios lugares:
Al crear la solicitud de autenticación
Al procesar la solicitud de autenticación
Al elaborar la solicitud de verificación
Al tramitar la solicitud de verificación
¿Qué diferencia hay si entonces decidimos gestionar los errores?
var myPhoneNumber = ...
var request = await StartVerificationRequest.Build().WithBrand("Monads Inc.").WithWorkflow(SmsWorkflow.Parse(myPhoneNumber)).Create();
if (request.IsFailure)
{
// Can fail when Brand is invalid
// Can fail when PhoneNumber is invalid
return this.AuthenticationFailed(new Failure("Invalid input."));
}
var response = verifyClient.StartVerificationAsync(request);
if (response.IsFailure)
{
// Can fail when process can't be initiated
return this.AuthenticationFailed(new Failure("Cannot initiate verification process."));
}
var myCode = ... // Receive verification code based on the specified workflow
var verificationRequest = response.BuildVerificationRequest(myCode)
if (response.IsFailure)
{
// Can fail when the code input is invalid
return this.AuthenticationFailed(new Failure("Invalid code."));
}
var result = verifyClient.VerifyCodeAsync(verificationRequest);
if (result.IsFailure)
{
// Can fail when the verification fails
return this.AuthenticationFailed(new Failure("Verification failed."));
}
return this.AuthenticationSuccessful();
Tenemos que añadir un importante código de caldera para hacer frente a posibles fallos. Este código es denso y difícil de leer.
Ahora tenemos más código para manejar los fallos que el propio camino feliz, lo cual no es una coincidencia.
Sólo tenemos un camino feliz pero múltiples razones para fallar.
Hay algo importante que observar aquí:
Este fragmento ofrece el mismo comportamiento que el de la sección Recapitulación rápida, con la diferencia de que tarda aproximadamente tres veces más código para hacerlo.
La complejidad ciclomática también aumenta considerablemente, de 1 a 6en un abrir y cerrar de ojos.
¿He despertado ya su interés? Es hora de presentar Programación orientada al ferrocarril.
Programación orientada al ferrocarril
Nota: Esta sección comparte recursos de la charla "Programación orientada a ferrocarriles: gestión de errores de forma funcional" por Scott Wlaschin. Más detalles en F#ParaDiversiónYGanancia.
La Programación Orientada a Ferrocarriles es un enfoque funcional de la gestión de errores. La idea principal es considerar el "Camino Feliz" (Verde) como la vía principal, que proporciona nuestro comportamiento esperado.
En el ejemplo anterior, nuestro flujo de trabajo se compone de tres pasos:
Validar una entrada.
Actualizar un registro - si la entrada es válida.
Enviar una notificación - si la actualización del registro se ha realizado correctamente.
Esto es lo que parece de forma imperativa:
if (this.Validate(input))
{
try
{
var record = this.Update(input);
this.SendNotification(record);
}
catch (Exception)
{
...
}
}Consideramos que nuestro flujo de trabajo tiene éxito sólo si estos tres pasos se realizan correctamente.
Permanecemos en ese "Camino Feliz" hasta que nos enfrentamos a un error, que nos lleva a un "Camino de Fracaso" (Rojo). Cuando esto ocurre el fallo hasta el final del flujo. Es crucial que nos atengamos a la ruta principal, ya que es la única manera de garantizar la ejecución completa de nuestro flujo de trabajo.
En el ejemplo anterior, nos saltamos el paso de actualización cuando falla la validación.
Esto sólo es posible porque nuestras funciones devuelven múltiples estados posibles: a Éxito o un Fallo.
¿Te suena? Qué casualidad, ¡para eso usamos mónadas!
No te lo esperabas, ¿verdad?
En nuestro post anteriorvimos cómo cambiar el estado de una mónada de un estado a otro utilizando el método .Bind() método.
Este concepto fundamental nos permite crear esta ramificación entre pistas.
Aquí no hay magia. La ramificación no desapareció con una varita mágica ni nada por el estilo. La ramificación es ahora interna a nuestra Mónada.
Echa un vistazo a cómo Bind en nuestra mónada personalizada:
return this.IsFailure ? Result<TB>.FromFailure(this.failure) : bind(this.success);Nuestro mecanismo de decisión -un operador ternario- verifica si el estado actual es un fracaso o un éxito.
Si es un fallo, devolvemos el fallo actual; no pasa nada.
Si es un éxito, llamamos a la función
bindpara continuar el flujo.
De este modo, podemos encadenar nuestras operaciones y el flujo cambiará automáticamente a la ruta de fallo si alguna de las operaciones falla. operaciones falla.
Aquí está el mismo flujo de trabajo que antes, pero esta vez usando ROP:
var result = input.Bind(Validate).Bind(Update).Bind(SendNotification);Impresionante, ¿verdad? Redujimos nuestro código a una sola línea y cubrimos las mismas funciones.
Juguemos a un pequeño juego
He aquí un ejemplo de código de nuestro SDK .NET que nos permite autenticarnos para una de nuestras API de red. ¿Puede averiguar cómo son las vías del tren? ¿Dónde puede fallar nuestro flujo de trabajo?
No necesitas saber qué está pasando aquí; no hay lógica. Céntrate en el flujo.
public Task<Result<AuthenticateResponse>> AuthenticateAsync(Result<AuthenticateRequest> request) =>
request.Map(BuildAuthorizeRequest)
.BindAsync(this.SendAuthorizeRequest)
.Map(BuildGetTokenRequest)
.BindAsync(this.SendGetTokenRequest)
.Map(BuildAuthenticateResponse);La respuesta es muy sencilla: nuestro ferrocarril tiene dos bifurcaciones:
Al enviar la solicitud de autorización.
Al enviar la solicitud get token.
Esto es explícito porque nos basamos en el método Bind para estas operaciones.
Para otra comparación, aquí está el mismo código escrito en un estilo imperativo:
public async Task<Result<AuthenticateResponse>> AuthenticateAsync(AuthenticateRequest request)
{
var authorizeRequest = BuildAuthorizeRequest(request);
var authorizeResponse = await this.SendAuthorizeRequest(authorizeRequest);
if (authorizeResponse.IsFailure)
{
return Result<AuthenticateResponse>.FromFailure(authorizeResponse.Failure);
}
var getTokenRequest = BuildGetTokenRequest(authorizeResponse.Success);
var getTokenResponse = await this.SendGetTokenRequest(getTokenRequest);
if (getTokenResponse.IsFailure)
{
return Result<AuthenticateResponse>.FromFailure(getTokenResponse.Failure);
}
return BuildAuthenticateResponse(getTokenResponse.Success);
}Como se puede ver, la lectura y la comprensión del flujo es mucho más fácil que en el estilo imperativo, y la función es más pequeña. Lo hemos conseguido reduciendo la carga cognitiva de nuestro método: reducir la cantidad necesaria de información técnica (ramificaciones, lógica, variables, etc.) ayuda a que el código "quepa en la cabeza" ( véase "Código que cabe en la cabeza" de Mark Seemann).
Los procesos fallan por múltiples razones
Mirando nuestro ejemplo anterior, nuestro flujo de trabajo puede fallar en dos puntos - esto podría ser un problema para nosotros.
Nuestra ruta de fallo lleva lo que definimos como Failure estado.
Implementamos nuestra Mónada de forma que pueda llevar tanto un Success o un Failure valor
utilizando genéricos.
Podemos imaginarlo como un Result<TFailure, TSuccess.
¿Ves por dónde voy?
Debido a cómo funcionan los genéricos en C#, una instancia de Result debe llevar siempre el mismo tipo de fallo. En otros términos,
todos nuestros fallos deben tener el mismo tipo - eso es un problema ya que puedes decidir tratar un fallo de parseo
de manera diferente que un fallo de la API.
No podemos culpar a Mónadas o a ROP de este problema, es una limitación del lenguaje. En comparación, aquí es donde un lenguaje como F# brilla, ya que nos permite definir un tipo de unión discriminado que puede llevar múltiples tipos de fallos. Aún así, la esperanza es posible - las uniones discriminadas en acabarán llegando a C#.
Aunque no podemos ignorar esta limitación, no significa que estemos totalmente estancados. Una forma de agrupar diferentes fallos bajo el mismo tipo genérico es utilizar una clase base o una interfaz.
En el SDK de .NET, implementé una función
IResultFailure interfaz
que todos los fallos deben implementar. Nos permite agrupar diferentes fallos (o razones para fallar - parsing,
autenticación, etc.) en la misma ruta de fallo y definir comportamientos dedicados utilizando la propiedad Type propiedad.
public interface IResultFailure
{
/// <summary>
/// The type of failure.
/// </summary>
Type Type { get; }
/// <summary>
/// Returns the error message defined in the failure.
/// </summary>
/// <returns>The error message.</returns>
string GetFailureMessage();
/// <summary>
/// Converts the failure to an exception.
/// </summary>
/// <returns>The exception.</returns>
Exception ToException();
/// <summary>
/// Converts the failure to a Result with a Failure state.
/// </summary>
/// <typeparam name="T">The underlying type of Result.</typeparam>
/// <returns>A Result with a Failure state.</returns>
Result<T> ToResult<T>();
}No es una solución perfecta y probablemente tampoco la más elegante. Pero es similar al manejo de diferentes tipos de excepciones, como en el siguiente ejemplo:
try { ... }
catch (ExceptionA) { ... }
catch (ExceptionB) { ... }
catch (ExceptionC) { ... }
// ---
switch (failure)
{
case HttpFailure httpFailure:
DoSomethingWithHttpFailure(httpFailure);
break;
case ResultFailure resultFailure:
DoSomethingWithResultFailure(resultFailure);
break;
default:
DoSomethingWithFailure(failure);
break;
}Como puedes ver, ROP nos permite usar las mismas funcionalidades que las excepciones - no es un reemplazo, sólo una alternativa al manejo de errores que parece un poco diferente. alternativa al manejo de errores que se ve un poco diferente.
Conclusión
Y aquí lo tenemos: nuestro tercer post de la serie "La invasión de las mónadas". Esta vez, nos hemos centrado en la (ROP) para mostrar cómo se puede utilizar Mónadas aún más para manejar los errores.
¿Cuál es la clave? No hay ninguna pérdida al utilizar Mónadas para el manejo de errores en comparación con un enfoque estándar basado en excepciones; todo lo que haces usando excepciones se puede hacer con Mónadas. Probablemente encontrará su código más limpio, más fácil de leer y más explícito en su intención. más explícito sobre su intención.
Ahora, la verdadera pregunta: ¿estás preparado para probarlo?
Te sugiero que empieces poco a poco. Hay una curva de aprendizaje, como con todo lo nuevo, pero sigue y verás los beneficios. verás los beneficios.
Si tienes alguna pregunta o quieres charlar, no dudes en ponerte en contacto conmigo en mi LinkedInComparta sus comentarios en el .NET SDK repositorio o únase a nosotros en el Slack para desarrolladores de Vonage. También puedes enviarnos un mensaje en @VonageDev en X. Estamos todos juntos en esto y tu voz importa.
Feliz codificación, ¡y hasta luego!
Compartir:
Guillaume es desarrollador senior de .Net en Vonage. Ha estado trabajando en .Net durante casi 15 años, mientras que en los últimos años se ha centrado en la defensa de Software Craftsmanship. Sus temas favoritos son la calidad del código, la automatización de pruebas, el mobbing y las katas de código. Fuera del trabajo, le gusta pasar tiempo con su mujer y su hija, hacer ejercicio o jugar.
