Перейти к основному содержимому

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}.jsone.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 Handlerapp.UseExceptionHandler("/error")Перехват необработанных исключений.ExceptionHandlerOptions: ExceptionHandlingPath, AllowStatusCode404Response.
HTTPS Redirectionapp.UseHttpsRedirection()307 → HTTPS (если исходный запрос HTTP).HttpsRedirectionOptions: SslPort (по умолчанию 443).
Static Filesapp.UseStaticFiles()Обслуживание wwwroot.StaticFileOptions: RequestPath, FileProvider, ServeUnknownFileTypes, OnPrepareResponse.
Routingapp.UseRouting()Инициализация endpoint routing. Должен идти ДО UseAuthorization, UseEndpoints.
Authenticationapp.UseAuthentication()Выполнение схем аутентификации (устанавливает HttpContext.User).
Authorizationapp.UseAuthorization()Проверка политик доступа. Должен идти ПОСЛЕ UseAuthentication, но ДО UseEndpoints.
Sessionapp.UseSession()Управление сессиями. Должен идти ПОСЛЕ UseRouting, но ДО UseAuthorization.SessionOptions: IdleTimeout, Cookie, IOTimeout.
CORSapp.UseCors("PolicyName")Кросс-доменные запросы.CorsPolicyBuilder: WithOrigins, AllowAnyOrigin, WithMethods, WithHeaders, AllowCredentials, SetIsOriginAllowed.
Response Cachingapp.UseResponseCaching()Кэширование ответов (на уровне middleware).ResponseCachingOptions: SizeLimit, UseCaseSensitivePaths.
Response Compressionapp.UseResponseCompression()Gzip/Brotli сжатие.ResponseCompressionOptions: Providers, EnableForHttps, MimeTypes.
Endpointsapp.UseEndpoints(...) / app.Map...Регистрация маршрутов. В minimal model — app.MapGet, app.MapControllers и т.д.

✅ Правильный порядок:
UseExceptionHandlerUseHttpsRedirectionUseStaticFilesUseRoutingUseAuthenticationUseAuthorizationUseSessionUseCorsUseResponseCachingUseResponseCompressionUseEndpoints / 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 APIapp.MapGet("/api/users", ...)app.MapPost("/items", (Item i) => Results.Created($"/items/{i.Id}", i));
Controllersapp.MapControllers()Требует AddControllers() / AddControllersWithViews().
Razor Pagesapp.MapRazorPages()Требует AddRazorPages().
SignalR Hubsapp.MapHub<ChatHub>("/chat")
gRPC Servicesapp.MapGrpcService<GreeterService>()
Health Checksapp.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 — архитектура и различия

КритерийMVCRazor 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, ViewBagModelState, TempData, PageContext
Рекомендация MicrosoftДля сложных SPA/REST APIДля page-centric приложений (CMS, админки, формы)

✅ Оба используют один и тот же движок представлений (Razor), DI, middleware, авторизацию.


2. Контроллеры и действия (Actions)

2.1. Базовые классы и интерфейсы

ТипИнтерфейсНаследованиеПримечание
ControllerBaseIActionResultControllerBaseМинимальный базовый класс (для API). Не имеет View().
ControllerControllerBaseControllerРасширяет ControllerBase: добавляет View(), PartialView(), ViewData, TempData. Для MVC с представлениями.

2.2. Типы возвращаемых значений действий

ТипМетод-помощникHTTP-кодСериализация
IActionResultOk(), 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=xmlQuery 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.0options.AssumeDefaultVersionWhenUnspecified = true;
Заголовокapi-version: 2.0options.ApiVersionReader = new HeaderApiVersionReader("api-version");
URL-путь/api/v2/usersoptions.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.DomainnullДомен (для subdomain-аутентификации: .example.com).
Cookie.SecurePolicySameAsRequestAlways (только HTTPS), None — опасно.
Cookie.HttpOnlytrueЗащита от XSS.
Cookie.SameSiteUnspecifiedLaxStrict, Lax, None (для кросс-сайтовых запросов).
ExpireTimeSpan14 днейПолный срок жизни cookie.
SlidingExpirationtrueПродлевать при активности.
Events.OnValidatePrincipalnullКастомная валидация (например, проверка отзыва токена).
1.2.2. AddJwtBearer()
Параметр (JwtBearerOptions)Описание
AuthorityURL OpenID Connect провайдера (автоматически загружает конфигурацию .well-known/openid-configuration).
AudienceЗначение aud в токене.
TokenValidationParametersТонкая настройка валидации (ключ, issuer, lifetime, clock skew).
RequireHttpsMetadatatrue в продакшене (запрещает HTTP-токены).
Events.OnTokenValidatedДоп. обработка после валидации (например, маппинг claims).
Events.OnAuthenticationFailedОбработка ошибок (логирование, кастомный ответ).
1.2.3. AddOAuth() / AddOpenIdConnect()
ПараметрОписание
ClientId, ClientSecretОт провайдера (Google, GitHub и др.).
AuthorizationEndpoint, TokenEndpoint, UserInformationEndpointURL’ы провайдера (часто подставляются автоматически).
Scopeopenid 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. propertiesAuthenticationProperties (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 ManagementUserManager<TUser>CreateAsync, DeleteAsync, FindByEmailAsync, AddToRoleAsync, GetRolesAsync, GetClaimsAsync, AddClaimAsync
Sign-inSignInManager<TUser>PasswordSignInAsync, SignInAsync, SignOutAsync, RefreshSignInAsync, IsSignedIn
Role ManagementRoleManager<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-заголовокОписание
DurationCache-Control: public, max-age=60Сколько кэшировать (сек).
LocationCache-Control: public/private/no-cacheГде: Any (CDN, браузер), Client (только браузер), None.
NoStoreCache-Control: no-storeПолный запрет кэширования.
VaryByHeaderVary: User-AgentРазные версии кэша по заголовкам.
VaryByQueryKeysVary: 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Продлевать при доступе.
PriorityLow, 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-onDateTimeOffset — абсолютное время.
expires-afterTimeSpan — относительно сейчас.
expires-slidingTimeSpan — 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, RequestId
  • Microsoft.EntityFrameworkCore.Database.Command: CommandId, ConnectionId

4.5. Провайдеры логирования

ПровайдерПакетОсобенности
ConsoleВстроенЦвета, JSON-режим ("Console": { "FormatterName": "json" }).
DebugВстроенВывод в окно «Вывод» Visual Studio.
EventSourceВстроенETW-трассировка (для dotnet-trace, PerfView).
EventLogMicrosoft.Extensions.Logging.EventLogТолько Windows.
SerilogSerilog.AspNetCoreСтруктурированный лог, обогащение, отправка в Seq/ELK.
Application InsightsMicrosoft.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 Resource
  • Custom Tool = ResXFileCodeGenerator (опционально, для типизированных классов)
Пример SharedResource.en.resx:
NameValue
WelcomeMessageWelcome, 0!
ValidationError.RequiredThe 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
CookieRequestCultureProviderCookie ".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–590, */10
Минуты0–590, 30
Часы0–232, 8-18
День месяца1–311, L (последний), 15W (ближайший будний к 15-му)
Месяц1–12 или JAN–DEC*, JUL
День недели1–7 или SUN–SAT? (не указан), MON-FRI, 1L (последний понедельник)
Год (опционально)1970–20992025

✅ Онлайн-валидатор: 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); // повторить позже
}
}
}
}