«Классические» friendly url в ASP.NET MVC

    image
    Речь в топике пойдет о том, как сделать ссылки типа www.site.com/helloworld в ASP.NET MVC-приложении. Слышу возмущенные возгласы «зачем это надо?» и «чем тебя не устраивает нормальный человеческий роутинг?» и у меня по этому поводу заготовлен ответ.

    Дело в том, что наш проект переезжает на MVC с Web Forms, и контекстная реклама уже давно оплачена. Routing — прекрасная вещь, однако, учитывая, что сайт состоит не только из приземляющих страниц, мы не можем так по-хитрому его настроить, чтобы продолжали работать и обычные MVC-ссылки /Controller/Action, и наши невероятно дружественные URL'ы. Причина проста: дефолтный роутинг ("{controller}/{action}/{id}", new { controller = "Default", action = "Index", id = UrlParameter.Optional }) предполагает значения по умолчанию, поэтому при обращении к www.site.com/helloworld нас попытаются перекинуть на контроллер helloworld в экшн Index.
    Печаль.
    Придется что-то делать. Причем вариант с созданием контроллера для каждой ссылки нас явно не устроит — их тьма, да и в целом это порнография, правда?

    Мы пойдем другим путём. ©


    Концепция


    Мы повесим хендлер на роутинг, который будет проверять ссылку на френдливость — для этого воспользуемся регулярными выражениями.
    Если ссылка MVC-шная — просто продолжим, не совершая лишних телодвижений.
    Если ссылка не похожа на типичную MVC-ссылку, мы попробуем поискать в базе соответствие — какой контроллер и экшн использовать.
    Если ничего не найдем, попробуем воспользоваться стандартным роутингом и значениями по умолчанию. Тут уж 404, так 404.


    Модель


    image

    Вот так просто и незатейливо. ContentID здесь внешний ключ, однако при желании можно использовать VARCHAR(MAX) и писать контент сразу здесь. У нас такая реализация обусловлена тем, что контент поделен на куски (основной контент, title, мета-тег description...) — SEO, сами понимаете.


    Реализация


    Основа реализации заключается вот в чем:
    routes.MapRoute(
        "Default",
        "{controller}/{action}/{id}",
        new { controller = "Default", action = "Index", id = UrlParameter.Optional }
    ).RouteHandler = new FriendlyUrlRouteHandler();
    

    К нашему роутеру мы приделываем хендлер (наследник MvcRouteHandler), который обрабатывает уже готовые значения. То есть сначала механизм роутинга разберется со ссылкой, заполнит значения по умолчанию, потом мы вступим в дело и при необходимости заменим значения на наши.

        public class FriendlyUrlRouteHandler : MvcRouteHandler
        {
            private static readonly Regex TypicalLink = new Regex("^.+/.+(/.*)?");
    
            protected override IHttpHandler GetHttpHandler(RequestContext requestContext)
            {
                // Path для www.site.com/helloworld?id=1 будет равняться /helloworld
                // поэтому мы убираем начальный слэш
                var url = requestContext.HttpContext.Request.Path.TrimStart('/');
    
                if (!string.IsNullOrEmpty(url) && !TypicalLink.IsMatch(url))
                {
                    PageItem page = RedirectManager.GetPageByFriendlyUrl(url);
                    if (page != null)
                    {
                        FillRequest(page.ControllerName, 
                            page.ActionName ?? "GetStatic", 
                            page.ID.ToString(), 
                            requestContext);
                    }
                }
    
                return base.GetHttpHandler(requestContext);
            }
    
            /// <summary> Заполнение request-контекста данными о контроллере, экшне и параметрах </summary>
            private static void FillRequest(string controller, string action, string id, RequestContext requestContext)
            {
                if (requestContext == null)
                {
                    throw new ArgumentNullException("requestContext");
                }
    
                requestContext.RouteData.Values["controller"] = controller;
                requestContext.RouteData.Values["action"] = action;
                requestContext.RouteData.Values["id"] = id;
            }
        }
    


    Я использую в качестве параметра ID записи, и при загрузке страницы использую вьюху, которая состоит из join'а нескольких таблиц. Если не нужны подобные заморочки, можно просто передавать в качестве параметра ContentID, а не id всей записи.

    По умолчанию используется экшн GetStatic, который определен как виртуальный в нашем базовом классе для контроллеров. В конкретном контроллере он берет текст страницы из базы, и генерирует страничку с учетом особенностей и layout'ов раздела.
        public abstract class BaseController : Controller
        {
            /// <summary> Получение страницы из базы </summary>
            public virtual ActionResult GetStatic(int id)
            {
                return HttpNotFound();
            }
        }
    


    И наконец сам механизм редиректа. Признаться, уже не помню, почему я вынес его в отдельный класс. С другой стороны, почему бы и нет.
        public static class RedirectManager
        {
            public static PageItem GetPageByFriendlyUrl(string friendlyUrl)
            {
                PageItem page = null;
    
                using (var cmd = new SqlCommand())
                {
                    cmd.Connection = new SqlConnection(/*YourConnectionString*/);
                    cmd.CommandText = "select * from FriendlyUrl where FriendlyUrl = @FriendlyUrl";
                    cmd.Parameters.Add("@FriendlyUrl", SqlDbType.NVarChar).Value = friendlyUrl.TrimEnd('/');
    
                    cmd.Connection.Open();
                    using (var reader = cmd.ExecuteReader(CommandBehavior.CloseConnection))
                    {
                        if (reader.Read())
                        {
                            page = new PageItem
                                       {
                                           ID = (int) reader["Id"],
                                           ControllerName = (string) reader["ControllerName"],
                                           ActionName = (string) reader["ActionName"],
                                           FriendlyUrl = (string) reader["FriendlyUrl"],
                                       };
                        }
                    }
    
                    return page;
                }
            }
        }
    



    Результат


    В конечном итоге мы получили именно то, к чему стремились. После заполнения базы у нас заработал редирект на правильные страницы, причем в адресной строке ничего не меняется, и мы получаем 200 OK без дополнительных плясок.

    Конечно, ситуация в достаточной степени нетипичная — в большинстве случаев стандартного роутинга более чем достаточно: можно сделать контроллер, например, Static, который также по URL будет забирать данные из базы. Но если контекстная реклама уже оплачена, или просто есть дикое желание сделать ссылки, в которых ни одного лишнего слэша, описанный вариант, на мой взгляд, весьма пригодный.

    В конце концов, мы поставили задачу, и благополучно с ней справились.
    По-моему, это хорошо.
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 20

      0
      Можно поинтересоваться, что вас толкнуло к переезду на WebForms? К слову, у меня была тоже заметка про настройку кастомного роутинга, правда тогда была версия 1.0, да и я уже забыл практически все, что связано с ASP .NET MVC. Но, возможно, кому-нибудь пригодится.
        0
        Как раз таки наоборот — с WebForms переехали на MVC.
        В большинстве случаев ни один менеджер не даст добро на перевозку работающего сайта на новые рельсы, а у нас просто настала глобальная перестройка проекта. И новую часть решили сделать с новыми технологиями.
        Лично я не пожалел ни разу: MVC — рай для ленивых разработчиков.:)
        0
        Можите привести пример «нашего дружественного url», а то немного не понятна какую вы проблему решали.
          –2
          В самом начале статьи привел — www.site.com/helloworld
          Т.е. friendly-url, идущий сразу же за доменным именем, без возможности явного указания контроллера, что подразумевается в стандартном роутинге.
          +5
          простите, но зачем огород городить?
          вы в курсе, что можете добавлять свои правила роутинга? и вашу проблему «friendly ulr» можно решить, написав что-нибудь в стиле:
          routes.MapRoute("YourHelloWorldRoute", "helloworld", new { controller = "Home", action = "Index" })
            +4
            ага, насколько я помню, там просто нужно прописать ее в таблице рутов раньше дефолтного, чтобы он первым обрабатывал этот маршрут, как-то так…
              –2
              С таким подходом придется создавать правило для каждой ссылки, не правда ли?
              У меня их не одна и даже не десять.
                0
                ниже правильно пишут, что основная ваша проблема — это лишний запрос к базе данных при любом обращении к сайту, все правила нужно прописывать один раз. извините, но прежде чем подобным образом решать задачи, и тем более писать статью, нужно хотя бы немного ознакомиться с соответствующей литературой — вы в любой книжке по MVC найдете рассмотрения этого вопроса.
                  –1
                  Ниже ответил, что у меня не каждый раз запрос к базе, а только когда ссылка не подходит под стандартный роутинг. Писать >50 правил в таблицу роутинга я считаю неприемлемым, а вы мне предлагаете именно это. Надо пользоваться более гибкими методами, мне кажется.

                  Я умею признавать свои ошибки, но для этого нужно указать более удачное решение.
                    0
                    Более удачное решение — таблицу роутинга в кэш и считывать из него, а не из БД
              +1
              Велосипед с квадратными колесами. Автор не очень хорошо знаком с гибкостью роутинга в ASP.NET.
              Можно начать с этого: www.asp.net/mvc/tutorials#Routing
                –1
                Можете привести более удачное решение?
                Есть, скажем, пятьдесят ссылок, каждая без возможности указать контроллер. Какие правила вы создадите в таблице роутинга?
                  +1
                  Что значит «без возможности указать контроллер»?
                  Если это 50 ссылок на разные контроллеры/действия, то надо сделать 50 правил.
                  Вы просто переносите те же правила в БД. И у вас каждый раз будет лишний запрос к БД.
                  Не проще ли задать все правила при старте приложения, как это и предполагается?

                  routes.MapRoute(
                      "helloworld",
                      "helloworld",
                      new { controller = "hello", action = "world" }
                  );
                  
                  routes.MapRoute(
                      "foobar",
                      "foobar",
                      new { controller = "foo", action = "bar" }
                  );
                  
                  //...ваши 50 правил
                  
                  routes.MapRoute(
                      "Default",
                      "{controller}/{action}/{id}",
                      new { controller = "Default", action = "Index", id = UrlParameter.Optional }
                  )
                  
                  


                  А еще я против дефолтного правила. Оно позволяет, при желании, вызывать любые действия любых контроллеров, зная их названия. Даже если для этих действий предназначены совсем другие урлы. Этого допускать нельзя.
                    –1
                    То есть вы считаете, что указать пятьдесят правил в глобал.асакс — это хорошее решение?
                    При таком подходе мы получаем необходимость править этот файл при малейшем изменении — это раз. И я не уверен, что такая дикая таблица роутинга хорошо скажется на производительности — прежде чем добраться до дефолтного правила, надо пройти другие 50. Не могу утверждать, но есть мнение, что времени процессору придется потратить не меньше, чем на запрос к базе.
                    А рассмотренное решение — гораздо более гибкое.

                    Проблемы с доступом к экшнам — совсем другая тема. Надо рассматривать детально, навскидку вспоминается NonActionAttribute — хотя всё зависит от того, что и как вы хотите ограничить.

                    И наконец самое главное — у меня не каждый раз лишний запрос к БД, а только тогда, когда ссылка не подходит под дефолтное правило. Я об этом также написал.
                      +1
                      >То есть вы считаете, что указать пятьдесят правил в глобал.асакс — это хорошее решение?
                      >При таком подходе мы получаем необходимость править этот файл при малейшем изменении — это раз.

                      Не обязательно в global.asax. Вы можете придумать удобную абстракцию для заполнения RouteTable.
                      Смысл в том, чтобы таблица заполнилась правилами во время запуска приложения.
                      Если вам надо часто менять роутинг без релиза (в чем я очень сомневаюсь), вы можете вынести класс, заполняющий таблицу, в отдельную сборку, чтобы легко ее заменять на продакшн сервере.

                      >И я не уверен, что такая дикая таблица роутинга хорошо скажется на производительности —
                      >прежде чем добраться до дефолтного правила, надо пройти другие 50. Не могу утверждать, но
                      >есть мнение, что времени процессору придется потратить не меньше, чем на запрос к базе.

                      Могу утверждать, что запрос к БД медленнее, чем работа с памятью приложения. А еще при проблемах с доступом к БД ваше приложение даже не сможет узнать какое действие вызвать. Значит вы не сможете реализовать разную логику обработки этого исключения для разных действий.

                      >А рассмотренное решение — гораздо более гибкое.

                      Оно менее гибкое потому, что позволяет задать только контроллер, фильтр и один параметр.
                      В реальной жизни в урлах бывает больше параметров.

                      >Проблемы с доступом к экшнам — совсем другая тема. Надо рассматривать детально, навскидку
                      >вспоминается NonActionAttribute — хотя всё зависит от того, что и как вы хотите ограничить.

                      routes.MapRoute(
                          "helloworld",
                          "helloworld",
                          new { controller = "hello", action = "world" }
                      );
                      
                      routes.MapRoute(
                          "Default",
                          "{controller}/{action}/{id}",
                          new { controller = "Default", action = "Index", id = UrlParameter.Optional }
                      )
                      
                      /*
                      * имеем два урла для одного действия:
                      *  /helloworld
                      *  /hello/world
                      */
                      
                      
                        –1
                        Если для каждой ссылки я буду прописывать правило (опустим детали реализации), то при обращении к MVC-урлам придется пройти все 50 правил, потому что они будут выше в таблице — и мне это видится не очень хорошим подходом.

                        Возможность работы в базе мне изначально нужна для CMS-ности — контент я также беру из базы, поэтому при проблемах доступа к ней я так и так не получу содержимого.
                        Таким образом, я могу сделать админку для отдела маркетинга, который сможет насиловать эти ссылки, как им заблагорассудится, без релиза и вообще моего участия. В этом и гибкость, о которой я говорил.
                        Даже если сделать вытягивание правил из базы при старте приложения, каждое изменение будет требовать рестарта приложения, а этого ответственный за контент делать не умеет.

                        > В реальной жизни в урлах бывает больше параметров.
                        Request.Param тоже не запретили, поэтому можно и их продолжать использовать. Более того, можно расширить это решение под конкретные проблемы, я не утверждал, что приведенный подход универсальный — наоборот, для вполне конкретных задач.

                        Собственно, мы с вами говорим о разных степенях гибкости — я хочу, чтобы сайтом можно было управлять без моего участия как программиста. В этом прелесть CMS и для этого данное решение очень подходит.
                          +1
                          Пройти 50 правил быстрее, чем сделать запрос к БД. Количество должно быть гораздо больше, чтобы издержки на запрос к базе стали меньшими, чем цикл по объектам в памяти. Едва ли у вас будет столько страниц.

                          Вы можете легко обновлять роутинг без перезапуска приложения.
                          RouteCollection реализует ICollection.
                            –1
                            Есть сомнения, но нет времени проверить. Единственное, что нашел по поводу изменения роутинга на лету, а не в Application_Start:

                            Hmm, I'm starting to think this might be true — debugging through the code, it looks like RouteTable.Routes.Add() successfully increases the number of routes, but apparently only for that Request cycle. It doesn't seem to persist it to the RouteTable that's used throughout the app.


                            Так что если это действительно так, и формирование таблицы роутинга доступно только при старте приложения, предложенный вами вариант всё же не подходит для CMS, управляемой не-программистами.
                +1
                Стивен Сандерсон предлагает следующее в своей книге:

                routes.MapRoute(null,
                «Page{page}», // Matches /Page2, /Page123, but not /PageXYZ
                new { controller = «Product», action = «List», category = (string)null },
                new { page = @"\d+" } // Constraints: page must be numerical
                );

                routes.MapRoute(null,
                "{category}", // Matches /Football or /AnythingWithNoSlash
                new { controller = «Product», action = «List», page = 1 }
                );

                Смысл думаю понятен, выглядит гораздо проще в реализации… У Вас не будет указания страниц и параметром будет строка.
                  –1
                  Хорошее решение, но опять не подошло в данной ситуации.
                  Дело в том, что мне необходимо оставить дефолтный роутинг для остальных контроллеров.
                  В связи с этим возникает загвоздка: варианты со значением по умолчанию получаются по формату такими же, как и friendly — не обязательно же вводить /Home/Index — можно ввести только /Home и это должно оставаться валидной ссылкой.

                  Как бы то ни было, возможность указать регулярное выражение в роутинге — замечательная опция. Спасибо.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое