5.05. Справочник по ASP.NET
Справочник по ASP.NET
🔹 Хостинг, Конфигурация, DI, Middleware, Маршрутизация
1. Хостинг и жизненный цикл приложения
1.1. Типы хостов
IHost— корневой интерфейс для .NET Generic Host (начиная с .NET Core 2.1).IWebHost— устаревший (deprecated) интерфейс для веб-хоста (ASP.NET Core ≤ 2.2).- В .NET 6+ используется minimal hosting model:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
1.2. Жизненный цикл
Основные события (через IHostedService и IHostApplicationLifetime):
| Интерфейс / событие | Описание |
|---|---|
IHostedService.StartAsync | Вызывается при старте приложения (до обработки первого запроса). Блокирующий — задержка старта. |
IHostedService.StopAsync | Вызывается при graceful shutdown. |
IHostApplicationLifetime.ApplicationStarted | Флаг и событие — приложение запущено и готово принимать запросы (app.Run() уже вызван). |
IHostApplicationLifetime.ApplicationStopping | Отправляется до вызова StopAsync всех hosted-сервисов. Можно подписаться. |
IHostApplicationLifetime.ApplicationStopped | Все сервисы остановлены, приложение завершено. |
Пример регистрации hosted-сервиса:
builder.Services.AddHostedService<MyBackgroundService>();
// или через фабрику:
builder.Services.AddHostedService(sp => new MyBackgroundService(sp.GetRequiredService<ILogger<MyBackgroundService>>()));
1.3. Startup-класс (только для non-minimal hosting, .NET ≤ 5 или явное использование)
public class Startup
{
public Startup(IConfiguration configuration) { Configuration = configuration; }
public IConfiguration Configuration { get; }
// ConfigureServices устарел в .NET 6+; заменён на builder.Services
public void ConfigureServices(IServiceCollection services) { }
// Configure заменён на app.Use... в minimal model
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { }
}
⚠️ В .NET 6+
Startupне требуется. Всё настраивается черезWebApplicationBuilder.
2. Конфигурация (IConfiguration)
2.1. Источники конфигурации (по умолчанию, порядок важен — последний побеждает):
| Провайдер | Путь / Примечание |
|---|---|
appsettings.json | Базовый файл. UTF-8 без BOM. |
appsettings.{Environment}.json | e.g. appsettings.Production.json. Автозагрузка по ASPNETCORE_ENVIRONMENT. |
| User secrets | Только в Development: dotnet user-secrets set "Key" "Value" → %APPDATA%\Microsoft\UserSecrets\{GUID}\secrets.json. |
| Environment variables | Префикс ASPNETCORE_ или DOTNET_. Вложенность: ConnectionStrings__Default = ConnectionStrings:Default. |
| Command-line args | --key value, /key=value, --key=value. |
Добавление кастомного провайдера:
builder.Configuration.AddJsonFile("custom.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables("MYAPP_");
2.2. Чтение значений
var value = builder.Configuration["Section:Key"]; // string
var intValue = builder.Configuration.GetValue<int>("Count", defaultValue: 10);
var section = builder.Configuration.GetSection("Database");
var options = section.Get<DatabaseOptions>(); // требует Microsoft.Extensions.Options.ConfigurationExtensions
2.3. Опции (IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T>)
| Интерфейс | Жизненный цикл | Reload | Применение |
|---|---|---|---|
IOptions<T> | Singleton | ❌ | Конфигурация, не меняющаяся после старта. |
IOptionsSnapshot<T> | Scoped | ✅ (на запрос) | Настройки, зависящие от request (e.g. tenant-specific). |
IOptionsMonitor<T> | Singleton | ✅ (callback) | Реакция на изменение (e.g. logging, cache invalidation). |
Регистрация:
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
// или с валидацией:
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.Validate(o => !string.IsNullOrWhiteSpace(o.Host));
3. Внедрение зависимостей (IServiceCollection)
3.1. Жизненные циклы сервисов
| Регистрация | Интерфейс | Примечание |
|---|---|---|
AddSingleton<T>() | T, IServiceProvider.GetService<T>() | Один экземпляр на всё приложение. Осторожно с state и scoped-зависимостями! |
AddScoped<T>() | T | Один экземпляр на HTTP-запрос (или на scope, созданный вручную). |
AddTransient<T>() | T | Новый экземпляр каждый раз при запросе. |
Примеры:
// Конкретный тип
services.AddSingleton<ILoggerProvider, FileLoggerProvider>();
// Интерфейс → реализация
services.AddScoped<IRepository, EfRepository>();
// Фабрика
services.AddTransient<IService>(sp => {
var config = sp.GetRequiredService<IConfiguration>();
return new MyService(config["ApiKey"]);
});
// Декоратор (ручная реализация)
services.AddScoped<ICacheService, RedisCacheService>();
services.Decorate<ICacheService, LoggingCacheDecorator>();
// (требует Microsoft.Extensions.DependencyInjection.Decorator или аналога)
3.2. Встроенные сервисы (часто используемые)
| Тип | Регистрация | Описание |
|---|---|---|
IConfiguration | Авто | Объект конфигурации. |
IWebHostEnvironment / IHostEnvironment | Авто | Информация об окружении (EnvironmentName, ContentRootPath, WebRootPath). |
ILogger<T> | Авто | Логгер с категорией T. |
HttpContextAccessor | Требует AddHttpContextAccessor() | Доступ к HttpContext вне request pipeline. Не рекомендуется — нарушает DI-принципы. |
HttpClient | Через AddHttpClient<T>() | Typed/Named клиенты с политикой (Polly), base address, headers. |
4. Middleware
Middleware — компоненты, обрабатывающие каждый HTTP-запрос и ответ в порядке регистрации («луковичная» модель).
4.1. Стандартные middleware (в порядке рекомендуемой регистрации)
| Middleware | Метод расширения | Описание | Важные параметры / настройки |
|---|---|---|---|
| Exception Handler | app.UseExceptionHandler("/error") | Перехват необработанных исключений. | ExceptionHandlerOptions: ExceptionHandlingPath, AllowStatusCode404Response. |
| HTTPS Redirection | app.UseHttpsRedirection() | 307 → HTTPS (если исходный запрос HTTP). | HttpsRedirectionOptions: SslPort (по умолчанию 443). |
| Static Files | app.UseStaticFiles() | Обслуживание wwwroot. | StaticFileOptions: RequestPath, FileProvider, ServeUnknownFileTypes, OnPrepareResponse. |
| Routing | app.UseRouting() | Инициализация endpoint routing. Должен идти ДО UseAuthorization, UseEndpoints. | — |
| Authentication | app.UseAuthentication() | Выполнение схем аутентификации (устанавливает HttpContext.User). | — |
| Authorization | app.UseAuthorization() | Проверка политик доступа. Должен идти ПОСЛЕ UseAuthentication, но ДО UseEndpoints. | — |
| Session | app.UseSession() | Управление сессиями. Должен идти ПОСЛЕ UseRouting, но ДО UseAuthorization. | SessionOptions: IdleTimeout, Cookie, IOTimeout. |
| CORS | app.UseCors("PolicyName") | Кросс-доменные запросы. | CorsPolicyBuilder: WithOrigins, AllowAnyOrigin, WithMethods, WithHeaders, AllowCredentials, SetIsOriginAllowed. |
| Response Caching | app.UseResponseCaching() | Кэширование ответов (на уровне middleware). | ResponseCachingOptions: SizeLimit, UseCaseSensitivePaths. |
| Response Compression | app.UseResponseCompression() | Gzip/Brotli сжатие. | ResponseCompressionOptions: Providers, EnableForHttps, MimeTypes. |
| Endpoints | app.UseEndpoints(...) / app.Map... | Регистрация маршрутов. В minimal model — app.MapGet, app.MapControllers и т.д. | — |
✅ Правильный порядок:
UseExceptionHandler→UseHttpsRedirection→UseStaticFiles→UseRouting→UseAuthentication→UseAuthorization→UseSession→UseCors→UseResponseCaching→UseResponseCompression→UseEndpoints/Map*.
4.2. Создание кастомного middleware
Вариант 1: Функция-делегат
app.Use(async (context, next) =>
{
// До обработки
context.Items["StartTime"] = DateTime.UtcNow;
await next(); // → следующее middleware
// После обработки
var elapsed = DateTime.UtcNow - (DateTime)context.Items["StartTime"];
context.Response.Headers["X-Elapsed"] = elapsed.TotalMilliseconds.ToString("F2");
});
Вариант 2: Класс с InvokeAsync
public class TimingMiddleware
{
private readonly RequestDelegate _next;
public TimingMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext context, ILogger<TimingMiddleware> logger)
{
var start = DateTime.UtcNow;
await _next(context);
var elapsed = DateTime.UtcNow - start;
logger.LogInformation("Request {Path} took {Elapsed} ms", context.Request.Path, elapsed.TotalMilliseconds);
}
}
// Регистрация:
app.UseMiddleware<TimingMiddleware>();
⚠️ Middleware не создаётся на каждый запрос (если не transient).
InvokeAsync— scoped по контексту.
5. Маршрутизация (Endpoint Routing)
ASP.NET Core использует Endpoint Routing (начиная с 3.0): сначала определяется endpoint, затем выбирается middleware для его обработки.
5.1. Типы endpoint-ов
| Тип | Метод | Пример |
|---|---|---|
| Minimal API | app.MapGet("/api/users", ...) | app.MapPost("/items", (Item i) => Results.Created($"/items/{i.Id}", i)); |
| Controllers | app.MapControllers() | Требует AddControllers() / AddControllersWithViews(). |
| Razor Pages | app.MapRazorPages() | Требует AddRazorPages(). |
| SignalR Hubs | app.MapHub<ChatHub>("/chat") | — |
| gRPC Services | app.MapGrpcService<GreeterService>() | — |
| Health Checks | app.MapHealthChecks("/health") | — |
5.2. Атрибуты маршрутизации (для контроллеров и Razor Pages)
| Атрибут | Применяется к | Параметры | Особенности |
|---|---|---|---|
[Route("api/[controller]")] | Класс | Шаблон маршрута. [controller] → имя контроллера без Controller. | Route на классе + HttpGet → объединяются. |
[HttpGet("list")] | Метод | Относительный путь. Может включать параметры: "{id:int}". | Поддерживает HTTP-методы: HttpPost, HttpPut, HttpDelete, HttpPatch. |
[Route("[action]")] | Класс / метод | [action] → имя метода. | Редко — конфликтует с REST. |
[NonAction] | Метод | — | Метод не является endpoint'ом. |
[ApiExplorerSettings(IgnoreApi = true)] | Класс / метод | — | Скрывает из OpenAPI/Swashbuckle. |
[Consumes("application/json")] | Метод | MIME-типы | Валидация Content-Type запроса. |
[Produces("application/json")] | Метод | MIME-типы | Устанавливает Content-Type ответа. |
5.3. Параметры маршрута и ограничения (Route Constraints)
[HttpGet("users/{id:int:min(1)}")] // id — int ≥ 1
[HttpGet("files/{name:regex(^\\w+\\.txt$)}")] // имя — word chars + ".txt"
[HttpGet("posts/{date:datetime:regex(\\d{{4}}-\\d{{2}}-\\d{{2}})}")] // кастомный формат
Встроенные ограничения:
| Ограничение | Пример | Описание |
|---|---|---|
int | {id:int} | int |
bool | {active:bool} | bool |
datetime | {date:datetime} | DateTime |
decimal | {price:decimal} | decimal |
double | {lat:double} | double |
float | {temp:float} | float |
guid | {id:guid} | Guid |
long | {count:long} | long |
min(val) | {age:min(18)} | ≥ val |
max(val) | {age:max(120)} | ≤ val |
range(min,max) | {count:range(1,10)} | min ≤ x ≤ max |
alpha | {name:alpha} | только буквы |
regex(pattern) | {code:regex(^[A-Z]{3}$)} | регулярное выражение |
required | {name:required} | не null и не пустая строка |
⚠️ Если ограничение не проходит — 404 (не 400!).
5.4. Именованные маршруты и генерация URL
[HttpGet("users/{id}", Name = "GetUserById")]
public IActionResult GetUser(int id) { ... }
// Генерация в контроллере:
var url = Url.RouteUrl("GetUserById", new { id = 42 }); // → "/users/42"
// Или в Minimal API через IUrlHelperFactory:
var urlHelper = urlHelperFactory.GetUrlHelper(actionContextAccessor.ActionContext);
var url = urlHelper.RouteUrl("GetUserById", new { id = 42 });
5.5. Fallback-маршруты
app.MapFallbackToFile("index.html"); // SPA: все несуществующие пути → index.html
app.MapFallback(() => Results.Redirect("/not-found")); // кастомный fallback
🔹 MVC, Razor Pages, API-разработка, OpenAPI
1. MVC и Razor Pages — архитектура и различия
| Критерий | MVC | Razor Pages |
|---|---|---|
| Единица модульности | Контроллер + Action + View (раздельно) | PageModel + .cshtml (в одном файле .cshtml.cs) |
| Маршрутизация | Атрибуты или conventional routing ({controller=Home}/{action=Index}/{id?}) | По пути файла: /Pages/Products/Index.cshtml → /Products/Index |
| Handler-методы | public IActionResult Index() | public void OnGet(), public IActionResult OnPostCreate() и т.д. |
| Bind-параметры | public IActionResult Create([FromBody] Product p) | public IActionResult OnPost([FromForm] Product p) |
| Хранение состояния | В основном через TempData, ViewData, ViewBag | ModelState, TempData, PageContext |
| Рекомендация Microsoft | Для сложных SPA/REST API | Для page-centric приложений (CMS, админки, формы) |
✅ Оба используют один и тот же движок представлений (Razor), DI, middleware, авторизацию.
2. Контроллеры и действия (Actions)
2.1. Базовые классы и интерфейсы
| Тип | Интерфейс | Наследование | Примечание |
|---|---|---|---|
ControllerBase | IActionResult | ControllerBase | Минимальный базовый класс (для API). Не имеет View(). |
Controller | ControllerBase | Controller | Расширяет ControllerBase: добавляет View(), PartialView(), ViewData, TempData. Для MVC с представлениями. |
2.2. Типы возвращаемых значений действий
| Тип | Метод-помощник | HTTP-код | Сериализация |
|---|---|---|---|
IActionResult | Ok(), CreatedAtAction(), Unauthorized(), NotFound() | По методу | Нет (уже сформирован ответ) |
ActionResult<T> | Ok<T>(T value), CreatedAtAction<T>(...) | По методу | Нет (уже сформирован ответ) |
T (POCO) | — | 200 | Да: через OutputFormatter (JSON/XML) |
Task<T> / Task<IActionResult> | — | 200 / по методу | Да / Нет |
⚠️ Возвращая
T, вы делегируете сериализацию фреймворку. При ошибке (исключение) — 500. ИспользуйтеActionResult<T>, если нужен контроль над статус-кодом.
2.3. Атрибуты действий (Action Attributes)
| Атрибут | Применение | Параметры / Эффект |
|---|---|---|
[HttpGet], [HttpPost], [HttpPut], [HttpDelete], [HttpPatch], [HttpHead], [HttpOptions] | Метод | Name, Order, Template (относительный маршрут) |
[AcceptVerbs("MOVE", "COPY")] | Метод | Произвольные HTTP-глаголы |
[ActionName("List")] | Метод | Заменяет имя действия в маршруте и Url.Action() |
[NonAction] | Метод | Исключает из маршрутизации (вспомогательный метод) |
[Route("api/products/{id}")] | Класс / Метод | Явный маршрут (см. часть 1) |
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))] | Метод | Применяет соглашение (например, 200/404 для GET) |
2.4. Привязка параметров (Model Binding)
| Атрибут | Источник | Примечание |
|---|---|---|
[FromQuery] | ?name=value | Значения строк, коллекции (?ids=1&ids=2), объекты (?user.name=Timur) |
[FromRoute] | {id} в маршруте | Требует совпадения имени параметра и placeholder’а |
[FromBody] | Тело запроса (JSON/XML) | Только один параметр на метод. Использует InputFormatter. |
[FromForm] | application/x-www-form-urlencoded, multipart/form-data | Для HTML-форм и загрузки файлов |
[FromHeader] | Заголовки | Например, [FromHeader(Name = "X-Request-Id")] string requestId |
[FromServices] | DI-контейнер | Внедрение сервиса напрямую в параметр (не через конструктор) |
[ModelBinder(typeof(MyBinder))] | Кастомный биндер | Для нетиповых сценариев |
Пример:
[HttpPost("upload")]
public IActionResult Upload(
[FromForm] IFormFile file, // файл
[FromForm] string description, // поле формы
[FromHeader("X-Api-Version")] string version, // заголовок
[FromServices] ILogger<HomeController> logger) // DI
{
// ...
}
⚠️
[FromBody]не работает сapplication/x-www-form-urlencoded— только сapplication/json,application/xml.
3. Представления (Views) и Razor-синтаксис
3.1. Основные директивы
| Директива | Назначение |
|---|---|
@page | Только в Razor Pages — делает файл страницей |
@model TypeName | Указывает тип модели представления (@Model) |
@inject ServiceType ServiceName | Внедрение сервиса в представление (не рекомендуется — нарушает разделение ответственности) |
@addTagHelper *, AssemblyName | Подключение тег-хелперов (обычно в _ViewImports.cshtml) |
@using Namespace | Импорт пространства имён |
@functions { ... } | Код C# внутри представления (устаревшее, лучше выносить в PageModel/контроллер) |
3.2. Layout и Sections
<!-- _Layout.cshtml -->
<!DOCTYPE html>
<html>
<head>
@RenderSection("Head", required: false)
</head>
<body>
<header>...</header>
<main>@RenderBody()</main>
<footer>...</footer>
@RenderSection("Scripts", required: false)
</body>
</html>
<!-- Index.cshtml -->
@{
Layout = "_Layout";
}
@section Head {
<meta name="description" content="Home page">
}
<h1>Home</h1>
@section Scripts {
<script src="/js/home.js"></script>
}
3.3. Частичные представления (Partial Views)
| Метод | Описание |
|---|---|
@await Html.PartialAsync("_Product", product) | Рендеринг частичного представления с моделью. Возвращает IHtmlContent. |
@await Html.RenderPartialAsync("_Sidebar", null) | То же, но пишет напрямую в TextWriter (чуть быстрее, но не возвращает значение). |
@{ await Html.RenderPartialAsync(...); } | При использовании RenderPartialAsync требуется await внутри блока. |
✅
_ViewStart.cshtml— выполняется перед каждым представлением (часто задаётLayout).
3.4. View Components
Аналог частичных представлений, но с логикой в классе и DI.
public class PriorityList : ViewComponent
{
private readonly IToDoItemRepository _repo;
public PriorityList(IToDoItemRepository repo) => _repo = repo;
public async Task<IViewComponentResult> InvokeAsync(int maxPriority)
{
var items = await _repo.GetItemsWithPriorityAsync(maxPriority);
return View(items); // ищет Views/Shared/Components/PriorityList/Default.cshtml
}
}
<!-- В представлении -->
@await Component.InvokeAsync("PriorityList", new { maxPriority = 2 })
<!-- Или строго типизированно: -->
@await Component.InvokeAsync<PriorityListViewComponent>(new { maxPriority = 2 })
🔹 Имена:
- Класс:
PriorityListViewComponent(илиPriorityList— фреймворк добавитViewComponentпри поиске)- Шаблон:
/Views/Shared/Components/PriorityList/Default.cshtml- Доп. шаблоны:
InvokeAsync(..., "HighPriority")→HighPriority.cshtml
4. Тег-хелперы (Tag Helpers)
Тег-хелперы — серверные компоненты, модифицирующие HTML-элементы по атрибутам/тегам.
4.1. Встроенные тег-хелперы (активируются через _ViewImports.cshtml: @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers)
| Тег-хелпер | Атрибуты | Описание |
|---|---|---|
<a> (AnchorTagHelper) | asp-controller, asp-action, asp-route-{param}, asp-fragment, asp-area, asp-protocol, asp-host | Генерирует URL через маршрутизацию. Пример: <a asp-action="List" asp-route-id="5">View</a> → <a href="/Home/List/5">View</a> |
<form> (FormTagHelper) | asp-controller, asp-action, asp-route-{param}, asp-antiforgery="true" | Генерирует <form method="post" action="..."> + <input name="__RequestVerificationToken" ...> при asp-antiforgery. |
<input> (InputTagHelper) | asp-for="Model.Property", asp-format, type | Генерирует <input> с id, name, value, type, data-val-* (для валидации). |
<label> (LabelTagHelper) | asp-for="Model.Property" | Генерирует <label for="...">ИмяСвойства</label>. |
<select> (SelectTagHelper) | asp-for, asp-items="IEnumerable<SelectListItem>" | Связывает select с моделью и коллекцией опций. |
<textarea> (TextAreaTagHelper) | asp-for | Аналогично input, но для multi-line. |
<img> (ImageTagHelper) | asp-append-version="true" | Добавляет ?v=hash к src для кэширования. |
<environment> | names="Development,Staging" | Условный рендеринг: <environment include="Development"><script src="~/js/dev.js"></script></environment> |
<cache> | expires-after, expires-on, vary-by-* | Кэширование фрагмента HTML на стороне сервера. |
<partial> | name, for, model, view-data | <partial name="_Product" model="product" /> — современная замена @Html.Partial. |
4.2. Валидация и data-val-*
При использовании asp-for генерируются атрибуты для клиентской валидации (требуется jquery.validate, jquery.validate.unobtrusive):
<input asp-for="Email" />
<!-- → -->
<input type="email" id="Email" name="Email" value=""
data-val="true"
data-val-required="The Email field is required."
data-val-email="The Email field is not a valid e-mail address." />
✅ Валидация:
ModelState.IsValid— проверка на сервереdata-val-*+ JS — проверка на клиенте[Required],[EmailAddress],[Range],[RegularExpression]и др. — изSystem.ComponentModel.DataAnnotations.
4.3. Кастомный тег-хелпер
[HtmlTargetElement("time-ago", Attributes = "datetime")]
public class TimeAgoTagHelper : TagHelper
{
[HtmlAttributeName("datetime")]
public DateTime DateTime { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = "span";
output.Attributes.SetAttribute("title", DateTime.ToString("O"));
output.Content.SetContent(DateTime.ToTimeAgo()); // "2 hours ago"
}
}
Регистрация в _ViewImports.cshtml:
@addTagHelper *, MyWebApp
Использование:
<time-ago datetime="@item.CreatedAt"></time-ago>
5. Minimal APIs
5.1. Сравнение с контроллерами
| Критерий | Minimal APIs | Контроллеры |
|---|---|---|
| Код | Меньше boilerplate | Более структурирован |
| Тестирование | Сложнее (делегаты) | Проще (классы) |
| Фильтры | Ограниченная поддержка (через AddEndpointFilter) | Полная ([Authorize], [ValidateAntiForgeryToken]) |
| Swagger/OpenAPI | Поддерживается, но требует аннотаций | Авто-документирование |
| Сложная логика | Не рекомендуется | Подходит |
5.2. Базовый синтаксис
var app = builder.Build();
// GET с параметром из маршрута
app.MapGet("/users/{id:int}", (int id) => Results.Ok(new { Id = id, Name = "Timur" }));
// POST с телом (автопривязка)
app.MapPost("/users", (User user) => {
// ModelState.IsValid — недоступен напрямую!
// Нужно:
// var http = httpContextAccessor.HttpContext!;
// if (!http.RequestServices.GetRequiredService<IModelValidator>().Validate(...))
return Results.Created($"/users/{user.Id}", user);
});
// DI в параметрах
app.MapGet("/time", (TimeProvider time) => Results.Ok(time.GetUtcNow()));
5.3. Продвинутые сценарии
Группировка:
var api = app.MapGroup("/api")
.WithTags("v1")
.RequireAuthorization();
api.MapGet("/users", () => ...);
api.MapPost("/users", (User u) => ...);
Валидация (без ModelState):
app.MapPost("/users", (User user, HttpContext ctx) =>
{
var validator = ctx.RequestServices.GetRequiredService<IValidator<User>>();
var result = validator.Validate(user);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
return Results.Created(...);
});
Endpoint filters (аналог Action Filters):
app.MapGet("/secret", () => "OK")
.AddEndpointFilter(async (efi, next) =>
{
if (efi.HttpContext.Request.Headers["X-Secret"] != "42")
return Results.Unauthorized();
return await next(efi);
});
6. Форматирование ответов (Output Formatters)
6.1. Регистрация форматтеров
builder.Services.AddControllers(options =>
{
options.OutputFormatters.Insert(0, new XmlSerializerOutputFormatter());
// или:
options.RespectBrowserAcceptHeader = true; // читать Accept: header
});
6.2. Управление форматом ответа
| Способ | Описание |
|---|---|
Accept: application/json | Лучший способ — клиент указывает предпочтения. |
?format=json / ?format=xml | Query string (требует options.ReturnHttpNotAcceptable = false). |
[Produces("application/xml")] | Принудительно для действия/контроллера. |
Content-Type: application/json в запросе | Не влияет на ответ — только на вход ([FromBody]). |
6.3. Problem Details (RFC 7807)
Стандартный формат ошибок:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-abc-def-00",
"errors": {
"Email": ["The Email field is required."]
}
}
Включается по умолчанию в AddControllers().
Настройка:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = ctx =>
{
var problem = new ValidationProblemDetails(ctx.ModelState)
{
Status = StatusCodes.Status400BadRequest,
Type = "https://example.com/errors/validation",
Title = "Validation failed"
};
return new BadRequestObjectResult(problem);
};
});
7. Версионирование API
7.1. Способы
| Метод | Пример | Настройка |
|---|---|---|
| Query string | /api/users?api-version=2.0 | options.AssumeDefaultVersionWhenUnspecified = true; |
| Заголовок | api-version: 2.0 | options.ApiVersionReader = new HeaderApiVersionReader("api-version"); |
| URL-путь | /api/v2/users | options.ApiVersionReader = new UrlSegmentApiVersionReader(); |
7.2. Регистрация (Microsoft.AspNetCore.Mvc.Versioning)
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // добавляет `api-supported-versions` в заголовки
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true;
});
7.3. Контроллеры с версиями
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("2.0")] // только для v2
public IActionResult GetV2() => Ok("v2");
[HttpGet]
[MapToApiVersion("1.0")] // только для v1
public IActionResult GetV1() => Ok("v1");
}
8. OpenAPI (Swagger) через Swashbuckle.AspNetCore
8.1. Установка и базовая настройка
dotnet add package Swashbuckle.AspNetCore
builder.Services.AddEndpointsApiExplorer(); // обязательно для Minimal APIs
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "ASP.NET Core 8 API",
Contact = new OpenApiContact { Name = "Timur Tagirov", Email = "timur@example.com" },
License = new OpenApiLicense { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") }
});
// XML-комментарии (включить в .csproj: <GenerateDocumentationFile>true</GenerateDocumentationFile>)
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath))
c.IncludeXmlComments(xmlPath);
// Добавить схемы безопасности (JWT)
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter 'Bearer {token}'"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
8.2. Middleware в pipeline
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
c.RoutePrefix = "swagger"; // доступ по /swagger
c.DisplayRequestDuration();
c.EnableTryItOutByDefault();
});
8.3. Аннотации для улучшения документации (Swashbuckle.AspNetCore.Annotations)
[SwaggerOperation(
Summary = "Creates a new user",
Description = "Creates a user and returns the created entity with ID",
OperationId = "CreateUser")]
[SwaggerResponse(StatusCodes.Status201Created, "User created", typeof(UserDto))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid input")]
public IActionResult Create([FromBody] CreateUserRequest request) { ... }
🔹 Аутентификация, авторизация, кэширование, логирование
1. Аутентификация (IAuthenticationService)
1.1. Архитектура: схемы (AuthenticationScheme)
Ключевая идея ASP.NET Core — мульти-схемная аутентификация: приложение может поддерживать несколько независимых способов аутентификации одновременно (например, JWT для API и Cookies для админки).
| Компонент | Роль |
|---|---|
AddAuthentication(string defaultScheme) | Регистрирует IAuthenticationService. defaultScheme — схема по умолчанию для Challenge/Forbid. |
AddScheme<THandler, TOptions>(string scheme, ...) | Низкоуровневая регистрация. Обычно не используется напрямую. |
AddCookie(), AddJwtBearer(), AddOAuth() и др. | Удобные методы-обёртки для популярных схем. |
Пример: две схемы
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies"; // для MVC
options.DefaultChallengeScheme = "oidc"; // для входа через Google
options.DefaultAuthenticateScheme = "Cookies"; // кто устанавливает User
})
.AddCookie("Cookies", options =>
{
options.LoginPath = "/account/login";
options.AccessDeniedPath = "/account/access-denied";
options.ExpireTimeSpan = TimeSpan.FromDays(14);
options.SlidingExpiration = true;
})
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://myapp.com",
ValidateAudience = true,
ValidAudience = "api",
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("very-long-secret-key"))
};
});
⚠️
DefaultAuthenticateSchemeопределяет, какая схема заполняетHttpContext.User.DefaultChallengeScheme— какая схема вызывается при[Authorize]безUser.Identity.IsAuthenticated.
1.2. Встроенные схемы и их параметры
1.2.1. AddCookie()
Параметр (CookieAuthenticationOptions) | Значение по умолчанию | Описание |
|---|---|---|
LoginPath | /Account/Login | Куда редиректить при Challenge. |
AccessDeniedPath | /Account/AccessDenied | Куда редиректить при Forbid. |
LogoutPath | /Account/LogOut | Обрабатывается автоматически (очистка cookie). |
Cookie.Name | .AspNetCore.Cookies | Имя cookie. |
Cookie.Domain | null | Домен (для subdomain-аутентификации: .example.com). |
Cookie.SecurePolicy | SameAsRequest | Always (только HTTPS), None — опасно. |
Cookie.HttpOnly | true | Защита от XSS. |
Cookie.SameSite | Unspecified → Lax | Strict, Lax, None (для кросс-сайтовых запросов). |
ExpireTimeSpan | 14 дней | Полный срок жизни cookie. |
SlidingExpiration | true | Продлевать при активности. |
Events.OnValidatePrincipal | null | Кастомная валидация (например, проверка отзыва токена). |
1.2.2. AddJwtBearer()
Параметр (JwtBearerOptions) | Описание |
|---|---|
Authority | URL OpenID Connect провайдера (автоматически загружает конфигурацию .well-known/openid-configuration). |
Audience | Значение aud в токене. |
TokenValidationParameters | Тонкая настройка валидации (ключ, issuer, lifetime, clock skew). |
RequireHttpsMetadata | true в продакшене (запрещает HTTP-токены). |
Events.OnTokenValidated | Доп. обработка после валидации (например, маппинг claims). |
Events.OnAuthenticationFailed | Обработка ошибок (логирование, кастомный ответ). |
1.2.3. AddOAuth() / AddOpenIdConnect()
| Параметр | Описание |
|---|---|
ClientId, ClientSecret | От провайдера (Google, GitHub и др.). |
AuthorizationEndpoint, TokenEndpoint, UserInformationEndpoint | URL’ы провайдера (часто подставляются автоматически). |
Scope | openid profile email для OIDC. |
SaveTokens | Сохранять access_token/refresh_token в AuthenticationProperties. |
ClaimActions | Фильтрация/преобразование claims (например, MapJsonKey(ClaimTypes.Name, "name")). |
Events.OnCreatingTicket | Обогащение ClaimsIdentity после получения данных. |
✅ Пример для Google:
.AddGoogle(options =>
{
options.ClientId = "xxx.apps.googleusercontent.com";
options.ClientSecret = "GOCSPX-xxx";
options.Scope.Add("email");
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
});
1.3. Управление сессией в коде
Метод (HttpContext) | Описание |
|---|---|
HttpContext.SignInAsync("Cookies", principal, properties) | Установить аутентификационную cookie. properties — AuthenticationProperties (IsPersistent, ExpiresUtc). |
HttpContext.SignOutAsync("Cookies") | Очистить cookie. |
HttpContext.AuthenticateAsync("Bearer") | Принудительно выполнить аутентификацию по схеме (например, в middleware). |
HttpContext.ChallengeAsync("oidc") | Инициировать вход (редирект на провайдера). |
HttpContext.ForbidAsync() | Вернуть 403 (или редирект на AccessDeniedPath). |
Пример контроллера входа:
[HttpPost]
public async Task<IActionResult> Login(string returnUrl)
{
var claims = new[]
{
new Claim(ClaimTypes.Name, "Timur"),
new Claim(ClaimTypes.Role, "Admin")
};
var identity = new ClaimsIdentity(claims, "Cookies");
var principal = new ClaimsPrincipal(identity);
var props = new AuthenticationProperties { IsPersistent = true };
await HttpContext.SignInAsync("Cookies", principal, props);
return LocalRedirect(returnUrl ?? "/");
}
2. Авторизация (IAuthorizationService)
2.1. Атрибуты и базовые политики
| Атрибут | Описание |
|---|---|
[Authorize] | Требует аутентификации. |
[Authorize(Roles = "Admin,Manager")] | Требует роль (проверяет ClaimTypes.Role). |
[Authorize(Policy = "RequireAdmin")] | Требует выполнения политики. |
[AllowAnonymous] | Отключает авторизацию для действия/контроллера. |
2.2. Политики: IAuthorizationRequirement и IAuthorizationHandler
Шаг 1: Требование
public class MinimumAgeRequirement : IAuthorizationRequirement
{
public int MinimumAge { get; }
public MinimumAgeRequirement(int age) => MinimumAge = age;
}
Шаг 2: Обработчик
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var dateOfBirthClaim = context.User.FindFirst(ClaimTypes.DateOfBirth);
if (dateOfBirthClaim is null) return Task.CompletedTask;
if (DateTime.Parse(dateOfBirthClaim.Value).AddYears(requirement.MinimumAge) <= DateTime.Today)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Шаг 3: Регистрация
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AtLeast21", policy =>
policy.Requirements.Add(new MinimumAgeRequirement(21)));
});
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
Шаг 4: Использование
[Authorize(Policy = "AtLeast21")]
public IActionResult Alcohol() => Ok("Here's your beer");
2.3. Продвинутые политики
| Метод политики | Эффект |
|---|---|
RequireAuthenticatedUser() | Аналог [Authorize]. |
RequireRole("Admin") | Аналог Roles = "Admin". |
RequireClaim("Department", "HR") | Наличие claim с конкретным значением. |
RequireAssertion(ctx => ctx.User.HasClaim(...)) | Произвольная логика. |
RequireUserName("timur") | По ClaimTypes.Name. |
AddAuthenticationSchemes("Bearer") | Ограничивает политику конкретной схемой. |
Пример: комбинированная политика
options.AddPolicy("HRManager", policy =>
{
policy.RequireRole("HR")
.RequireClaim("Level", "Manager")
.RequireAssertion(ctx => ctx.User.HasClaim("Clearance", "TopSecret"));
});
2.4. ASP.NET Core Identity
2.4.1. Базовые сервисы
| Сервис | Интерфейс | Основные методы |
|---|---|---|
| User Management | UserManager<TUser> | CreateAsync, DeleteAsync, FindByEmailAsync, AddToRoleAsync, GetRolesAsync, GetClaimsAsync, AddClaimAsync |
| Sign-in | SignInManager<TUser> | PasswordSignInAsync, SignInAsync, SignOutAsync, RefreshSignInAsync, IsSignedIn |
| Role Management | RoleManager<TRole> | CreateAsync, DeleteAsync, FindByNameAsync, AddClaimAsync |
2.4.2. Настройка Identity
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Настройки пароля
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
// Блокировка
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
// Email
options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders(); // для сброса пароля, подтверждения email
2.4.3. Кастомные claim’ы после входа
// В Startup или через событие
services.ConfigureApplicationCookie(options =>
{
options.Events.OnSignedIn = async ctx =>
{
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.GetUserAsync(ctx.Principal);
var claims = await userManager.GetClaimsAsync(user);
var appIdentity = new ClaimsIdentity(claims, "Application");
ctx.Principal.AddIdentity(appIdentity);
};
});
3. Кэширование
3.1. ResponseCache (HTTP-кэширование)
Атрибут [ResponseCache]
[ResponseCache(Duration = 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new[] { "id" })]
public IActionResult GetProduct(int id) => Ok(...);
| Параметр | HTTP-заголовок | Описание |
|---|---|---|
Duration | Cache-Control: public, max-age=60 | Сколько кэшировать (сек). |
Location | Cache-Control: public/private/no-cache | Где: Any (CDN, браузер), Client (только браузер), None. |
NoStore | Cache-Control: no-store | Полный запрет кэширования. |
VaryByHeader | Vary: User-Agent | Разные версии кэша по заголовкам. |
VaryByQueryKeys | Vary: Accept-Encoding + внутренняя логика | По query-параметрам. |
⚠️ Не работает с авторизованными запросами по умолчанию (из-за
privateвCache-Control). Нужно явно указатьLocation = ResponseCacheLocation.Any.
3.2. IMemoryCache
Регистрация
builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // в условных единицах
});
Использование
public class CacheService
{
private readonly IMemoryCache _cache;
public CacheService(IMemoryCache cache) => _cache = cache;
public T GetOrCreate<T>(string key, Func<ICacheEntry, T> factory)
{
return _cache.GetOrCreate(key, entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.Size = 1;
return factory(entry);
});
}
}
Параметр ICacheEntry | Описание |
|---|---|
AbsoluteExpiration | Точная дата истечения. |
AbsoluteExpirationRelativeToNow | Относительно текущего времени. |
SlidingExpiration | Продлевать при доступе. |
Priority | Low, Normal, High, NeverRemove. |
Size | Для учёта SizeLimit. |
PostEvictionCallbacks | Обратный вызов при удалении (например, логирование). |
3.3. IDistributedCache (Redis, SQL Server)
Регистрация Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
options.InstanceName = "MyApp_";
});
Интерфейс
public interface IDistributedCache
{
byte[] Get(string key);
Task<byte[]> GetAsync(string key, CancellationToken token = default);
void Set(string key, byte[] value, DistributedCacheEntryOptions options);
Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default);
void Refresh(string key); // обновить sliding expiration
Task RefreshAsync(string key, CancellationToken token = default);
void Remove(string key);
Task RemoveAsync(string key, CancellationToken token = default);
}
Помощники для работы с объектами
public static class DistributedCacheExtensions
{
public static async Task<T> GetAsync<T>(this IDistributedCache cache, string key)
{
var bytes = await cache.GetAsync(key);
return bytes == null ? default : JsonSerializer.Deserialize<T>(bytes);
}
public static async Task SetAsync<T>(this IDistributedCache cache, string key, T value, DistributedCacheEntryOptions options)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
await cache.SetAsync(key, bytes, options);
}
}
3.4. Тег-хелпер <cache>
<cache expires-after="TimeSpan.FromMinutes(10)" vary-by-user="true">
<p>Last updated: @DateTime.Now</p>
</cache>
| Атрибут | Описание |
|---|---|
expires-on | DateTimeOffset — абсолютное время. |
expires-after | TimeSpan — относительно сейчас. |
expires-sliding | TimeSpan — sliding expiration. |
vary-by-user | Разные версии для каждого пользователя. |
vary-by-query | ?sort=asc → разные кэши. |
vary-by-route | По параметрам маршрута (id). |
vary-by-header | По заголовкам (Accept-Language). |
vary-by-cookie | По cookie (theme=dark). |
✅ Хранится в
IMemoryCacheпо умолчанию. Для распределённого кэша — кастомныйICacheTagHelperProvider.
4. Логирование (ILogger<T>)
4.1. Уровни логирования (от низкого к высокому)
| Уровень | Когда использовать |
|---|---|
Trace | Детальные данные (например, каждый шаг в алгоритме). Только в разработке. |
Debug | Отладочная информация (SQL-запросы, внутренние состояния). |
Information | Основные события («User logged in», «Order created»). |
Warning | Нестандартная, но обработанная ситуация («Retry #1 failed»). |
Error | Ошибка, не приводящая к падению («Database timeout»). |
Critical | Катастрофическая ошибка («Disk full», «Config missing»). |
4.2. Конфигурация уровней
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"MyApp.Services": "Debug"
}
}
}
Или программно:
builder.Logging.ClearProviders()
.AddConsole()
.AddFilter("Microsoft", LogLevel.Warning)
.AddFilter("System", LogLevel.Warning)
.AddFilter("MyApp", LogLevel.Debug);
4.3. Использование ILogger<T>
public class OrderService
{
private readonly ILogger<OrderService> _logger;
public OrderService(ILogger<OrderService> logger) => _logger = logger;
public void ProcessOrder(Order order)
{
_logger.LogInformation("Processing order {OrderId} for {CustomerId}",
order.Id, order.CustomerId);
try
{
// ...
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Failed to save order {OrderId}", order.Id);
throw;
}
}
}
✅ Шаблон
{PropertyName}— для структурированного логирования (Serilog, Application Insights).
4.4. Scopes
Группировка логов по контексту (например, по HTTP-запросу или ID операции).
using (_logger.BeginScope("OrderId: {OrderId}", order.Id))
{
_logger.LogInformation("Starting validation");
Validate(order);
_logger.LogInformation("Saving to DB");
Save(order);
}
// Все логи внутри будут содержать {OrderId}
Встроенные scopes:
Microsoft.AspNetCore.Hosting.Diagnostics:RequestPath,RequestIdMicrosoft.EntityFrameworkCore.Database.Command:CommandId,ConnectionId
4.5. Провайдеры логирования
| Провайдер | Пакет | Особенности |
|---|---|---|
| Console | Встроен | Цвета, JSON-режим ("Console": { "FormatterName": "json" }). |
| Debug | Встроен | Вывод в окно «Вывод» Visual Studio. |
| EventSource | Встроен | ETW-трассировка (для dotnet-trace, PerfView). |
| EventLog | Microsoft.Extensions.Logging.EventLog | Только Windows. |
| Serilog | Serilog.AspNetCore | Структурированный лог, обогащение, отправка в Seq/ELK. |
| Application Insights | Microsoft.ApplicationInsights.AspNetCore | Интеграция с Azure Monitor. |
Пример Serilog:
builder.Host.UseSerilog((ctx, logger) =>
{
logger.WriteTo.Console()
.WriteTo.File("logs/log-.txt", rollingInterval: RollingInterval.Day)
.Enrich.FromLogContext()
.Enrich.WithProperty("Application", "MyApp")
.ReadFrom.Configuration(ctx.Configuration);
});
🔹 SignalR, gRPC, Health Checks, Тестирование
1. SignalR
1.1. Архитектура
SignalR обеспечивает real-time двустороннюю связь между сервером и клиентами. Поддерживает fallback-транспорты:
| Транспорт | Поддержка | Характеристики |
|---|---|---|
| WebSockets | Все современные браузеры и серверы | Полный дуплекс, низкая задержка. Предпочтительный. |
| Server-Sent Events (SSE) | Все кроме IE | Только сервер → клиент. |
| Long Polling | Универсальный | Высокая задержка, высокое потребление ресурсов. |
✅ SignalR автоматически выбирает лучший доступный транспорт.
1.2. Хабы (Hubs)
Хаб — серверный класс, наследуемый от Hub или Hub<T> (строго типизированный).
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
// Отправить всем клиентам
await Clients.All.SendAsync("ReceiveMessage", user, message);
// Отправить вызвавшему клиенту
await Clients.Caller.SendAsync("Ack", "Delivered");
// Отправить всем, кроме вызвавшего
await Clients.Others.SendAsync("Notification", $"{user} wrote: {message}");
}
public override async Task OnConnectedAsync()
{
var connectionId = Context.ConnectionId;
var userId = Context.UserIdentifier; // см. AddUserIdProvider
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Очистка ресурсов
await base.OnDisconnectedAsync(exception);
}
}
Строго типизированный хаб
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task Ack(string status);
}
public class TypedChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
await Clients.All.ReceiveMessage(user, message);
}
}
1.3. Регистрация и маршрутизация
builder.Services.AddSignalR(options =>
{
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.HandshakeTimeout = TimeSpan.FromSeconds(15);
options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB
});
var app = builder.Build();
app.MapHub<ChatHub>("/chat");
⚠️
MapHubдолжен идти послеUseRouting()и доUseEndpoints().
1.4. Авторизация
// В хабе:
[Authorize]
public class SecureChatHub : Hub { ... }
// Или на методе:
public class ChatHub : Hub
{
[Authorize(Roles = "Admin")]
public Task AdminCommand(string cmd) { ... }
}
Кастомный IUserIdProvider
public class CustomUserIdProvider : IUserIdProvider
{
public string GetUserId(HubConnectionContext connection)
{
return connection.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
}
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
Теперь можно отправлять клиенту по ID:
await Clients.User("user123").SendAsync("PrivateMessage", message);
1.5. Масштабирование (Scale-out)
Для работы в кластере требуется backplane — Redis, Azure SignalR Service или SQL Server.
Redis backplane
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
.AddStackExchangeRedis(options =>
{
options.Configuration = "localhost:6379";
options.ChannelPrefix = "MyApp_SignalR";
});
✅ Все серверы подписываются на один Redis-канал. Сообщения реплицируются автоматически.
1.6. Streaming
Серверный стриминг
public async IAsyncEnumerable<int> Counter(int count, int delay)
{
for (int i = 1; i <= count; i++)
{
await Task.Delay(delay);
yield return i;
}
}
Клиент (JavaScript):
const stream = connection.stream("Counter", 10, 1000);
for await (const item of stream) {
console.log(item);
}
Двусторонний стриминг
public async Task UploadStream(IAsyncEnumerable<Chunk> stream)
{
await foreach (var chunk in stream)
{
// обработка чанка
}
}
2. gRPC
2.1. Базовая настройка
.proto файл (Protos/greet.proto)
syntax = "proto3";
option csharp_namespace = "MyApp.Protos";
package greet;
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc SayHellos (HelloRequest) returns (stream HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
Генерация кода (в .csproj)
<ItemGroup>
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
Сервис
public class GreeterService : Greeter.GreeterBase
{
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply { Message = $"Hello {request.Name}" });
}
public override async Task SayHellos(HelloRequest request, IServerStreamWriter<HelloReply> responseStream, ServerCallContext context)
{
for (int i = 1; i <= 5; i++)
{
await responseStream.WriteAsync(new HelloReply { Message = $"Hello {request.Name} #{i}" });
await Task.Delay(500);
}
}
}
Регистрация
builder.Services.AddGrpc(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.MaxReceiveMessageSize = 4 * 1024 * 1024; // 4 MB
options.MaxSendMessageSize = 4 * 1024 * 1024;
});
app.MapGrpcService<GreeterService>();
2.2. Клиент
var channel = GrpcChannel.ForAddress("https://localhost:5001");
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(new HelloRequest { Name = "Timur" });
Console.WriteLine(reply.Message);
2.3. Interceptor’ы
Логирование
public class LoggingInterceptor : Interceptor
{
private readonly ILogger<LoggingInterceptor> _logger;
public LoggingInterceptor(ILogger<LoggingInterceptor> logger) => _logger = logger;
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
_logger.LogInformation("gRPC call: {Method}", context.Method);
return await continuation(request, context);
}
}
// Регистрация:
builder.Services.AddGrpc(options =>
{
options.Interceptors.Add<LoggingInterceptor>();
});
2.4. JSON Transcoding
Позволяет вызывать gRPC-методы через HTTP/JSON (REST-like).
Шаг 1: Аннотации в .proto
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
get: "/v1/greet/{name}"
};
}
}
Шаг 2: Включение
builder.Services.AddGrpc()
.AddJsonTranscoding(options =>
{
options.JsonSettings = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
});
Шаг 3: Маршрутизация
app.MapGrpcService<GreeterService>();
// Теперь GET /v1/greet/Timur → SayHello("Timur")
⚠️ Требует
Google.Api.CommonProtosиMicrosoft.AspNetCore.Grpc.JsonTranscoding.
2.5. Валидация
Protobuf не поддерживает валидацию «из коробки». Используйте:
- Атрибуты
[Required],[Range]в сгенерированных классах (ручное редактирование — неустойчиво) - Или кастомный interceptor с FluentValidation:
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(...)
{
var validator = context.GetService<IValidator<TRequest>>();
if (validator != null)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
throw new RpcException(new Status(StatusCode.InvalidArgument, string.Join("; ", result.Errors.Select(e => e.ErrorMessage))));
}
return await continuation(request, context);
}
3. Health Checks
3.1. Базовая настройка
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy("Service is up"))
.AddSqlServer(connectionString, name: "database")
.AddRedis("localhost:6379", name: "redis")
.AddUrlGroup(new Uri("https://api.example.com/health"), name: "external-api");
Endpoint
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = async (ctx, report) =>
{
ctx.Response.ContentType = "application/json";
var result = new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
e.Key,
e.Value.Status.ToString(),
e.Value.Description,
duration = e.Value.Duration.TotalMilliseconds
})
};
await ctx.Response.WriteAsync(JsonSerializer.Serialize(result));
}
});
3.2. Кастомные проверки
public class DiskSpaceCheck : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var drive = DriveInfo.GetDrives().First(d => d.IsReady && d.Name == "C:\\");
var freePercent = (double)drive.AvailableFreeSpace / drive.TotalSize;
if (freePercent < 0.1) // < 10%
return HealthCheckResult.Unhealthy("Low disk space", null, new { FreePercent = freePercent });
return HealthCheckResult.Healthy("Disk space OK");
}
}
// Регистрация:
services.AddHealthChecks().AddCheck<DiskSpaceCheck>("disk");
3.3. Readiness и Liveness
- Liveness — «жив ли процесс?» (не завис ли).
- Readiness — «готов ли принимать трафик?» (миграции БД не завершены?).
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
// Регистрация с тегами:
services.AddHealthChecks()
.AddCheck("self", () => ..., tags: new[] { "live", "ready" })
.AddCheck("migration", () => ..., tags: new[] { "ready" });
3.4. Health Checks UI
Пакет AspNetCore.HealthChecks.UI:
builder.Services.AddHealthChecksUI()
.AddInMemoryStorage();
app.MapHealthChecksUI();
// Доступен по /healthchecks-ui
4. Тестирование
4.1. Интеграционные тесты (WebApplicationFactory<T>)
Базовый класс
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram> where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// Замена реальных сервисов на моки
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
}
}
Тест
public class ProductControllerTests : IClassFixture<CustomWebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ProductControllerTests(CustomWebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetProduct_ReturnsOk_WhenProductExists()
{
// Arrange
var response = await _client.GetAsync("/api/products/1");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Laptop", content);
}
}
4.2. Unit-тесты контроллеров
[Fact]
public void GetProduct_ReturnsNotFound_WhenProductIsNull()
{
// Arrange
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.GetByIdAsync(1)).ReturnsAsync((Product)null);
var controller = new ProductsController(mockRepo.Object);
// Act
var result = controller.GetProduct(1);
// Assert
Assert.IsType<NotFoundResult>(result.Result);
}
4.3. Тестирование middleware
[Fact]
public async Task TimingMiddleware_AddsHeader()
{
// Arrange
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.UseMiddleware<TimingMiddleware>();
app.Run(ctx => ctx.Response.WriteAsync("OK"));
using var server = new TestServer(app);
var client = server.CreateClient();
// Act
var response = await client.GetAsync("/");
// Assert
Assert.True(response.Headers.Contains("X-Elapsed"));
}
4.4. Проверка маршрутов
[Fact]
public void ProductsController_GetProduct_HasRoute()
{
// Arrange
var action = typeof(ProductsController).GetMethod(nameof(ProductsController.GetProduct));
var attributes = action!.GetCustomAttributes(typeof(HttpGetAttribute), false);
// Assert
Assert.Single(attributes);
var route = ((HttpGetAttribute)attributes[0]).Template;
Assert.Equal("api/products/{id}", route);
}
4.5. Проверка DI-регистраций
[Fact]
public void AllServicesAreRegistered()
{
var services = new ServiceCollection();
var startup = new Startup(new ConfigurationBuilder().Build());
startup.ConfigureServices(services);
var provider = services.BuildServiceProvider();
var scope = provider.CreateScope();
// Попытка разрешить все transient-сервисы
foreach (var descriptor in services.Where(d => d.Lifetime == ServiceLifetime.Transient))
{
if (descriptor.ServiceType.IsInterface || descriptor.ImplementationType != null)
{
var instance = scope.ServiceProvider.GetService(descriptor.ServiceType);
Assert.NotNull(instance);
}
}
}
🔹 Локализация и интернационализация, Фоновые задачи
1. Локализация и интернационализация
1.1. Основные интерфейсы и сервисы
| Компонент | Описание |
|---|---|
IStringLocalizer<T> | Локализатор строк по ключу (обычно T = класс или SharedResource). |
IStringLocalizer | Без типа — требует явного указания ресурса. |
IHtmlLocalizer<T> | Аналог IStringLocalizer, но возвращает HtmlString (без экранирования HTML). |
IViewLocalizer | Локализатор для представлений (инжектится в Razor). |
ResourceManagerStringLocalizerFactory | Реализация по умолчанию (работает с .resx-файлами). |
1.2. Ресурсы (.resx)
Структура проекта:
/Resources
├── SharedResource.cs // маркер-класс (пустой)
├── SharedResource.ru.resx // русский
├── SharedResource.en.resx // английский
└── Controllers
└── HomeController.ru.resx
└── HomeController.en.resx
⚠️ Файлы должны быть:
Build Action = Embedded ResourceCustom Tool = ResXFileCodeGenerator(опционально, для типизированных классов)
Пример SharedResource.en.resx:
| Name | Value |
|---|---|
| WelcomeMessage | Welcome, 0! |
| ValidationError.Required | The 0 field is required. |
1.3. Использование в коде
public class AccountController : Controller
{
private readonly IStringLocalizer<AccountController> _localizer;
public AccountController(IStringLocalizer<AccountController> localizer) => _localizer = localizer;
public IActionResult Login()
{
// Простая строка
ViewData["Message"] = _localizer["WelcomeMessage"];
// С параметрами (форматирование по порядку)
var greeting = _localizer["WelcomeMessage", User.Identity.Name];
// Проверка существования
if (_localizer["NotFoundKey"].ResourceNotFound)
throw new InvalidOperationException("Missing translation");
return View();
}
public IActionResult Error()
{
// Безопасное значение по умолчанию
var msg = _localizer.GetString("UnknownError", "An unknown error occurred");
return Content(msg);
}
}
В представлении (Razor):
@inject IViewLocalizer Localizer
<h1>@Localizer["WelcomeMessage", User.Identity.Name]</h1>
<span class="text-danger">@Localizer["ValidationError.Required", "Email"]</span>
✅
_ViewImports.cshtml:@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer
1.4. RequestLocalizationMiddleware
Контекстно-зависимое определение культуры на основе запроса.
Регистрация:
var supportedCultures = new[] { "en", "ru", "es" };
var localizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture("en"),
SupportedCultures = supportedCultures.Select(c => new CultureInfo(c)).ToList(),
SupportedUICultures = supportedCultures.Select(c => new CultureInfo(c)).ToList()
};
// Провайдеры (порядок важен — первый найденный выигрывает)
localizationOptions.RequestCultureProviders.Insert(0, new QueryStringRequestCultureProvider());
localizationOptions.RequestCultureProviders.Insert(1, new CookieRequestCultureProvider());
localizationOptions.RequestCultureProviders.Insert(2, new AcceptLanguageHeaderRequestCultureProvider());
app.UseRequestLocalization(localizationOptions);
Провайдеры:
| Провайдер | Источник | Пример |
|---|---|---|
QueryStringRequestCultureProvider | ?culture=ru&ui-culture=ru | Явное указание в URL |
CookieRequestCultureProvider | Cookie ".AspNetCore.Culture" | c=ru|uic=ru |
AcceptLanguageHeaderRequestCultureProvider | Заголовок Accept-Language: ru-RU,ru;q=0.9,en;q=0.8 | Браузерный язык |
RouteDataRequestCultureProvider | Параметр маршрута {culture=en} | /ru/products |
Кастомный провайдер (например, по домену):
public class DomainCultureProvider : RequestCultureProvider
{
private static readonly Dictionary<string, string> _domainMap = new()
{
["example.ru"] = "ru",
["example.es"] = "es"
};
public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
{
var host = httpContext.Request.Host.Host;
if (_domainMap.TryGetValue(host, out var culture))
return Task.FromResult(new ProviderCultureResult(culture));
return Task.FromResult<ProviderCultureResult>(null!);
}
}
Регистрация:
localizationOptions.RequestCultureProviders.Insert(0, new DomainCultureProvider());
1.5. Псевдолокализация
Для тестирования layout’а без перевода.
// В Development:
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<IStringLocalizerFactory, PseudoLocalizerFactory>();
}
public class PseudoLocalizerFactory : IStringLocalizerFactory
{
public IStringLocalizer Create(Type resourceSource) => new PseudoLocalizer();
public IStringLocalizer Create(string baseName, string location) => new PseudoLocalizer();
}
public class PseudoLocalizer : IStringLocalizer
{
public LocalizedString this[string name] => new(name, $"«{name}» [pseudo]", resourceNotFound: false);
public LocalizedString this[string name, params object[] arguments] =>
new(name, $"«{string.Format(name, arguments)}» [pseudo]", resourceNotFound: false);
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Enumerable.Empty<LocalizedString>();
}
1.6. Pluralization
Для языков с разным количеством форм множественного числа (например, русский: 1 товар, 2 товара, 5 товаров).
Подход 1: Через IStringLocalizer и ключи
<!-- SharedResource.ru.resx -->
Item_Count_Singular = {0} товар
Item_Count_Few = {0} товара
Item_Count_Many = {0} товаров
string GetMessage(int count)
{
return count switch
{
1 => _localizer["Item_Count_Singular", count],
int n when n % 10 is 2 or 3 or 4 && (n % 100 < 10 || n % 100 > 20) => _localizer["Item_Count_Few", count],
_ => _localizer["Item_Count_Many", count]
};
}
Подход 2: Microsoft.Extensions.Localization.Pluralization (ограниченная поддержка)
Требует сторонней библиотеки (OrchardCore.Localization.Core или Humanizer).
Подход 3: ICU (International Components for Unicode) через Microsoft.ICU
.NET 5+ поддерживает ICU на всех платформах. Можно использовать PluralRules:
using System.Globalization;
var rules = PluralRules.Create("ru");
var category = rules.GetCategory(5); // → PluralCategory.Many
⚠️ Пока не интегрирован «из коробки» в ASP.NET Core. Требует ручной обвязки.
2. Фоновые задачи
2.1. IHostedService и BackgroundService
Интерфейс IHostedService
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
Абстрактный класс BackgroundService
public abstract class BackgroundService : IHostedService
{
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
}
Пример: периодическая задача
public class TimerHostedService : BackgroundService
{
private readonly ILogger<TimerHostedService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private Timer? _timer;
public TimerHostedService(ILogger<TimerHostedService> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_timer = new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
await Task.CompletedTask;
}
private void DoWork(object? state)
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IEmailService>();
try
{
service.SendPendingEmails();
_logger.LogInformation("Email batch sent at {Time}", DateTimeOffset.Now);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send emails");
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.Infinite, 0);
await base.StopAsync(cancellationToken);
}
protected override void Dispose(bool disposing)
{
_timer?.Dispose();
base.Dispose(disposing);
}
}
Регистрация:
builder.Services.AddHostedService<TimerHostedService>();
✅ Всегда используйте
IServiceScopeFactory, чтобы избежать утечек scoped-зависимостей.
2.2. Отчёт о прогрессе (IProgress<T>)
Для задач с длительным выполнением и UI-обратной связью.
public class ProgressService
{
public async Task ProcessDataAsync(IProgress<ProgressReport> progress, CancellationToken ct)
{
var total = 100;
for (int i = 0; i <= total; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(50, ct);
progress?.Report(new ProgressReport { PercentComplete = i, Message = $"Step {i}" });
}
}
}
public record ProgressReport(int PercentComplete, string Message);
Использование (например, в MVC-действии с SSE или SignalR):
[HttpGet("process")]
public async Task Process()
{
var channel = Channel.CreateUnbounded<ProgressReport>();
var writer = channel.Writer;
_ = Task.Run(async () =>
{
try
{
await _progressService.ProcessDataAsync(new Progress<ProgressReport>(r => writer.TryWrite(r)), HttpContext.RequestAborted);
}
finally { writer.Complete(); }
});
Response.Headers.Append("Content-Type", "text/event-stream");
await foreach (var report in channel.Reader.ReadAllAsync())
{
await Response.WriteAsync($"data: {JsonSerializer.Serialize(report)}\n\n");
await Response.Body.FlushAsync();
}
}
2.3. Quartz.NET
Для сложного планирования (cron, интервалы, распределённое выполнение).
Установка:
dotnet add package Quartz
dotnet add package Quartz.Extensions.DependencyInjection
dotnet add package Quartz.Extensions.Hosting
Job
public class BackupJob : IJob
{
private readonly IBackupService _backup;
private readonly ILogger<BackupJob> _logger;
public BackupJob(IBackupService backup, ILogger<BackupJob> logger)
{
_backup = backup;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Starting backup at {Time}", DateTime.UtcNow);
await _backup.RunAsync(context.CancellationToken);
_logger.LogInformation("Backup completed");
}
}
Регистрация и настройка
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
// Job и Trigger
var jobKey = new JobKey("backup");
q.AddJob<BackupJob>(jobKey, j => j.WithDescription("Nightly backup"));
q.AddTrigger(t => t
.ForJob(jobKey)
.WithIdentity("backup-trigger")
.WithCronSchedule("0 0 2 * * ?") // ежедневно в 02:00 UTC
.WithDescription("Triggers backup every day at 2 AM"));
});
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
Расшифровка cron-выражений (Quartz-формат):
| Поле | Допустимые значения | Примеры |
|---|---|---|
| Секунды | 0–59 | 0, */10 |
| Минуты | 0–59 | 0, 30 |
| Часы | 0–23 | 2, 8-18 |
| День месяца | 1–31 | 1, L (последний), 15W (ближайший будний к 15-му) |
| Месяц | 1–12 или JAN–DEC | *, JUL |
| День недели | 1–7 или SUN–SAT | ? (не указан), MON-FRI, 1L (последний понедельник) |
| Год (опционально) | 1970–2099 | 2025 |
✅ Онлайн-валидатор: https://www.freeformatter.com/cron-expression-generator-quartz.html
2.4. Distributed Locking (для кластера)
Чтобы задача не запускалась на всех узлах одновременно.
Через Redis (Redlock.net):
public class DistributedBackgroundService : BackgroundService
{
private readonly IDistributedLockFactory _lockFactory;
public DistributedBackgroundService(IDistributedLockFactory lockFactory)
=> _lockFactory = lockFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var lockHandle = await _lockFactory.CreateLock("backup-lock", TimeSpan.FromMinutes(10));
if (await lockHandle.TryAcquireAsync(stoppingToken))
{
await DoWork(stoppingToken);
await Task.Delay(TimeSpan.FromHours(24), stoppingToken); // следующий запуск
}
else
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); // повторить позже
}
}
}
}