Skip to content

Latest commit

 

History

History
1532 lines (1151 loc) · 104 KB

README.md

File metadata and controls

1532 lines (1151 loc) · 104 KB

Шаблонизатор Razor

В этом задании предстоит внедрить ASP.NET Core MVC с шаблонизатором Razor в существующий новостной проект, расширить его функционал, настроить чтение конфигурации, обработку и логирование ошибок.

0. Исследование приложения

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

Посмотри как эти страницы формируются в Startup.cs. Для правильной адресации используются методы Map и MapWhen, а ответы формируются в методах RenderIndexPage и RenderFullArticlePage.

Полезно понять, как это работает, но детали можно не запоминать — этот код предстоит отрефакторить.

1. Обработка ошибок и логирование

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

1.1. Страница для кода состояния

Для начала открой сайт новостей и попробуй перейти по пути /somepath в рамках сайта. Ты должен увидеть страницу с 404 ошибкой, только не от сайта новостей, а от браузера.

Это неплохо, но правильный подход — не надеяться на браузер пользователя, а показать специальную страницу ошибки. На ней понятно объяснить, что произошла ошибка, а также предложить дальнейшие действия для исправления ситуации. Например, можно предложить пользователю вернуться на главную страницу, или обновить страницу, или обратиться в техподдержку.

В ASP.NET Core для этого достаточно добавить соответствующий middleware для редиректа пользователя по адресу, на котором находится специальная страница с описанием ошибки:

app.UseStatusCodePagesWithReExecute("/StatusCode/{0}");

А также разместить по этому адресу страницу. Страница пока подождет, а прямо сейчас добавь UseStatusCodePagesWithReExecute сразу после UseStaticFiles и перед первым вызовом app.Map.

В перспективе хочется перевести весь сайт новостей на ASP.NET Core MVC, поэтому стоит подключить MVC уже сейчас и разрабатывать весь новый функционал с его использованием.

Для этого куда-нибудь в конфигурацию контейнера, т.е. в метод ConfigureServices, надо добавить контроллеры с представлениями:

services.AddControllersWithViews();

А в конфигурацию последовательности обработки запроса добавить вот такие middleware после подключения всех остальных:

app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("status-code", "StatusCode/{code?}", new
    {
        controller = "Errors",
        action = "StatusCode"
    });
});

Они подключают обработку путей с помощью MVC и конкретно в данной конфигурации написано очень четко, что ровно один тип путей должен обрабатываться методом StatusCode контроллера ErrorsController. Причем этому методу будет передаваться параметр code.

Дополнительные пояснения про UseRouting и UseEndpoints

UseRouting отмечает тот момент обработки запроса, когда запросу сопоставляется его обработчик в MVC. Например, сопоставляется конкретный метод контроллера, который будет его обрабатывать. Этот обработчик в MVC называется endpoint.

UseEndpoints отмечает тот момент обработки запроса, когда вызывается сопоставленный запросу endpoint. Не должно смущать, что конфигурируются endpoints при вызове UseEndpoints, хотя сопоставление происходит в UseRouting. На самом деле эта конфигурация становится общей и при обработке запроса используется и в UseRouting middleware, и в UseEndpoints middleware. А само разделение функционала на два middleware было сделано для того, чтобы в момент, когда уже известно кто будет обрабатывать запрос, но обработка еще не произошла, можно было встроить еще какой-нибудь middleware. Например, промежуточный слой для авторизации, который проверит права пользователя и, возможно, отменит обработку:

app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => { ... });

Итак, осталось немного. Добавь контроллер ErrorsController с методом StatusCode. Для этого создай папку Controllers и создай в ней файл ErrorsController.cs с таким содержимым:

using Microsoft.AspNetCore.Mvc;

namespace BadNews.Controllers
{
    public class ErrorsController : Controller
    {
        public IActionResult StatusCode(int? code)
        {
            return View(code);
        }
    }
}

Обрати внимание, что в качестве модели в представление передается код ошибки, ведь иначе предсталение не сможет этот код ошибки отобразить.

Также добавь представление, которое и сформирует страницу.

Раз контроллер называется Errors, а метод StatusCode, то для него MVC будет искать представление по пути /Views/Errors/StatusCode.cshtml, либо /Views/Shared/StatusCode.cshtml.

Пояснение: В папке /Views/Shared можно хранить представления, которые используются в нескольких контроллерах.

Создай файл /Views/Errors/StatusCode.cshtml, а содержимое для него возьми в заготовке /$Content/Drafts/StatusCode.cshtml.

Снова попробуй перейти по пути /somepath в рамках сайта. Теперь должна отобразиться красивая страница с кодом ошибки.

1.2. Страница для исключений

Следующий тип ошибок, который должен корректно обрабатываться — непредвиденные, исключительные ошибки. В смысле, исключения.

Открой сайт новостей и перейди на любую новость. В адресной строке ты увидешь путь вида /news/fullarticle/5ab19137-3e28-4eca-bd19-3185ebeba0c6. В конце пути идет идентификатор новости. Поменяй в нем что-нибудь, можно даже так: /news/fullarticle/123. В результате ты увидишь страницу, которая рассказывает о том, что произошло исключение, и всякие подробности. Эта страница генерируется с помощью промежуточного слоя UseDeveloperExceptionPage.

Это очень удобно для разработки. А вот сайт в продакшене не должен показывать всем подряд столько информации, ведь это может упростить атаку на ваше приложение. Пользователю надо показывать менее информативную страницу.

Действия аналогичны добавлению страницы для кода статуса:

  1. Закомментируй строчку app.UseDeveloperExceptionPage(); и после нее добавь app.UseExceptionHandler("/Errors/Exception");.

  2. Добавь метод Exception в ErrorsController, аналогичный StatusCode, но без параметров. Внутри вызови метод View вот так: View(null, HttpContext.TraceIdentifier). В качестве viewName передается null, а значит будет искаться представление, соответствующее имени метода. В качестве model здесь передается HttpContext.TraceIdentifier, как и задумано. Вариант View(HttpContext.TraceIdentifier) здесь работать не будет. Подумай почему. Подсказка: обрати внимание на тип TraceIdentifier.

  3. Добавь новое представление Exception.cshtml в папку /Views/Errors. Содержимое для него возьми в заготовке /$Content/Drafts/Exception.cshtml.

  4. Добавь еще одну строчку конфигурации в UseEndpoints, на этот раз более универсальную, подходящую для разных контроллеров и методов в них: endpoints.MapControllerRoute("default", "{controller}/{action}"); Теперь должно быть две конфигурации: status-code и default.

Убедись, что после проделанных действий по пути /news/fullarticle/123 открывается заготовленная страница.

Ответ на вопрос из пункта 2. Если метод View вызывается с единственным параметром типа string, то будет использоваться перегрузка метода, которая считает этот единственный параметр именем представления, которое надо использовать. Нам же надо использовать представление по-умолчанию, а строку передать в качестве модели. Вот и приходится использовать другую перегрузку с большим числом параметров.

Все же это довольно удобно, когда при разработке выводится подробная информация об исключениях. Информацию о том, в какой среде выполняется приложение ASP.NET Core, можно получить с помощью объекта IWebHostEnvironment. Поэтому правильная настройка страницы для исключений выглядит так:

if (env.IsDevelopment())
    app.UseDeveloperExceptionPage();
else
    app.UseExceptionHandler("/Errors/Exception");

Используй ее и перезапусти приложение.

Снова перейди по пути /news/fullarticle/123. Ты должен снова увидеть страницу с подробной информацией об исключении.

