В этом задании предстоит внедрить ASP.NET Core MVC с шаблонизатором Razor в существующий новостной проект, расширить его функционал, настроить чтение конфигурации, обработку и логирование ошибок.
Запусти приложение и убедись, что открылся сайт новостей. Попробуй перейти на следующую страницу списка, зайди в любую из новостей.
Посмотри как эти страницы формируются в Startup.cs
.
Для правильной адресации используются методы Map
и MapWhen
,
а ответы формируются в методах RenderIndexPage
и RenderFullArticlePage
.
Полезно понять, как это работает, но детали можно не запоминать — этот код предстоит отрефакторить.
Хорошо, когда приложение работает правильно. Но наивно полагать, что ошибки не будут возникать. Игнорировать ошибки — значит поступать безответственно, заставлять пользователей страдать. Правильная стратегия — обрабатывать ошибки так, чтобы о них как можно быстрее узнать и решить проблему.
Для начала открой сайт новостей и попробуй перейти по пути /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
в рамках сайта. Теперь должна отобразиться красивая страница с кодом ошибки.
Следующий тип ошибок, который должен корректно обрабатываться — непредвиденные, исключительные ошибки. В смысле, исключения.
Открой сайт новостей и перейди на любую новость.
В адресной строке ты увидешь путь вида /news/fullarticle/5ab19137-3e28-4eca-bd19-3185ebeba0c6
.
В конце пути идет идентификатор новости. Поменяй в нем что-нибудь, можно даже так: /news/fullarticle/123
.
В результате ты увидишь страницу, которая рассказывает о том, что произошло исключение, и всякие подробности.
Эта страница генерируется с помощью промежуточного слоя UseDeveloperExceptionPage
.
Это очень удобно для разработки. А вот сайт в продакшене не должен показывать всем подряд столько информации, ведь это может упростить атаку на ваше приложение. Пользователю надо показывать менее информативную страницу.
Действия аналогичны добавлению страницы для кода статуса:
-
Закомментируй строчку
app.UseDeveloperExceptionPage();
и после нее добавьapp.UseExceptionHandler("/Errors/Exception");
. -
Добавь метод
Exception
вErrorsController
, аналогичныйStatusCode
, но без параметров. Внутри вызови методView
вот так:View(null, HttpContext.TraceIdentifier)
. В качествеviewName
передаетсяnull
, а значит будет искаться представление, соответствующее имени метода. В качествеmodel
здесь передаетсяHttpContext.TraceIdentifier
, как и задумано. ВариантView(HttpContext.TraceIdentifier)
здесь работать не будет. Подумай почему. Подсказка: обрати внимание на типTraceIdentifier
. -
Добавь новое представление
Exception.cshtml
в папку/Views/Errors
. Содержимое для него возьми в заготовке/$Content/Drafts/Exception.cshtml
. -
Добавь еще одну строчку конфигурации в
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
.
Ошибки важно не только показывать, но и сохранять для дальнейшего исправления.
Система логирования уже встроена в ASP.NET Core и многие компоненты благодаря этому сразу умеют писать логи. Другой вопрос — куда сообщения от системы логирования попадут. Тут есть простор для выбора, благо встроенная система логирования легко расширяется.
Расширить можно с помощью библиотеки Serilog. Serilog может генерировать сообщения в обычном формате человекочитаемых строчек, но также поддерживает так называемое «структурное логирование», т.е. может генерировать сообщения в виде JSON. Сообщения в виде JSON легче обрабатывать автоматически, а еще можно сразу отправлять в сервис централизованного сбора и хранения логов. Писать Serilog умеет и в консоль, и в файлы, и в удаленные сервисы сбора логов, а еще можно написать своего «потребителя логов», в который Serilog будет писать.
Дополнительные пояснения про структурное логирование
Структурное логирование и централизованное хранение логов в специальном сервисе — это современный тренд. Это позволяет разработчикам в одном месте искать логи последних событий при разборе обращений от пользователей, либо других проблем в сервисе. Но для того, чтобы работал поиск, надо ключевые значения из сообщения логов вычленить. Чтобы сервису сбора логов не приходилось этого делать, лучше сразу логировать сообщения в виде JSON, в полях которого записывать необходимые ключевые значения.
Пришло время добавить логирование.
-
NuGet-пакет Serilog уже подключен к проекту — ура!
-
Подключи 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
хоста,
которая собирается стандартным образом.
- Положи настройки логирования в файл
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": "*"
}
-
Обрати внимание на конфигурацию. В ней настроен
rollingInterval
иrollOnFileSizeLimit
. В реальных приложениях за день могут накопиться сотни мегабайт логов, если не больше. Крупными файлами неудобно манипулировать, поэтому лучше логи разбивать по датам, а еще по размеру, если почему-то логов за сутки слишком много. Добавление этих настроек обеспечивает разделение на файлы. -
Перейди по пути
/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.
Пришло время перевести страницы новостей на MVC.
Для начала надо сделать так, чтобы по пути /
отображался список новостей на 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.
Теперь выполни конкретные шаги:
- В методе
Index
контроллераNewsController
добавь параметрint pageIndex = 0
со значением по-умолчанию, чтобы получить значение из query string. - В методе
Index
используйnewsModelBuilder.BuildIndexModel
, чтобы построить модель страницы. Параметры уBuildIndexModel
в контроллере аналогичны параметрамBuildIndexModel
вRenderIndexPage
. Передай построенную модель воView
. - В представлении
Index.cshtml
добавь первой строкой директиву, описывающую тип модели@model BadNews.Models.News.IndexModel
. - Место
{{articles}}
в представлении используется для того, чтобы отображать список новостей. При этом используется шаблон/$Content/Templates/NewsArticle.hbs
. Вставь текст из этого файла вместо{{articles}}
, а затем оберни скопированный текст в цикл:
@foreach (var article in Model.PageArticles)
{
/* шаблон новости */
}
- Запусти приложение и открой страницы: убедись, что отображается 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);
});
Проверь, что сайт продолжает правильно работать!
Пришло время переделать страницу новости.
Алгоритм переделки повторяется:
- Надо создать метод в контроллере для пути
/news/fullarticle
- Добавить в метод необходимый параметр
Guid id
, т.е. идентификатор новости - В методе использовать
newsModelBuilder
для построение модели аналогично тому, как это сейчас происходит в методеRenderFullArticlePage
вStartup.cs
- Создать представление на основании
/$Content/Templates/FullArticle.hbs
- Добавить в представление модель с помощью директивы @model
- Заменить все
{{какое-то-имя}}
в представлении на значения из модели - Удалить
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-и.
Сейчас в нескольких страницах используется одна и та же шапка страницы, подключаются одни и те же скрипты. Есть общий код, который хотелось бы выделить отдельно. Для этого в 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
.
Конечно, стоило бы проверить, что страница исключений продолжает работать как надо, но не проверяй в этот раз:
к пользователям это не пойдет, а время можно потратить на следующее задание.
На странице списка новостей надо добавить «важные новости».
Разметка для такой «важной новости» находится в файле /$Content/Drafts/FeaturedArticles.html
.
Сами новости должны располагаться вверху страницы. Это место было помечено <!-- Header /-->
,
а метка должна была сохраниться в _Layout.cshtml
.
И вроде бы все, что надо сделать — пробежаться по массиву Model.FeaturedArticles
с помощью @foreach
,
ведь newsModelBuilder.BuildIndexModel
корректно заполняет этот массив при передаче соответствующего параметра...
Но нюанс в том, что этот @foreach
должен вызываться в _Layout.cshtml
, где модель конкретной страницы недоступна.
Короче, нужны секции. Секция — это некоторая метка, которая ставится в макете, а конкретное представление может определить разметку для этой метки.
Что нужно сделать конкретно:
- При вызове
newsModelBuilder.BuildIndexModel
вNewsController
вторым параметром передавайtrue
- Замени в
_Layout.cshtml
метку<!-- Header /-->
на@RenderSection("Header", false)
- Добавь в
Index.cshtml
в начало или в конец следующий код:
@section Header
{
}
- Добавь внутрь разметку из
/$Content/Drafts/FeaturedArticles.html
- Сделай так, чтобы внутри показывались новости из
Model.FeaturedArticles
Сейчас, когда все страницы отображаются с помощью контроллеров, можно изменить способ генерации адресов в ссылках.
Т.е. вместо явного задания 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 { ... }
.
Теперь пришло время добавить возможность добавления новых новостей.
Для этого нужно создать страницу с формой, в которой можно будет задать все поля новости
и сохранить результат. Страница должна открываться по адресу /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
});
Добавь этот код и убедись, что новость с непустым заголовком создается, а страница с ней показывается.
Форма работает, но есть проблема. Если данные формы заполнены некорректно: не задан заголовок статьи, либо в тизере или в содержимом статьи реально без обмана используется действительно одно из стоп-слов, то автору не показываются сообщения об этих ошибках. А должны бы.
Чтобы сообщения об ошибках добавлялись в представление, достаточно использовать tag-хелпер asp-validation-for
.
Таким атрибутом нужно отметить некоторый тег, а в качестве значения указать имя поля модели.
После этого все сообщения об ошибках, связанные с этим полем, будут добавляться к помеченному тегу.
В разметке страницы с формой для отображения ошибок предусмотрены такие теги: <span class="text-danger"></span>
.
Пометь их атрибутом asp-validation-for
с одним из значений Header
, Teaser
и ContentHtml
.
Теперь убедись, что если попытаться создать новость с пустым заголовком, словом «действительно» в тизере и словом «реально» в статье, то с сервера вернется 3 сообщения об ошибке и каждое отобразится в своем месте.
Но хочется большего. Хочется, чтобы сообщения об ошибках появлялись не после отправки формы, а сразу после редактирования любого из полей формы. Другими словами, проверки должны работать в браузере, без участия сервера.
Для этого потребуются некоторые библиотеки на JavaScript:
jquery.validate
— стандартная библиотека валидаций для jQueryjquery.validate.unobtrusive
— расширение библиотеки валидаций от Microsoft, чтобы проверять значения форм «unobtrusive», т.е. «ненавязчиво» на стороне браузера
Подключи эти скрипты с помощью отдельного partial view.
- Создай файл
/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>
- Подключи partial view в
_Layout.cshtml
после всех тегов<script>
так:
<partial name="_ValidationScriptsPartial" />
После подключения этих скриптов, перейди на страницу добавления новости, введи в заголовке что-нибудь,
а затем сотри — появится сообщение об ошибке. Все уже работает, потому что для проверки поля Header
используется встроенный атрибут Required
. А вот для того, чтобы написанная для этого сайта проверка StopWords
тоже начала работать в браузере, надо приложить дополнительные усилия.
Как же добавить свою собственную проверку для модели, чтобы она работала и на сервере и на клиенте? Если делать с нуля, то придется выполнить 5 шагов. В нашем случае надо будет выполнить всего два.
Для начала полный список шагов добавления собственной проверки:
-
Надо создать на C# атрибут проверки
StopWordsAttribute
. С его помощью можно отмечать свойства модели, которые должны проверяться этой проверкой. В атрибуте нужно реализовать методIsValid
, который как раз и будет осуществлять проверку на стороне сервера. Посмотри, как это сделано в файле/Validation/StopWordsAttribute.cs
. -
Надо написать код проверки на JS, аналогичный коду на C#, и подключить скрипт с этим кодом. Посмотри, как это сделано в файле
/wwwroot/js/validation.js
. -
Надо добавить на C# класс
StopWordsAttributeAdapter
, который будет добавлять к тегам полей формы информацию, необходимую для работы проверки на JS. Посмотри, как это сделано в файле/Validation/StopWordsAttributeAdapter.cs
. -
Надо добавить класс
StopWordsAttributeAdapterProvider
, который будет связыватьStopWordsAttribute
иStopWordsAttributeAdapter
. Посмотри, как это сделано в файле/Validation/StopWordsAttributeAdapterProvider.cs
. -
Зарегистрировать класс
StopWordsAttributeAdapterProvider
в DI-контейнере в методеConfigureServices
.
А вот что из перечисленного осталось сделать.
Во-первых, добавь регистрацию провайдера в метод ConfigureServices
:
services.AddSingleton<IValidationAttributeAdapterProvider, StopWordsAttributeAdapterProvider>();
Во-вторых, подключи скрипт с кодом проверки в _ValidationPartial.cshtml
:
<script src="/js/validation.js"></script>
Теперь перейди на страницу добавления новости и напиши слово «действительно» в тизер или текст статьи. После того, как ты переведешь фокус в другое поле, должно появиться сообщение, что нельзя использовать стоп-слова.
Ура! Теперь можно создавать новые новостные статьи, а ошибки в них будут показываться сразу без перезагрузки страницы.
Когда на странице много разных элементов с разными данными, не очень удобно строить одну большую модель, а затем в большом представлении распределять эти данные по нужным местам. Надо как-то декомпозировать задачу на более маленькие. Компонентный подход позволяет это сделать.
Компонент — это элемент страницы с собственным методом Invoke
или InvokeAsync
, который строит модель компонента,
а также с собственным представлением, которое эту модель отрисовывает.
Использование компонентов позволяет упростить конкретную страницу. А еще компоненты можно легко переиспользовать на других страницах.
Необходимо добавить новый функционал — архив новостей. Архив новостей должен показывать ссылки на новости по годам, а при переходе по ссылке за конкретный год в списке новостей должны остаться только новости за этот год.
Сделай список ссылок на новости разных лет в виде компонента.
Создай файл /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.
- Добавь первым аргументом
int? year
в методIndex
вNewsController
. - Прокинь это аргумент в вызов метода
BuildIndexModel
. - В
/Views/News/Index.cshtml
поправь ссылки «Новее» и «Старше»: в них должен передаваться год аналогичноpageIndex
, а текущее значение года можно взять из моделиModel.Year
. - Убедись, что по пути
/news?year={текущий-год}
открывается список новостей с одной новостью «Настал Новый год!», а «важные новости» не показываются.
Теперь осталось доработать представление компонента, т.е. /Views/Shared/Components/ArchiveLinks/Default.cshtml
.
- В качестве модели используй
IList<int>
, ведь компонент передает в представление список лет - Используй свои знания Razor и tag-хелперов, чтобы заполнить страницу данными, а ссылки сделать рабочими.
При этом добейся того, чтобы в коде остался только один тег
<a>
.
Подсказка: если обернуть некоторое значение в тег <text>
, то Razor будет воспринимать его как разметку, а не как код,
но сам тег <text>
на страницу не добавит.
Надо создать еще один компонент. На этот раз для отображения погоды.
Буду краток:
- Для определенности пусть компонент называется
WeatherViewComponent
. - Данные можно получить методом
GetWeatherForecastAsync
изIWeatherForecastRepository
. Этот интерфейс реализуется классомWeatherForecastRepository
. Не забудь зарегистрировать вConfigureServices
. - Разметку можно взять в
/$Content/Drafts/Weather.html
. - Образец компонента —
ArchiveLinksViewComponent
. Но тебе понадобится асинхронный вариантInvoke
:
public async Task<IViewComponentResult> InvokeAsync()
{
}
- В представлениях можно объявлять методы. Пожалуй, этот тебе пригодится:
@{
string FormatTemperature(int temperature) => temperature > 0 ? $"+{temperature}" : temperature.ToString();
}
- Размести этот компонент в боковой части страницы списка новостей и страницы новости над компонентом «Архив новостей»
На этом все. Сделай компонент для отображения погоды: температуры и иконки погоды
Надо научиться получать реальную погоду в Екатеринбурге, а не показывать случайное значение температуры.
Дело за малым — подключить готовый 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
. Для этого:
- Настрой заполнение опций для погоды по секции
OpenWeather
конфигурации, добавив в методConfigureServices
вStartup
такую строчку:
services.Configure<OpenWeatherOptions>(configuration.GetSection("OpenWeather"));
- Добавь параметр
IOptions<OpenWeatherOptions> weatherOptions
в конструкторWeatherForecastRepository
. - Достань необходимое значение вот так:
weatherOptions?.Value.ApiKey
- Profit!
После прокидывания ApiKey до WeatherForecastRepository
осталось совсем немного:
- Начни получать погоду в методе
GetWeatherForecastAsync
черезOpenWeatherClient
- Чтобы перейти от формата, в котором данные получает
OpenWeatherClient
, к форматуWeatherForecast
, который надо вернуть, используй статический методWeatherForecast.CreateFrom
. - Если по каким-то причинам
OpenWeatherClient
не отдает данные о погоде — должен использоваться рандомный генератор погоды!
Как закончишь, убедись, что теперь компонент показывает правильную погоду.
На сайте уже есть функционал, который не должен быть доступен всем: добавление новости. Надо добавить простейшую систему разграничения прав: с привилегиями и без. При этом отличаться привилегированные пользователи от обычных будут по наличию cookie.
Несмотря на то, что такая система разграничения прав будет использовать техники, применяемые в адекватных системах аутентификации и авторизации: http-only cookie, 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
в последовательность обработки запросов.
Подсказки:
-
Чаще всего middleware не должен влиять на запрос, поэтому должен просто передать управление дальше:
await next(context)
-
Информацию о пути запроса можно получить через
context.Request.Path
-
Информацию про query string запроса можно получить через
context.Request.Query
-
Сделать редирект на главную можно так:
context.Response.Redirect("/")
-
Чтобы задать cookie, надо отправить соответствующие заголовки браузеру, поэтому cookie добавляется через
context.Response
так:
context.Response.Cookies.Append(ElevationConstants.CookieName, ElevationConstants.CookieValue);
- Удаляется cookie тоже через
context.Response
:
context.Response.Cookies.Delete(ElevationConstants.CookieName);
- Добавить 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
,
проверяя наличие сообщения в шапке главной страницы.
Выключи привилегированный режим, найди новость «Настал Новый год!» и открой страницу с ней. Ты увидишь желтый баннер «ЗДЕСЬ МОГЛА БЫТЬ ВАША РЕКЛАМА».
А теперь включи привилегированный режим и перейди в ту же новость. Баннер исчез! Что за магия?! Но если открыть 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 в страницу похоже неопасны, хоть это и не точно. А вот вместе — создают дыру в безопасности.
Теперь, когда привилегированные пользователи отличаются от всех остальных, можно обрабатывать какие-то запросы только от них.
Добавь этот код последней строчкой метода Configure
в Startup.cs
:
app.MapWhen(context => context.Request.IsElevated(), branchApp =>
{
branchApp.UseDirectoryBrowser("/files");
});
Перейди в привилегированный режим и перейди по пути /files
. Ты должен увидеть файлы директории /wwwroot
.
Выйди из привилегированного режима и снова перейди по пути /files
. Ты должен увидеть страницу 404.
Более гибко ограничивать доступ к методам контроллеров позволяют фильтры. Запрос при обработке MVC проходит несколько стадий. Фильтры — это своего рода промежуточные слои, которые можно добавить на разные стадии обработки запроса в MVC.
Есть такие фильтры:
- Authorization Filters
- Resource Filters
- Action Filters
- Exception Filters
- Result Filters
Специально для проверки прав в MVC встроены фильтры авторизации. Они узкоспециализированы и хорошо работают вместе с middleware для аутентификации и авторизации. Только без этих middleware такие фильтры подключать неудобно.
А вот на уровне Resource Filters можно отклонить запрос на привилегированное действие от непривилегированного пользователя.
Для текущей схемы разграничения прав такой фильтр уже создан — /Elevation/ElevationRequiredFilterAttribute.cs
.
Запрос к методу, помеченному таким атрибутом, от непривилегированного пользователя будет отклоняться фильтром.
Также этим атрибутом можно пометить контроллер. В этом случае фильтр будет работать для всех методов контроллера.
Пометь EditorController
атрибутом [ElevationRequiredFilter]
, чтобы к нему не могли получить доступ
непривилегированные пользователи, и убедись, что фильтр действительно работает.
Раз новости можно добавлять, значит их надо уметь удалять. Удобно добавить кнопку для удаления новости прямо на страницу новости, но только для привилегированных пользователей.
Удаление — это изменение. Поэтому для удаления нельзя использовать обычный переход по ссылке, на который браузер выполняет метод 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 можно легко создать диалог подтверждения действия.
Пользователи твоих сервисов заслуживают лучшего интерфейса, но для админки пойдет.
Новости читают многие, поэтому надо задумываться о производительности. Требуется повысить производительность за счет сжатия данных, а также использования клиентского и серверного кэширования.
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
Статические файлы: документы, скрипты, стили, изображения — меняются редко, а используются снова и снова на разных страницах. Поэтому, чтобы не гонять лишний трафик, их стоит закэшировать в браузере, причем надолго. Браузеры сами неплохо справляются с задачей кэширования статики с использованием разных эвристик. Например, такой: чем больше времени прошло с момента модификации файла, тем на больший срок его можно закэшировать.
Но можно кэширование статики взять под свой контроль. Для этого в 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, в котором подключаются стили и скрипты
Пока новостей в приложении немного — все работает быстро. Но с течением времени новости будут накапливаться: 10 в день, 300 в месяц, 1 000 в квартал, 10 000 за 3 года. Как же будет вести себя сервис в такой ситуации? Это можно проверить, сгенерировав 1 000 новостей и загрузив их в базу.
В файле Program.cs
найди метод InitializeDataBase
и поменяй значение константы newsArticleCount
на 1 000.
Перезапусти приложение, зайди на главную и в логах в консоли посмотри сколько времени выполнялся
запрос главной страницы. Если ответ формируется быстрее, чем за секунду, то увеличь newsArticleCount
до 2 000 — 10 000.
Думаю смысл ты уже понял: новости грузятся тем дольше, чем их больше. Это принципиальная проблема используемого хранилища новостей. И с этим пока делать ничего не надо. А вот что надо сделать, так это уменьшить влияние проблемы за счет использования кэширования.
Клиентское кэширование означает, что браузер сохраняет полученный контент, а при следующем запросе использует сохраненную версию. При кэшировании браузеру надо как-то понимать, что контент устарел и его надо получить снова. И есть две стратегии получения обновлений: по прошествии времени и при изменении хэша контента.
Стратегия При изменении хэша предполагает, что браузер вместе с контентом получает
заголовок 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, но есть сторонние реализации. В этом проекте нет смысла использовать клиентское кэширование с обновлением по изменению хэша, потому что оно требует генерации контента на стороне сервера на каждый запрос. А именно генерация контента, точнее чтение новостей из хранилища, работает медленно.
Серверное кэширование означает, что ответы сервера клиенту, либо какие-то промежуточные данные, используемые в ответе клиенту, сохраняются на сервере. И при следующем аналогичном запросе от этого клиента, либо других клиентов, используется сохраненная версия, а не строится новая. При серверном кэшировании также надо как-то понимать, что данные в кэше стали неактуальными, и это может быть нетривиально. При использовании кэширования может потребоваться много памяти на сервере. Поэтому каждый раз надо выбирать подходящую стратегию вытеснения данных из кэша при нехватке памяти. Возможные стратегии: по времени попадания в кэш (вытесняется запись, которая дольше всего находится в кэше), по времени последнего использования (вытесняется запись, которая дольше всего не использовалась), по количеству использований (вытесняется запись, которая использовалась меньше всего раз), гибридные варианты.
Плюсы:
- Контент, сгенерированный для одного клиента, в некоторых случаях может быть использован для другого
- Снижает нагрузку на ресурсы сервера, потому что запросы, попавшие в кэш, обрабатываются быстрее
Минусы:
- Не экономится трафик между клиентом и сервером
- Не снижается количество запросов
- Нужно вовремя обновлять данные в кэше
- Может потребоваться много памяти на сервере
В 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
,
иначе для всех новостей из кэша будет возвращаться одно и то же содержимое.
Проверь, что страница новости грузится почти мгновенно во второй и последующие разы.