Compartir a través de



Junio de 2016

Volumen 31, número 6

ASP.NET: Uso de middleware personalizado para detectar y corregir errores 404 en aplicaciones de ASP.NET Core

Por Steve Smith

Si alguna vez ha perdido algo en una estación o en un parque de atracciones, puede que haya tenido la suerte de recuperarlo en el departamento de objetos perdidos. En las aplicaciones web, con frecuencia los usuarios realizan solicitudes para rutas que no controla el servidor, lo que provoca códigos de respuesta 404 No encontrado (y en ocasiones páginas cómicas que explican el problema al usuario). Normalmente, que el usuario encuentre lo que está buscando depende de si es capaz de hacerlo por sí mismo, ya sea mediante repetidas suposiciones o quizá con un motor de búsqueda. Sin embargo, con algo de middleware es posible agregar un "departamento de objetos perdidos" a la aplicación de ASP.NET Core que ayudará a que los usuarios encuentren los recursos que están buscando.

¿Qué significa middleware?

La documentación de ASP.NET Core define el middleware como "componentes que se ensamblan en la canalización de una aplicación para controlar solicitudes y respuestas". En su forma más simple, el middleware es un delegado de solicitud que se puede representar como una expresión lambda, como la siguiente:

app.Run(async context => {
  await context.Response.WriteAsync(“Hello world”);
});

Si la aplicación solo consta de este trozo de middleware, devolverá "Hello world" a todas las solicitudes. Como no hace referencia al siguiente trozo de middleware, se dice que este ejemplo concreto finaliza la canalización; no se ejecutará nada definido después. No obstante, solo porque sea el final de la canalización no significa que no se pueda "encapsular" en middleware adicional. Por ejemplo, se podría agregar middleware que agregue un encabezado a la respuesta anterior:

app.Use(async (context, next) =>
{
  context.Response.Headers.Add("Author", "Steve Smith");
  await next.Invoke();
});
app.Run(async context =>
{
  await context.Response.WriteAsync("Hello world ");
});

La llamada a app.Use encapsula la llamada a app.Run y la llama mediante next.Invoke. Cuando escriba su propio middleware, puede elegir si quiere que realice operaciones antes, después, o tanto antes como después del siguiente middleware de la canalización. También puede evitar recorrer la canalización si decide no llamar a next. Le mostraré cómo esto puede ayudarle a crear su middleware para corregir errores 404.

Si utiliza la plantilla de MVC Core predeterminada, no encontrará código middleware basado en delegados a un nivel tan bajo en el archivo Startup inicial. Se recomienda que encapsule middleware en sus propias clases y ofrezca métodos de extensión (en concreto, UseMiddlewareName) a los que se pueda llamar desde Startup. El middleware integrado de ASP.NET sigue esta convención, como se demuestra con estas llamadas:

if (env.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
}
app.UseStaticFiles()
app.UseMvc();

El orden del middleware es importante. En el código anterior, la llamada a UseDeveloperExceptionPage (que solo se configura cuando la aplicación se ejecuta en un entorno de desarrollo) se debería encapsular en (y por tanto agregarse antes de) cualquier otro middleware que pueda provocar un error.

En una clase propia

No quiero enturbiar la clase Startup con todas las expresiones lambda e implementaciones detalladas del middleware. Como sucede con el middleware integrado, quiero que mi middleware se agregue a la canalización con solo una línea de código. También preveo que mi middleware necesitará servicios insertados mediante inserción de dependencias (DI), lo que se consigue fácilmente cuando el middleware se refactoriza en su propia clase (consulte mi artículo de mayo en msdn.com/magazine/mt703433 para saber más sobre DI en ASP.NET Core).

Como utilizo Visual Studio, puedo agregar middleware si uso Add New Item y elijo la plantilla Clase de middleware. En la Figura 1 se muestra el contenido predeterminado que esta plantilla produce, incluido un método de extensión para agregar el middleware a la canalización mediante UseMiddleware.

Figura 1. Plantilla Clase de middleware

