ASP.NET MVC: Оптимизация ссылок на сайте или SEO friendly MVC

Сайтостроение | создано: 24.01.2017 | опубликовано: 24.01.2017 | обновлено: 13.01.2024 | просмотров: 6793

Несмотря на то, что ссылки в ASP.NET MVC достаточно сильно, опять же силу своей архитектуры, оптимизированы под поисковые сервисы, рано или поздно возникает потребность в переименовании ссылок. Обычно, такие вопросы поднимаются со стороны людей отвечающих за продвижение сайта (например, сайта компании) в поисковых запросах, то есть SEO-менеджерами компании. Я предлагаю своё решение данного вопроса.

Задачи к решению

В моем проекте, созданном для примера, уже существует настройка роутинга по умолчанию, как и в любом другом, который создается при помощи Visual Studio. Итак, задача состоит в следующем. Требуется реализовать возможность изменения URL для любого контролера или/и метода для оптимизации ссылок для поисковых систем (SEO). Причем стандартный роутинг, который уже существует в ASP.NET MVC, должен остаться без изменений. Более того, также существует потребность создать “виртуальную” страницу (то есть виртуальный адрес route), который должен вести на страницу, которая читается из базы данных.

Если вывести озвученные задачи в список, то мы получим следующий набор требований:

  • Стандартные механизмы ASP.NET MVC роутинга должны быть сохранены и расширены.
  • На любой существующий маршрут (route) может быть наложен "виртуальный" URL (псевдо-маршрут).
  • Любой "виртуальный" URL может вообще не ссылаться на конкретный Controller и Action, а читать данные для отображения страницы из базы данных.

Стандарные и нестандартные маршруты (routes) в ASP.NET MVC

В системе по умолчанию существует один стандартный маршрут (и одно исключение, но это нам не интересно):

