Il y a un certain temps, Julien a écrit un post sur la gestion des erreurs Http dans ASP.NET MVC. Après la lecture de son post, je me suis aperçu qu’il ne traite qu’une partie de la gestion des erreurs. J’ai décidé de vous proposer une approche légèrement différente pour implémenter la gestion des erreurs http.

Les objectifs

Avant de passer à l’implémentation il faut qu’on établisse des objectifs à atteindre, où la plus grande difficulté pose la gestion des erreurs 404 dont parlait Julien dans son blog :

  1. Afficher le statut 404 lorsqu’une route correspond, un contrôleur est trouvé mais pas l’action.
  2. Afficher le statut 404 lorsqu’une route correspond mais le contrôleur n’est pas trouvé.
  3. Afficher le statut 404 lorsqu’une route ne correspond pas à nos routes définies. Ces erreurs ne doivent pas remonter jusqu’à Global.asax ou IIS car ensuite il n’est pas possible de rediriger proprement dans notre application.
  4. Afficher le statut 404 lorsqu’une ressource n’est pas trouvée. Par exemple un Id passé en paramètre d’une route n’existe pas. La manière propre (RESTFul) est indiquer ceci à l’utilisateur par un code Http.
  5. Afficher le statut 500 pour toutes les exceptions non catchées dans l’application.
  6. Afficher les codes de statuts souhaités lorsqu’on en a besoin (400, 401, etc.).
  7. Afficher une vue appropriée pour chaque type d’erreur qui est une page dynamique et non juste une page statique où on ne peut pas afficher du contenu dynamique. Ces pages doivent retourner les codes http appropriés (même si la page d’erreur s’affiche on devrait avoir le code 404 pour une ressource non trouvé et non 200 comme dans le cas d’une mauvaise gestion). Cela est important pour les moteurs de recherche qui indexent votre site web.

Passons à l’action.

Objectif n° 1

Pour couvrir l’objectif n°1 nous devons d’abord introduire un élément qui permettra la gestion des erreurs centralisée. J’évite de mettre du code dans Global.asax, car d’une au bout d’un moment les bouts de code pour traiter les différentes problématiques se mélangent entre eux, et de deux, le code n’est pas très exploitable par d’autres parties de l’application (souvent la duplication pointe son nez). Pour cela je préfère de créer un Controller pour la gestion des erreurs Http. Voici un exemple de code :

   1: public class ErrorController : BaseErrorController
   2: {
   3:     public ActionResult Http404(string url)
   4:     {
   5:         Response.StatusCode = (int)HttpStatusCode.NotFound;
   6:         var model = GetModel(url);            
   7:         return View("404", model);
   8:     }
   9:  
  10:     public ActionResult Http500(string url)
  11:     {
  12:         Response.StatusCode = (int)HttpStatusCode.InternalServerError;        
  13:         var model = GetModel(url);
  14:         return View("500", model);
  15:     }
  16:  
  17:     public ActionResult Generic(string url, int statusCode)
  18:     {
  19:         Response.StatusCode = statusCode;
  20:         var model = GetModel(url);
  21:         return View("Error", model);
  22:     }
  23:  
  24:     private NotFoundViewModel GetModel(string url)
  25:     {
  26:         var model = new NotFoundViewModel();
  27:         
  28:         model.RequestedUrl = Request.Url.OriginalString.Contains(url) & Request.Url.OriginalString != url ? Request.Url.OriginalString : url;
  29:         model.ReferrerUrl = Request.UrlReferrer != null && Request.UrlReferrer.OriginalString != model.RequestedUrl ? Request.UrlReferrer.OriginalString : null;
  30:  
  31:         return model;
  32:     }
  33: }

Ce qui est important de noter ce que la seule responsabilité de ce contrôleur est d’afficher une vue correspondante à l’erreur avec un bon code de statut HTTP. Donc par exemple, l’action Http404 affiche une vue “404” en y passant un view model personnalisé avec une Url demandé et Url d’origine et avec un bon code de statut Http qui dans notre cas et le 404 (NotFound). Le view modèle peut être différent, suivant les informations que vous voulez afficher dans la vue.