public class MyMiddleware
{
  private readonly RequestDelegate _next;
  public MyMiddleware(RequestDelegate next)
  {
    _next = next;
  }
  public Task Invoke(HttpContext httpContext)
  {
    return _next(httpContext);
  }
}
// Extension method used to add the middleware to the HTTP request pipeline.
public static class MyMiddlewareExtensions
{
  public static IApplicationBuilder UseMyMiddleware(this IApplicationBuilder builder)
  {
    return builder.UseMiddleware<MyMiddleware>();
  }
}

Normalmente, agregaré async a la signatura del método Invoke y luego cambiaré su cuerpo a:

await _next(httpContext);

Esto hace que la invocación sea asincrónica.

Una vez he creado una clase de middleware independiente, muevo la lógica de mi delegado al método Invoke. A continuación, reemplazo la llamada de Configure por una llamada al método de extensión UseMyMiddleware. La ejecución de la aplicación en este momento debería comprobar que el middleware aún se comporta como lo hacía antes; la clase Configure es mucho más fácil de seguir cuando consta de una serie de instrucciones UseSomeMiddleware.

Detección y registro de respuestas 404 No encontrado

En una aplicación de ASP.NET, si se realiza una solicitud que no coincida con ningún controlador, la respuesta incluirá un elemento StatusCode con un valor de 404. Puedo crear middleware que compruebe si se da este código de respuesta (después de llamar a _next) y que tome las acciones necesarias para registrar los detalles de la solicitud:

await _next(httpContext);
if (httpContext.Response.StatusCode == 404)
{
  _requestTracker.Record(httpContext.Request.Path);
}

Quiero poder mantener un seguimiento de cuántos errores 404 ha tenido una ruta concreta, de forma pueda corregir los más habituales y sacar el máximo partido a las acciones correctivas. Para hacerlo, creo un servicio denominado RequestTracker que registra las instancias de las solicitudes 404 en función de su ruta. RequestTracker se pasa al middleware a través de DI, como se muestra en la Figura 2.

Figura 2. Inserción de dependencias donde se pasa RequestTracker a middleware

public class NotFoundMiddleware
{
  private readonly RequestDelegate _next;
  private readonly RequestTracker _requestTracker;
  private readonly ILogger _logger;
  public NotFoundMiddleware(RequestDelegate next,
    ILoggerFactory loggerFactory,
    RequestTracker requestTracker)
  {
    _next = next;
    _requestTracker = requestTracker;
    _logger = loggerFactory.CreateLogger<NotFoundMiddleware>();
  }
}

Para agregar NotFoundMiddleware a mi canalización, llamo al método de extensión UseNotFoundMiddleware. Sin embargo, como ahora depende de un servicio personalizado que se configura en el contenedor de servicios, también debo asegurarme de que el servicio esté registrado. Creo un método de extensión en IServiceCollection denominado AddNotFoundMiddleware y llamo a este método en ConfigureServices en Startup:

public static IServiceCollection AddNotFoundMiddleware(
  this IServiceCollection services)
{
  services.AddSingleton<INotFoundRequestRepository,
    InMemoryNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

En mi caso, mi método AddNotFoundMiddleware garantiza que una instancia de mi elemento RequestTracker se configure como Singleton en el contenedor de servicios, de forma que estará disponible para insertarse en NotFoundMiddleware cuando se cree. También conecta con una implementación en memoria de INotFoundRequestRepository, que RequestTracker usa para conservar sus datos.

Como podrían llegar muchas solicitudes simultáneas para una misma ruta que falte, el código de la Figura 3 usa un bloqueo simple para asegurarse de que no se agreguen instancias duplicadas de NotFoundRequest, y los contadores se incrementan correctamente.

Figura 3. RequestTracker

public class RequestTracker
{
  private readonly INotFoundRequestRepository _repo;
  private static object _lock = new object();
  public RequestTracker(INotFoundRequestRepository repo)
  {
    _repo = repo;
  }
  public void Record(string path)
  {
    lock(_lock)
    {
      var request = _repo.GetByPath(path);
      if (request != null)
      {
        request.IncrementCount();
      }
      else
      {
        request = new NotFoundRequest(path);
        request.IncrementCount();
        _repo.Add(request);
      }
    }
  }
  public IEnumerable<NotFoundRequest> ListRequests()
  {
    return _repo.List();
  }
  // Other methods
}

Visualización de las solicitudes de No encontrado

Ahora que tengo una forma de registrar los errores 404, necesito una manera de ver esos datos. Para hacerlo, crearé otro pequeño componente de middleware que mostrará una página donde aparecerán todos los elementos NotFoundRequest registrados, ordenados según el número de veces que se hayan producido. Este middleware comprobará si la solicitud actual coincide con una ruta concreta y omitirá (y pasará a través de) las solicitudes que no coincidan con la ruta. Para las rutas que coincidan, el middleware devolverá una página con una tabla que contenga las solicitudes NotFound, ordenadas según su frecuencia. A partir de ahí, el usuario podrá asignar a solicitudes individuales una ruta corregida, que utilizarán solicitudes futuras en lugar de devolver un error 404.

En la Figura 4 se muestra lo fácil que es conseguir que NotFoundPageMiddleware compruebe una ruta concreta y realizar actualizaciones en función de valores QueryString mediante esa misma ruta. Por motivos de seguridad, el acceso a la ruta de NotFoundPageMiddleware debería estar restringido a usuarios administradores.

Figura 4. NotFoundPageMiddleware

public async Task Invoke(HttpContext httpContext)
{
  if (!httpContext.Request.Path.StartsWithSegments("/fix404s"))
  {
    await _next(httpContext);
    return;
  }
  if (httpContext.Request.Query.Keys.Contains("path") &&
      httpContext.Request.Query.Keys.Contains("fixedpath"))
  {
    var request = _requestTracker.GetRequest(httpContext.Request.Query["path"]);
    request.SetCorrectedPath(httpContext.Request.Query["fixedpath"]);
    _requestTracker.UpdateRequest(request);
  }
  Render404List(httpContext);
}

Como está escrito, el middleware está codificado de forma rígida para que escuche en la ruta /fix404s. Es una buena idea que eso se pueda configurar, para que distintas aplicaciones puedan especificar la ruta que prefieran. En la lista de solicitudes representada se muestran todas las solicitudes ordenadas en función de cuántos errores 404 han registrado, sin importar si se ha configurado una ruta corregida. No sería muy complicado mejorar el middleware para que ofreciese algún tipo de filtrado. Otras características interesantes podrían ser el registro de información más detallada, de forma que se pudiera ver qué redirecciones han sido más populares o qué errores 404 han sido los más habituales en los últimos siete días, pero esto se deja como un ejercicio para el lector (o la comunidad de código abierto).

En la Figura 5 se muestra un ejemplo del aspecto que tiene una página representada.

Página de corrección de errores 404
Figura 5. Página de corrección de errores 404

Agregar opciones

Me gustaría poder especificar una ruta distinta para la Página de corrección de errores 404 dentro de aplicaciones distintas. La mejor forma de hacerlo es crear una clase Options y pasarla al middleware mediante DI. Para este middleware, he creado una clase, NotFoundMiddlewareOptions, que incluye una propiedad denominada Path con un valor que de manera predeterminada es /fix404s. Puedo pasarlo a NotFoundPageMiddleware mediante la interfaz IOptions<T> y luego establecer un campo local como la propiedad Value de este tipo. Después, será posible actualizar la referencia a la cadena mágica /fix404s:

if (!httpContext.Request.Path.StartsWithSegments(_options.Path))

Corrección de errores 404

Cuando se recibe una solicitud que coincide con un elemento NotFoundRequest que tiene un valor CorrectedUrl, el elemento NotFoundMiddleware debe modificar la solicitud para que utilice el valor CorrectedUrl. Esto se puede hacer simplemente actualizando la propiedad path de la solicitud:

string path = httpContext.Request.Path;
string correctedPath = _requestTracker.GetRequest(path)?.CorrectedPath;
if(correctedPath != null)
{
  httpContext.Request.Path = correctedPath; // Rewrite the path
}
await _next(httpContext);

Con esta implementación, cualquier dirección URL corregida funcionará como si su solicitud se hubiera realizado directamente a la ruta corregida. Después, la canalización de la solicitud continúa, ahora utilizando la ruta reescrita. Esto puede ser el comportamiento deseado o no, por un motivo: los resultados de los motores de búsqueda pueden verse penalizados por tener contenido duplicado indexado en varias direcciones URL. Este enfoque podría provocar que docenas de direcciones URL se asignaran a la misma ruta de aplicación subyacente. Por este motivo, a menudo es preferible corregir los errores 404 con una redirección permanente (código de estado 301).

Si se modifica el middleware para que envíe una redirección, es posible hacer que en ese caso el middleware deje de recorrer la canalización, ya que no hay necesidad de que se ejecute el resto de ella si se ha decidido que se va a devolver un código 301:

if(correctedPath != null)
{
  httpContext.Response. Redirect(httpContext.Request.PathBase + correctedPath +
    httpContext.Request.QueryString, permanent: true);
  return;
}
await _next(httpContext);

Tenga cuidado para no establecer rutas corregidas que resulten en bucles de redirección infinitos.

Idealmente, el elemento NotFoundMiddleware debería admitir tanto reescritura de rutas como redirecciones permanentes. Puedo implementarlo mediante NotFoundMiddlewareOptions y permitir que una u otra estén establecidas para todas las solicitudes, o puedo modificar CorrectedPath en la ruta NotFoundRequest para que incluya tanto la ruta como el mecanismo que se debe usar. Por ahora simplemente actualizaré la clase options para que admita el comportamiento y pasaré IOptions<NotFoundMiddleOptions> a NotFoundMiddleware como estoy haciendo con NotFoundPageMiddleware. Con las opciones en su lugar, la lógica de redirección y reescritura queda como sigue:

if(correctedPath != null)
{
  if (_options.FixPathBehavior == FixPathBehavior.Redirect)
  {
    httpContext.Response.Redirect(correctedPath, permanent: true);
    return;
  }
  if(_options.FixPathBehavior == FixPathBehavior.Rewrite)
  {
    httpContext.Request.Path = correctedPath; // Rewrite the path
  }
}

En este momento, la clase NotFoundMiddlewareOptions tiene dos propiedades, una de las cuales es de tipo enum:

public enum FixPathBehavior
{
  Redirect,
  Rewrite
}
public class NotFoundMiddlewareOptions
{
  public string Path { get; set; } = "/fix404s";
  public FixPathBehavior FixPathBehavior { get; set; } 
    = FixPathBehavior.Redirect;
}

Configuración del middleware

Cuando haya configurado Options para el middleware, debe pasar una instancia de dichas opciones al middleware cuando las configure en Startup. De forma alternativa, puede enlazar las opciones a la configuración. La configuración de ASP.NET es muy flexible y se puede enlazar a variables de entorno, archivos de configuración o se puede crear mediante programación. Sin importar dónde se establezca la configuración, Options se puede enlazar a la configuración con una sola línea de código:

services.Configure<NotFoundMiddlewareOptions>(
  Configuration.GetSection("NotFoundMiddleware"));

Con esto en su sitio, puedo configurar el comportamiento de NotFoundMiddleware actualizando appsettings.json (la configuración que estoy utilizando en esta instancia):

"NotFoundMiddleware": {
  "FixPathBehavior": "Redirect",
  "Path": "/fix404s"
}

Tenga en cuenta que el marco realiza automáticamente la conversión de valores JSON basados en cadenas del archivo de configuración al tipo enum de FixPathBehavior.

Persistencia

Por ahora, todo está funcionando a la perfección, pero lamentablemente mi lista de errores 404 y sus rutas corregidas se almacenan en una colección en memoria. Esto significa que cada vez que se reinicie la aplicación, se perderán todos los datos. Podría ser interesante que mi aplicación reiniciara periódicamente sus contadores de errores 404, de forma que pueda saber cuáles son actualmente los más comunes, pero definitivamente no quiero perder las rutas corregidas que he establecido.

Afortunadamente, como configuré RequestTracker para que dependiera de una abstracción para su persistencia (INotFoundRequestRepository), es bastante fácil agregar compatibilidad para almacenar los resultados en una base de datos mediante Entity Framework Core (EF). Y lo que es más, puedo facilitar que las aplicaciones individuales elijan si quieren usar EF o la configuración en memoria (lo cual es fantástico para realizar pruebas) ofreciendo métodos auxiliares independientes.

Lo primero que necesito para usar EF y guardar y recuperar elementos NotFoundRequest es un elemento DbContext. No quiero depender de uno que la aplicación pueda haber configurado, por lo que crearé uno solo para NotFoundMiddleware:

public class NotFoundMiddlewareDbContext : DbContext
{
  public DbSet<NotFoundRequest> NotFoundRequests { get; set; }
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    base.OnModelCreating(modelBuilder);
    modelBuilder.Entity<NotFoundRequest>().HasKey(r => r.Path);
  }
}