public class RouteConfig {
    public static void RegisterRoutes(RouteCollection routes) {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

Чтобы в системе совместно со стандартными маршрутами существовали нестандартные требуется некая таблица соответствий (mapping table), которая будет хранить названия URL соответствующие SEO принципам и их соответствие уже существующим маршрутам (ASP.NET MVC) или виртуальным страницам, содержимое которых будет хранятся в базе данных.

Структура решения (Visual Studio 2015)

Я создал в решении три проекта, чтобы разделить на слои.

У меня есть основной проект FriendlyUrlForMvc.Web, который как раз и есть сайт ASP.NET MVC 5. Также я добавил FriendlyUrlForMvc.Models, где у меня будут лежать domain-модели, и еще один проект FriendlyUrlForMvc.Data, в котором будет реализован доступ к базе данных (DAL). Для своего примера я буду использовать парадигму Code First на основе EntityFramework. Из инструментов у меня Visual Studio 2015.

Как уже упоминалось ранее, в моём проекте уже есть настройка маршрутов по умолчанию RouteConfig, а также существует один контролер HomeController с тремя методами Index, About, Contants.

Domain модели

Первым делом я добавлю класс FriendlyUrl в проект FriendlyUrlForMvc.Models:

/// <summary>
/// Friendly Url for SEO Management
/// </summary>
public class FriendlyUrl {

    public int Id { get; set; }

    [RegularExpression(@"^[a-z0-9-]+$")]
    [Display(Name = "SEO friendly url: only lowercase, number and dash (-) character allowed")]
    public string Permalink { get; set; }

    public string ControllerName { get; set; }

    public string ActionName { get; set; }

    public int? PageId { get; set; }

    public virtual EditablePage Page { get; set; }
}

Как должно быть понятно из приведенного кода, этот класс будет реализовывать ту самую таблицу соответствий (mapping table). В классе есть название ссылки (Permalink) и соответствие этой ссылки Controller и Action или ссылку на виртуальную страницу EditablePage.

Класс EditablePage - это представление виртуальной страницы, я его также добавлю в FriendlyUrlForMvc.Models.

/// <summary>
/// Editable Content
/// </summary>
public class EditablePage : Audit, IHaveIdentifier {

    public int Id { get; set; }

    public string Title { get; set; }

    public string Content { get; set; }

    public string Keywords { get; set; }

    public string Description { get; set; }
}

В этом классе содержатся свойства для поддрежания SEO-консистенции страницы на должном уровне. Другими словами, в классе содержаться все необходитмые аттрибуты для HTML-разметки, которые влияют на привлекательность контента со стороны поисковых систем.

База данных и EntityFramework

Теперь в проект FriendlyUrlForMvc.Data я установил nuget-пакет EntityFramework. После чего создал ApplicationDbContext:

/// <summary>
/// Main application DbContext
/// </summary>
public class ApplicationDbContext : DataContext, IContext {

    /// <summary>
    /// Экспонаты музея
    /// </summary>
    public IDbSet<EditablePage> EditablePages { get; set; }

    /// <summary>
    /// SEO Friendly Urls
    /// </summary>
    public IDbSet<FriendlyUrl> FriendlyUrls { get; set; }

    #region overrides and other staff

    public static ApplicationDbContext Create() {
        return new ApplicationDbContext();
    }

    /// <summary>
    /// Maps table names, and sets up relationships between the various user entities
    /// </summary>
    /// <param name="modelBuilder"/>
    protected override void OnModelCreating(DbModelBuilder modelBuilder) {
        modelBuilder.Configurations.AddFromAssembly(Assembly.GetAssembly(GetType()));
        base.OnModelCreating(modelBuilder);
    }

    #endregion
}

После этого требуется подключить миграции (enable-migrations) к проекта FriendlyUrlForMvc.Data и настроить строку подключения в файле web.config:

<connectionStrings>
  <add name="DefaultConnection"
        connectionString="Data Source=sql;Initial Catalog=friendly-url-demo;Integrated Security=True"
        providerName="System.Data.SqlClient" />
</connectionStrings>

Теперь выполним команду, которая подключит миграции CodeFirst, что позволит в дальнейшем управлять "вручную" созданием базу данных:

PM> Enable-Migrations -ProjectName FriendlyUrlForMvc.Data
Checking if the context targets an existing database...
Code First Migrations enabled for project FriendlyUrlForMvc.Data.
PM> 

И, наконец, требуется изменить настройки конфигурации EntityFramework, которые система использует при создания базы данных:

internal sealed class Configuration : DbMigrationsConfiguration<ApplicationDbContext> {
    public Configuration() {
        AutomaticMigrationsEnabled = true;
        AutomaticMigrationDataLossAllowed = true;
    }

    protected override void Seed(ApplicationDbContext context) {
        //  This method will be called after migrating to the latest version.

        //  You can use the DbSet<T>.AddOrUpdate() helper extension method
        //  to avoid creating duplicate seed data. E.g.
        //
        //    context.People.AddOrUpdate(
        //      p => p.FullName,
        //      new Person { FullName = "Andrew Peters" },
        //      new Person { FullName = "Brice Lambson" },
        //      new Person { FullName = "Rowan Miller" }
        //    );
        //
    }
}

В строке 3 и 4, я разрешил EntityFramework изменять структуру существующей базу данных, а также сбрасывать данные в таблицах, если таковые имеются. Теперь осталось выполнить команду, которая создаст базу в Microsoft SQL Server (Express в моем случаи).

PM> update-database -ProjectName FriendlyUrlForMvc.Data
Specify the '-Verbose' flag to view the SQL statements being applied to the target database.
No pending explicit migrations.
Applying automatic migration: 201701240939095_AutomaticMigration.
Running Seed method.
PM> 

Я использую автоматические миграции, создание миграций не входит в задачи этой статьи. Вот что у нас получилось:

Опаньки! Стоп! Кажется структура данных не совсем соответствует моим ожиданиям.

Все свойства полей установленые в значения по умолчанию. Меня такой расклад не устраивает, поэтому я добавлю настройки для каждого из свойств каждой сущности. Файлы положу в папку Configurations, которую создал в проекте FriendlyUrlForMvc.Data.

/// <summary>
/// FriendlyUrl Model Configuration
/// </summary>
public class FriendlyUrlModelConfiguration : EntityTypeConfiguration<FriendlyUrl> {
    public FriendlyUrlModelConfiguration() {

        ToTable("FriendlyUrl");

        HasKey(x => x.Id);

        Property(x => x.ActionName).HasMaxLength(256).IsRequired();
        Property(x => x.ControllerName).HasMaxLength(256).IsRequired();
        Property(x => x.PageId).IsOptional();
        Property(x => x.Permalink).HasMaxLength(512).IsRequired();
        HasOptional(x => x.Page);
    }
}

И еще один для EditablePage:

/// <summary>
/// EditablePage Model Configuration
/// </summary>
public class EditablePageModelConfiguration : EntityTypeConfiguration<EditablePage> {

    public EditablePageModelConfiguration() {

        ToTable("EditablePage");

        HasKey(x => x.Id);

        Property(x => x.Id).IsRequired().HasDatabaseGeneratedOption(DatabaseGeneratedOption.Identity);
        Property(x => x.Title).HasMaxLength(512);
        Property(x => x.Keywords).HasMaxLength(1024).IsRequired();
        Property(x => x.Description).HasMaxLength(4096).IsRequired();
        Property(x => x.Content).HasMaxLength(16384);
        Property(x => x.CreatedAt).IsRequired();
        Property(x => x.CreatedBy).HasMaxLength(50).IsRequired();
        Property(x => x.UpdatedAt).IsOptional();
        Property(x => x.UpdatedBy).HasMaxLength(50).IsOptional();
    }
}

Для конфигурации сущностей я использовал Fluent API. В файле ApplicationDbContext.cs уже добавлена обработка созданных конфигураций моделей базы данных в методе OnModelCreating:

/// <summary>
/// Maps table names, and sets up relationships between the various user entities
/// </summary>
/// <param name="modelBuilder"/>
protected override void OnModelCreating(DbModelBuilder modelBuilder) {
    modelBuilder.Configurations.AddFromAssembly(Assembly.GetAssembly(GetType()));
    base.OnModelCreating(modelBuilder);
}

Теперь, для того чтобы изменения вступили в силу, я удалю базу данных и заново запущу команду ​update-database.

Следует заметить, что существует и другая возможность обновить базу данных. Совсем даже не таким радикальным способом, который выбрал я. В EntityFramework предусмотрены migrations, которыми можно создавать строго типизированные изменения базы данных и хранить их в системе контроля версий (GIT, TFS, Mercurial, etc). Просто в моем случае база данных только что создана, и не имеет смысла тратить время на сохрание в ней данных (то ради чего в конечном счете призваны миграции). Простое удаление и пересоздание с нуля существенно ускорит процесс.

Ну вот, теперь совсем другое дело.

Замечательно! Теперь свойства в моих классах соответствуют моим представлениям. Надо сказать, что необходимость проверить каким образом созданы таблицы, дабы проверить правильные типы и связи были мной настроены - это единственное, ради чего я захожу в Microsoft SQL Management Studio. Подход CodeFirst полностью исключает неоходимость конфигурирования таблиц и связей напрямую в Microsoft SQL Server (или в любом другом SQL-сервере, который имеет провайдера для подключения к EntityFramework).

Что дальше? А дальше я добавил одну строчку в таблицу EditablePages "вручную" (это еще одна необходимость для запуска Microsoft SQL Management Studio), чтобы можно было в таблице FriendlyUrl сослаться на ее. Она позже мне пригодится при создании ссылки на виртуальную страницу.

Далее мы создадим ключевой для статьи класс, который возмет на себя обязанность "разруливать" маршруты, которые не подпадают под стандартные шаблоны ASP.NET MVC.

Создаем свой MvcRouterHandler

Исходя из поставленной задачи, требуется определенным образом обрабатывать маршруты, которые поступают в нашу систему. Для этого я создал класс SeoMvcRouteHandler:

/// <summary>
/// Custom Route Handler for SEO Management
/// </summary>
public class SeoMvcRouteHandler : MvcRouteHandler {

    /// <summary>
    /// This is default MVC route template
    /// </summary>
    private static readonly Regex TypicalLink = new Regex("^.+/.+(/.*)?");

    /// <summary>Returns the HTTP handler by using the specified HTTP context.</summary>
    /// <returns>The HTTP handler.</returns>
    /// <param name="requestContext">The request context.</param>
    protected override IHttpHandler GetHttpHandler(RequestContext requestContext) {
        if (requestContext == null) {
            throw new ArgumentNullException(nameof(requestContext));
        }
        var url = requestContext.HttpContext.Request.Path.TrimStart('/');
        if (string.IsNullOrEmpty(url) || TypicalLink.IsMatch(url)) return base.GetHttpHandler(requestContext);
        var page = FriendlyUrlProvider.Default.GetPageByFriendlyUrl(url);
        if (page == null) return base.GetHttpHandler(requestContext);
        requestContext.RouteData.Values["controller"] = page.ControllerName;
        requestContext.RouteData.Values["action"] = page.ActionName;
        requestContext.RouteData.Values["id"] = page.PageId.ToString();
        return base.GetHttpHandler(requestContext);
    }
}

В строке 9, я определил паттерн для Regex, чтобы проверять совпадает ли запрошенный URL со стандартным шаблоном для UrlTemplate, который используется в ASP.NET MVC. Если совпадает (проверка в строке 19), то я отдаю дальнейшее управление маршрутами ASP.NET MVC. Если нет, то я ищу (строка 20) совпадение в таблице соответствий (mapping table). При наличии записи в таблице соответствий, выдается найденная страница, на основании которой выбирается маршрут. Если маршрут не найден ни стандарных настройках, ни в таблице соответствий - выдается 404 ошибка.

Singleton - паттерн или анти-паттерн

Как вы могли заметить в строке 20 предыдущего листинга есть некий FriendlyUrlProvider. Этот отвечает за хранение (кэширование) уже найденых FriendlyUrl экземпляров и поиск при отсутствии в кэше в базе данных.

Кэширование, которое приведено на примере требует доработки, потому что при изменении настроек в FriendlyUrl кэш должен быть сброшен и страница должна быть получена из базы данных. Путь реализация данного функционала будет вашим домашним заданием.

/// <summary>
/// This is a Singleton (anti-pattern) implementation for extremely fast reading from database.
/// And caching resolved URLs
/// </summary>
public class FriendlyUrlProvider {

    private static readonly Lazy<FriendlyUrlProvider> Instance = new Lazy<FriendlyUrlProvider>(() => new FriendlyUrlProvider());
    private static readonly Dictionary<string, FriendlyUrl> CachedUrl = new Dictionary<string, FriendlyUrl>();

    private FriendlyUrlProvider() { }

    /// <summary>
    /// Single instance
    /// </summary>
    public static FriendlyUrlProvider Default {
        get { return Instance.Value; }
    }

    /// <summary>
    /// Returns an instance from cache or from database (if not found) of the FriendlyPage
    /// </summary>
    /// <param name="url">url name for filtering</param>
    /// <returns></returns>
    public FriendlyUrl GetPageByFriendlyUrl(string url) {
        FriendlyUrl page;

        if (CachedUrl.ContainsKey(url)) {
            return CachedUrl[url];
        }

        using (var context = new ApplicationDbContext()) {
            var urlParams = url.TrimEnd('/');
            page = context.FriendlyUrls.SingleOrDefault(x => x.Permalink.ToLower() == urlParams.ToLower());
            if (page != null) {
                context.Entry(page).Reference(x => x.Page).Load();
                CachedUrl.Add(url, page);
            }
        }
        return page;
    }

    /// <summary>
    /// Returns an instance from cache or from database (if not found) of the FriendlyPage
    /// </summary>
    /// <param name="id">id as parameter for filtering</param>
    /// <returns></returns>
    public FriendlyUrl GetPageByFriendlyId(int id) {
        FriendlyUrl page;
        if (CachedUrl.ContainsKey(id.ToString())) {
            return CachedUrl[id.ToString()];
        }
        using (var context = new ApplicationDbContext()) {
            page = context.FriendlyUrls.SingleOrDefault(x => x.PageId == id);
            if (page != null) {
                context.Entry(page).Reference(x => x.Page).Load();
                CachedUrl.Add(id.ToString(), page);
            }
        }
        return page;
    }
}

Я привел весь класс целиком. Класс создан по правилам паттерна Одиночка (Singleton) и полностью удовлетворяет моим потребностям в данном проекте.

Несмотря на то, что данный пример реализации паттерна Singleton выглядит очень практично, я всё равно считаю его анти-паттерном. Хотя среди разработчиков утверждение, что Singleton является анти-паттерном, остается спорным вопросом. Я считаю его анти-паттерном, потому что количество "минусов" при его использовании, гораздо больше чем "плюсов".

В строке 24 метод получает FriendlyUrl по URL, а в строке 47 получает по идентификатору EditblePage. Второй метод используется для правильного перенаправления на "виртуальную" страницу после ее редактирования.

Пути, дороги, маршруты

На данный момент у меня есть один реальный контролер, в котором есть три метода. Данный факт открывает для посетителя следующие пути:

http://localhost:12323/ он же /Home/Index
http://localhost:12323/Home/Index
http://localhost:12323/Home/About
http://localhost:12323/Home/Contacts

Если я попытаюсь открыть какой-нибудь несуществующий метод или контролер, то я увижу сообщение об ошибке:

В данном примере, система MVC будет искать метод Main-Page в контролере Home. В моем примере для проверки URL на предмет совпадения с существующими маршрутами используется паттерн:

/// <summary>
/// This is default MVC route template
/// </summary>
private static readonly Regex TypicalLink = new Regex("^.+/.+(/.*)?");

где проверяется наличие {Controller} и {Action} в запрошенном URL. Поэтому я буду для своих виртуальных страниц определять SEO-адреса следующим образом:

http://localhost:12323/seo-friendly-url
http://localhost:12323/my-favorite-blog-post
http://localhost:12323/typoi_sposob_imenovat_ccylki

Если вам потребуется более сложный вариант разбора URL перед тем как перенаправить на виртуальную страницу, вам нужно будет придумать свой механизм определения совпадений.

Итак, давайте "вручную" создадим в таблице FriendlyUrl запись, которая будет вести на страницу "О компании" (About) но только по другой ссылке, отличной от http://localhost:12323/Home/About

То есть при запросе странице по адресу http://localhost:12323/about-our-company я должен увидеть страницу http://localhost:12323/Home/About. Попробую выполнить запрос: "Не удалось найти данный ресурс."

Регистрация MvcRouteHandler

Всё хорошо, но кажется я забыл зарегистрировать обработчик маршрутов, который был создан выше, вследствии чего система MVC по-стандартному обработала мой запрос, выкинув предупреждение о несуществовании маршрута. Добавим обработчик в систему:

public class RouteConfig {
    public static void RegisterRoutes(RouteCollection routes) {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        ).RouteHandler = new SeoMvcRouteHandler();
    }
}

Вся магия показана в строке 9. После выполнения команды Rebuild и запуска проекта, снова пытаюсь открыть http://localhost:12323/about-our-company и как результат:

Можно сказать, что одна из задач решена. Я могу назначить любой URL, который мне нравится, на уже существующий в системе маршрутизации.

Виртуальные страницы

Самый интересный момент - это реализация виртуальных страниц. Причем не просто просмотр, а с возможностью редактирования, а также возможностью назначения SEO-friendly адреса для просмотра этих страниц. Напомню, что я уже "вручную" добавил в таблицу EditablePage одну запись:

Теперь займемся ее отображением на страницах сайта. Надо сказать, что часть пути уже пройдена. И перед тем, как создать перенаправление в системе роутинга, надо создать контролер, который будет обрабатывать виртуальные странины на виртуальных адресах. Также мне потребуется EditablePageUpdateViewModel, который как DTO модель при редактировании страницы.

/// <summary>
/// EditablePage ViewModel for Updating
/// </summary>
public class EditablePageUpdateViewModel : IHaveIdentifier {

    public int Id { get; set; }

    public string Title { get; set; }

    [DataType(DataType.MultilineText)]
    public string Content { get; set; }

    public string Keywords { get; set; }

    [DataType(DataType.MultilineText)]
    public string Description { get; set; }
}

И, собственно говоря, репозиторий для сущности EditablePage.

/// <summary>
/// EditablePage Repository
/// </summary>
public class EditablePageRepository
    : WritableRepositoryBase<EditablePage, EditablePageUpdateViewModel, EditablePage, PagedListQueryParams> {
    public EditablePageRepository(
        IContext context,
        IAppConfig config,
        IMapper mapper,
        IServiceSettings settings,
        ILogService logger)
        : base(context, config, mapper, settings, logger) {
    }
}

Для EditablePageRepository я использую базовый класс ​WritableRepositoryBase из nuget-пакета OperationResult.WebApi. В этом nuget-пакете реализованы абстрактные репозитории на чтение (ReadOnlyRepositoryBase) и на запись (WritableRepositoryBase). Классы устанавливаются в проект не как сборка, а как cs-классы, которые можно редактировать. Я обычно создаю папку Infrastructure для своих классов. Таким образом, nuget-пакет установит файлы в папку Infrastructure/Services/Base. После установки этого пакета, я его удаляю упоминание о нем из packages.config, а файлы раскидываю как мне нужно по проекта. Чтобы при обновлении nuget-пакетов, они не были установлены заново в проект.

Вы можете реализовать свои репозитории и/или сервисы, если мой вариант по каким-то причинам вас не устроит.

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

CurrentAppSettings - класс настроек (требуется для пакета MvcConfig)

public class CurrentAppSettings : AppSettings {

    /// <summary>
    /// Название компании в отчетных документах и в уведомительных письмах
    /// </summary>
    public string CompanyName { get; set; }

}

Следующий класс AppConfig:

public class AppConfig : ConfigServiceBase<CurrentAppSettings>, IAppConfig {
    public AppConfig(IConfigSerializer serializer, ICacheService cacheService)
        : base(serializer, cacheService) {
    }

    public AppConfig(string configFileName, IConfigSerializer serializer, ICacheService cacheService)
        : base(configFileName, serializer, cacheService) {
    }
}

А также интерфейс IAppConfig:

    public interface IAppConfig {
        CurrentAppSettings Config { get; }
    }

После удаления дублирующих классов (я нечаянно создал классы, которые были установлены вместе с пакетом OperationResult.WebApi) и разрешении namespace'ов мой проект стал успешно собираться. Но при этом мне пришлось немного изменить IContext:

public interface IContext : IDbContext {

    /// <summary>
    /// System logs
    /// </summary>
    IDbSet<LogItem> Logs { get; set; }

    /// <summary>
    /// SEO Friendly Urls
    /// </summary>
    IDbSet<FriendlyUrl> FriendlyUrls { get; set; }

    /// <summary>
    /// Экспонаты музея
    /// </summary>
    IDbSet<EditablePage> EditablePages { get; set; }
}

Я добавил в базу данных возможность сохранения LogItem (строка 6). И, соответственно, это свойтство пришлось добавить в ApplicationDbContext.

Контролер для EditablePage

Мой контролер я назвал SiteController. Для начала он выглядит таким образом:

/// <summary>
/// Controler to process EditablePage entity
/// </summary>
public class SiteController : Controller {

    private readonly EditablePageRepository _editablePageRepository;
    private readonly IAppConfig _appConfig;

    public SiteController(EditablePageRepository editablePageRepository, IAppConfig appConfig) {
        _editablePageRepository = editablePageRepository;
        _appConfig = appConfig;
    }
}

Я использую Autofac Dependency Container, чтобы разрешать зависимости. Поэтому "вливаю" EditablePageRepository как зависимость в конструктор контролера. Создадим метод, который будет "брать" из базы данных виртуальную страницу и "показывать" ее в специальном представлении (view):

public ActionResult View(int id) {
    var operationResult = _editablePageRepository.GetById(id);
    if (operationResult.Ok) {
        return View("ViewEditablePage", operationResult.Result);
    }
    return View("404");
}

Теперь View для просмотра EditablePage​, я создам в папке Views/Shared:

@model FriendlyUrlForMvc.Models.EditablePage
@{
    ViewBag.Title = Model.Title;
}

@section header
{
    <meta name="description" content="@Model.Description">
    <meta name="keywords" content="@Model.Keywords">
}
<div class="row">
    <div class="col-md-12">
        <div class="page-header">
            <h1>
                @ViewBag.Title
                <small>просмотр</small>
            </h1>
        </div>
        @Html.Raw(Model.Content)
    </div>
    @*@if (User.Identity.IsAuthenticated && User.IsInRole("Administrator")) {*@
        <a href="@Url.Action("edit","site", new {id = Model.Id})" class="btn btn-info">Правка</a>
    @*}*@
</div>

Строка 21 и 23 включают кнопки редактирования страницы. Для этого пользователь должен быть аутентифицирован на сайте и иметь роль "Администратор". Реализация авторизации выходит за рамки данной статьи.

Перед тем как шагать дальше, надо позаботиться об инъекциях зависимостей (Dependency Injection). В ASP.NET MVC 5 по умолчанию нет механизма, который бы отвечал за DI, но зато инфраструктура построена таким образом, что можно с легкостью подключить любой DI-контейнер. Я обычно использую Autofac. Послу установки пакета надо подключить контейнер к системе и зарегистрировать зависимости.

protected void Application_Start() {
    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
    DependencyContainer.Initialize();
}

В строке 6 инициализируем DI-контейнер.

Регистрация зависимостей

public static class DependencyContainer {

    public static void Initialize() {
        var builder = new ContainerBuilder();
        builder.RegisterControllers(Assembly.GetExecutingAssembly());

        builder.RegisterModelBinders(Assembly.GetExecutingAssembly());
        builder.RegisterAssemblyTypes(typeof(MvcApplication).Assembly).AsImplementedInterfaces();
        builder.RegisterSource(new ViewRegistrationSource());
        builder.RegisterModule(new AutofacWebTypesModule());
        builder.RegisterFilterProvider();

        builder.RegisterType<ApplicationDbContext>().As<IContext>().InstancePerRequest();

        builder.RegisterType<EditablePageRepository>().AsSelf();
        builder.RegisterType<AppConfig>().As<IAppConfig>().InstancePerRequest();
        builder.RegisterType<CacheService>().As<ICacheService>().InstancePerRequest();

        builder.RegisterType<JsonConfigSerializer>().As<IConfigSerializer>().InstancePerRequest();
        builder.RegisterType<CacheService>().As<ICacheService>().InstancePerRequest();


        MapperRegistration.Register(builder);

        var logger = LogManager.GetLogger(typeof(MvcApplication));
        builder.Register(c => logger).As<ILog>().SingleInstance();

        builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
            .Where(x => x.Name.EndsWith("Processor"))
            .AsImplementedInterfaces()
            .InstancePerRequest();

        builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
            .Where(x => x.Name.EndsWith("Service"))
            .AsImplementedInterfaces()
            .InstancePerRequest();

        builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly())
            .Where(x => x.Name.EndsWith("Repository"))
            .AsSelf()
            .InstancePerRequest();

