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

5.05. Пример работы бэкенда

Разработчику Архитектору

Пример работы бэкенда в C#

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

ASP.NET Core — это кроссплатформенная платформа от Microsoft, предназначенная для построения современных, высокопроизводительных и масштабируемых веб-приложений и API. Она реализует архитектуру MVC (Model–View–Controller), а также предоставляет средства для разработки RESTful-сервисов с минимальной конфигурацией.

В данной главе рассматривается полный цикл реализации бэкенд-логики для сценария «показать цену товара». Мы поэтапно пройдём следующие компоненты:

  1. Подготовка базы данных и модели данных.
  2. Настройка Entity Framework Core для взаимодействия с БД.
  3. Реализация контроллера, обрабатывающего HTTP-запросы.
  4. Организация клиентского взаимодействия (через JavaScript или Razor Pages).
  5. Внедрение Redis для кэширования ответов и снижения нагрузки на базу данных.
  6. Обсуждение жизненного цикла запроса и архитектурных особенностей ASP.NET Core.

1. Подготовка базы данных и модели данных

1.1. Структура данных

Для простоты предположим, что в интернет-магазине используется реляционная СУБД, например PostgreSQL или Microsoft SQL Server. Таблица товаров может выглядеть следующим образом:

CREATE TABLE Products (
Id SERIAL PRIMARY KEY,
Name VARCHAR(255) NOT NULL,
Price DECIMAL(18, 2) NOT NULL
);

1.2. Создание модели на C#

В ASP.NET Core модели обычно размещаются в папке Models. Класс модели должен отражать структуру таблицы:

// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}

Примечание. В современных версиях C# (начиная с C# 8 и особенно C# 9+) рекомендуется использовать инициализаторы по умолчанию, чтобы избежать null-значений при десериализации или конфигурации с nullable-аннотациями.


2. Настройка Entity Framework Core

Entity Framework Core (EF Core) — это ORM (Object-Relational Mapper), который абстрагирует работу с базой данных через работу с объектами C#. Это позволяет писать запросы на LINQ, а не на SQL.

2.1. Создание контекста базы данных

Контекст — это основной класс, через который приложение взаимодействует с БД:

// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using YourProjectName.Models;

public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }

public DbSet<Product> Products { get; set; }
}

Здесь DbSet<Product> представляет таблицу Products в БД.

2.2. Регистрация контекста в DI-контейнере

В файле Program.cs (в ASP.NET Core 6+) необходимо зарегистрировать контекст с указанием строки подключения:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Добавление контекста с подключением к БД (например, PostgreSQL)
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

В appsettings.json указывается строка подключения:

{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=ShopDb;Username=postgres;Password=yourpassword"
}
}

Для SQL Server используется UseSqlServer, для SQLite — UseSqlite и т.д.

2.3. Миграции

EF Core использует миграции для синхронизации модели C# с физической схемой БД. Создание и применение миграций выполняется через CLI:

dotnet ef migrations add InitialCreate
dotnet ef database update

Это сгенерирует SQL-скрипты и применит их к базе данных.


3. Реализация контроллера

Контроллер в ASP.NET Core — это класс, унаследованный от ControllerBase (для Web API) или Controller (для MVC/Razor Pages). Он содержит методы, соответствующие HTTP-методам (GET, POST и т.д.).

3.1. Простой API-контроллер

// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using YourProjectName.Data;
using YourProjectName.Models;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _context;

public ProductsController(AppDbContext context)
{
_context = context;
}

[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var product = await _context.Products.FindAsync(id);
if (product == null)
return NotFound();

return product;
}
}

Запрос к /api/products/5 вернёт JSON-представление товара с ID = 5.

Атрибуты [ApiController] и [Route] включают автоматическую обработку ошибок, привязку моделей и маршрутизацию. Метод FindAsync использует первичный ключ для эффективного поиска.


4. Клиентское взаимодействие

4.1. Через JavaScript (SPA или статический HTML)

На клиенте можно отправить запрос с помощью fetch:

<button onclick="fetchPrice(1)">Показать цену товара 1</button>
<div id="result"></div>

<script>
async function fetchPrice(productId) {
const response = await fetch(`/api/products/${productId}`);
if (response.ok) {
const product = await response.json();
document.getElementById('result').innerText =
`Товар: ${product.name}, Цена: ${product.price}`;
} else {
document.getElementById('result').innerText = 'Товар не найден';
}
}
</script>

Этот подход характерен для одностраничных приложений (SPA) и не зависит от серверного рендеринга.

4.2. Через Razor Pages (серверный рендеринг)

Если используется Razor Pages, логика может быть реализована непосредственно в модели страницы:

// Pages/Products/Details.cshtml.cs
public class DetailsModel : PageModel
{
private readonly AppDbContext _context;

public DetailsModel(AppDbContext context) => _context = context;

public Product? Product { get; set; }

public async Task<IActionResult> OnGetAsync(int id)
{
Product = await _context.Products.FindAsync(id);
if (Product == null) return NotFound();
return Page();
}
}

И представление (Details.cshtml):

<h2>@Model.Product.Name</h2>
<p>Цена: @Model.Product.Price ₽</p>

В этом случае HTML-страница генерируется на сервере, и запрос к API не требуется.


5. Внедрение Redis для кэширования

Кэширование критически важно для снижения нагрузки на базу данных при высокой частоте повторяющихся запросов. Redis — это высокопроизводительное in-memory хранилище, которое часто используется как кэш.

5.1. Подключение Redis в ASP.NET Core

Установите пакет:

dotnet add package StackExchange.Redis

Добавьте в Program.cs:

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")));

В appsettings.json:

{
"ConnectionStrings": {
"Redis": "localhost:6379"
}
}

5.2. Реализация кэширования в контроллере

[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var redis = HttpContext.RequestServices.GetRequiredService<IConnectionMultiplexer>();
var cache = redis.GetDatabase();

var cached = await cache.StringGetAsync($"product:{id}");
if (!cached.IsNullOrEmpty)
{
var product = JsonSerializer.Deserialize<Product>(cached!);
return Ok(product);
}

var product = await _context.Products.FindAsync(id);
if (product == null)
return NotFound();

var json = JsonSerializer.Serialize(product);
await cache.StringSetAsync($"product:{id}", json, TimeSpan.FromMinutes(10));

return product;
}

Здесь используется упрощённый подход. В production-коде рекомендуется вынести логику кэширования в отдельный сервис и использовать MemoryCache или IDistributedCache (ASP.NET Core предоставляет абстракцию IDistributedCache, которую можно реализовать через Redis).

5.3. Использование IDistributedCache

Более архитектурно чистый способ:

builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});

Затем внедрить IDistributedCache:

private readonly IDistributedCache _cache;

public ProductsController(AppDbContext context, IDistributedCache cache)
{
_context = context;
_cache = cache;
}

И использовать:

var cachedBytes = await _cache.GetAsync(cacheKey);
if (cachedBytes != null)
{
var json = Encoding.UTF8.GetString(cachedBytes);
return JsonSerializer.Deserialize<Product>(json);
}

// ... загрузка из БД

await _cache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(json), new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});

Это упрощает тестирование и обеспечивает совместимость с другими провайдерами кэша (например, SQL Server Cache).


6. Жизненный цикл HTTP-запроса в ASP.NET Core

  1. Получение запроса — Kestrel (встроенный веб-сервер) принимает HTTP-запрос.
  2. Промежуточное ПО (Middleware) — запрос проходит через цепочку middleware (логирование, маршрутизация, аутентификация и т.д.).
  3. Маршрутизация — система маршрутизации ASP.NET Core сопоставляет URL с контроллером и методом.
  4. Привязка модели — параметры URL, тела запроса или заголовков автоматически преобразуются в аргументы метода контроллера.
  5. Выполнение логики — контроллер взаимодействует с сервисами (БД, кэш и т.д.).
  6. Формирование ответа — результат сериализуется (обычно в JSON) и отправляется клиенту.
  7. Освобождение ресурсов — DI-контейнер утилизирует scoped-объекты (например, AppDbContext).

ASP.NET Core обеспечивает строгую изоляцию компонентов, поддержку async/await и эффективное управление памятью.


7. Архитектурные улучшения: паттерн Repository и разделение ответственности

Хотя прямое использование DbSet<T> в контроллере допустимо для простых приложений, в промышленной разработке рекомендуется изолировать логику доступа к данным с помощью паттерна Repository. Это упрощает тестирование, поддержку и замену хранилища.

7.1. Интерфейс и реализация репозитория

// Interfaces/IProductRepository.cs
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
// Repositories/ProductRepository.cs
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;

public ProductRepository(AppDbContext context) => _context = context;

public async Task<Product?> GetByIdAsync(int id) =>
await _context.Products.FindAsync(id);

public async Task<IEnumerable<Product>> GetAllAsync() =>
await _context.Products.ToListAsync();

public async Task AddAsync(Product product)
{
_context.Products.Add(product);
await _context.SaveChangesAsync();
}

public async Task UpdateAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
}

public async Task DeleteAsync(int id)
{
var product = await _context.Products.FindAsync(id);
if (product != null)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync();
}
}
}

7.2. Регистрация в DI-контейнере

builder.Services.AddScoped<IProductRepository, ProductRepository>();

Теперь контроллер зависит от абстракции:

public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
private readonly IDistributedCache _cache;

public ProductsController(IProductRepository repository, IDistributedCache cache)
{
_repository = repository;
_cache = cache;
}

[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
// ... кэширование
var product = await _repository.GetByIdAsync(id);
// ... сериализация и сохранение в кэш
}
}

Такой подход обеспечивает тестируемость: при unit-тестировании можно подставить mock-реализацию IProductRepository.


8. Обработка ошибок и централизованное логирование

8.1. Глобальный обработчик исключений

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

// Program.cs
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";

var exceptionHandlerPathFeature =
context.Features.Get<IExceptionHandlerPathFeature>();

var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
logger.LogError(exceptionHandlerPathFeature?.Error, "Произошла внутренняя ошибка");

await context.Response.WriteAsync(JsonSerializer.Serialize(new
{
error = "Внутренняя ошибка сервера",
path = exceptionHandlerPathFeature?.Path
}));
});
});

Для среды разработки можно подключить детализированные страницы ошибок:

if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

8.2. Валидация входных данных

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

public class CreateProductDto
{
[Required, MaxLength(200)]
public string Name { get; set; } = string.Empty;

[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}

В контроллере:

[HttpPost]
public async Task<ActionResult> Create([FromBody] CreateProductDto dto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);

// ... сохранение
}

ASP.NET Core автоматически возвращает 400 Bad Request при нарушении правил валидации, если включён атрибут [ApiController].


9. Документирование API: Swagger/OpenAPI

Для внутреннего использования и интеграции с фронтендом или внешними системами важно документировать API. Пакет Swashbuckle.AspNetCore генерирует интерактивную документацию в формате OpenAPI.

Установка:

dotnet add package Swashbuckle.AspNetCore

Настройка в Program.cs:

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Shop API", Version = "v1" });
});

// ...

if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Shop API v1"));
}

Теперь по адресу /swagger доступен UI для тестирования методов, просмотра схем запросов и ответов.


10. Безопасность: CORS, аутентификация и авторизация

10.1. CORS (Cross-Origin Resource Sharing)

Если фронтенд размещён на другом домене (например, http://localhost:3000), необходимо настроить CORS:

builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
{
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod();
});
});

// ...

app.UseCors("AllowFrontend");

10.2. Аутентификация через JWT

Для защищённых методов (например, «изменить товар») подключается JWT-аутентификация:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
};
});

В appsettings.json:

{
"Jwt": {
"Key": "your-256-bit-secret",
"Issuer": "ShopApi",
"Audience": "ShopFrontend"
}
}

Методы, требующие авторизации, помечаются атрибутом:

[Authorize]
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, UpdateProductDto dto) { ... }

11. Docker-развёртывание и внешние зависимости

Для воспроизводимости окружения и упрощения развёртывания приложение упаковывается в Docker-контейнеры. Пример Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ShopApi.dll"]

Для развёртывания вместе с PostgreSQL и Redis используется docker-compose.yml:

version: '3.8'
services:
web:
build: .
ports:
- "5000:80"
depends_on:
- db
- redis
environment:
- ConnectionStrings__DefaultConnection=Host=db;Database=shop;Username=postgres;Password=secret
- ConnectionStrings__Redis=redis:6379

db:
image: postgres:15
environment:
POSTGRES_DB: shop
POSTGRES_PASSWORD: secret
volumes:
- pgdata:/var/lib/postgresql/data

redis:
image: redis:7-alpine

volumes:
pgdata:

Это обеспечивает локальное воспроизведение production-подобной среды.


12. Тестирование

12.1. Unit-тесты для репозитория

С использованием Moq и xUnit:

[Fact]
public async Task GetByIdAsync_ExistingId_ReturnsProduct()
{
// Arrange
var mockContext = new Mock<AppDbContext>();
var mockSet = new Mock<DbSet<Product>>();
mockSet.Setup(s => s.FindAsync(1))
.ReturnsAsync(new Product { Id = 1, Name = "Тест", Price = 100 });
mockContext.Setup(c => c.Products).Returns(mockSet.Object);

var repo = new ProductRepository(mockContext.Object);

// Act
var result = await repo.GetByIdAsync(1);

// Assert
Assert.NotNull(result);
Assert.Equal("Тест", result.Name);
}

12.2. Интеграционные тесты с TestServer

ASP.NET Core предоставляет WebApplicationFactory<T> для запуска приложения в памяти:

public class ProductsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;

public ProductsControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}

[Fact]
public async Task GetProduct_ReturnsOk()
{
var response = await _client.GetAsync("/api/products/1");
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Тест", content);
}
}