Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 25 de enero de 2022
ASP.NET Core

El middleware de gestión de errores DeveloperExceptionPageMiddleware ha estado con nosotros desde la llegada de .NET Core, allá por 2016, ayudándonos a mostrar información detallada de las excepciones producidas en tiempo de ejecución mientras estábamos desarrollando. De hecho, era frecuente encontrar un código como el siguiente en las clases Startup de las aplicaciones ASP.NET Core, pues se incluía por defecto en todas las plantillas de proyecto de este tipo:

...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    ... // Otros middlewares
}

El extensor UseDeveloperExceptionPage() era el encargado de añadir el middleware DeveloperExceptionPageMiddleware al pipeline, de forma que éste podía capturar las excepciones y mostrar una página amigable y con información útil para los desarrolladores.

Sin embargo, si echamos un vistazo al código de configuración del pipeline en los proyectos ASP.NET Core 6 (que, por cierto, sabréis que ya no se encuentra en Startup.cs sino en Program.cs), vemos que ya no aparece ninguna referencia a este middleware. ¿Qué ha pasado? ¿Qué has hecho con nuestro DeveloperExceptionPage, ASP.NET Core 6?

Por defecto, amigable para los desarrolladores

Realmente, el equipo de ASP.NET Core ha hecho un gran esfuerzo por simplificar el código de configuración y arranque de las aplicaciones. La entrada del (amado por unos y criticado por otros tantos) minimal hosting ha permitido reducir la cantidad de artefactos y código necesarios para echar a andar una aplicación web mediante la introducción de nuevas abstracciones y convenciones.

Una de estas convenciones ataca directamente al hecho que comentábamos algo más arriba: si realmente la introducción de este middleware estaba siempre en nuestro pipeline mientras ejecutábamos en el entorno de desarrollo, ¿por qué no hacer que las propias abstracciones ya lo incluyan de serie y nos ahorramos ese código repetido en la configuración de todas nuestras aplicaciones?

Y en efecto, esto es lo que ha ocurrido ;)

Recordemos que la magia de minimal hosting y minimal APIs consiguen dejar todo el código de inicialización, e incluso una pequeña API que retorna el clásico saludo, en cuatro líneas:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();

La llamada a WebApplication.CreateBuilder() retorna un objeto WebApplicationBuilder, que posteriormente, mediante la llamada a su método Build(), retornará un objeto WebApplication con ciertas configuraciones ya preestablecidas. Este objeto es en la práctica el resultado de introducir en la coctelera la configuración del host (IHost), del pipeline (IApplicationBuilder) y del routing (IEndpointRouteBuilder), y es sobre el que mapearemos los endpoints para, finalmente, poner en marcha el proyecto con la llamada a Run().

La cuestión es que cuando el builder comienza a modelar el objeto WebApplication, introduce "de serie", justo al principio de su recién creado pipeline, el middleware de excepciones para el desarrollador. Podemos verlo en el código fuente de la clase en GitHub:

public sealed class WebApplicationBuilder 
{
    ...
    private void ConfigureApplication(WebHostBuilderContext context, IApplicationBuilder app)
    {
        Debug.Assert(_builtApplication is not null);

        // UseRouting called before WebApplication such as in a StartupFilter
        // lets remove the property and reset it at the end so we don't mess
        // with the routes in the filter
        if (app.Properties.TryGetValue(EndpointRouteBuilderKey, out var priorRouteBuilder))
        {
            app.Properties.Remove(EndpointRouteBuilderKey);
        }

        if (context.HostingEnvironment.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        ...

Por tanto, el uso de DeveloperExceptionPageMiddleware está inexorablemente unido a la creación de la aplicación, eso sí, sólo cuando trabajemos en el entorno de desarrollo. Es decir, las excepciones que se produzcan ejecutando en el entorno Development siempre retornarán la pantalla de errores detallados, sin tener que hacer nada.

Pero, ¿y si quisiéramos evitarlo?

La verdad es que no se me ocurren motivos por lo que esto podría ser útil, pero bueno, todo sea por la curiosidad de ver cómo salvar el escollo ;)

Por ejemplo, imaginad que estamos ejecutando la aplicación en un entorno Development, pero, por cualquier razón, no queremos que los errores detallados lleguen al navegador del usuario.

A priori no hay una forma sencilla de conseguirlo por la profundidad en la que este comportamiento está hardcodeado en WebApplicationBuilder. Por tanto, la solución pasa por evitar el uso de esta clase y configurar nuestro host y aplicación de forma manual.

Una fórmula sería reescribir el código de inicialización a la vieja usanza, con la clase Startup, que seguro os resultará familiar si habéis trabajado con ASP.NET Core 5 o anteriores:

// File: Program.cs

var builder = Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
    });
