
Compartilhar:
Guillaume é um ex-membro da equipe da Vonage. Ele atuou como Promotor Sênior de Desenvolvimento .NET na Vonage. Trabalha com .NET há quase 15 anos, tendo se dedicado, nos últimos anos, à promoção do Software Craftsmanship. Seus temas favoritos incluem qualidade de código, automação de testes, mobbing e code katas. Fora do trabalho, ele gosta de passar tempo com a esposa e a filha, malhar ou jogar videogame.
A Invasão das Mônadas - Parte 3: Programação Orientada a Trilhos
Tempo de leitura: 7 minutos
Olá, amigos,
Nosso post anterior, “A Invasão das Monads – Parte 2: Monads em ação!”, apresentou um conjunto de várias mônadas e como elas podem se encaixar em cenários do mundo real.
Hoje, vamos nos concentrar na Programação Orientada a Trilhos (ROP), uma abordagem funcional para o tratamento de erros.
Spoiler: Se você leu nossos posts anteriores, já observou o ROP sem nem mesmo perceber.
Resumo rápido
Usamos nossa Autenticação de Dois Fatores (2FA) como um exemplo prático. Esse fluxo de trabalho em duas etapas exige que se inicie uma verificação primeiro e, em seguida, se verifique o código enviado ao usuário.
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 sugerido anteriormente, esse trecho de código já aplica o ROP. Você provavelmente deve estar com muitas dúvidas neste momento. O que é ROP? Como funciona? Por que é útil?
Vamos começar do início, certo?
O fracasso é esperado
É absolutamente impossível desenvolver software sem lidar com erros. No mundo real, podem ocorrer falhas a qualquer momento
durante a execução, e não podemos ignorá-las.
E se tivéssemos o mesmo fluxo de trabalho, escrito em 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 semelhante, mas falta algo importante: o tratamento de erros.
De fato, esse trecho de código mostra apenas o cenário ideal, em que tudo ocorre conforme o esperado.
É claro que isso não é suficiente — nosso objetivo é alcançar a paridade de funcionalidades entre esses trechos de código. No estado atual, qualquer falha faria com que nosso sistema travasse. Como mencionamos anteriormente, esse fluxo pode falhar em vários pontos:
Ao criar a solicitação de autenticação
Ao processar a solicitação de autenticação
Ao criar a solicitação de verificação
Ao processar a solicitação de verificação
Qual é a diferença, então, se decidirmos lidar com os erros?
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();
Precisamos adicionar um código padrão importante para lidar com possíveis falhas. Esse código é denso e de difícil leitura.
Agora temos mais código para lidar com falhas do que para o próprio caminho normal, o que não é por acaso.
Temos apenas um único caminho normal, mas várias razões para falhar.
Há algo importante a se observar aqui:
Este trecho de código apresenta o mesmo comportamento que o da seção “Resumo rápido”, exceto que leva aproximadamente três vezes mais código para fazer isso.
A complexidade ciclomática também aumenta significativamente, passando de 1 para 6, num piscar de olhos.
Já despertei seu interesse? É hora de apresentar Programação Orientada a Trilhos.
Programação Orientada a Trilhos
Nota: Esta seção compartilha recursos da palestra “Programação Orientada a Trilhos — tratamento de erros de maneira funcional" , ministrada por Scott Wlaschin. Mais detalhes estão disponíveis em F#ForFunAndProfit.
A Programação Orientada por Trilhos é uma abordagem funcional para o tratamento de erros. A ideia principal é considerar o “Caminho Feliz” (verde) como a via principal, proporcionando o comportamento esperado.
No exemplo acima, nosso fluxo de trabalho é composto por três etapas:
Validar uma entrada.
Atualizar um registro — desde que os dados inseridos sejam válidos.
Enviar uma notificação — caso a atualização do registro seja bem-sucedida.
Veja como fica na forma imperativa:
if (this.Validate(input))
{
try
{
var record = this.Update(input);
this.SendNotification(record);
}
catch (Exception)
{
...
}
}Consideramos nosso fluxo de trabalho bem-sucedido somente se essas três etapas forem bem-sucedidas.
Permanecemos nesse “Caminho do Sucesso” até encontrarmos um erro, o que nos leva a um “Caminho da Falha” (vermelho). Quando isso acontece, nós carregamos a falha até o final do fluxo. É fundamental que nos mantenhamos no caminho principal, pois é a única maneira de garantir a execução completa do nosso fluxo de trabalho.
No exemplo acima, pulamos a etapa de atualização quando a validação falha.
Isso só é possível porque nossas funções retornam vários estados possíveis: um Sucesso ou um Falha.
Isso te diz alguma coisa? Que coincidência, a gente usa monads para isso!
Você não esperava por essa — ou esperava?
Em nosso post anterior, vimos como alterar o status de uma Monad de um estado para outro usando o .Bind() método.
Esse conceito fundamental nos permite criar essa ramificação entre as faixas.
Não há mágica nisso. A ramificação não desapareceu com uma varinha mágica nem nada parecido. A ramificação agora faz parte da nossa Monad.
Veja como Bind é implementado em nossa Monad personalizada:
return this.IsFailure ? Result<TB>.FromFailure(this.failure) : bind(this.success);Nosso mecanismo de decisão — um operador ternário — Verify se o estado atual é uma falha ou um sucesso.
Se for uma falha, retornamos a falha atual; nada acontece.
Se for bem-sucedido, chamamos a
bindfunção para continuar o fluxo.
Dessa forma, podemos encadear nossas operações, e o fluxo mudará automaticamente para o caminho de falha caso alguma das operações falhe.
Aqui está o mesmo fluxo de trabalho de antes, mas, desta vez, usando o ROP:
var result = input.Bind(Validate).Bind(Update).Bind(SendNotification);Impressionante, não é? Reduzimos nosso código a uma única linha, mantendo as mesmas funcionalidades.
Vamos fazer um joguinho
Aqui está um exemplo de código do nosso SDK .NET que nos permite autenticar em uma de nossas APIs de rede. Você consegue descobrir como são os trilhos da ferrovia? Em que pontos nosso fluxo de trabalho pode falhar?
Você não precisa saber o que está acontecendo aqui; não há lógica alguma. Concentre-se no fluxo.
public Task<Result<AuthenticateResponse>> AuthenticateAsync(Result<AuthenticateRequest> request) =>
request.Map(BuildAuthorizeRequest)
.BindAsync(this.SendAuthorizeRequest)
.Map(BuildGetTokenRequest)
.BindAsync(this.SendGetTokenRequest)
.Map(BuildAuthenticateResponse);A resposta é bem simples — nossa ferrovia possui dois pontos de bifurcação:
Ao enviar a solicitação de autorização.
Ao enviar a solicitação para obter o token.
Isso fica explícito porque nos baseamos no Bind método para essas operações.
Para fazer outra comparação, eis o mesmo código escrito em 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 você pode ver, ler e entender o fluxo é muito mais fácil do que no estilo imperativo, e a função é mais curta. Conseguimos isso reduzindo a carga cognitiva do nosso método — diminuir a quantidade necessária de informações técnicas (ramificações, lógica, variáveis etc.) ajuda a fazer com que o código “caiba na sua cabeça” ( veja “Código que cabe na sua cabeça”, de Mark Seemann).
Os processos falham por diversos motivos
Analisando nosso exemplo anterior, nosso fluxo de trabalho pode falhar em dois pontos — isso pode ser um problema para nós.
Nosso caminho de falha carrega o que definimos como um Failure estado.
Implementamos nossa Monad de forma que ela possa conter tanto um Success ou um Failure valor
usando gênéricos.
Podemos imaginá-la como um Result<TFailure, TSuccess.
Entende onde quero chegar?
Devido à forma como os genéricos funcionam em C#, uma instância de Result deve sempre apresentar o mesmo tipo de falha. Em outras palavras,
todas as nossas falhas devem ter o mesmo tipo — isso é um problema, pois você pode decidir tratar uma falha de análise
de maneira diferente de uma falha de API.
Não podemos culpar o Monads ou o ROP por esse problema — trata-se de uma limitação da linguagem. Em comparação, é nesse ponto que uma linguagem como o F# se destaca, pois nos permite definir um tipo de união discriminada capaz de abranger vários tipos de falhas. Ainda assim, há esperança — as uniões discriminadas acabarão chegarão, eventualmente, chegar ao C#.
Embora não possamos ignorar essa limitação, isso não significa que estejamos totalmente sem saída. Uma maneira de agrupar diferentes falhas sob o mesmo tipo genérico é usar uma classe base ou uma interface.
No SDK do .NET, implementei uma
IResultFailure interface
que todas as falhas devem implementar. Isso nos permite agrupar diferentes falhas (ou motivos de falha — análise,
autenticação etc.) no mesmo caminho de falha e definir comportamentos específicos usando a Type propriedade.
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>();
}Essa não é uma solução perfeita e, provavelmente, também não é a mais elegante. Mas é semelhante ao tratamento de diferentes tipos de exceções, como no exemplo a seguir:
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 você pode ver, o ROP nos permite usar as mesmas funcionalidades que as exceções — não é um substituto, mas apenas uma alternativa para o tratamento de erros, que funciona de maneira um pouco diferente.
Conclusão
E aí está — nosso terceiro post da série “A Invasão das Monads”. Desta vez, nos concentramos na Programação Orientada a Ferrovias (ROP) para mostrar como você pode usar as Monads ainda mais para lidar com erros.
Qual é a principal lição? Não há nenhuma desvantagem em usar monades para o tratamento de erros em comparação com uma abordagem padrão baseada em exceções; tudo o que você faz usando exceções pode ser feito com monades. Você provavelmente perceberá que seu código fica mais limpo, mais fácil de ler e mais explícito quanto à sua intenção.
Agora, a verdadeira pergunta: você está pronto para experimentar?
Sugiro começar aos poucos e ir se adaptando gradualmente. Como tudo que é novo, há uma curva de aprendizado, mas persista e você verá os benefícios.
Se você tiver alguma dúvida ou quiser bater um papo, fique à vontade para me mandar uma mensagem no meu LinkedIn, compartilhe seus comentários no repositório do repositório do SDK do .NET ou se junte a nós no canal Slack dos desenvolvedores da Vonage. Você também pode nos enviar uma mensagem no @VonageDev no X. Estamos todos juntos nessa, e a sua voz é importante.
Boa programação, e até mais tarde!
Compartilhar:
Guillaume é um ex-membro da equipe da Vonage. Ele atuou como Promotor Sênior de Desenvolvimento .NET na Vonage. Trabalha com .NET há quase 15 anos, tendo se dedicado, nos últimos anos, à promoção do Software Craftsmanship. Seus temas favoritos incluem qualidade de código, automação de testes, mobbing e code katas. Fora do trabalho, ele gosta de passar tempo com a esposa e a filha, malhar ou jogar videogame.