Ceci n’est cependant pas fini. Nous n’avons pas encore couvert l’objectif n°1. Pour cela nous introduisant un contrôleur de base. Tous les contrôleurs qui veulent couvrir l’objectif n° 1 doivent en hériter.

Les erreurs où l’action n’est pas trouvée doivent être attrapées dans tous les contrôleurs. Vous pouvez le faire en surchargeant la méthode HandleUnknownAction. Au lieu de le faire dans chaque contrôleur, il est plus judicieux d’introduire une classe de base dont tous les autres contrôleurs doivent hériter. En voici son implémentation :

   1: public abstract class BaseErrorController : Controller
   2: {
   3:     protected override void HandleUnknownAction(string actionName)  
   4:     {         
   5:         // Ne pas boucler s'il y a des exceptions dans ErrorController
   6:         if (GetType() != typeof(ErrorController))
   7:             HandleHttpException(HttpContext, 404);
   8:     }
   9:  
  10:     public ActionResult HandleHttpException(HttpContextBase httpContext, int statusCode)
  11:     {
  12:         var errorController = (IController)ControllerFactory.CreateDependencyCallback(typeof(ErrorController));
  13:         var errorRoute = new RouteData();
  14:  
  15:         switch (statusCode)
  16:         {
  17:             case 404 : 
  18:                 errorRoute.Values.Add("controller", "Error");
  19:                 errorRoute.Values.Add("action", "Http404");
  20:                 break;
  21:             case 500:
  22:                 errorRoute.Values.Add("controller", "Error");
  23:                 errorRoute.Values.Add("action", "Http500");
  24:                 break;
  25:             default:
  26:                 errorRoute.Values.Add("controller", "Error");
  27:                 errorRoute.Values.Add("action", "Generic");
  28:                 errorRoute.Values.Add("statusCode", statusCode);
  29:                 break;
  30:         }
  31:  
  32:         errorRoute.Values.Add("url", httpContext.Request.Url.OriginalString);
  33:         errorController.Execute(new RequestContext(httpContext, errorRoute));          
  34:         
  35:         return new EmptyResult();
  36:     }
  37: }

Comme vous pouvez le voir, son implémentation est assez explicite. L’action HandleUnknownAction est évoquée à chaque fois qu’une action demandée n’a pas été trouvé. Nous invoquons ensuite la méthode HandleHttpException pour y analyser le code de statut et rediriger le flux vers le contrôleur ErrorController et une action correspondante à l’erreur. Objectif n° 1 est atteint !

Avantages
  • le code de gestion des erreurs est regroupé à un endroit. Nous respectons le principe DRY.
  • possibilité d’affichage des vues dynamiques ce qui est très avantageux lorsqu’on veut afficher des informations dynamiques.
Désavantages
  • dans l’immédiat, je n’en vois qu’un. Obliger les développeurs à penser d’hériter de cette classe de base.

Nous pouvons maintenant passe à l’objectif n° 2.

Objectif n° 2

Pour couvrir l’objectif n° 2, j’ai modifié ma ControllerFactory basée sur StructureMap pour faire de l’injection de dépendance. Si cependant vous n’utilisez pas l’injection de dépendance (j’espère que ce n’est pas le cas) vous pouvez toujours recourir au bon vieux Global.asax (attention aux problèmes de redirection). Il vaut tout de même mieux attraper les erreurs au plus prêt de leur source. Voici ma ControllerFactory :

   1: public class ControllerFactory : DefaultControllerFactory
   2: {
   3:     public static Func<Type, object> CreateDependencyCallback = type => Activator.CreateInstance(type);
   4:  
   5:     protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType)
   6:     {
   7:         try
   8:         {
   9:             if (controllerType == null)
  10:                 return base.GetControllerInstance(requestContext, controllerType); ;
  11:         }
  12:         catch (HttpException ex)
  13:         {
  14:             if (ex.GetHttpCode() == 404)
  15:             {
  16:                 var errorController = (IController)CreateDependencyCallback(typeof(ErrorController)); 
  17:                 ((ErrorController)errorController).HandleHttpException(requestContext.HttpContext, 404); 
  18:                 return errorController;
  19:             }
  20:             
  21:             throw ex;
  22:         }
  23:  
  24:         // check whetever is a asynchronous controller, because if handled the standard way the 404 is generetaed.
  25:         if (!CheckIsAsyncController(controllerType))
  26:         {
  27:             var controller = (Controller)CreateDependencyCallback(controllerType);
  28:             controller.ActionInvoker = (IActionInvoker)CreateDependencyCallback(typeof(ConventionActionInvoker));
  29:             return controller;
  30:         }
  31:  
  32:         return base.GetControllerInstance(requestContext, controllerType);
  33:     }
  34:  
  35:     private static bool CheckIsAsyncController(Type controllerType)
  36:     {
  37:         if (controllerType.BaseType != null && controllerType.BaseType.FullName != null && controllerType.BaseType.FullName.Contains(".Async"))
  38:             return true;
  39:         return false;
  40:     }
  41: }

Votre implémentation peut être différente mais le principe de base est le même. Ce qui est important se trouve entre les lignes 7 et 22. Lorsqu’un contrôleur n’est pas trouvé par le Framework MVC, une HttpException avec le code statut 404 est levée. Nous interceptons cette erreurs, obtenons l’instance de ErrorController de notre containeur DI et ensuite nous invoquons l’action qui va bien. Objectif n° 2 est atteint !

Avantages
  • l’utilisation de notre implémentation de ErrorController pour traiter l’exception.
Désavantages
  • un 2ème endroit où l’erreur est attrapée. En même temps je préfère cette approche car elle me donne plus de possibilité d’agir par rapport au Global.asax.

Maintenant que nous avons rempli l’objectif suivant nous pouvons passer à l’objectif n° 3.

Objectif n° 3

Celui-ci est très facile à remplir. Il suffit d’ajouter une route qui sera évoluée lorsqu’aucune autre route n’intercepte pas la requête http. Cette route doit pointer vers l’action Http404 de notre ErrorController :

   1: MvcRoute.MapUrl("{*url}")
   2:                 .WithDefaults(new { controller = "Error", action = "Http404" })
   3:                 .AddWithName("catch-all", routes)
   4:                 .RouteHandler = new DomainNameRouteHandler();

La syntaxe est différente car j’utilise les extensions MvcContrib mais je suis sur que vous savez l’écrire avec la syntaxe par défaut du Framework MVC.

Avantages
  • lorsqu’une route n’est pas trouvé pour la requête http courante, le Framework MVC redirige par défaut le problème dans Global.asax ce qu’on ne veut pas car nous voudrions afficher les erreurs d’une manière dynamique.
Désavantages
  • un 3ème endroit où l’erreur est attrapée. En même temps je préfère cette approche car elle me donne plus de possibilité d’agir par rapport au Global.asax.

Objectif n° 3 est atteint !

Maintenant que nous avons rempli l’objectif suivant nous pouvons passer à l’objectif n° 4.

Objectif n° 4