Una vez tengo el elemento dbContext, debo implementar la interfaz del repositorio. Creo un elemento EfNotFoundRequestRepository, que solicita una instancia de NotFoundMiddlewareDbContext en su constructor y la asigna a un campo privado, _dbContext. La implementación de los métodos individuales es sencilla; por ejemplo:

public IEnumerable<NotFoundRequest> List()
{
  return _dbContext.NotFoundRequests.AsEnumerable();
}
public void Update(NotFoundRequest notFoundRequest)
{
  _dbContext.Entry(notFoundRequest).State = EntityState.Modified;
  _dbContext.SaveChanges();
}

En este momento, lo único que queda es conectar el elemento DbContext y el repositorio de EF en el contenedor de servicios de la aplicación. Esto se realiza en un nuevo método de extensión (y cambio el nombre del método de extensión original para indicar que es para la versión InMemory):

public static IServiceCollection AddNotFoundMiddlewareEntityFramework(
  this IServiceCollection services, string connectionString)
{
    services.AddEntityFramework()
      .AddSqlServer()
      .AddDbContext<NotFoundMiddlewareDbContext>(options =>
        options.UseSqlServer(connectionString));
  services.AddSingleton<INotFoundRequestRepository,
    EfNotFoundRequestRepository>();
  return services.AddSingleton<RequestTracker>();
}