Как же теперь проверить, что в production все будет правильно работать? Надо задать окружение вручную! В ASP.NET Core среда задается переменной окружения ASPNETCORE_ENVIRONMENT. В проекте переменные окружения можно задать в файле /Properties/launchSettings.json. Причем значения переменных, заданные в файле launchSettings.json, переопределяют значения, заданные в системе. Таким образом, чтобы посмотреть, как все будет выглядеть в production, найди файл /Properties/launchSettings.json и задай для ASPNETCORE_ENVIRONMENT значение Production.

Перезапусти приложение и проверь, что для пути /news/fullarticle/123 снова отдается страница с минимумом информации об исключении.

Еще один способ задать среду — задать ее через код. Поменяй параметр вызова ConfigureWebHostDefaults вот так:

ConfigureWebHostDefaults(webBuilder =>
{
    webBuilder.UseStartup<Startup>();
    webBuilder.UseEnvironment(Environments.Development);
})

Перезапусти приложение и проверь, что для пути /news/fullarticle/123 отдается страница с подробной информацией об исключении.

Среда разработки Development подходит, поэтому можно больше ничего не менять. В реальном приложении, конечно, среда должна управляться настройками или переменными окружения, а не жестко задаваться в коде. И для реальных пользователей должен быть Production.

1.3. Логирование

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

Система логирования уже встроена в ASP.NET Core и многие компоненты благодаря этому сразу умеют писать логи. Другой вопрос — куда сообщения от системы логирования попадут. Тут есть простор для выбора, благо встроенная система логирования легко расширяется.

Расширить можно с помощью библиотеки Serilog. Serilog может генерировать сообщения в обычном формате человекочитаемых строчек, но также поддерживает так называемое «структурное логирование», т.е. может генерировать сообщения в виде JSON. Сообщения в виде JSON легче обрабатывать автоматически, а еще можно сразу отправлять в сервис централизованного сбора и хранения логов. Писать Serilog умеет и в консоль, и в файлы, и в удаленные сервисы сбора логов, а еще можно написать своего «потребителя логов», в который Serilog будет писать.

Дополнительные пояснения про структурное логирование

Структурное логирование и централизованное хранение логов в специальном сервисе — это современный тренд. Это позволяет разработчикам в одном месте искать логи последних событий при разборе обращений от пользователей, либо других проблем в сервисе. Но для того, чтобы работал поиск, надо ключевые значения из сообщения логов вычленить. Чтобы сервису сбора логов не приходилось этого делать, лучше сразу логировать сообщения в виде JSON, в полях которого записывать необходимые ключевые значения.

Пришло время добавить логирование.

  1. NuGet-пакет Serilog уже подключен к проекту — ура!

  2. Подключи Serilog к хосту, чтобы использовался он, а не реализация логирования от Microsoft. Для этого обнови метод CreateHostBuilder в Program.cs:

public static IHostBuilder CreateHostBuilder(string[] args)
{
    return Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            ...
        })
        .UseSerilog((hostingContext, loggerConfiguration) =>
            loggerConfiguration.ReadFrom.Configuration(hostingContext.Configuration));
}

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

  1. Положи настройки логирования в файл appsettings.json, раз уж настроил, что они берутся из IConfiguration. Настройки обычного логирования от Microsoft при этом можно удалить. В итоге должно быть так:
{
  "Serilog": {
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": ".logs/log-.txt",
          "formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact",
          "rollingInterval": "Day",
          "rollOnFileSizeLimit": true
        }
      },
      {
        "Name": "Console",
        "Args": {
          "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
        }
      }
    ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "System": "Warning"
      }
    }
  },
  "AllowedHosts": "*"
}
  1. Обрати внимание на конфигурацию. В ней настроен rollingInterval и rollOnFileSizeLimit. В реальных приложениях за день могут накопиться сотни мегабайт логов, если не больше. Крупными файлами неудобно манипулировать, поэтому лучше логи разбивать по датам, а еще по размеру, если почему-то логов за сутки слишком много. Добавление этих настроек обеспечивает разделение на файлы.

  2. Перейди по пути /news/fullarticle/123, чтобы получить исключение. Убедись, что оно было залогировано в файле /.logs/log-{current-date} в виде JSON.

Замечание. На момент 2021.02.12 использование UseSerilog ломает логику автоматического запуска браузера в VS Code и, возможно, в других IDE, потому что эта логика ориентируется на формат сообщений из логов: Подробнее в этом и связанных issue.

Кроме ошибок полезно логировать вообще все запросы. Как минимум, это помогает понять последовательноть действий пользователя, которые к ошибке привели. С Serilog это элементарно — достаточно добавить промежуточный слой!

Добавь строчку app.UseSerilogRequestLogging(); сразу после промежуточного слоя статических файлов, т.к. информация об обращении к статике не особо интересна.

Сделай несколько запросов и убедись, что они залогировались в виде JSON в файлах, а также в читабельном виде (а не в виде JSON) попали в отладочную консоль. В консоли сообщения должны выглядеть примерно так: [14:12:26 INF] HTTP GET / responded 200 in 58.2818 ms {"SourceContext": "Serilog.AspNetCore.RequestLoggingMiddleware"}

Еще одна нередкая ситуация — это когда конфигурация приложения оказалась некорректной по той или иной причине. Чтобы просимулировать эту ситуацию, полностью удали содержимое файла appsettings.Development.json, ведь там осталась только ненужная конфигурация логирования от Microsoft, которую не жалко. А вот сам файл оставь.

Теперь попробуй запустить приложение.

Вообще-то оно должно сразу упасть. А что же в логах? Ничего? Относительно редкая ситуация, когда приложение даже не может стартануть, но все равно было бы неплохо зафиксировать что-то в логах. А значит логирование надо сконфигурировать хотя бы минимальным образом еще до чтения конфигурации.

Поменяй содержимое метода Main в Program.cs:

public static void Main(string[] args)
{
    InitializeDataBase();

    Log.Logger = new LoggerConfiguration()
        .WriteTo.File(".logs/start-host-log-.txt",
            rollingInterval: RollingInterval.Day,
            rollOnFileSizeLimit: true,
            outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
        .CreateLogger();

    try
    {
        Log.Information("Creating web host builder");
        var hostBuilder = CreateHostBuilder(args);
        Log.Information("Building web host");
        var host = hostBuilder.Build();
        Log.Information("Running web host");
        host.Run();
    }
    catch (Exception ex)
    {
        Log.Fatal(ex, "Host terminated unexpectedly");
    }
    finally
    {
        Log.CloseAndFlush();
    }
}

Теперь будет залогировано любое необработанное исключение, из-за которого упадет весь хост. Но важно, что здесь до построения хоста добавляется логирование в .logs/start-host-log-{current-date}.txt. Благодаря этому, если до обычного конфигурирования логирования произойдет исключение, оно будет залогировано в start-host-log. При этом после успешной конфигурации логирования по IConfiguration, Logger будет заменен на сконфигурированный по IConfiguration.

Снова попробуй запустить приложение. После падения убедись, что появился файл .logs/start-host-log-{current-date}.txt, в котором залогировано исключение.

Все же придется написать в appsettings.Development.json пустой объект {}, чтобы приложение перестало падать.

Базовый уровень логирования обеспечен. Но бывает нужно залогировать что-то дополнительно. Добавь дополнительное логирование в метод StatusCode контроллера ErrorsController. Для этого придется добавить конструктор и принять из контейнера ILogger<>, а затем уже залогировать сообщение:

private readonly ILogger<ErrorsController> logger;

public ErrorsController(ILogger<ErrorsController> logger)
{
    this.logger = logger;
}

public IActionResult StatusCode(int? code)
{
    logger.LogWarning("status-code {code} at {time}", code, DateTime.Now);
    return View(code);
}

...

Перейди по пути /somepath, дождись появления страницы с кодом 404. Убедись, что в лог попало дополнительное сообщение про status-code.

2. Рефакторинг новостей

Пришло время перевести страницы новостей на MVC.

2.1. Новый список новостей

Для начала надо сделать так, чтобы по пути / отображался список новостей на MVC, а по пути /news — старый. Это позволит их сравнивать.

Прежде всего закомментируй app.MapWhen в методе настройки промежуточных слоев, чтобы запросы по пути / доходили до MVC. А конфигурацию default в UseEndpoints поменяй на такую:

endpoints.MapControllerRoute("default", "{controller=News}/{action=Index}");

В такой конвеции заданы значения по умолчанию для контроллера и экшена, поэтому для пути / управление будет передаваться в метод Index контроллера News.

Следующий шаг — создать контроллер NewsController с методом Index в папке /Controllers. Используй вот эту «болванку» для начала:

using BadNews.ModelBuilders.News;
using Microsoft.AspNetCore.Mvc;

namespace BadNews.Controllers
{
    public class NewsController : Controller
    {
        private readonly INewsModelBuilder newsModelBuilder;

        public NewsController(INewsModelBuilder newsModelBuilder)
        {
            this.newsModelBuilder = newsModelBuilder;
        }

        public IActionResult Index()
        {
            return View();
        }
    }
}

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

Теперь надо добавить представление. Оно должно оказаться в папке /Views/News/Index.cshtml. Заполни этот файл из шаблона, который используется для отображения списка новостей сейчас. Другими словами, скопируй в него текст из файла /$Content/Templates/Index.hbs. Это станет хорошей основой для представления, но код с формата Handlebars на формат Razor еще только предстоит переписать.

Перейди по пути / и убедись, что какая-то «кривая» страничка с текстом {{articles}} отображается. Значит в первом приближении контроллер работает.

Найди в Startup.cs метод RenderIndexPage и разберись как он работает. Твоя задача, в целом, — повторить то, что он делает, только средствами MVC.

Теперь выполни конкретные шаги:

  1. В методе Index контроллера NewsController добавь параметр int pageIndex = 0 со значением по-умолчанию, чтобы получить значение из query string.
  2. В методе Index используй newsModelBuilder.BuildIndexModel, чтобы построить модель страницы. Параметры у BuildIndexModel в контроллере аналогичны параметрам BuildIndexModel в RenderIndexPage. Передай построенную модель во View.
  3. В представлении Index.cshtml добавь первой строкой директиву, описывающую тип модели @model BadNews.Models.News.IndexModel.
  4. Место {{articles}} в представлении используется для того, чтобы отображать список новостей. При этом используется шаблон /$Content/Templates/NewsArticle.hbs. Вставь текст из этого файла вместо {{articles}}, а затем оберни скопированный текст в цикл:
@foreach (var article in Model.PageArticles)
{
    /* шаблон новости */
}
  1. Запусти приложение и открой страницы: убедись, что отображается 5 шаблонов новостей и нет ошибок.

В этот момент правки над контроллером закончены, а вот представление придется править много раз. Чтобы не перезапускать каждый раз приложение можно добавить функционал, который будет без остановки сервиса перестраивать и обновлять представления, если их код поменялся. Конечно, делать это следует только в разработке. Подключи этот функционал, заменив в ConfigureServices строчку services.AddControllersWithViews(); на

var mvcBuilder = services.AddControllersWithViews();
if (env.IsDevelopment())
    mvcBuilder.AddRazorRuntimeCompilation();

Теперь после редактирования представления достаточно обновить страницу браузера, чтобы увидеть изменения.

Далее нужно аккуратно заполнить шаблон новости по модели. Это означает, что надо заменить все {{какое-то-имя}} в шаблоне на значения из объекта article. В качестве подсказки используй метод BuildIndexPageHtml в Startup.cs.

Вот какие при этом случатся нюансы:

  • Чтобы отрендерить значение в Razor, перед ним надо поставить знак @:
@someValue
  • Чтобы использовать переменную culture, определи ее в начале представления такими строчками:
@using System.Globalization
@{
    var culture = CultureInfo.CreateSpecificCulture("ru-ru");
}
  • Когда захочется заменить {{url}}, то чтобы не запутаться в кавычках и скобочках, стоит заменить {{url}} на @url, а переменную url определить в первой строке тела цикла. Razor достаточно умен, чтобы понять, что это не тег, а значит это код на C#.
  • Чтобы использовать HttpUtility добавь в начале представления строчку:
@using System.Web

Теперь список новостей должен выглядеть корректно, а ссылки «Читать полностью» должны работать.

Осталось сделать корректный переход по страницам. Для этого надо вычислить адреса для кнопок перехода по страницам. Сделать это можно прямо в представлении, даже прямо в теге <nav>:

<nav class="news-pagination">
    @{
        var newerUrl = !Model.IsFirst
            ? $"/news?pageIndex={HttpUtility.UrlEncode((Model.PageIndex - 1).ToString())}"
            : "";
        var olderUrl = !Model.IsLast
            ? $"/news?pageIndex={HttpUtility.UrlEncode((Model.PageIndex + 1).ToString())}"
            : "";
    }

    <!-- разметка ссылок -->
</nav>

Определи newerUrl и olderUrl как показано и используй эти значения вместо {{newerUrl}} и {{olderUrl}}. И проверь, что переходы по страницам работают.

Итак, страница списка новостей переделана и можно ее использовать и для адреса /news. При этом важно не сломать страницу новости, а значит Map надо переделать. Добавь вот такой Map, а ВСЕ остальные удали:

app.Map("/news/fullarticle", fullArticleApp =>
{
    fullArticleApp.Run(RenderFullArticlePage);
});

Проверь, что сайт продолжает правильно работать!

2.2. Новая страница новости

Пришло время переделать страницу новости.

Алгоритм переделки повторяется:

  1. Надо создать метод в контроллере для пути /news/fullarticle
  2. Добавить в метод необходимый параметр Guid id, т.е. идентификатор новости
  3. В методе использовать newsModelBuilder для построение модели аналогично тому, как это сейчас происходит в методе RenderFullArticlePage в Startup.cs
  4. Создать представление на основании /$Content/Templates/FullArticle.hbs
  5. Добавить в представление модель с помощью директивы @model
  6. Заменить все {{какое-то-имя}} в представлении на значения из модели
  7. Удалить Map, который отвечает за отображение новости по-старому

Но есть несколько нюансов:

  • Надо поправить конфигурацию путей default на вот такую, чтобы определить параметр id:
endpoints.MapControllerRoute("default", "{controller=News}/{action=Index}/{id?}");
  • Свойство ContentHtml в модели содержит HTML, поэтому его нельзя рендерить с помощью @. Вместо этого надо использовать хелпер: @Html.Raw(content) (название можно перевести как «сырой html»). Этот хелпер не применяет html-кодирование к принимаемой строке.
  • Дату из свойство Date надо правильно отформатировать, как в методе BuildFullArticlePageHtml

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

if (model == null)
    return NotFound();

Это все инструкции по переделке страницы новостей. Можно переделывать!

После успешной переделки можно удалить не нужные больше методы формирования страниц новостей из Startup.cs. Удали методы RenderIndexPage, BuildIndexPageHtml, RenderFullArticlePage, BuildFullArticlePageHtml, а также поле culture. После этого в Startup.cs можно удалить не нужные больше using-и.

2.3. Выделение общего кода

Сейчас в нескольких страницах используется одна и та же шапка страницы, подключаются одни и те же скрипты. Есть общий код, который хотелось бы выделить отдельно. Для этого в MVC предусмотрены Layout'ы (макеты).

Чтобы выделить общий код представлений Index и FullArticle, создай в папке /Views/News файл _Layout.cshtml. В него скопируй содержимое любой из двух страниц. Удали директивы, начинающиеся с @ из начала. А затем замени то, что находится между <!-- Body --> и <!--/ Body -->, на вызов @RenderBody(). Именно в RenderBody отрисуется представление, для которого будет установлен макет.

В обоих представлениях Index и FullArticle наоборот вырежи все вне <!-- Body --> и <!--/ Body -->, но оставь директивы, начинающиеся с @ в начале. А еще добавь в начало подключение макета:

@{
    Layout = "_Layout";
}

Замечание. Содержимое нескольких @{ ... }, идущих подряд можно объединять — это ни на что не повлияет.

Проверь, что страницы новостей, несмотря на манипуляции, выглядят как раньше.

Писать в каждом представлении имя макета, если оно для всех страниц одинаковое, тоже не хочется. И в MVC есть возможность этого не делать — файл _ViewStart.cshtml. Код из _ViewStart.cshtml будет выполняться в начале выполнения любого из view в папке с этим _ViewStart.cshtml.

Создай в папке /Views/News файл _ViewStart.cshtml и добавь в него следующее содержимое:

@using System.Globalization
@{
    ViewBag.Culture = CultureInfo.CreateSpecificCulture("ru-ru");
    Layout = "_Layout";
}

Соответственно, аналогичный код можно удалить из Index.cshtml и FullArticle.cshtml.

Нюанс здесь в том, что локальные переменные не передаются между отдельными view. А вот специальный объект ViewBag передается между контроллером и всеми view. Поэтому в него можно положить Culture, а затем достать в Index.cshtml и FullArticle.cshtml.

Используй ViewBag.Culture вместо culture в Index.cshtml и FullArticle.cshtml.

Замечание. Примерно теми же свойствами, что и ViewBag, обладает словарь ViewData. Им тоже можно было воспользоваться.

Проверь, что страницы новостей снова выглядят как раньше.

Следующий шаг — задать макет для всего сайта. Для этого надо перенести _ViewStart.cshtml в папку Views. Тогда код из нее будет выполняться для всех view, в том числе в папке /Views/Errors. А вот _Layout.cshtml надо перенести в папку /Views/Shared, потому что представления из этой папки доступны во всех других папках, т.е. и для представлений из папки /Views/News, и из папки /Views/Errors. Убедись, что после переносов, на страницах новостей ничего не поменялось.

А вот для страниц ошибок — поменялось. Перейди по пути /somepath и убедись, что у страницы появился заголовок. Это ненужный побочный эффект и надо его убрать. Для этого в начало StatusCode.cshtml добавь код, убирающий макет для страницы:

@{
    Layout = null;
}

На странице исключений для Production должно было бы произойти задвоение заголовка страницы, ведь он там уже был. Но сейчас это сложно проверить: известная ситуация, в которой кидается исключение уже починена. Поэтому просто вырежи код вне <!-- Body --> и <!--/ Body --> в представлении Exception, но оставь директиву @model. Конечно, стоило бы проверить, что страница исключений продолжает работать как надо, но не проверяй в этот раз: к пользователям это не пойдет, а время можно потратить на следующее задание.

2.4. Секции

На странице списка новостей надо добавить «важные новости». Разметка для такой «важной новости» находится в файле /$Content/Drafts/FeaturedArticles.html. Сами новости должны располагаться вверху страницы. Это место было помечено <!-- Header /-->, а метка должна была сохраниться в _Layout.cshtml.

И вроде бы все, что надо сделать — пробежаться по массиву Model.FeaturedArticles с помощью @foreach, ведь newsModelBuilder.BuildIndexModel корректно заполняет этот массив при передаче соответствующего параметра... Но нюанс в том, что этот @foreach должен вызываться в _Layout.cshtml, где модель конкретной страницы недоступна.

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

Что нужно сделать конкретно:

  1. При вызове newsModelBuilder.BuildIndexModel в NewsController вторым параметром передавай true
  2. Замени в _Layout.cshtml метку <!-- Header /--> на @RenderSection("Header", false)
  3. Добавь в Index.cshtml в начало или в конец следующий код:
@section Header
{    
}
  1. Добавь внутрь разметку из /$Content/Drafts/FeaturedArticles.html
  2. Сделай так, чтобы внутри показывались новости из Model.FeaturedArticles

2.5. Хелперы для ссылок

Сейчас, когда все страницы отображаются с помощью контроллеров, можно изменить способ генерации адресов в ссылках. Т.е. вместо явного задания href использовать tag-хелпер для тега <a>.

Чтобы пользоваться tag-хелперам, их необходимо подключить. Чтобы подключить сразу для всех страниц, добавь в папку /Views файл _ViewImports.cshtml с таким содержимым:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

_ViewImports.cshtml — специальный тип файлов, который позволяет добавлять директивы @using и @addTagHelper для всех представлений в папке, тогда как обычно эти директивы действуют в рамках одного файла.

Теперь можно заменять обычные ссылки на tag-хелперы. Сделай это для всех ссылок в Index.cshtml, как описано далее.

Сначала сделай это для ссылок на страницу новости, т.е. вместо href пропиши следующие атрибуты:

  • asp-controller с именем контроллера, но без суффикса Controller
  • asp-action с именем метода
  • asp-route-id с идентификатором новости (через атрибуты с префиксом asp-route- передаются параметры метода)

Убедись, что ссылки работают.

Далее сделай это для ссылок «Новее» и «Старше», т.е. вместо href пропиши следующие атрибуты:

  • asp-controller с именем контроллера, но без суффикса Controller
  • asp-route-pageIndex с индексом страницы

Подсказка: если хочется, чтобы Razor вычислил выражение и напечатал результат надо использовать круглые скобки после @: @(value + 5)

А, чтобы пользователь не кликал лишний раз, когда уже находится на первой или последней странице, при условии Model.IsFirst показывай заблокированную версию ссылки «Новее», а при условии Model.IsLast — заблокированную версию ссылки «Старше». Эти варианты ссылок уже есть на странице в комментариях.

Подсказка: используй @if { ... } else { ... }.

3. Форма создания новостей

Теперь пришло время добавить возможность добавления новых новостей.

3.1. Отправка данных формы

Для этого нужно создать страницу с формой, в которой можно будет задать все поля новости и сохранить результат. Страница должна открываться по адресу /editor, ссылок на сайте на нее не будет.

Вот заготовочка для контроллера:

using System;
using BadNews.Models.Editor;
using BadNews.Repositories.News;
using Microsoft.AspNetCore.Mvc;

namespace BadNews.Controllers
{
    public class EditorController : Controller
    {
        private readonly INewsRepository newsRepository;

        public EditorController(INewsRepository newsRepository)
        {
            this.newsRepository = newsRepository;
        }

        public IActionResult Index()
        {
            return View(new IndexViewModel());
        }
    }
}

Сделай с ее помощью контроллер.

Модель для создания новости уже добавлена в проект по пути /Models/Editor/IndexModel.cs. В ней с помощью атрибутов сразу прописаны правила валидации. Посмотри, что она из себя представляет.

В /$Content/Drafts/Editor.html находится разметка для страницы добавления новости. Сделай из нее view. Обязательно добавь в получившийся view модель с помощью директивы @model.

Приготовления закончены. Зайди на страницу /editor и убедись, что открылась форма для добавления новости.

Теперь надо сделать так, чтобы данные формы отправлялись на сервер.

Для начала добавь в контроллер заглушку метода, на который форма будет отправлять данные:

[HttpPost]
public IActionResult CreateArticle([FromForm] IndexViewModel model)
{
    return View("Index", model);
}

При отправке формы, данные отправляются в теле запроса в специальном формате application/x-www-form-urlencoded. Атрибут [FromForm] позволяет получить из тела эти данные в виде объекта.

Обычно, чтобы форма что-то куда-то отправляла ей надо задать атрибуты action и method. Но благодаря tag-хелперу <form>, можно сделать это иначе. Добавь в тег <form> два атрибута:

<form asp-controller="Editor" asp-action="CreateArticle">

и форма будет отправляться по кнопке «Отправить».

Но для заполнения модели этого недостаточно, ведь неизвестно каким полям модели соответствуют текстовые поля формы. Здесь опять помогут tag-хелперы. В первом текстовом поле (это input) добавь атрибут asp-for со значением Header. Выглядеть должно так <input asp-for="Header" type="text" size="80">. В двух других (это textarea) также добавь атрибут asp-for, но уже со значениями Teaser и ContentHtml.

Теперь проверь форму. Для этого в каждое из текстовых полей добавь как-то текст и нажми отправить. В результате страница должна перезагрузиться, ведь метод CreateArticle написан так, что вновь показывает страницу с формой. Но при этом текст в полях должен сохраниться, т.к. данные из формы преобразуются в модель ([FromForm] IndexViewModel model), а затем по модели строится страница с формой (return View("Index", model)).

Данные формы в контроллер приходят, значит осталось их просто корректно обработать.

В IndexModel с помощью атрибутов заданы правила проверки. Проверка модели по этим атрибутам будет происходить до передачи модели в метод контроллера, а все ошибки окажутся в свойстве ModelState контроллера. Проверить, есть ли ошибки, можно с помощью свойства ModelState.IsValid.

Если ошибки есть, то автору надо вернуть статью на доработку вот так:

if (!ModelState.IsValid)
    return View("Index", model);

Если ошибок нет, то надо создать новый NewsArticle по данным формы, сохранить его и отправить автора на страницу только что добавленной новости:

var id = newsRepository.CreateArticle(new NewsArticle {
    Date = DateTime.Now.Date,
    Header = model.Header,
    Teaser = model.Teaser,
    ContentHtml = model.ContentHtml,
});

return RedirectToAction("FullArticle", "News", new {
    id = id
});

Добавь этот код и убедись, что новость с непустым заголовком создается, а страница с ней показывается.

3.2. Валидация данных формы

Форма работает, но есть проблема. Если данные формы заполнены некорректно: не задан заголовок статьи, либо в тизере или в содержимом статьи реально без обмана используется действительно одно из стоп-слов, то автору не показываются сообщения об этих ошибках. А должны бы.

Чтобы сообщения об ошибках добавлялись в представление, достаточно использовать tag-хелпер asp-validation-for. Таким атрибутом нужно отметить некоторый тег, а в качестве значения указать имя поля модели. После этого все сообщения об ошибках, связанные с этим полем, будут добавляться к помеченному тегу.

В разметке страницы с формой для отображения ошибок предусмотрены такие теги: <span class="text-danger"></span>. Пометь их атрибутом asp-validation-for с одним из значений Header, Teaser и ContentHtml.

Теперь убедись, что если попытаться создать новость с пустым заголовком, словом «действительно» в тизере и словом «реально» в статье, то с сервера вернется 3 сообщения об ошибке и каждое отобразится в своем месте.

Но хочется большего. Хочется, чтобы сообщения об ошибках появлялись не после отправки формы, а сразу после редактирования любого из полей формы. Другими словами, проверки должны работать в браузере, без участия сервера.

Для этого потребуются некоторые библиотеки на JavaScript:

  • jquery.validate — стандартная библиотека валидаций для jQuery
  • jquery.validate.unobtrusive — расширение библиотеки валидаций от Microsoft, чтобы проверять значения форм «unobtrusive», т.е. «ненавязчиво» на стороне браузера

Подключи эти скрипты с помощью отдельного partial view.

  1. Создай файл /Views/Shared/_ValidationScriptsPartial.cshtml и заполни так:
<script src="/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
  1. Подключи partial view в _Layout.cshtml после всех тегов <script> так:
<partial name="_ValidationScriptsPartial" />

После подключения этих скриптов, перейди на страницу добавления новости, введи в заголовке что-нибудь, а затем сотри — появится сообщение об ошибке. Все уже работает, потому что для проверки поля Header используется встроенный атрибут Required. А вот для того, чтобы написанная для этого сайта проверка StopWords тоже начала работать в браузере, надо приложить дополнительные усилия.

Как же добавить свою собственную проверку для модели, чтобы она работала и на сервере и на клиенте? Если делать с нуля, то придется выполнить 5 шагов. В нашем случае надо будет выполнить всего два.

Для начала полный список шагов добавления собственной проверки:

  1. Надо создать на C# атрибут проверки StopWordsAttribute. С его помощью можно отмечать свойства модели, которые должны проверяться этой проверкой. В атрибуте нужно реализовать метод IsValid, который как раз и будет осуществлять проверку на стороне сервера. Посмотри, как это сделано в файле /Validation/StopWordsAttribute.cs.

  2. Надо написать код проверки на JS, аналогичный коду на C#, и подключить скрипт с этим кодом. Посмотри, как это сделано в файле /wwwroot/js/validation.js.

  3. Надо добавить на C# класс StopWordsAttributeAdapter, который будет добавлять к тегам полей формы информацию, необходимую для работы проверки на JS. Посмотри, как это сделано в файле /Validation/StopWordsAttributeAdapter.cs.

  4. Надо добавить класс StopWordsAttributeAdapterProvider, который будет связывать StopWordsAttribute и StopWordsAttributeAdapter. Посмотри, как это сделано в файле /Validation/StopWordsAttributeAdapterProvider.cs.

  5. Зарегистрировать класс StopWordsAttributeAdapterProvider в DI-контейнере в методе ConfigureServices.

А вот что из перечисленного осталось сделать.

Во-первых, добавь регистрацию провайдера в метод ConfigureServices:

services.AddSingleton<IValidationAttributeAdapterProvider, StopWordsAttributeAdapterProvider>();

Во-вторых, подключи скрипт с кодом проверки в _ValidationPartial.cshtml:

<script src="/js/validation.js"></script>

Теперь перейди на страницу добавления новости и напиши слово «действительно» в тизер или текст статьи. После того, как ты переведешь фокус в другое поле, должно появиться сообщение, что нельзя использовать стоп-слова.

Ура! Теперь можно создавать новые новостные статьи, а ошибки в них будут показываться сразу без перезагрузки страницы.

4. Компоненты

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

Компонент — это элемент страницы с собственным методом Invoke или InvokeAsync, который строит модель компонента, а также с собственным представлением, которое эту модель отрисовывает.

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

4.1. Архив новостей

Необходимо добавить новый функционал — архив новостей. Архив новостей должен показывать ссылки на новости по годам, а при переходе по ссылке за конкретный год в списке новостей должны остаться только новости за этот год.

Сделай список ссылок на новости разных лет в виде компонента.

Создай файл /Components/ArchiveLinksViewComponent.cs с таким содержимым:

using BadNews.Repositories.News;
using Microsoft.AspNetCore.Mvc;

namespace BadNews.Components
{
    public class ArchiveLinksViewComponent : ViewComponent
    {
        private readonly INewsRepository newsRepository;

        public ArchiveLinksViewComponent(INewsRepository newsRepository)
        {
            this.newsRepository = newsRepository;
        }

        public IViewComponentResult Invoke()
        {
            var years = newsRepository.GetYearsWithArticles();
            return View(years);
        }
    }
}

Создай представление /Views/Shared/Components/ArchiveLinks/Default.cshtml с содержимым из заготовки /$Content/Drafts/ArchiveLinks.html.

Да, путь до представления именно такой, потому что:

  • Shared намекает, что для всех контроллеров компонент будет выглядеть одинаково
  • Components/ArchiveLinks — без комментариев
  • Default.cshtml задает представление по-умолчанию, ведь у компонента их может быть несколько

Добавь в тег <aside> в представлениях /Views/News/Index.cshtml и /Views/News/FullArticle.cshtml подключение компонента:

<vc:archive-links></vc:archive-links>

Такое подключение компонента использует стиль tag-хелперов, поэтому добавь такую строчку в _ViewImports.cshtml:

@addTagHelper *, BadNews

Она обеспечит подключение tag-хелперов, определенных в проекте BadNews.

Убедись, что статический список ссылок на новости появляется на странице списка новостей и странице новости.

Чтобы ссылки в архиве работали, надо доработать страницу списка новостей: она должна получать и использовать параметр year из query string.

  1. Добавь первым аргументом int? year в метод Index в NewsController.
  2. Прокинь это аргумент в вызов метода BuildIndexModel.
  3. В /Views/News/Index.cshtml поправь ссылки «Новее» и «Старше»: в них должен передаваться год аналогично pageIndex, а текущее значение года можно взять из модели Model.Year.
  4. Убедись, что по пути /news?year={текущий-год} открывается список новостей с одной новостью «Настал Новый год!», а «важные новости» не показываются.

Теперь осталось доработать представление компонента, т.е. /Views/Shared/Components/ArchiveLinks/Default.cshtml.

  1. В качестве модели используй IList<int>, ведь компонент передает в представление список лет
  2. Используй свои знания Razor и tag-хелперов, чтобы заполнить страницу данными, а ссылки сделать рабочими. При этом добейся того, чтобы в коде остался только один тег <a>.

Подсказка: если обернуть некоторое значение в тег <text>, то Razor будет воспринимать его как разметку, а не как код, но сам тег <text> на страницу не добавит.

4.2. Случайная погода

Надо создать еще один компонент. На этот раз для отображения погоды.

Буду краток:

  1. Для определенности пусть компонент называется WeatherViewComponent.
  2. Данные можно получить методом GetWeatherForecastAsync из IWeatherForecastRepository. Этот интерфейс реализуется классом WeatherForecastRepository. Не забудь зарегистрировать в ConfigureServices.
  3. Разметку можно взять в /$Content/Drafts/Weather.html.
  4. Образец компонента — ArchiveLinksViewComponent. Но тебе понадобится асинхронный вариант Invoke:
public async Task<IViewComponentResult> InvokeAsync()
{
}
  1. В представлениях можно объявлять методы. Пожалуй, этот тебе пригодится:
@{
    string FormatTemperature(int temperature) => temperature > 0 ? $"+{temperature}" : temperature.ToString();
}
  1. Размести этот компонент в боковой части страницы списка новостей и страницы новости над компонентом «Архив новостей»

На этом все. Сделай компонент для отображения погоды: температуры и иконки погоды

4.3. Погода из открытых источников

Надо научиться получать реальную погоду в Екатеринбурге, а не показывать случайное значение температуры.

Дело за малым — подключить готовый OpenWeatherClient к WeatherForecastRepository. Но, чтобы OpenWeatherClient работал, ему надо передать секретный ApiKey. Хранить такой ApiKey можно только в настройках, причем таких, которые в системе контроля версий не хранятся.

Создай файл /appsettings.Secret.json и заполни так:

{
  "OpenWeather": {
    "ApiKey": "58cb7c533322b79c68aa0908f5bbbd0b"
  }
}

.gitignore уже настроен игнорировать файлы с окончанием .Secret.json, поэтому ты не сможешь ApiKey случайно закоммитить.

Теперь настройки из этого файла надо зачитать в IConfiguration. Для этого добавь такой вызов в цепочку вызовов в методе CreateHostBuilder в файле Program.cs:

.ConfigureHostConfiguration(config => {
    config.AddJsonFile("appsettings.Secret.json", optional: true, reloadOnChange: false);
})

Следующий шаг — передать полученные настройки в WeatherForecastRepository. Идея, лежащая на поверхности — добавить в конструктор WeatherForecastRepository параметр IConfiguration configuration, а затем из этой конфигурации зачитать нужные настройки.

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

Есть способ лучше — использовать IOptions. Для этого:

  1. Настрой заполнение опций для погоды по секции OpenWeather конфигурации, добавив в метод ConfigureServices в Startup такую строчку:
services.Configure<OpenWeatherOptions>(configuration.GetSection("OpenWeather"));
  1. Добавь параметр IOptions<OpenWeatherOptions> weatherOptions в конструктор WeatherForecastRepository.
  2. Достань необходимое значение вот так: weatherOptions?.Value.ApiKey
  3. Profit!

После прокидывания ApiKey до WeatherForecastRepository осталось совсем немного:

  • Начни получать погоду в методе GetWeatherForecastAsync через OpenWeatherClient
  • Чтобы перейти от формата, в котором данные получает OpenWeatherClient, к формату WeatherForecast, который надо вернуть, используй статический метод WeatherForecast.CreateFrom.
  • Если по каким-то причинам OpenWeatherClient не отдает данные о погоде — должен использоваться рандомный генератор погоды!

Как закончишь, убедись, что теперь компонент показывает правильную погоду.

5. Особые права

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

Несмотря на то, что такая система разграничения прав будет использовать техники, применяемые в адекватных системах аутентификации и авторизации: http-only cookie, middleware, фильтры запросов, за счет чего дает представления как такие системы устроены внутри, НЕ НАДО ИСПОЛЬЗОВАТЬ приведенную здесь систему В ПРОДАКШЕНЕ!

5.1. Cookie и Custom Middleware

Итак, некоторая cookie должна помечать пользователей, у которых есть некоторые права. Для определенности ее название и значение уже задано в файле /Elevation/ElevationConstants.cs. А в файле /Elevation/ElevationExtensions.cs есть пара методов, которыми можно проверить наличие этой cookie, используя HttpContext (доступен в контроллерах и много еще где), и ViewContext (доступен в представлениях).

Надо реализовать возможность добавления и удаления этой cookie при некоторых условиях. Пусть cookie выставляется при переходе по адресу /elevation?up (здесь up — это параметр в query string) и удаляется при переходе по адресу /elevation, если в query string нет параметра up. После успешного выставления или удаления прав, должен происходить редирект на главную страницу.

Добавить этого можно по-разному. Сделай это через самостоятельно написанный middleware. Начальная версия middleware уже есть в файле /Elevation/ElevationMiddleware.cs. Реализуй корректно метод InvokeAsync и подключи ElevationMiddleware в последовательность обработки запросов.

Подсказки:

  1. Чаще всего middleware не должен влиять на запрос, поэтому должен просто передать управление дальше: await next(context)

  2. Информацию о пути запроса можно получить через context.Request.Path

  3. Информацию про query string запроса можно получить через context.Request.Query

  4. Сделать редирект на главную можно так: context.Response.Redirect("/")

  5. Чтобы задать cookie, надо отправить соответствующие заголовки браузеру, поэтому cookie добавляется через context.Response так:

context.Response.Cookies.Append(ElevationConstants.CookieName, ElevationConstants.CookieValue);
  1. Удаляется cookie тоже через context.Response:
context.Response.Cookies.Delete(ElevationConstants.CookieName);
  1. Добавить middleware в последовательность запросов можно в методе Configure в Startup.cs:
app.UseMiddleware<ElevationMiddleware>();

Добавлять стоит перед промежуточными слоями MVC, т.е. перед UseRouting.

Чтобы видеть, когда задан привилегированный режим, добавь сообщение об этом в _Layout.cshtml после ссылки с классом news-header-logo:

@if (ViewContext.IsElevated())
{
    <span class="text-primary ml-2">есть привилегии</span>
}

Теперь на всех страницах рядом с названием сайта в привилегированном режиме должно появиться сообщение.

Сделай переходы в привилегированный режим /elevation?up и обратно /elevation, проверяя наличие сообщения в шапке главной страницы.

5.2. HttpOnly

Выключи привилегированный режим, найди новость «Настал Новый год!» и открой страницу с ней. Ты увидишь желтый баннер «ЗДЕСЬ МОГЛА БЫТЬ ВАША РЕКЛАМА».

А теперь включи привилегированный режим и перейди в ту же новость. Баннер исчез! Что за магия?! Но если открыть HTML-код страницы новости в браузере, то легко обнаружится такой скрипт в содержимом новости:

<script>
    var elevationCookies = document.cookie.split(';').filter(it => it.split('=')[0] === 'elevation');
    if (elevationCookies.length === 0) {
        ...
    }
</script>

Похоже кто-то из авторов новостей хочет показывать рекламу пользователям так, чтобы об этом не узнали другие авторы! Именно для этого баннер не показывается в привилегированном режиме!

Автор новости, конечно, виноват. Но проблема не в нем, а в дыре в безопасности. Браузер позволяет JavaScript получить cookie о привилегированном режиме, потому что сервер этого не запретил при создании cookie. А надо.

Cookie, которые используются для входа пользователя и разграничения прав, не должны быть доступны скриптам, т.е. должны быть http-only. В реальности хакеры не будут скрывать свои баннеры. Они просто через JS получат и отправят себе куки твоих пользователей, а затем будут выдавать себя за них.

Поэтому поправь код добавления cookie:

context.Response.Cookies.Append(ElevationConstants.CookieName, ElevationConstants.CookieValue,
    new CookieOptions
    {
        HttpOnly = true
    });

Выключи привилегированный режим, затем включи заново с http-only cookie, перейди в новость про новый год и убедись, что баннер показывается.

Из этой ситуации надо сделать еще один вывод — вставка HTML (@Html.Raw), который могут написать внешние пользователи, либо внутренние сотрудники, напрямую в страницы сайта — это зло. По отдельности ошибка с cookie или возможность вставки HTML в страницу похоже неопасны, хоть это и не точно. А вот вместе — создают дыру в безопасности.

5.3. Расширенные возможности

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

Добавь этот код последней строчкой метода Configure в Startup.cs:

app.MapWhen(context => context.Request.IsElevated(), branchApp =>
{
    branchApp.UseDirectoryBrowser("/files");
});

Перейди в привилегированный режим и перейди по пути /files. Ты должен увидеть файлы директории /wwwroot. Выйди из привилегированного режима и снова перейди по пути /files. Ты должен увидеть страницу 404.

5.4. Фильтрация запросов

Более гибко ограничивать доступ к методам контроллеров позволяют фильтры. Запрос при обработке MVC проходит несколько стадий. Фильтры — это своего рода промежуточные слои, которые можно добавить на разные стадии обработки запроса в MVC.

Есть такие фильтры:

  • Authorization Filters
  • Resource Filters
  • Action Filters
  • Exception Filters
  • Result Filters

Специально для проверки прав в MVC встроены фильтры авторизации. Они узкоспециализированы и хорошо работают вместе с middleware для аутентификации и авторизации. Только без этих middleware такие фильтры подключать неудобно.

А вот на уровне Resource Filters можно отклонить запрос на привилегированное действие от непривилегированного пользователя.

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

Пометь EditorController атрибутом [ElevationRequiredFilter], чтобы к нему не могли получить доступ непривилегированные пользователи, и убедись, что фильтр действительно работает.

5.5. Удаление новости

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

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

Остается 2 варианта: либо делать запрос через JS, либо использовать форму с методом POST.

Замечание: по стандарту HTML в формах можно использовать либо метод GET, либо метод POST, в отличие от стандарта HTTP, который позволяет, например, метод DELETE.

Воспользуйся вариантом с формой с методом POST.

Добавь метод в EditorController:

[HttpPost]
public IActionResult DeleteArticle(Guid id)
{
    newsRepository.DeleteArticleById(id);
    return RedirectToAction("Index", "News");
}

Добавь следующий код на страницу новости после заголовка и даты новости:

@if (ViewContext.IsElevated())
{
    <form class="mb-4" onsubmit="return confirm('Удалить новость?')"
        asp-controller="Editor" asp-action="DeleteArticle" asp-route-id="@(Model.Article.Id)">
        <button type="submit" class="btn-danger">Удалить</button>
    </form>
}

Это все! Проверь, что новости удаляются.

Обрати внимание, как с помощью функции confirm на JavaScript можно легко создать диалог подтверждения действия. Пользователи твоих сервисов заслуживают лучшего интерфейса, но для админки пойдет.

6. Оптимизация производительности

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

6.1. Сжатие данных

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

Наиболее современный и эффективный формат сжатия — Brotli. Он поддерживается современными браузерами, но для поддержки устаревших браузеров можно использовать gzip.

Поддержка сжатия добавляется в ASP.NET Core с помощью добавления промежуточного слоя. Причем этот промежуточный слой обеспечивает поддержку как Brotli (по-умолчанию), так и gzip. Выбор будет сделан на основании заголовка Accept-Encoding, который пришлет браузер.

Запусти сервис, открой вкладку Network в Developer Tools браузера и перейди на главную страницу (или перезагрузи ее). В списке запросов на вкладке Network найди получение страницы (т.е. Name — localhost, Type — document) и посмотри на колонку «Size» и запомин размер переданных данных.

А теперь добавь в методе ConfigureServices:

services.AddResponseCompression(options =>
{
    options.EnableForHttps = true;
});

И сразу перед app.UseStaticFiles() в методе Configure добавь строчку:

app.UseResponseCompression();

Перезапусти приложение, перезагрузи страницу и сравни результат. Размер должен уменьшиться в 3-4 раза, а в ответе должен появиться заголовок Content-Encoding: br, означающий сжатие контента алгоритмом Brotli.

Заголовки можно посмотреть, кликнув на запрос в Developer Tools

6.2. Кэширование статики

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

Но можно кэширование статики взять под свой контроль. Для этого в UseStaticFiles добавь такие опции:

app.UseStaticFiles(new StaticFileOptions()
{
    OnPrepareResponse = options =>
    {
        options.Context.Response.GetTypedHeaders().CacheControl =
            new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
            {
                Public = false,
                MaxAge = TimeSpan.FromSeconds(30)
            };
    }
});

После этого все статические файлы будут кэшироваться только на 30 секунд. В Developer Tool можно увидеть был ли файл взят из кэша, либо был полноценный запрос. Можешь убедиться, что если сделать пару переходов между страницами быстро, то скрипты и стили будут взяты из кэша, а если подождать секунд 35, то будут запрошены снова.

Замечания:

  • важно именно переходить между страницами по ссылкам, а не обновлять страницу, потому что в случае обновления браузер сам решит обновить кэш несмотря на рекомендации с сайта.
  • в Developer Tools на вкладке Network не должен быть включен режим «Disable cache». Этот режим удобен при разработке, но в данном тесте навредит.

После проверки установи время кэширования в одни сутки.

Если предложить браузеру закэшировать статику надолго, то при обновлении скриптов или стилей разработчиком, новые версии скриптов и стилей заново скачиваться не будут. Это можно обойти, если поменять путь до скрипта или файла стилей. Например, можно добавить «версию»: было style.css?v=1, стало style.css?v=2. Вручную добавлять версию неудобно, но можно добиться того, чтобы ASP.NET Core добавлял хэш по содержимому файла в query string. Тогда, если при обновлении сайта содержимое файла поменяется — изменится хэш в query string и браузер этот файл заново скачает, а если содержимое не поменяется — браузер продолжит использовать версию из кэша и будет прав.

В файлах _Layout.cshtml и _ValidationScriptsPartial.cshtml во всех тегах <link> и <script>, которые ссылаются на внутренние файлы сайта, добавь атрибут asp-append-version="true", чтобы ASP.NET Core стал проставлять версию.

Перезагрузи страницу и посмотри ее код: в адресах в этих тегах должны появиться версии.

В итоге разумная стратегия кэширования статики:

  • кэшировать статику надолго: сутки, неделю, месяц
  • использовать версионирование
  • не кэшировать вообще или кэшировать на небольшой срок HTML, в котором подключаются стили и скрипты

6.3. Проблема объема данных

Пока новостей в приложении немного — все работает быстро. Но с течением времени новости будут накапливаться: 10 в день, 300 в месяц, 1 000 в квартал, 10 000 за 3 года. Как же будет вести себя сервис в такой ситуации? Это можно проверить, сгенерировав 1 000 новостей и загрузив их в базу.

В файле Program.cs найди метод InitializeDataBase и поменяй значение константы newsArticleCount на 1 000. Перезапусти приложение, зайди на главную и в логах в консоли посмотри сколько времени выполнялся запрос главной страницы. Если ответ формируется быстрее, чем за секунду, то увеличь newsArticleCount до 2 000 — 10 000.

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

6.4. Клиентское кэширование

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

Стратегия При изменении хэша предполагает, что браузер вместе с контентом получает заголовок ETag (от entity tag), в котором находится хэш контента. При следующем запросе браузер отправляет полученный ETag, а сервер либо возвращает ответ 304 Not Modified без тела, либо присылает новую версию контента с новым хэшем.

Плюсы:

  • браузер всегда получает актуальную на момент запроса версию контента
  • экономия трафика между клиентом и сервером

Минусы:

  • серверу каждый раз приходится генерировать контент, чтобы посчитать хэш
  • для каждого нового клиента надо генерировать контент заново, даже если он совпадает

Стратегия По прошествии времени означает, что сервер сообщил браузеру через какое время надо запросить обновленную версию.

Плюсы:

  • браузер вообще не делает запросы в течение некоторого времени, а значит не загружает сервер
  • экономия трафика между клиентом и сервером

Минусы:

  • в течение некоторого времени браузер может использовать неактуальный контент
  • для каждого нового клиента надо генерировать контент заново, даже если он совпадает

Добавь атрибут [ResponseCache(Duration = 30, Location = ResponseCacheLocation.Client)] к контроллеру NewsController, чтобы подключить клиентское кэширование.

Убедись, что если переходить между страницами списка новостей, например, с помощью кнопок «Новее» и «Старше», то при возвращении на уже показанную страницу, она отображается значительно быстрее. Также убедись, что браузер стал получать от сервера заголовок Cache-Control: private,max-age=30.

Но есть баг. Открой главную страницу, а затем включи привилегированный режим. Если ты это сделал достаточно быстро, то надпись «есть привилегии» не появится. А все потому, что страница была взята из кэша, ведь браузер не знает, что кэш надо сбросить, когда меняются cookie.

Обнови атрибут у NewsController вот так: [ResponseCache(Duration = 30, Location = ResponseCacheLocation.Client, VaryByHeader = "Cookie")]

Убедись, что теперь надпись «есть привилегии» показывается вовремя и в ответах появился заголовок Vary: Cookie.

Наконец, отметь метод FullArticle в NewsController атрибутом [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)], чтобы отключить для него клиентское кэширования. Оно будет мешать проверять работу серверного кэширования, которое будет подключено далее.

В ASP.NET Core нет встроенной поддержки генерации ETag, но есть сторонние реализации. В этом проекте нет смысла использовать клиентское кэширование с обновлением по изменению хэша, потому что оно требует генерации контента на стороне сервера на каждый запрос. А именно генерация контента, точнее чтение новостей из хранилища, работает медленно.

6.5. Серверное кэширование

Серверное кэширование означает, что ответы сервера клиенту, либо какие-то промежуточные данные, используемые в ответе клиенту, сохраняются на сервере. И при следующем аналогичном запросе от этого клиента, либо других клиентов, используется сохраненная версия, а не строится новая. При серверном кэшировании также надо как-то понимать, что данные в кэше стали неактуальными, и это может быть нетривиально. При использовании кэширования может потребоваться много памяти на сервере. Поэтому каждый раз надо выбирать подходящую стратегию вытеснения данных из кэша при нехватке памяти. Возможные стратегии: по времени попадания в кэш (вытесняется запись, которая дольше всего находится в кэше), по времени последнего использования (вытесняется запись, которая дольше всего не использовалась), по количеству использований (вытесняется запись, которая использовалась меньше всего раз), гибридные варианты.

Плюсы:

  • Контент, сгенерированный для одного клиента, в некоторых случаях может быть использован для другого
  • Снижает нагрузку на ресурсы сервера, потому что запросы, попавшие в кэш, обрабатываются быстрее

Минусы:

  • Не экономится трафик между клиентом и сервером
  • Не снижается количество запросов
  • Нужно вовремя обновлять данные в кэше
  • Может потребоваться много памяти на сервере

В ASP.NET Core есть встроенная поддержка серверного кэширования, но только в оперативной памяти веб-сервера. В реальных проектах с множеством серверов стоит использовать распределенный кэш. Благо можно найти библиотеки и рецепты для создания распределенного кэша на основе Redis, SQL Server и других хранилищ.

Чтобы включить поддержку серверного кэширования в оперативной памяти, добавь в метод ConfigureServices:

services.AddMemoryCache();

Убедись, что страница отдельной новости грузится долго. Это происходит из-за того, что компонент ArchiveLinksViewComponent. долго строится. В этом компоненте вызывается метод newsRepository.GetYearsWithArticles, который работает медленно. А результат выполнения метода при этом меняется примерно раз в год. Вот этот результат и закэшируй на сервере.

Во-первых, получи IMemoryCache из DI-контейнера через конструктор.

Во-вторых, замени код построения years на такой:

string cacheKey = nameof(ArchiveLinksViewComponent);
if (!memoryCache.TryGetValue(cacheKey, out var years))
{
    years = newsRepository.GetYearsWithArticles();
    if (years != null)
    {
        memoryCache.Set(cacheKey, years, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
        });
    }
}

// тут доступна переменная years

Проверь, что скорость загрузки страницы новости возросла.

А теперь самостоятельно добавь кэширование модели в метод FullArticle в NewsController. Но вместо настройки AbsoluteExpirationRelativeToNow (кэш устареет через указанное время после создания) используй настройку SlidingExpiration (кэш устареет через указанное время после последнего обращения) Помни, что модель зависит от параметра id, а значит cacheKey тоже должен зависеть от id, иначе для всех новостей из кэша будет возвращаться одно и то же содержимое.

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