builder.Build().Run();

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Registrar servicios aquí
    }

    public void Configure(IApplicationBuilder app)
    {
        // Configurar pipeline aquí
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", () => "Hello world!");
            endpoints.MapGet("/boom", (ctx) => throw new Exception("Boom!"));
        });
    }
}

O si lo preferimos, podemos usar un enfoque más compacto que, si bien no es minimal del todo, al menos tiene un aire más moderno que el anterior al dejar toda la inicialización en un único bloque de código:

// File: Program.cs

var builder = Host.CreateDefaultBuilder(args)
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.ConfigureServices(services =>
        {
            // Registrar servicios aquí
        });
        webBuilder.Configure(app =>
        {
            // Configurar pipeline aquí
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGet("/", () => "Hello world!");
                endpoints.MapGet("/boom", (ctx) => throw new Exception("Boom!"));
            });
        });
    });

builder.Build().Run();

Por supuesto, otra posibilidad bastante menos glamourosa sería, partiendo del código inicial, cortocircuitar la bajada de excepciones por el pipeline, de forma que nunca lleguen al DeveloperExceptionMiddleware. Esto lo podríamos conseguir insertando el siguiente middleware al principio del pipeline:

// File: Program.cs

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (ctx, next) =>
{
    try
    {
        await next(ctx);
    }
    catch
    {
        if (ctx.Response.HasStarted)
        {
            // Nada que hacer, la respuesta ya comenzó
            return;
        }
        ctx.Response.StatusCode = 500;
        await ctx.Response.WriteAsync("Error");
    }
});
app.MapGet("/", () => "Hello world!");
app.MapGet("/boom", async (ctx) =>
{
    throw new Exception("Boom!");
});
app.Run();

Internamente este middleware se insertará por detrás del DeveloperExceptionMiddleware que añadió por defecto el builder, por lo que capturará las respuestas con excepciones antes de que llegue a mostrarse la página con el error.

Publicado en Variable not found.

9 Comentarios:

MontyCLT dijo...

Me gusta bastante las minimal API, pero sí que es cierto que me gustaría que estas cosas estuviesen explicitas en el fichero Program.cs, de forma que como desarrollador de un vistazo pudiese ver todos los Middleware, y que sea menos "mágico".

Para mi es esencial saber por qué ocurren las cosas en todo el flujo de ejecución, tanto de la aplicación como de cada petición HTTP.

José María Aguilar dijo...

Hola!

Coincido en la sensación. Aunque el resultado me gusta, es cierto que por detrás de las minimal API hay bastantes convenciones (magia) para lograr ese código de inicialización tan simple. De hecho, hay varios aspectos (como el orden de los middlewares relativos al routing) donde se hace un auténtico juego de malabares para que todo encaje. Para entenderlo bien no queda otra que ir al fuente del framework.

Pero bueno, es cierto que, como en la buena magia, a veces hay que dejarse llevar y no preocuparse por lo que hay por detrás... simplemente, disfrutar del espectáculo ;)

Saludos & gracias por comentar!

Ariel Max dijo...

Hola que tal? Muy buen post, gracias por compartir!
Dejo una consulta aca por si alguien puede ayudarme. Estoy implementando unos tests donde utilizo mi clase que heredo de WebApplicationFactory (estoy bajo net 6 solo con Program, sin Startup y asi debo seguir). Todo funciona correctamente cuando intento llamar a mis endpoints hasta este punto, tanto desde el cliente "in memory" como desde un cliente externo.
El problema ocurre cuando quiero usar un custom Middleware, porque sea donde sea que lo configuro los endpoints dejan de ser visibles (404). Alguien puede ayudarme con esto por favor? Muchas gracias de antemano!

José María Aguilar dijo...

Hola,

¿tienes alguna forma de crear un mini-proyecto que muestre este comportamiento y colgarlo en algún sitio para poder echarle un vistazo?

Saludos!

Ariel Max dijo...

Hola, gracias por tu respuesta e interes Jose Maria.

Aqui comparto el repo en bitbucket del POC

Basicamente, ya que recien lo subi y no hay readme (perdon), es la tipica webapi para NET6 del weather forecast.

Luego tenemos un assembly de unit test donde realizo "contract test" utilizando una lib PactNet. Muy resumidamente se pueden realizar pruebas de contrato tanto desde un consumer (un cliente UI, etc) y desde un provider (webapi) para hacer todo mas light y no llegar a realizar pruebas de integracion donde a veces se necesitan levantar varias cosas, etc.

