
Речь в топике пойдет о том, как сделать ссылки типа 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.
Модель

Вот так просто и незатейливо. 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 будет забирать данные из базы. Но если контекстная реклама уже оплачена, или просто есть дикое желание сделать ссылки, в которых ни одного лишнего слэша, описанный вариант, на мой взгляд, весьма пригодный.
В конце концов, мы поставили задачу, и благополучно с ней справились.
По-моему, это хорошо.