Pour traiter l’objectif n° 4. J’ai opté pour la création d’un Filtre personnalisé. Le but de ce filtre est d’attraper toutes les exceptions produites dans l’application et rediriger le problème vers notre ErrorController. Donc potentiellement toutes ces erreurs levées devraient être attrapées par notre filtre :

   1: public ActionResult Index(int id)
   2: {
   3:     // une logique d'application et ensuite levée d'exceptions car
   4:     // la logique a échoué
   5:     // ressource non trouvée
   6:     throw new HttpException(404, "Not found");
   7:     
   8:     // une erreur de traitement
   9:     throw new Exception("Erreur de traitement");
  10:     
  11:     // une autre erreur de traitement
  12:     throw new HttpException(500, "500 qui tue");
  13:     
  14:     // la requête mal formatée
  15:     throw new HttpException(400, "bad request");

Et voici l’implémentation de notre filtre :

   1: public class ErrorViewExceptionFilter :  ContainerBaseActionFilter, IExceptionFilter
   2: {
   3:     readonly int _statusCode; 
   4:     
   5:     public ErrorViewExceptionFilter(HttpStatusCodeResult prototype) : this(prototype.StatusCode) { 
   6:     }
   7:  
   8:     public ErrorViewExceptionFilter(int statusCode)
   9:     { 
  10:         _statusCode = statusCode; 
  11:     }
  12:  
  13:     public ErrorViewExceptionFilter()
  14:     {
  15:     }
  16:  
  17:     public void OnException(ExceptionContext filterContext)
  18:     {
  19:         int currentStatusCode = GetHttpCode(filterContext);
  20:         if ((currentStatusCode == _statusCode || _statusCode == 0) && !filterContext.ExceptionHandled)
  21:         {
  22:             filterContext.ExceptionHandled = true;
  23:             HandleExceptionWithViewResult(filterContext.Controller.ControllerContext, currentStatusCode);
  24:         }
  25:     }
  26:  
  27:     private int GetHttpCode(ExceptionContext filterContext)
  28:     {
  29:         var httpException = filterContext.Exception as HttpException;
  30:         if (httpException == null)
  31:             return 500; // returns 500 for non HTTP exceptions
  32:  
  33:         return httpException.GetHttpCode();
  34:     }
  35:  
  36:     private void HandleExceptionWithViewResult(ControllerContext controllerContext, int statusCode)
  37:     {
  38:         var errorController = (IController)CreateDependency<ErrorController>();
  39:         controllerContext.HttpContext.Response.TrySkipIisCustomErrors = true;
  40:         ((ErrorController)errorController).HandleHttpException(controllerContext.HttpContext, statusCode);
  41:     }
  42: }

Comme vous pouvez le constater, nous implémentons l’interface IExceptionFilter du Framework MVC ce qui nous permet d’intercepter les erreurs lorsqu’une exception se produit au sein d’un contrôleur. Attention, le filtre doit être appliqué au niveau du contrôleur. Ce qui est important c’est la méthode HandleExceptionWithViewResult. Lorsqu’une exception se produit, elle permet d’obtenir une instance de ErrorController et invoquer l’action correspondante pour afficher l’erreur. Il suffit maintenant d’enregistrer le filtre afin qu’il soit automatiquement appliqué à tous les contrôleurs. Pour cela nous allons utiliser une fonctionnalité disponible dans MVC 3 qui sont les GlobalFilters :

   1: GlobalFilters.Filters.Add(new ErrorViewExceptionFilter()); // attraper toutes les exceptions

Nous pouvons également paramétrer le filtre pour ne traiter que les erreurs souhaitées :

   1: GlobalFilters.Filters.Add(new ErrorViewExceptionFilter(new HttpNotFoundResult())); // erreurs 404
   2: GlobalFilters.Filters.Add(new ErrorViewExceptionFilter(500)); // erreurs 500

Objectif n° 4 est atteint ! 

Avantages
  • nous pouvons traiter toutes les exceptions ou les exceptions choisies d’une manière globale.
  • nous utilisons la même infrastructure (ErrorController) pour afficher les erreurs.
Désavantages
  • un 4ème endroit où l’erreur est attrapée. En même temps je préfère cette approche car elle me donne plus de possibilité d’agir par rapport au Global.asax.

Maintenant que nous avons rempli l’objectif suivant nous pouvons passer aux autres objectifs.

Objectifs n° 5, 6 et 7

Ces objectifs sont couverts par toutes les actions que nous avons mis en place dans les étapes précédentes.

Avantages
  • l’utilisation de l’infrastructure existante.
Désavantages
  • il n’y en a pas.

Conclusion

Bien que la gestion des erreurs http n’est pas complexe en soi, elle pose beaucoup de problèmes car la manière de gérer les codes d’erreur n’est pas la même suivant s’il s’agit des 404 ou d’autres types d’erreur. D’ailleurs cette gestion doit être compatible avec les prérequis du web pour éviter les problèmes dont Julien parlait dans son post initial (redirections 302 avant l’affichage de 404). Peut-être que l’implémentation que je propose n’est pas optimale mais néanmoins elle couvre fonctionnellement tous les cas de figure (j’espère). Si vous avez des suggestions n’hésitez pas à me faire un petit commentaire.

// Thomas