Junho de 2016

Volume 31 - Número 6

ASP.NET - Use Middleware Personalizado para Detectar e Corrigir 404s em Aplicativos do ASP.NET Core

Por Steve Smith

Se algum dia você perdeu algo na escola ou em um parque de diversões, talvez você tenha tido a sorte de recuperá-lo nos Achados e Perdidos do local. Em aplicativos Web, os usuários fazem com frequência solicitações por caminhos que não são tratados pelo servidor, resultando em códigos de resposta 404 Não encontrado (e, às vezes, páginas engraçadas explicando o problema para o usuário). Normalmente, depende do usuário descobrir por conta própria o que ele está procurando, por suposições repetidas ou talvez usando um mecanismo de pesquisa. No entanto, com um pouco de middleware, você pode adicionar um “achados e perdidos” ao seu aplicativo ASP.NET Core que ajudará os usuários a encontrar os recursos que eles estão procurando.

O que é Middleware?

A documentação do ASP.NET Core define middleware como “componentes organizados no pipeline de um aplicativo para lidar com solicitações e respostas”. Resumindo, middleware é um representante de solicitação, que pode ser representado como uma expressão lambda, como esta:

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

Se o seu aplicativo for composto por apenas esse bit único de middleware, ele retornará “Hello world” para qualquer solicitação. Como ele não faz referência à próxima parte do middleware, pode-se dizer que esse exemplo específico encerra o pipeline. Nada definido; depois ele será executado. No entanto, só porque ele é o fim do pipeline não significa que você não pode “envolvê-lo” em outro middleware. Por exemplo, é possível adicionar um middleware que adiciona um cabeçalho à resposta 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 ");
});

A chamada para app.Use envolve a chamada para app.Run, que é chamada usando next.Invoke. Ao escrever seu próprio middleware, você pode escolher se deseja que ele execute operações antes, depois ou tanto antes quanto depois do próximo middleware no pipeline. Você também pode aplicar um curto-circuito no pipeline escolhendo não chamar o próximo. Vou mostrar como isso pode ajudar você a criar seu próprio middleware para correção de 404.

Se estiver usando o modelo MVC Core padrão, você não encontrará esse código de middleware baseado em representante de nível inferior em seu arquivo inicial de Inicialização. Recomendamos que você encapsule o middleware em suas próprias classes e forneça métodos de extensão (chamados UseMiddlewareName) que podem ser chamados a partir da Inicialização. O middleware interno de ASP.NET segue essa convenção, como demonstram essas chamadas:

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

A ordem de seu middleware é importante. No código anterior, a chamada para UseDeveloperExceptionPage (configurada apenas quando o aplicativo está em execução em um ambiente de desenvolvimento) deve envolver (ou seja, ser adicionado antes) qualquer outro middleware que possa produzir um erro.

Em uma classe própria

Não quero poluir minha classe de Inicialização com todas as expressões lambda e implementação detalhada de meu middleware. Assim como ocorre com o middleware interno, quero que meu middleware seja adicionado ao pipeline com uma linha de código. Também antecipo que meu middleware precisará da injeção de serviços usando a injeção de dependência (DI), o que pode ser realizado com facilidade quando o middleware for refatorado em sua própria classe. (Confira meu artigo de maio em msdn.com/magazine/mt703433 para saber mais sobre DI no ASP.NET Core.)

Como estou usando o Visual Studio, posso adicionar middleware usando Adicionar Novo Item e escolhendo o modelo Classe de Middleware. A Figura 1 mostra o conteúdo padrão que esse modelo produz, incluindo um método de extensão para adição do middleware ao pipeline por meio de UseMiddleware.

Figura 1 - Modelo Classe 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, eu adiciono async à assinatura do método Invoke, e depois mudo seu corpo para:

await _next(httpContext);

Isso torna a invocação assíncrona.

Depois de criar uma classe de middleware separada, eu movo a lógica de meu representante para o método Invoke. Depois, eu substituo a chamada em Configure por uma chamada para o método de extensão UseMyMiddleware. A execução do aplicativo neste ponto deve confirmar se o middleware ainda se comporta como antes, e a classe Configure é muito fácil de seguir quando é composta por uma série de instruções UseSomeMiddleware.

Detecção e gravação de respostas 404 Não encontrado

Em um aplicativo ASP.NET, se for feita uma solicitação que não corresponde a qualquer identificador, a resposta incluirá um StatusCode definido como 404. Posso criar um trecho de middleware que verificará esse código de resposta (após chamar _next) e executará alguma ação para registrar os detalhes da solicitação:

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

Quero poder acompanhar quantos 404s um caminho específico apresentou, para que eu possa corrigir os mais comuns e aproveitar ao máximo minhas ações corretivas. Para fazer isso, eu crio um serviço chamado RequestTracker que grava instâncias de solicitações 404 com base no caminho. O RequestTracker é passado para meu middleware por meio de DI, como mostra a Figura 2.

Figura 2 - Injeção de dependência passando RequestTracker para o 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 adicionar NotFoundMiddleware ao meu pipeline, eu chamo o método de extensão UseNotFoundMiddleware. No entanto, como isso depende de um serviço personalizado configurado no contêiner de serviços, também preciso garantir que o serviço esteja registrado. Eu crio um método de extensão no IServiceCollection chamado de AddNotFoundMiddleware e chamo esse método em ConfigureServices na Inicialização:

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

Nesse caso, meu método AddNotFoundMiddleware assegura que uma instância de meu RequestTracker seja configurada como um Singleton no contêiner de serviços, para que fique disponível para injeção no NotFoundMiddleware quando este for criado. Ele também engatilha uma implementação na memória do INotFoundRequestRepository, que será usada pelo RequestTracker para persistir seus dados.

Como muitas solicitações simultâneas podem chegar para o mesmo caminho ausente, o código na Figura 3 usa um bloqueio simples para assegurar que instâncias duplicadas de NotFoundRequest não sejam adicionadas, e as contagens sejam incrementadas apropriadamente.

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
}

Exibição de solicitações não encontradas

Agora que consigo gravar os 404s, preciso de uma forma de exibir esses dados. Para fazer isso, vou criar outro componente pequeno de middleware que exibirá uma página mostrando todos os NotFoundRequests registrados, organizados de acordo com a quantidade de vezes que ocorreram. Esse middleware verificará se a solicitação atual corresponde a um caminho específico e ignorará (e passará por) quaisquer solicitações que não correspondam ao caminho. Para caminhos correspondentes, o middleware retornará uma página com uma tabela contendo as solicitações NotFound, organizadas por frequência. A partir daí, o usuário poderá atribuir às solicitações individuais um caminho corrigido, que será usado por solicitações futuras em vez de retornar um 404.

A Figura 4 demonstra como é simples fazer o NotFoundPageMiddleware verificar um determinado caminho e fazer atualizações com base em valores de querystring usando esse mesmo caminho. Por motivos de segurança, o acesso ao caminho de NotFoundPageMiddleware deve ser restrito aos usuários administrativos.

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);
}

Conforme foi escrito, o middleware é codificado para escutar no caminho /fix404s. Convém tornar isso configurável, de modo que aplicativos diferentes possam especificar o caminho que quiserem. A lista renderizada de solicitações mostra todas as solicitações organizadas de acordo com a quantidade de 404s que elas gravaram, independentemente de um caminho corrigido ter sido configurado. Não seria difícil aprimorar o middleware para oferecer alguma filtragem. Outros recursos interessantes podem estar gravando informações mais detalhadas. Assim, você pode ver quais redirecionamentos foram mais populares ou quais 404s foram mais comuns nos últimos sete dias. Mas esses recursos ficam como um exercício para o leitor (ou para a comunidade de software livre).

A Figura 5 mostra um exemplo da aparência da página renderizada.

A página Fix 404s
Figura 5 - A página Fix 404s

Como adicionar opções

Eu gostaria de poder especificar um caminho diferente para a página Fix 404s em aplicativos diferentes. A melhor maneira de fazer isso é criar uma classe Options e passá-la para o middleware usando DI. Para esse middleware, eu crio uma classe, NotFoundMiddlewareOptions, que inclui uma propriedade chamada Path com um valor que assume o padrão de /fix404s. Posso passar isso no NotFoundPageMiddleware usando a interface IOptions<T> e depois defino um campo local para a propriedade Value desse tipo. Em seguida, minha referência mágica de sequência a /fix404s pode ser atualizada:

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

Como corrigir 404s

Quando uma solicitação chega e corresponde a uma NotFoundRequest que tem um CorrectedUrl, o NotFoundMiddleware deverá modificar a solicitação para usar o CorrectedUrl. Isso pode ser feito simplesmente atualizando a propriedade path da solicitação:

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

Com essa implementação, qualquer URL corrigida funcionará como se sua solicitação tivesse sido feita diretamente ao caminho corrigido. Em seguida, o pipeline da solicitação continua, usando agora o caminho reescrito. Esse pode ou não ser o comportamento desejado; por um motivo: as listagens do mecanismo de pesquisa podem sofrer com conteúdo duplicado indexado em várias URLs. Essa abordagem pode resultar em dezenas de URLs mapeando para o mesmo caminho de aplicativo subjacente. Por esse motivo, normalmente é preferível corrigir 404s usando um redirecionamento permanente (código de status 301).

Se eu modificar o middleware para enviar um redirecionamento, poderei fazer com que o middleware sofra um curto-circuito nesse caso, pois não há necessidade de executar o restante do pipeline se eu decidir que vou apenas retornar um 301:

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

Tome cuidado para não definir caminhos corrigidos que resultam em loops de redirecionamento infinitos.

O ideal é que o NotFoundMiddleware ofereça suporte à regravação de caminho e aos redirecionamentos permanentes. Posso implementar isso usando meu NotFoundMiddlewareOptions, permitindo a definição de um ou outro para todas as solicitações, ou posso modificar CorrectedPath no caminho NotFoundRequest, para que inclua o caminho e o mecanismo a ser usado. Por enquanto, vou apenas atualizar a classe de opções para oferecer suporte ao comportamento, e passar IOptions<NotFoundMiddleOptions> no NotFoundMiddleware da mesma forma que já estou fazendo para o NotFoundPageMiddleware. Com as opções aplicadas, a lógica de redirecionamento/regravação se torna:

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
  }
}