        var container = builder.Build();

        DependencyResolver.SetResolver(new AutofacDependencyResolver(container));
    }
}

Контейнер инициализирован, нужные зависимости в нем зарегистрированы, можно открыть нашу виртуальную страницу.

Виртуальные странци ASP.NET MVC

На данном этапе осталось добавить SEO-ссылку в таблицу FriendlyUrl:

Обратите внимание, на колонку PageId. Где-то в начале статьи, я добавил в таблицу EditablePage запись. SQL сервер присвоил идентификатор этой записи под номером 1. Исходя из того, что у нас есть стандартный контролер (SiteController) и стандартный метод (View), который требует параметр идентификатор виртуальной страницы, то мы можем открыть данную страницу по стандартному URL и по Permalink (FriendlyUrl)

Осталось только подключить возможность редактирования виртуальных страниц.

Редактирование виртуальных страниц

Редактировать страницы можно в страндартном Html-контроле типа input, а можно подключить какой-нибудь WYSIWYG-редактор, который добавит разнообразие инструментов для редактирования html-разметки. В данном примере я буду использовать FroalaEditor.

PM> Install-Package FroalaEditor


Attempting to gather dependency information for package 'FroalaEditor.2.3.5' with respect to project 'FriendlyUrlForMvc.Web', targeting '.NETFramework,Version=v4.6.2'
Gathering dependency information took 1,7 sec
Attempting to resolve dependencies for package 'FroalaEditor.2.3.5' with DependencyBehavior 'Lowest'
Resolving dependency information took 0 ms
Resolving actions to install package 'FroalaEditor.2.3.5'
Resolved actions to install package 'FroalaEditor.2.3.5'
Retrieving package 'FroalaEditor 2.3.5' from 'nuget.org'.
Retrieving package 'FontAwesome 4.6.3' from 'nuget.org'.
Adding package 'FontAwesome.4.6.3' to folder 'C:\Projects\_Temp\FriendlyUrlForMvc\packages'
Added package 'FontAwesome.4.6.3' to folder 'C:\Projects\_Temp\FriendlyUrlForMvc\packages'
Added package 'FontAwesome.4.6.3' to 'packages.config'
Executing script file 'C:\Projects\_Temp\FriendlyUrlForMvc\packages\FontAwesome.4.6.3\tools\install.ps1'
Successfully installed 'FontAwesome 4.6.3' to FriendlyUrlForMvc.Web
Adding package 'FroalaEditor.2.3.5' to folder 'C:\Projects\_Temp\FriendlyUrlForMvc\packages'
Added package 'FroalaEditor.2.3.5' to folder 'C:\Projects\_Temp\FriendlyUrlForMvc\packages'
Added package 'FroalaEditor.2.3.5' to 'packages.config'
Successfully installed 'FroalaEditor 2.3.5' to FriendlyUrlForMvc.Web
Executing nuget actions took 4,77 sec
Time Elapsed: 00:00:07.1754332
PM> 

После установки nuget-пакета осталось настроить минификацию в файле BundleConfig:

public class BundleConfig {
    // For more information on bundling, visit http://go.microsoft.com/fwlink/?LinkId=301862
    public static void RegisterBundles(BundleCollection bundles) {
        bundles.Add(new ScriptBundle("~/bundles/jquery").Include(
                    "~/Scripts/jquery-{version}.js"));

        bundles.Add(new ScriptBundle("~/bundles/jqueryval").Include(
                    "~/Scripts/jquery.validate*"));

        // Use the development version of Modernizr to develop with and learn from. Then, when you're
        // ready for production, use the build tool at http://modernizr.com to pick only the tests you need.
        bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
                    "~/Scripts/modernizr-*"));

        bundles.Add(new ScriptBundle("~/bundles/bootstrap")
            .Include("~/Scripts/bootstrap.js")
            .Include("~/Scripts/respond.js"));

        bundles.Add(new ScriptBundle("~/bundles/editor")
            .Include("~/Scripts/froala-editor/js/froala_editor.min.js",
                "~/Scripts/froala-editor/js/languages/ru.js")
            .IncludeDirectory("~/Scripts/froala-editor/js/plugins", "*.js"));
        //.IncludeDirectory("~/Scripts/froala-editor/js/plugins", "*.min.js"));

        bundles.Add(new StyleBundle("~/Content/css")
            .Include("~/Content/bootstrap.css")
            .Include("~/Content/font-awesome.css")
            .Include("~/Content/site.css"));

        bundles.Add(new StyleBundle("~/Content/editor").Include(
                    "~/Scripts/froala-editor/css/froala_editor.min.css",
                    "~/Scripts/froala-editor/css/plugins/*.css",
                    "~/Scripts/froala-editor/css/froala_style.css"));

        BundleTable.EnableOptimizations = true;
    }
}

В строке 19 создается bundle JavaScript-файлов для редактора, а в строке 30 css-файлов, соответственно.

Осталось теперь создать методы для редактирования в контролере SiteController и представление (View) для этих методов:

public ActionResult Edit(int id) {
    var operationResult = _editablePageRepository.GetEditById(id);
    if (operationResult.Ok) {
        return View("EditEditablePage", operationResult.Result);
    }
    return View("404");
}
[HttpPost]
[ValidateInput(false)]
public ActionResult Edit(EditablePageUpdateViewModel page) {
    if (ModelState.IsValid) {
        var updateResult = _editablePageRepository.Update(page);
        if (updateResult.Ok) {
            var friendlyPage = FriendlyUrlProvider.Default.GetPageByFriendlyId(page.Id);
            if (friendlyPage == null) {
                return View("ViewEditablePage", updateResult.Result);
            }
            return RedirectPermanent(string.Concat("/", friendlyPage.Permalink));
        }
    }
    ModelState.AddModelError("", "Page model is not valid");
    return View("EditEditablePage", page);
}

И представление:

@model FriendlyUrlForMvc.Web.Models.EditablePageUpdateViewModel
@{
    ViewBag.Title = Model.Title;
}

@section header
{
    <meta name="description" content="@Model.Description">
    <meta name="keywords" content="@Model.Keywords">
    @Styles.Render("~/Content/editor")
}
@if (User.Identity.IsAuthenticated && User.IsInRole("Administrator")) {
    <a href="" class="btn btn-info">Правка</a>
}
<div class="row">
    <div class="col-md-12">
        <div class="page-header">
            <h1>
                @ViewBag.Title
            </h1>
        </div>
        @using (Html.BeginForm()) {
            <div class="form-group">
                @Html.LabelFor(x => Model.Title)
                @Html.TextBoxFor(x => Model.Title, new { @class = "form-control" })
                @Html.ValidationMessageFor(x => Model.Title)
            </div>

            <div class="form-group">
                @Html.LabelFor(x => Model.Description)
                @Html.TextBoxFor(x => Model.Description, new { @class = "form-control" })
                @Html.ValidationMessageFor(x => Model.Description)
            </div>

            <div class="form-group">
                @Html.LabelFor(x => Model.Keywords)
                @Html.TextBoxFor(x => Model.Keywords, new { @class = "form-control" })
                @Html.ValidationMessageFor(x => Model.Keywords)
            </div>

            <div class="form-group">
                @Html.LabelFor(x => Model.Content)
                @Html.TextAreaFor(x => Model.Content, new { @class = "form-control froala-editor" })
                @Html.ValidationMessageFor(x => Model.Content)
            </div>
            <div class="form-group">
                <div data-bind="editor: comments"></div>
            </div>
            <p>
                <input class="btn btn-primary" type="submit" value="Сохранить" />
            </p>
        }
    </div>
</div>

@section scripts
{
    @Scripts.Render("~/bundles/editor")
    <script src="~/Scripts/app.editable.page.js"></script>
}

Наконец-то всё готово! Тестируем. Открываю страницу http://localhost:12323/seo-freindly-virtual-page, нажимаю кнопку "Правка":

Открылась страница редактирования, на которой доступны к изменениям ключевые параметры страницы (контента).

Заключение

Все поставленные задачи решены. Проект лежит в GitHub.