5.05. Пример работы бэкенда
Пример работы бэкенда в C#
Создание веб-приложения требует разделения зон ответственности между клиентской и серверной частями. В случае интернет-магазина, как в данном примере, бэкенд (сервер) отвечает за хранение, обработку и передачу данных по запросу клиента. Клиент, в свою очередь — это браузер, мобильное приложение или другой сервис, инициирующий HTTP-запросы.
ASP.NET Core — это кроссплатформенная платформа от Microsoft, предназначенная для построения современных, высокопроизводительных и масштабируемых веб-приложений и API. Она реализует архитектуру MVC (Model–View–Controller), а также предоставляет средства для разработки RESTful-сервисов с минимальной конфигурацией.
В данной главе рассматривается полный цикл реализации бэкенд-логики для сценария «показать цену товара». Мы поэтапно пройдём следующие компоненты:
- Подготовка базы данных и модели данных.
- Настройка Entity Framework Core для взаимодействия с БД.
- Реализация контроллера, обрабатывающего HTTP-запросы.
- Организация клиентского взаимодействия (через JavaScript или Razor Pages).
- Внедрение Redis для кэширования ответов и снижения нагрузки на базу данных.
- Обсуждение жизненного цикла запроса и архитектурных особенностей 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
- Получение запроса — Kestrel (встроенный веб-сервер) принимает HTTP-запрос.
- Промежуточное ПО (Middleware) — запрос проходит через цепочку middleware (логирование, маршрутизация, аутентификация и т.д.).
- Маршрутизация — система маршрутизации ASP.NET Core сопоставляет URL с контроллером и методом.
- Привязка модели — параметры URL, тела запроса или заголовков автоматически преобразуются в аргументы метода контроллера.
- Выполнение логики — контроллер взаимодействует с сервисами (БД, кэш и т.д.).
- Формирование ответа — результат сериализуется (обычно в JSON) и отправляется клиенту.
- Освобождение ресурсов — 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);
}
}