Neste ponto, a classe NotFoundMiddlewareOptions tem duas propriedades, uma delas é enum:

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

Configuração do Middleware

Após a configuração das Opções para seu middleware, passe uma instância destas opções em seu middleware ao configurá-las na Inicialização. Como alternativa, você pode associar as opções à sua configuração. A configuração do ASP.NET é bastante flexível e pode ser associada a variáveis do ambiente, arquivos de configuração ou compilada programaticamente. Independentemente de onde a configuração é definida, as Opções podem ser associadas à configuração com uma única linha de código:

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

Com isso aplicado, posso configurar o comportamento do meu NotFoundMiddleware atualizando appsettings.json (a configuração que estou usando nesta instância):

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

Observe que a conversão de valores JSON baseados em cadeia de caracteres no arquivo de configurações para o enum para FixPathBehavior é realizada automaticamente pela estrutura.

Persistência

Até o momento, tudo está funcionando muito bem, mas infelizmente minha lista de 404s e seus caminhos corrigidos estão armazenados em uma coleção na memória. Isso significa que sempre que meu aplicativo reinicia, todos esses dados são perdidos. Não tem problema meu aplicativo redefinir periodicamente sua contagem de 404s, para que eu tenha uma ideia de quais são os mais comuns no momento, mas não quero definitivamente perder os caminhos corrigidos que eu defini.

Felizmente, como eu configurei o RequestTracker para depender de uma abstração para sua persistência (INotFoundRequestRepository), é muito fácil adicionar suporte ao armazenamento dos resultados em um banco de dados usando Entity Framework Core (EF). Além disso, eu posso facilitar aos aplicativos individuais a possibilidade de escolha se desejam usar o EF ou a configuração na memória (ótima para testar as coisas) fornecendo métodos auxiliares separados.

A primeira coisa de que eu preciso para usar o EF a fim de salvar e recuperar NotFoundRequests é um DbContext. Não quero depender de um configurado pelo aplicativo, então eu crio um apenas para o 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);
  }
}

Quando eu tiver o dbContext, será necessário implementar a interface do repositório. Eu crio um EfNotFoundRequestRepository, que solicita uma instância do NotFoundMiddlewareDbContext em seu construtor e a atribui a um campo privado, _dbContext. A implementação do método individual é bem simples, por exemplo:

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

Neste ponto, tudo o que resta é conectar o DbContext e o repositório do EF no contêiner de serviço do aplicativo. Isso é feito em um novo método de extensão (e eu renomeio o método de extensão original a fim de indicar que serve para a versão 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>();
}

Eu escolho passar a cadeia de conexão, em vez de armazená-la em NotFoundMiddlewareOptions, pois a maioria dos aplicativos ASP.NET que está usando EF já fornecerá uma cadeia de conexão para isso no método ConfigureServices. Se quiser, a mesma variável pode ser usada ao chamar services.AddNotFoundMiddleware­EntityFramework(connectionString).

A última coisa que um novo aplicativo precisa fazer antes de poder usar a versão do EF deste middleware é executar as migrações para assegurar que a estrutura da tabela do banco de dados seja configurada apropriadamente. Preciso especificar o DbContext do middelware quando fizer isso, pois o aplicativo (no meu caso) já tem seu próprio DbContext. O comando, executado da raiz do projeto, é:

dotnet ef database update --context NotFoundMiddlewareContext

Se você receber um erro sobre um provedor de banco de dados, certifique-se de que esteja chamando services.AddNotFoundMiddlewareEntityFramework na Inicialização.

Próximas Etapas

O exemplo que mostrei aqui funciona bem e inclui uma implementação na memória e uma que usa o EF para armazenar contagens de solicitações Não encontrado e caminhos corrigidos em um banco de dados. A lista de 404s e a capacidade de adicionar caminhos corrigidos deve ser protegida e permitir o acesso apenas de administradores. Por fim, a implementação do EF atual não inclui qualquer lógica de armazenamento em cache, resultando em uma consulta de banco de dados feita com qualquer solicitação ao aplicativo. Por questões de desempenho, eu adicionaria o armazenamento em cache usando o padrão CachedRepository.

O código-fonte atualizado para este exemplo está disponível em bit.ly/1VUcY0J.


Steve Smithé um treinador, mentor e consultor independente, bem como um ASP.NET MVP. Ele contribuiu com dezenas de artigos para a documentação oficial do ASP.NET Core (docs.asp.net) e ajuda as equipes a incorporar rapidamente o ASP.NET Core. Entre em contato com ele em ardalis.com.

Agradecemos ao seguinte especialista técnico da Microsoft pela revisão deste artigo: Chris Ross
Chris Ross é um desenvolvedor que trabalha na equipe de ASP.NET da Microsoft. No momento, o cérebro dele é composto por middleware.