Decidí que la cadena de conexión se pasara, en lugar de almacenarla en NotFoundMiddlewareOptions, ya que la mayoría de aplicaciones de ASP.NET que utilizan EF ya ofrecerán una cadena de conexión para ello en el método ConfigureServices. Si se desea, se puede usar la misma variable cuando se llama a services.AddNotFoundMiddleware­EntityFramework(connectionString).

Lo último que necesita hacer una nueva aplicación antes de poder usar la versión de EF de este middleware es ejecutar las migraciones para asegurarse de que la estructura de tablas de la base de datos está configurada correctamente. Debo especificar el elemento DbContext del middleware cuando lo haga, ya que la aplicación (en mi caso) ya tiene su propio DbContext. El comando, que se ejecuta desde la raíz del proyecto, es:

dotnet ef database update --context NotFoundMiddlewareContext

Si obtiene un error relacionado con un proveedor de base de datos, asegúrese de que llama a services.AddNotFoundMiddlewareEntityFramework en Startup.

Pasos siguientes

El ejemplo que he mostrado aquí funciona correctamente e incluye una implementación en memoria y una que usa EF para almacenar los contadores de solicitudes No encontrado y rutas fijas en una base de datos. La lista de errores 404 y la capacidad para agregar rutas corregidas deberían protegerse de forma que solo los administradores puedan acceder a ellas. Por último, la implementación actual de EF no incluye lógica de almacenamiento en caché, lo que provoca que se realice una consulta a la base de datos con cada solicitud a la aplicación. Por motivos de rendimiento, yo agregaría el almacenamiento en caché mediante el patrón CachedRepository.

El código fuente actualizado de este ejemplo está disponible en bit.ly/1VUcY0J.


Steve Smithes instructor, mentor y asesor independiente, además de MVP de ASP.NET. Ha aportado decenas de artículos a la documentación oficial de ASP.NET Core (docs.asp.net) y ayuda a equipos para que saquen el máximo provecho de ASP.NET Core rápidamente. Puede ponerse en contacto con él en ardalis.com.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Chris Ross
Chris Ross es un desarrollador que trabaja en el equipo de ASP.NET de Microsoft. En este momento, su mente está repleta de middleware.