Entonces aqui tengo mi clase que hereda de WebApplitacionFactory donde levanto mi host que tambien sea visible externamente, puesto que por default levanta un host "in memory" donde solo lo puede acceder el cliente devuelto por CreateClient() o CreateDefaultClient(). En este caso puedo acceder de esa forma y tambien externamente.
Aqui, el externamente es esta lib PactNet puesto que utilizar su propio http client por dento (podria ser un browser).

Esta lib tiene un metodo llamado WithProviderStateUrl(), el cual se utiliza en el momento de verificacion en mi unit test (ahora comentado seguramente) y por esta razon necesite agregar mi propio middleware para responder a ese endpoint para esto pero si es otra cosa deberia responder el endpoint correcto de la webapi.
En este momento de verificacion basicamente se fija que debe verificar en el json que llama y esta en el repo a modo de rapidez, donde le dice que deberia responder ante tal llamado (un GET en mi caso, algo simple).

En mi clase LocalWebApplicationFactory se vera comentado donde quise configurar ese middleware. Resulta que cuando no utilizo el middleware todo funciona correctamente, tanto cuando llamo desde mi cliente interno como cuando PactNet llama con su cliente externo.
En cuanto quiero usar mi middleware con el Configure, levanta bien el Middleware pero los endpoints de la webapi son todos 404 not found.

He aqui mi cuestion, espero haberme explicado. Desde ya muchas gracias y saludos!

José María Aguilar dijo...

Hola,

vaya, no he utilizando nunca Pactnet, por lo que me temo que mucho no voy a poder profundizar, pero bueno, te cuento lo que he hecho.

En un vistazo rápido, veo que puedo insertar un middleware custom en el pipeline (eso sí, desde program.cs), le he puesto un breakpoint, y veo que efectivamente se ejecuta en cada petición cuando ejecuto los tests.

Mi middleware va insertado antes del UseEndpoints(), y es algo tan simple como lo siguiente (obviamente, en lugar de esto podría ser tu middleware personalizado):

app.Use(async (ctx, next) =>
{
await next(); // Por aquí pasa en todas las peticiones
});

¿Es esto lo que quieres hacer? Si fuera el caso, supongo que podrías condicionar la inserción del middleware al entorno de ejecución, de forma que no se añada cuando estés fuera de los tests, o algo así.

Saludos!

Ariel Max dijo...

Encontre la forma! Basicamente fue registrar un ISetupFilter donde en mi clase concreta tengo la configuracion de usar el middleware; lo acabo de encontrar y luego analizo si puede que tenga algo que ver con tu respuesta.
En unos minutos modifico el repo pero basicamente en mi middleware, la registracion lo hago asi:

protected override IHost CreateHost(IHostBuilder builder)
{
// need to create a plain host that we can return.
var dummyHost = builder.Build();

// configure and start the actual host.
builder.ConfigureWebHost(builder =>
{
builder.UseKestrel();
builder.ConfigureServices(s =>
{
s.AddSingleton();
});
});

var host = builder.Build();
host.Start();

return dummyHost;
}


Y luego, tengo esta nueva clase que queda de la siguiente forma:

public class CustomStartupFilter : IStartupFilter
{
public Action Configure(Action next)
{
return app =>
{
app.UseProviderState();
next(app);
};
}
}

Registrando de esa forma ya funciona todo Ok. Yo supongo que entre cambiar el modelo de hosting mas el middleware, cambian formas y "orden" de como registrar estos componentes. Lo cual estaria genial que este mejor documentado, no me parece nada super extraño lo que queria lograr.

En un rato aviso cuando modifique mi repo por si en algun momento a alguien le sirve, pero desde ya MUCHAS GRACIAS por la atencion y compartir conocimiento!

Ariel Max dijo...

Listo, mismo repo nombrado mas arriba ya actualizado.

@JoseMaria, ahi lei mejor tu comentario y estas en lo correcto en todo, inclusive meter por ahi un "IsContracTestEnv()"; realmente lo pense aunque me tenia loco esto de porque no funcionaba, puesto que mismo bajo Net6 pero con el viejo Startup y Program si funcionaba y era como mas... legible y fluido.

En la vieja usanza basicamente en mis tests tengo un TestStartup que por dentro uso el Startup del webapi y luego solo voy implementando los override llamando al de los startup (como si heredase pero por compose esto) y antes de llamar al startup.Configure(), solo tenia que meter el UseMiddleware() y listo. Aqui como que fue mucho mas dificil saber como inyectarlo, hace 3 dias venia ya me estaba a punto de bajar aspnetcore para debuguear todo.

Muchas gracias nuevamente!

José María Aguilar dijo...

Genial! Muchas gracias por compartir tus descubrimientos :)