Современная разработка программного обеспечения постоянно ищет пути повышения эффективности организации кода. Архитектурные паттерны появляются, эволюционируют, и те, что проявляют свою жизнеспособность, укореняются в индустрии. CQRS (Command Query Responsibility Segregation) – один из таких паттернов, который обретает всё большую популярность в .NET-экосистеме, благодаря разумному подходу к организации кода и масштабируемости систем.
Суть CQRS заключается в разделении операций чтения (Query) и записи (Command) внутри системы. Каждый из этих типов операций имеет разные характеристики, требования и модели оптимизации. Команды изменяют состояние системы и должны обеспечивать консистентность и бизнес-правила. Запросы, напротив просто возвращают данные и часто требуют специфической формы представления для различных клиентов.
Если посмотреть на традиционную монолитную архитектуру, в ней обычно используется одна и та же модель как для чтения, так и для записи данных. Такой подход прост, но ограничен – чем сложнее становится система, тем больше растет напряжение между потребностями операций чтения и записи. Нормализованная для записи модель данных может быть неоптимальной для чтения. В сложных системах возникает необходимость создания проекций, денормализации и кэширования, что усложняет код и загромождает основную модель. CQRS предлагает альтернативу: явное разделение этих аспектов на отдельные модели. Это позволяет каждой стороне развиваться независимо и оптимизироваться под конкретные нужды. Модели запросов могут быть денормализованы для производительности чтения без компромиссов для логики записи. Модели команд могут полностью сосредоточиться на бизнес-правилах и целостности данных.
Жизненный цикл медиаторного запроса в такой архитектуре выглядит следующим образом: запрос/команда создается клиентским кодом и направляется в медиатор. Медиатор определяет соответствующий обработчик, возможно применяя промежуточные обработчики или валидаторы. Затем основной обработчик выполняет логику, взаимодействует с репозиториями или другими службами и возвращает результат. MediatR – библиотека, которая реализует паттерн медиатора в .NET и идеально подходит для CQRS. Она отвечает за маршрутизацию команд и запросов к соответствующим обработчикам, минимизируя связность между компонентами. Отправитель запроса/команды не знает и не заботится о том, кто будет обрабатывать его сообщение – это задача медиатора.
Технические преимущества MediatR перед самостоятельной реализацией очевидны. Библиотека предоставляет проверенную, тщательно протестированную инфраструктуру с минимальными накладными расходами. Она легко интегрируется с контейнерами внедрения зависимостей, поддерживает асинхронные операции, позволяет создавать гибкие конвейеры обработки (pipeline behaviors) для перехвата запросов и добавления сквозной функциональности вроде валидации, логирования или кэширования. MediatR также упрощает обработку доменных событий через механизм уведомлений, где одно событие может иметь несколько обработчиков. Эта функциональность критична для построения систем, следующих принципам доменно-ориентированного проектирования (DDD).
Применение CQRS с помощью MediatR помогает решить множество архитектурных проблем. Оно делает код более структурированным и предсказуемым, облегчает понимание потоков данных в системе, улучшает тестируемость компонентов, позволяет оптимизировать каждую сторону независимо и создает естественное разделение, которое упрощает горизонтальное масштабирование. Этот подход особенно полезен в системах со сложной бизнес-логикой, высокими требованиями к производительности чтения данных или в ситуациях, где модели чтения и записи естественным образом различаются. Но, как и любой архитектурный паттерн CQRS не является универсальным решением – его применение требует понимания компромиссов и соответствия требованиям конкретного проекта.
Теоретические основы
Принципы разделения операций чтения и записи
В основе CQRS лежит фундаментальное разделение операций на две категории: чтение и запись. Эта идея восходит к принципу Command-Query Separation (CQS), предложенному Бертраном Мейером, который утверждает, что методы должны либо выполнять действие (команда), либо возвращать данные (запрос), но не делать оба действия одновременно.
CQRS выводит этот принцип на архитектурный уровень, применяя его к целым компонентам системы. При таком подходе:- Команды (Commands) изменяют состояние системы, но не возвращают данных.
- Запросы (Queries) возвращают данные, но не изменяют состояние.
Это разделение не просто синтаксическое – оно отражает фундаментально различные характеристики этих операций. Команды должны соблюдать бизнес-правила, поддерживать транзакционность и целостность данных. Запросы же должны быть оптимизированы для скорости и удобства представления.
Эволюция подхода в .NET экосистеме
В экосистеме .NET подход к CQRS претерпел значительную эволюцию:
Первые реализации были громоздкими и часто слишком абстрактными. Они страдали от чрезмерной сложности, вызванной стремлением создать универсальный фреймворк для всех возможных сценариев. С развитием .NET Core начали появляться более прагматичные подходы. Разработчики стали применять CQRS более избирательно, фокусируясь на тех частях системы, где разделение действительно приносило пользу. Появление библиотеки MediatR от Джимми Богарда стало поворотным моментом – она предоставила простой и элегантный способ реализации паттерна медиатора, который прекрасно подходит для CQRS. MediatR взял на себя рутинную работу по маршрутизации сообщений к обработчикам, позволяя разработчикам сосредоточиться на бизнес-логике.
В современном .NET подход к CQRS часто комбинируется с организацией кода по вертикальным срезам (vertical slices), когда код группируется не по техническим слоям, а по бизнес-возможностям. Это позволяет избежать излишней фрагментации и улучшает понимание кода.
Связь CQRS с другими архитектурными паттернами
CQRS редко существует в изоляции и часто дополняет другие архитектурные подходы:
Domain-Driven Design (DDD) – естественный спутник CQRS. Команды хорошо работают с богатой доменной моделью, обеспечивая целостность бизнес-правил, в то время как запросы могут использовать упрощенные представления данных, оптимизированные для конкретных сценариев.
Event Sourcing – часто применяется вместе с CQRS. Вместо хранения текущего состояния системы, сохраняется история событий, изменяющих это состояние. Модель чтения (read model) строится путем проигрывания этих событий. Такой подход обеспечивает полную аудируемость и возможность восстановления состояния на любой момент времени.
Микросервисная архитектура – CQRS предоставляет естественную точку разделения для микросервисов. Сервисы команд и запросов могут быть реализованы и масштабированы независимо, используя технологии, наиболее подходящие для их конкретных задач.
Событийно-ориентированная архитектура – CQRS часто включает публикацию доменных событий после выполнения команд, что хорошо вписывается в событийно-ориентированную парадигму и способствует слабой связности компонентов.
Преимущества и недостатки разделения моделей чтения и записи
Преимущества:
Специализация моделей – каждая модель оптимизируется для своей конкретной задачи без компромиссов.
Масштабируемость – операции чтения и записи могут масштабироваться независимо.
Эволюция системы – изменение одной модели не влияет напрямую на другую.
Чистота доменной модели – модель записи фокусируется на бизнес-правилах, а не на удобстве чтения.
Безопасность – разные модели позволяют реализовать более точный контроль доступа.
Недостатки:
Увеличение сложности – поддержка двух отдельных моделей требует дополнительного кода.
Проблемы согласованности – может возникать временное рассогласование между моделями чтения и записи.
Объем кода – двойные модели означают больше кода для написания и поддержки.
Кривая обучения – разработчикам требуется время для освоения этого подхода.
Избыточность для простых случаев – в небольших проектах CQRS может быть излишним.
Анализ производительности CQRS систем при высоких нагрузках
При высоких нагрузках CQRS демонстрирует свои сильные стороны:- Денормализованные модели чтения могут обслуживать запросы значительно быстрее, чем нормализованные структуры, оптимизированные для записи. Это особенно заметно при сложных агрегациях данных, которые в традиционных системах требуют множества соединений таблиц.
- Разделение операций позволяет распределить нагрузку на разные серверы. Операции чтения, которые обычно составляют 80-90% всех операций в типичном приложении, могут быть распределены на множество узлов, использующих реплики базы данных.
- Модели чтения прекрасно подходят для кэширования, поскольку они уже оптимизированы для конкретных сценариев использования. Это позволяет существенно снизить нагрузку на базу данных для часто запрашиваемых данных.
- Асинхронная обработка команд с использованием очередей сообщений позволяет системе сохранять отзывчивость даже в периоды пиковой нагрузки, равномерно распределяя обработку во времени.
Исследования производительности показывают, что CQRS-системы часто демонстрируют более линейную кривую масштабирования при увеличении нагрузки по сравнению с традиционными архитектурами, где чтение и запись конкурируют за одни и те же ресурсы.
Вертикальное и горизонтальное масштабирование CQRS-систем
CQRS предоставляет гибкие возможности для масштабирования:
Горизонтальное масштабирование позволяет распределить нагрузку на несколько серверов. Особенно эффективно это работает для операций чтения, которые можно распараллелить на многие узлы. Для моделей чтения часто используются специализированные хранилища данных вроде документных баз данных или поисковых движков, которые лучше подходят для сценариев запросов и легко масштабируются горизонтально.
Вертикальное масштабирование также выигрывает от разделения моделей. Серверы для обработки команд могут быть оптимизированы для транзакционных операций с быстрыми дисками и небольшим числом мощных процессоров. Серверы для запросов могут быть оптимизированы для параллельной обработки с большим количеством ядер и объемом оперативной памяти.
Полиглотная персистентность – еще одно преимущество CQRS. Модель записи может использовать реляционную базу данных для обеспечения транзакционности, а модель чтения – NoSQL решения для скорости и масштабируемости. Такой подход использует сильные стороны разных технологий хранения данных.
Согласованность данных в системах CQRS
Одной из главных проблем при разделении моделей чтения и записи является обеспечение согласованности данных между ними. В отличие от традиционных систем, где данные хранятся в единой модели, CQRS требует механизма синхронизации.
Существует несколько стратегий обновления моделей чтения:- Синхронное обновление происходит непосредственно в ходе обработки команды. После изменения основной модели данных, перед завершением транзакции, система обновляет все соответствующие представления для чтения. Этот подход обеспечивает немедленную согласованность, но увеличивает время обработки команды и создает тесную связь между моделями.
- Асинхронное обновление использует события, генерируемые при изменении модели записи, для последующего обновления моделей чтения. Это вводит концепцию "eventually consistent" (итоговой согласованности) — состояние, когда система гарантирует, что при отсутствии новых обновлений все представления в конечном счете придут к согласованному состоянию.
Исследования показывают, что большинство бизнес-систем вполне успешно могут работать с итоговой согласованностью, так как пользователи редко требуют мгновенного отображения внесенных изменений, особенно в системах с высокой нагрузкой.
Доменное моделирование в контексте CQRS
При применении CQRS доменная модель становится более чистой и сфокусированной на бизнес-правилах. Она освобождается от ограничений, связанных с представлением данных, что позволяет более точно отражать предметную область. В таком подходе агрегаты, сущности и объекты-значения (концепции DDD) формируют основу модели записи. Они инкапсулируют бизнес-правила и гарантируют целостность данных. Команды взаимодействуют именно с этими объектами, а не напрямую с хранилищем данных.
Для модели чтения, напротив, характерны плоские DTO (Data Transfer Objects), оптимизированные для конкретных сценариев использования интерфейса. Они не содержат бизнес-логики и могут включать данные из нескольких агрегатов или даже других контекстов.
Транзакционность в распределенных CQRS-системах
В распределенных системах с CQRS традиционная ACID-транзакционность часто уступает место шаблону компенсирующих транзакций. Вместо того чтобы пытаться поддерживать распределенные транзакции, которые могут блокировать ресурсы на длительное время, используется серия локальных транзакций, которые можно отменить при необходимости.
Паттерн Saga — популярное решение в этом контексте. Он представляет собой последовательность локальных транзакций, где каждая публикует событие, инициирующее следующую транзакцию. Если какая-либо транзакция не удается, выполняются компенсирующие действия для отмены эффекта предыдущих шагов. Этот подход хорошо сочетается с асинхронной природой CQRS и обеспечивает устойчивость системы к частичным сбоям, что критично для современных распределенных приложений.
Эволюция модели данных
Одно из скрытых преимуществ CQRS — упрощение эволюции модели данных. Поскольку модели чтения и записи разделены, их можно изменять независимо друг от друга. Например, при необходимости добавить новое представление данных для конкретного сценария использования, достаточно создать новую проекцию и заполнить ее данными, не затрагивая существующую модель записи. Аналогично, изменения в бизнес-правилах могут быть внесены в модель записи без необходимости немедленно обновлять все проекции. это особенно ценно в системах с длительным жизненным циклом, где требования постоянно эволюционируют, а объем исторических данных слишком велик для простой миграции.
Пограничные контексты и интеграция
В сложных системах с множеством подсистем концепция ограниченных контекстов (Bounded Contexts) из DDD естественно дополняет CQRS. Каждый ограниченный контекст может иметь свои модели чтения и записи, оптимизированные для его конкретных потребностей. Интеграция между контекстами часто осуществляется через события, публикуемые при изменении состояния в одном контексте и потребляемые другими контекстами для обновления их собственных моделей. Такой слабосвязанный подход увеличивает автономность команд разработчиков и уменьшает риск каскадных изменений при эволюции отдельных частей системы.
Жизненный цикл событий в CQRS
События играют центральную роль в архитектуре CQRS, особенно в сочетании с Event Sourcing. Они служат как для обновления моделей чтения, так и для интеграции между различными частями системы. Типичный жизненный цикл события выглядит следующим образом:
1. Команда обрабатывается и изменяет состояние агрегата.
2. Агрегат генерирует события, отражающие произошедшие изменения.
3. События сохраняются в хранилище событий (при использовании Event Sourcing).
4. События публикуются в шину событий.
5. Обработчики событий обновляют модели чтения и/или инициируют побочные эффекты.
6. При необходимости события транслируются во внешние системы.
Важно отметить, что события представляют собой факты, которые произошли в прошлом, и их нельзя изменить. Эта неизменность является ключевым свойством, обеспечивающим надежность системы и возможность ее аудита.
Практические соображения при выборе CQRS
Несмотря на все преимущества, CQRS не всегда является оптимальным выбором. Практика показывает, что этот подход наиболее эффективен в следующих случаях:- Системы со сложной бизнес-логикой, где чистота доменной модели критична.
- Системы с высокой нагрузкой, особенно с асимметричными паттернами чтения/записи.
- Системы, требующие независимого масштабирования операций чтения и записи.
- Проекты с четко разделенными командами разработчиков для бэкенда и фронтенда.
- Системы с высокими требованиями к аудиту и истории изменений.
С другой стороны, для простых CRUD-приложений, небольших проектов или систем с ограниченными ресурсами разработки, CQRS может создать ненужную сложность без соразмерных выгод.
Правильная оценка контекста проекта и требований к системе помогает принять взвешенное решение о применимости CQRS в конкретном случае.
CQRS Добрый день. Подскажите, где можно почитать материал по этой теме, для написания курсовой. Разница между ASP.NET Core 2, ASP.NET Core MVC, ASP.NET MVC 5 и ASP.NET WEBAPI 2 Здравствуйте. Я в бекенд разработке полный ноль. В чем разница между вышеперечисленными... Удаленный SQL-сервер Ado.Net + .Net remoting + Asp .Net Всем привет!
Нужно написать клиент-серверное приложение на основе Microsoft Sql Server 2005... Возможности VB.NET, VC++.NET и VC#.NET. Различаются ли возможности VB.NET, VC++.NET и VC#.NET.
Пошаговая реализация
Теория дает понимание принципов, но практическая реализация CQRS с MediatR требует конкретных шагов и решений. Рассмотрим процесс внедрения этой архитектуры в проект на платформе .NET.
Настройка проекта и установка зависимостей
Первым шагом является создание нового проекта и добавление необходимых библиотек. Для .NET приложений это можно сделать несколькими способами:
C# | 1
2
3
4
5
6
7
| // Через консоль
dotnet new webapi -n CQRSWithMediatR
cd CQRSWithMediatR
// Установка основных пакетов
dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection |
|
Альтернативно, можно использовать менеджер пакетов NuGet через графический интерфейс Visual Studio или добавить соответствующие ссылки в файл проекта:
XML | 1
2
3
4
| <ItemGroup>
<PackageReference Include="MediatR" Version="12.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
</ItemGroup> |
|
Для реализации полноценного CQRS может потребоваться установка дополнительных пакетов:
C# | 1
2
3
4
5
6
7
| // Для валидации
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
// Для работы с БД (пример с Entity Framework Core)
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer |
|
Интеграция MediatR с DI-контейнером
Регистрация MediatR в контейнере зависимостей .NET выполняется в методе ConfigureServices или непосредственно в Program.cs для приложений с минимальным API:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
| // Program.cs
var builder = WebApplication.CreateBuilder(args);
// Регистрация MediatR
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Альтернативный вариант для более ранних версий MediatR
// builder.Services.AddMediatR(typeof(Program).Assembly);
var app = builder.Build();
// ... |
|
Ключевой момент здесь — указание сборок, в которых MediatR должен искать обработчики. Обычно это основная сборка приложения, но можно указать и другие сборки, если логика разделена между ними.
Структурирование команд и запросов
Организация кода в CQRS-проекте имеет решающее значение для поддержания порядка. Один из распространенных подходов — структурирование по функциональным областям:
Code | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| src/
├── Features/
│ ├── Products/
│ │ ├── Commands/
│ │ │ ├── CreateProduct/
│ │ │ │ ├── CreateProductCommand.cs
│ │ │ │ ├── CreateProductCommandHandler.cs
│ │ │ │ └── CreateProductCommandValidator.cs
│ │ │ └── DeleteProduct/
│ │ │ ├── DeleteProductCommand.cs
│ │ │ └── DeleteProductCommandHandler.cs
│ │ └── Queries/
│ │ ├── GetProduct/
│ │ │ ├── GetProductQuery.cs
│ │ │ └── GetProductQueryHandler.cs
│ │ └── ListProducts/
│ │ ├── ListProductsQuery.cs
│ │ └── ListProductsQueryHandler.cs |
|
Команды и запросы оформляются как объекты сообщений, реализующие интерфейсы IRequest<T> или IRequest :
C# | 1
2
3
4
5
6
7
8
| // Запрос, возвращающий данные
public record GetProductQuery(int Id) : IRequest<ProductDto>;
// Команда, возвращающая результат операции (например, ID созданного объекта)
public record CreateProductCommand(string Name, decimal Price) : IRequest<int>;
// Команда без возвращаемого значения
public record DeleteProductCommand(int Id) : IRequest; |
|
Использование record-типов в C# 9+ упрощает создание неизменяемых объектов сообщений, что хорошо соответствует принципам CQRS. Для проектов на более ранних версиях .NET можно использовать обычные классы.
Обработчики и их жизненный цикл
Обработчики реализуют интерфейс IRequestHandler<TRequest, TResponse> и содержат фактическую логику обработки запросов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDto>
{
private readonly ApplicationDbContext _dbContext;
public GetProductQueryHandler(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<ProductDto> Handle(GetProductQuery request, CancellationToken cancellationToken)
{
var product = await _dbContext.Products
.AsNoTracking() // Оптимизация для запросов
.FirstOrDefaultAsync(p => p.Id == request.Id, cancellationToken);
if (product == null)
throw new NotFoundException($"Product with ID {request.Id} not found");
return new ProductDto
{
Id = product.Id,
Name = product.Name,
Price = product.Price
};
}
} |
|
Обработчик команды работает аналогично:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly ApplicationDbContext _dbContext;
private readonly IMediator _mediator;
public CreateProductCommandHandler(
ApplicationDbContext dbContext,
IMediator mediator)
{
_dbContext = dbContext;
_mediator = mediator;
}
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
CreatedAt = DateTime.UtcNow
};
_dbContext.Products.Add(product);
await _dbContext.SaveChangesAsync(cancellationToken);
// Публикация события после успешного создания
await _mediator.Publish(new ProductCreatedEvent(product.Id), cancellationToken);
return product.Id;
}
} |
|
По умолчанию MediatR создает новый экземпляр обработчика для каждого запроса, что соответствует транзиентному жизненному циклу. Это обеспечивает изоляцию запросов и предотвращает проблемы с параллельным доступом. Зависимости, внедряемые в обработчик, следуют своим собственным правилам жизненного цикла, определенным в DI-контейнере.
Использование MediatR в контроллерах
API-контроллеры становятся тонкими при использовании CQRS и MediatR, делегируя всю логику обработчикам:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| [ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> Get(int id)
{
try
{
var product = await _mediator.Send(new GetProductQuery(id));
return Ok(product);
}
catch (NotFoundException ex)
{
return NotFound(ex.Message);
}
}
[HttpPost]
public async Task<ActionResult<int>> Create(CreateProductCommand command)
{
var productId = await _mediator.Send(command);
return CreatedAtAction(nameof(Get), new { id = productId }, productId);
}
} |
|
Шаблоны проектирования проекта с CQRS на практике
При внедрении CQRS особенно важно правильно структурировать проект. Современный подход "вертикальных срезов" (vertical slices) хорошо сочетается с CQRS, группируя код по бизнес-возможностям, а не по техническим слоям:
Вместо традиционного разделения на горизонтальные слои:
C# | 1
2
3
4
| Controllers/
Models/
Services/
Repositories/ |
|
Используется организация по бизнес-возможностям:
Code | 1
2
3
4
5
6
7
8
9
10
| Features/
├── Products/
│ ├── List.cs (запрос + обработчик)
│ ├── Details.cs (запрос + обработчик)
│ ├── Create.cs (команда + обработчик)
│ └── ProductsController.cs
├── Orders/
│ ├── List.cs
│ ├── Details.cs
│ └── OrdersController.cs |
|
Этот подход уменьшает необходимость перемещаться между папками при работе над одной функциональностью и делает код более связным.
Реализация вертикальных срезов с помощью MediatR
Вертикальные срезы (vertical slices) становятся все популярнее как способ организации кода в CQRS-приложениях. Этот подход позволяет сгруппировать все классы, относящиеся к одной бизнес-операции, в одном месте. Джимми Богард, создатель MediatR, активно пропагандирует этот стиль организации кода вместо традиционной "слоистой" архитектуры.
В вертикальных срезах команды и запросы часто размещаются в одном файле вместе с обработчиками:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public static class CreateProduct
{
// Команда
public record Command(string Name, decimal Price) : IRequest<int>;
// Обработчик
public class Handler : IRequestHandler<Command, int>
{
private readonly ApplicationDbContext _context;
public Handler(ApplicationDbContext context)
{
_context = context;
}
public async Task<int> Handle(Command request, CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
CreatedAt = DateTime.UtcNow
};
_context.Products.Add(product);
await _context.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
// Валидатор (если используется FluentValidation)
public class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
RuleFor(x => x.Price).GreaterThan(0);
}
}
} |
|
Такая структура позволяет быстро найти и понять всю логику, связанную с конкретной операцией, без необходимости перемещаться между многочисленными файлами и папками. Это особенно полезно при коллективной разработке, так как разработчики могут работать над разными вертикальными срезами с минимальными конфликтами при слиянии кода.
Динамическое определение обработчиков через механизм рефлексии
MediatR использует механизм рефлексии для автоматического поиска и регистрации обработчиков команд и запросов. Это избавляет от необходимости вручную регистрировать каждый обработчик, что делает систему более гибкой и менее подверженной ошибкам. Посмотрим, как происходит регистрация обработчиков в современных версиях MediatR:
C# | 1
2
3
4
5
6
7
8
9
10
| builder.Services.AddMediatR(cfg => {
// Регистрация обработчиков из указанных сборок
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
// Можно добавить несколько сборок
cfg.RegisterServicesFromAssembly(typeof(SomeTypeFromAnotherAssembly).Assembly);
// Кастомизация регистрации
cfg.Lifetime = ServiceLifetime.Scoped; // по умолчанию Transient
}); |
|
При запуске приложения MediatR сканирует указанные сборки, находит все классы, реализующие интерфейсы IRequestHandler<TRequest, TResponse> , и регистрирует их в DI-контейнере. Это позволяет добавлять новые команды и запросы без изменения кода регистрации. Если необходимо более тонкое управление регистрацией или условная регистрация обработчиков можно использовать расширенные возможности:
C# | 1
2
3
4
5
6
7
8
9
10
11
| // Ручная регистрация конкретного обработчика
services.AddTransient<IRequestHandler<MySpecialCommand, Unit>, MySpecialCommandHandler>();
// Условная регистрация с фильтрацией по атрибутам или другим критериям
services.AddMediatR(cfg => {
cfg.RegisterServicesFromAssemblyContaining<Program>();
// Добавление фильтра для исключения обработчиков с определенным атрибутом
cfg.RegistrationStrategy = RegistrationStrategy.Skip(t =>
t.GetCustomAttributes(typeof(ExcludeFromRegistrationAttribute), true).Any());
}); |
|
Стратегии проектирования запросов и команд
При разработке CQRS-системы с MediatR важно продумать стратегии проектирования команд и запросов:
Гранулярность команд и запросов: Команды обычно соответствуют одному атомарному действию в системе. Запросы могут быть более разнообразными — от простой выборки по ID до сложных поисковых запросов с фильтрацией и сортировкой.
Результаты запросов: Для запросов рекомендуется возвращать DTO (Data Transfer Objects), а не доменные сущности. Это разделяет доменную модель и модель представления, что соответствует принципам CQRS.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Запрос, возвращающий DTO
public record GetProductDetailsQuery(int Id) : IRequest<ProductDetailsDto>;
// Соответствующий DTO
public class ProductDetailsDto
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public string Category { get; set; }
public List<ReviewDto> Reviews { get; set; } = new();
} |
|
Параметризация запросов: Для сложных запросов с множеством параметров лучше использовать объекты-параметры, а не примитивные типы:
C# | 1
2
3
4
5
6
7
8
9
10
11
| public record SearchProductsQuery : IRequest<List<ProductSummaryDto>>
{
public string? NameContains { get; init; }
public decimal? MinPrice { get; init; }
public decimal? MaxPrice { get; init; }
public string? Category { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 10;
public string? SortBy { get; init; }
public bool SortDescending { get; init; }
} |
|
Именование: Используйте последовательную систему именования. Распространенная практика — название команд в повелительном наклонении (CreateProduct, UpdateOrder), а запросов как существительные или вопросы (GetProduct, SearchProducts, ProductDetails).
Обработка транзакций в CQRS
В CQRS-системах обработка транзакций требует особого внимания, особенно когда команда затрагивает несколько агрегатов или публикует события, которые должны быть обработаны в рамках той же транзакции.
Один из подходов — использование Unit of Work паттерна, который обеспечивает атомарность операций:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMediator _mediator;
public CreateOrderCommandHandler(IUnitOfWork unitOfWork, IMediator mediator)
{
_unitOfWork = unitOfWork;
_mediator = mediator;
}
public async Task<int> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// Начало транзакции
await _unitOfWork.BeginTransactionAsync();
try
{
// Создание заказа
var order = new Order
{
CustomerId = request.CustomerId,
OrderDate = DateTime.UtcNow,
Status = OrderStatus.Created
};
// Добавление позиций заказа
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.Price);
}
// Сохранение заказа
await _unitOfWork.Orders.AddAsync(order);
await _unitOfWork.SaveChangesAsync(cancellationToken);
// Публикация события внутри той же транзакции
await _mediator.Publish(new OrderCreatedEvent(order.Id), cancellationToken);
// Если все успешно, коммит транзакции
await _unitOfWork.CommitTransactionAsync();
return order.Id;
}
catch
{
// При ошибке откат транзакции
await _unitOfWork.RollbackTransactionAsync();
throw;
}
}
} |
|
Для работы с Entity Framework Core можно использовать TransactionScope или DbContext.Database.BeginTransaction():
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public async Task<int> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
{
using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
// Логика обработки команды
await _dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return orderId;
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
} |
|
Имплементация доменных событий
Доменные события — важный аспект CQRS, особенно в сочетании с DDD. MediatR предоставляет механизм уведомлений (notifications), который отлично подходит для реализации доменных событий:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Доменное событие
public record ProductPriceChangedEvent(int ProductId, decimal OldPrice, decimal NewPrice) : INotification;
// Обработчик события
public class ProductPriceChangedHandler : INotificationHandler<ProductPriceChangedEvent>
{
private readonly IEmailService _emailService;
public ProductPriceChangedHandler(IEmailService emailService)
{
_emailService = emailService;
}
public async Task Handle(ProductPriceChangedEvent notification, CancellationToken cancellationToken)
{
// Отправка уведомления подписчикам об изменении цены
await _emailService.NotifyPriceChangeAsync(
notification.ProductId,
notification.OldPrice,
notification.NewPrice,
cancellationToken);
}
} |
|
Публикация события осуществляется через метод Publish интерфейса IMediator :
C# | 1
| await _mediator.Publish(new ProductPriceChangedEvent(product.Id, oldPrice, product.Price), cancellationToken); |
|
Продвинутые техники
После освоения базовых принципов CQRS и MediatR пришло время углубиться в более продвинутые техники, которые помогут сделать приложение надежнее, производительнее и удобнее в поддержке.
Обработка исключений через pipeline
Один из наиболее мощных механизмов MediatR – это поведения конвейера (pipeline behaviors), позволяющие внедрять сквозную функциональность во все запросы и команды. Особенно полезна эта возможность для централизованной обработки исключений:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public class ExceptionHandlingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> _logger;
public ExceptionHandlingBehavior(
ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
try
{
return await next();
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Валидация не пройдена для {RequestName}",
typeof(TRequest).Name);
throw;
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, "Ресурс не найден для {RequestName} с {ExceptionMessage}",
typeof(TRequest).Name, ex.Message);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Необработанная ошибка для {RequestName}",
typeof(TRequest).Name);
throw new ApplicationException(
$"Произошла ошибка при обработке запроса {typeof(TRequest).Name}", ex);
}
}
} |
|
Регистрация этого поведения в DI-контейнере:
C# | 1
2
| builder.Services.AddTransient(typeof(IPipelineBehavior<,>),
typeof(ExceptionHandlingBehavior<,>)); |
|
Асинхронная обработка команд с использованием очередей
Для систем с высокой нагрузкой или операций, которые могут выполняться в фоновом режиме, полезно реализовать асинхронную обработку команд через очереди сообщений. Это позволяет разгрузить основной поток выполнения и обеспечить отказоустойчивость. Можно использовать различные технологии очередей: RabbitMQ, Azure Service Bus, Amazon SQS или даже in-memory очереди для простых случаев. Вот пример интеграции с RabbitMQ:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| public interface ICommandQueue
{
Task EnqueueAsync<TCommand>(TCommand command) where TCommand : IRequest;
}
public class RabbitMqCommandQueue : ICommandQueue
{
private readonly IModel _channel;
private readonly JsonSerializerSettings _serializerSettings;
public RabbitMqCommandQueue(IConnection connection)
{
_channel = connection.CreateModel();
_channel.QueueDeclare("commands", durable: true, exclusive: false,
autoDelete: false);
_serializerSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All
};
}
public Task EnqueueAsync<TCommand>(TCommand command) where TCommand : IRequest
{
var messageBody = Encoding.UTF8.GetBytes(
JsonConvert.SerializeObject(command, _serializerSettings));
_channel.BasicPublish(exchange: "", routingKey: "commands",
body: messageBody);
return Task.CompletedTask;
}
} |
|
Для обработки команд из очереди нужен фоновый сервис:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
| public class CommandQueueConsumer : BackgroundService
{
private readonly IModel _channel;
private readonly IServiceScopeFactory _scopeFactory;
private readonly JsonSerializerSettings _serializerSettings;
public CommandQueueConsumer(IConnection connection, IServiceScopeFactory scopeFactory)
{
_channel = connection.CreateModel();
_scopeFactory = scopeFactory;
_serializerSettings = new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All
};
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var consumer = new EventingBasicConsumer(_channel);
consumer.Received += async (model, ea) =>
{
var body = ea.Body.ToArray();
var message = Encoding.UTF8.GetString(body);
try
{
// Десериализация команды с сохранением типа
var commandObj = JsonConvert.DeserializeObject(message, _serializerSettings);
using var scope = _scopeFactory.CreateScope();
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
// Динамическая отправка команды
await mediator.Send(commandObj, stoppingToken);
_channel.BasicAck(ea.DeliveryTag, false);
}
catch (Exception)
{
// Обработка ошибок, возможно возврат в очередь
// или перемещение в очередь "мертвых писем"
_channel.BasicNack(ea.DeliveryTag, false, requeue: false);
}
};
_channel.BasicConsume(queue: "commands", autoAck: false, consumer: consumer);
return Task.CompletedTask;
}
} |
|
Реализация политики повторных попыток для команд с транзиентными ошибками
Транзиентные ошибки – это временные сбои, которые обычно исчезают при повторном выполнении операции (например, проблемы с сетью или временная недоступность базы данных). Для их обработки полезно реализовать механизм повторных попыток:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
| public class RetryBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<RetryBehavior<TRequest, TResponse>> _logger;
private readonly int _retryCount;
private readonly int _delayMilliseconds;
public RetryBehavior(ILogger<RetryBehavior<TRequest, TResponse>> logger,
int retryCount = 3, int delayMilliseconds = 1000)
{
_logger = logger;
_retryCount = retryCount;
_delayMilliseconds = delayMilliseconds;
}
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
int retryAttempt = 0;
Exception lastException = null;
while (retryAttempt <= _retryCount)
{
try
{
// Если это не первая попытка, добавляем задержку
if (retryAttempt > 0)
{
int delay = _delayMilliseconds * (int)Math.Pow(2, retryAttempt - 1);
_logger.LogWarning("Повторная попытка {RetryAttempt} для {RequestName} через {Delay}мс",
retryAttempt, typeof(TRequest).Name, delay);
await Task.Delay(delay, cancellationToken);
}
return await next();
}
catch (Exception ex) when (IsTransientException(ex))
{
lastException = ex;
_logger.LogWarning(ex, "Транзиентная ошибка при выполнении {RequestName}, попытка {RetryAttempt}",
typeof(TRequest).Name, retryAttempt);
retryAttempt++;
}
catch (Exception ex)
{
// Нетранзиентная ошибка - пробрасываем без повторных попыток
_logger.LogError(ex, "Нетранзиентная ошибка при выполнении {RequestName}",
typeof(TRequest).Name);
throw;
}
}
throw new MaxRetryAttemptsExceededException($"Превышено максимальное количество попыток ({_retryCount}) для {typeof(TRequest).Name}",
lastException);
}
private bool IsTransientException(Exception exception)
{
// Логика определения транзиентных ошибок
// Можно использовать готовые решения вроде Polly
return exception is TimeoutException
|| exception is IOException
|| (exception is SqlException sqlEx && (
sqlEx.Number == -2 || // Timeout
sqlEx.Number == 4060 || // Cannot open database
sqlEx.Number == 40613 // Database unavailable
));
}
} |
|
Для более комплексных сценариев рекомендуется использовать библиотеку Polly, которая предоставляет гибкие механизмы обработки ошибок, включая Circuit Breaker, Retry и Fallback:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // Регистрация Polly в MediatR
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PolicyBehavior<,>));
// Пример поведения с использованием Polly
public class PolicyBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IAsyncPolicy _policy;
public PolicyBehavior()
{
_policy = Policy
.Handle<SqlException>(ex => ex.Number == -2)
.Or<TimeoutException>()
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
return await _policy.ExecuteAsync(() => next());
}
} |
|
Валидация входных данных
Валидация – критически важный аспект любого приложения. Для CQRS-систем с MediatR наиболее популярный подход – использование FluentValidation в сочетании с поведением конвейера:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
// Выполняем все валидаторы параллельно
var validationResults = await Task.WhenAll(
_validators.Select(v =>
v.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
}
return await next();
}
} |
|
Пример валидатора для команды:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(p => p.Name)
.NotEmpty().WithMessage("Имя продукта обязательно")
.MaximumLength(100).WithMessage("Имя продукта не должно превышать 100 символов");
RuleFor(p => p.Price)
.GreaterThan(0).WithMessage("Цена должна быть больше нуля")
.LessThan(1000000).WithMessage("Цена не может быть больше 1,000,000");
RuleFor(p => p.CategoryId)
.NotEmpty().WithMessage("Категория обязательна");
}
} |
|
Обработка исключений через pipeline
MediatR предоставляет мощный механизм pipeline behaviors (конвейерных поведений), который можно использовать для централизованной обработки исключений во всех командах и запросах. Это позволяет избежать дублирования кода обработки ошибок и обеспечить единообразное поведение всей системы. Рассмотрим реализацию базового обработчика исключений:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| public class ExceptionHandlingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> _logger;
public ExceptionHandlingBehavior(
ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
try
{
return await next();
}
catch (ValidationException ex)
{
_logger.LogWarning("Ошибка валидации: {Message}", ex.Message);
throw new ApiValidationException(ex.Errors);
}
catch (DomainException ex)
{
_logger.LogWarning("Бизнес-ошибка: {Message}", ex.Message);
throw new ApiBusinessException(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Необработанное исключение: {Message}", ex.Message);
throw new ApiServerException("Произошла внутренняя ошибка сервера");
}
}
} |
|
Такое поведение нужно зарегистрировать в контейнере зависимостей:
C# | 1
| services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ExceptionHandlingBehavior<,>)); |
|
Теперь все команды и запросы будут проходить через этот конвейер, что обеспечит единообразную обработку исключений.
Асинхронная обработка команд с использованием очередей
В высоконагруженных системах или при выполнении длительных операций полезно использовать асинхронную обработку команд через очереди сообщений. Это позволяет быстро ответить клиенту, а затем обработать команду в фоновом режиме.
Для реализации такого подхода можно использовать MassTransit, RabbitMQ или Azure Service Bus в сочетании с MediatR:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // Интерфейс для отложенной обработки
public interface ICommandQueue
{
Task EnqueueAsync<TCommand>(TCommand command) where TCommand : IRequest;
}
// Реализация с использованием MassTransit и RabbitMQ
public class MassTransitCommandQueue : ICommandQueue
{
private readonly IBus _bus;
public MassTransitCommandQueue(IBus bus)
{
_bus = bus;
}
public async Task EnqueueAsync<TCommand>(TCommand command) where TCommand : IRequest
{
await _bus.Publish(command);
}
} |
|
Затем на стороне потребителя сообщений:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class CommandConsumer<TCommand> : IConsumer<TCommand> where TCommand : IRequest
{
private readonly IMediator _mediator;
public CommandConsumer(IMediator mediator)
{
_mediator = mediator;
}
public async Task Consume(ConsumeContext<TCommand> context)
{
await _mediator.Send(context.Message, context.CancellationToken);
}
} |
|
Для команд, которые не требуют немедленного ответа, контроллер может выглядеть так:
C# | 1
2
3
4
5
6
7
8
9
| [HttpPost]
public async Task<IActionResult> ProcessLongRunningTask(ProcessLongRunningCommand command)
{
// Помещаем команду в очередь вместо прямого выполнения
await _commandQueue.EnqueueAsync(command);
// Сразу возвращаем ответ клиенту
return Accepted();
} |
|
Политика повторных попыток для транзиентных ошибок
При работе с распределенными системами важно учитывать возможность транзиентных ошибок — временных сбоев, которые могут исчезнуть при повторной попытке. С помощью pipeline behaviors MediatR можно реализовать автоматические повторные попытки для команд:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| public class RetryBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<RetryBehavior<TRequest, TResponse>> _logger;
private readonly RetryPolicy _retryPolicy;
public RetryBehavior(ILogger<RetryBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
// Использование Polly для определения политики повторных попыток
_retryPolicy = Policy
.Handle<SqlException>(ex => IsTransientError(ex))
.Or<TimeoutException>()
.WaitAndRetryAsync(
3, // количество повторных попыток
attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)), // экспоненциальная задержка
(exception, timeSpan, retryCount, context) =>
{
_logger.LogWarning(
exception,
"Попытка {RetryCount} завершилась ошибкой. Повтор через {RetryDelay}...",
retryCount, timeSpan);
});
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Применяем политику повторов только к командам
if (typeof(TRequest).IsAssignableTo(typeof(ICommand)))
{
return await _retryPolicy.ExecuteAsync(() => next());
}
// Для запросов выполняем без повторов
return await next();
}
private bool IsTransientError(SqlException ex)
{
// Коды ошибок SQL Server, которые считаются транзиентными
int[] transientErrorNumbers = { 4060, 40197, 40501, 40613, 49918, 49919, 49920 };
return transientErrorNumbers.Contains(ex.Number);
}
} |
|
Валидация входных данных
Валидация является критически важным аспектом любой системы. В CQRS с MediatR валидацию можно реализовать как часть pipeline с помощью библиотеки FluentValidation:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
// Создаем контекст валидации
var context = new ValidationContext<TRequest>(request);
// Выполняем все валидаторы параллельно
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
// Собираем все ошибки из результатов валидации
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
} |
|
Для валидации конкретной команды создается класс-валидатор:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator(ApplicationDbContext dbContext)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Название продукта обязательно")
.MaximumLength(200).WithMessage("Название не должно превышать 200 символов");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Цена должна быть положительной");
RuleFor(x => x.CategoryId)
.MustAsync(async (categoryId, cancellation) => {
return await dbContext.Categories
.AnyAsync(c => c.Id == categoryId, cancellation);
}).WithMessage("Указанная категория не существует");
}
} |
|
Кэширование и мониторинг производительности
Для повышения производительности запросов можно внедрить кэширование в pipeline MediatR:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| public class CachingBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IMemoryCache _cache;
private readonly ICacheKeyService _cacheKeyService;
public CachingBehavior(
IMemoryCache cache,
ICacheKeyService cacheKeyService)
{
_cache = cache;
_cacheKeyService = cacheKeyService;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Кэшируем только запросы, помеченные атрибутом Cachable
if (request is not ICachableQuery<TResponse> cachableQuery)
return await next();
var cacheKey = _cacheKeyService.GetCacheKey(request);
// Проверяем наличие результата в кэше
if (_cache.TryGetValue(cacheKey, out TResponse cachedResponse))
return cachedResponse;
// Если в кэше нет, выполняем запрос
var response = await next();
// Помещаем результат в кэш с указанным временем жизни
_cache.Set(
cacheKey,
response,
TimeSpan.FromSeconds(cachableQuery.CacheTimeInSeconds));
return response;
}
} |
|
Для мониторинга производительности можно использовать еще один behavior:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| public class PerformanceBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly ILogger<PerformanceBehavior<TRequest, TResponse>> _logger;
private readonly Stopwatch _timer;
public PerformanceBehavior(
ILogger<PerformanceBehavior<TRequest, TResponse>> logger)
{
_logger = logger;
_timer = new Stopwatch();
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
_timer.Start();
var response = await next();
_timer.Stop();
var elapsedMilliseconds = _timer.ElapsedMilliseconds;
if (elapsedMilliseconds > 500)
{
var requestName = typeof(TRequest).Name;
_logger.LogWarning(
"CQRS Long Running Request: {Name} ({ElapsedMilliseconds} ms)",
requestName, elapsedMilliseconds);
}
return response;
}
} |
|
Реализация идемпотентности операций
Идемпотентность — важное свойство, особенно в распределенных системах, где повторная отправка команды не должна приводить к дублированию эффекта. Это можно реализовать с помощью уникальных идентификаторов команд:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| // Маркерный интерфейс для идемпотентных команд
public interface IIdempotentCommand : IRequest
{
Guid CommandId { get; }
}
// Пример команды
public record ProcessPaymentCommand : IIdempotentCommand
{
public Guid CommandId { get; init; } = Guid.NewGuid();
public Guid OrderId { get; init; }
public decimal Amount { get; init; }
}
// Behavior для обеспечения идемпотентности
public class IdempotencyBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse> where TRequest : IRequest<TResponse>
{
private readonly IIdempotencyStore _idempotencyStore;
public IdempotencyBehavior(IIdempotencyStore idempotencyStore)
{
_idempotencyStore = idempotencyStore;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
// Обрабатываем только идемпотентные команды
if (request is not IIdempotentCommand idempotentCommand)
return await next();
// Проверяем, была ли команда уже обработана
var result = await _idempotencyStore.GetResultAsync<TResponse>(
idempotentCommand.CommandId,
cancellationToken);
if (result != null)
return result;
// Если команда ещё не обрабатывалась, выполняем её
var response = await next();
// Сохраняем результат выполнения
await _idempotencyStore.SaveResultAsync(
idempotentCommand.CommandId,
response,
cancellationToken);
return response;
}
} |
|
Практические примеры
Теоретические концепции CQRS и MediatR наилучшим образом раскрываются в контексте их практического применения. Рассмотрим несколько конкретных примеров, демонстрирующих, как эти паттерны решают реальные задачи, возникающие в промышленной разработке.
Типовой пример управления электронной библиотекой
Представим систему электронной библиотеки, где пользователи могут искать книги, просматривать детали и добавлять их в персональные коллекции. Это классический случай, где операции чтения и записи имеют разные характеристики и требования к производительности. Пример реализации запроса поиска книг:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| // Запрос поиска книг
public record SearchBooksQuery : IRequest<SearchResultDto>
{
public string SearchTerm { get; init; } = string.Empty;
public string[] Genres { get; init; } = Array.Empty<string>();
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 10;
}
// Обработчик поиска
public class SearchBooksQueryHandler : IRequestHandler<SearchBooksQuery, SearchResultDto>
{
private readonly IBookReadRepository _bookRepository;
public SearchBooksQueryHandler(IBookReadRepository bookRepository)
{
_bookRepository = bookRepository;
}
public async Task<SearchResultDto> Handle(SearchBooksQuery request, CancellationToken cancellationToken)
{
// Используем специализированные методы репозитория для чтения
var (books, totalCount) = await _bookRepository.SearchBooksAsync(
request.SearchTerm,
request.Genres,
request.Page,
request.PageSize,
cancellationToken);
return new SearchResultDto
{
Books = books.Select(b => new BookSummaryDto
{
Id = b.Id,
Title = b.Title,
Author = b.Author,
CoverUrl = b.CoverUrl,
Rating = b.CalculateAverageRating()
}).ToList(),
TotalCount = totalCount,
Page = request.Page,
PageSize = request.PageSize,
TotalPages = (int)Math.Ceiling((double)totalCount / request.PageSize)
};
}
} |
|
Реализация команды добавления книги в коллекцию:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| // Команда добавления в коллекцию
public record AddToCollectionCommand : IRequest<Unit>
{
public Guid UserId { get; init; }
public Guid BookId { get; init; }
public string CollectionName { get; init; } = "Избранное";
}
// Обработчик команды
public class AddToCollectionCommandHandler : IRequestHandler<AddToCollectionCommand, Unit>
{
private readonly IUserWriteRepository _userRepository;
private readonly IMediator _mediator;
public AddToCollectionCommandHandler(
IUserWriteRepository userRepository,
IMediator mediator)
{
_userRepository = userRepository;
_mediator = mediator;
}
public async Task<Unit> Handle(AddToCollectionCommand request, CancellationToken cancellationToken)
{
// Получаем пользователя из репозитория для записи
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user == null)
throw new UserNotFoundException(request.UserId);
// Вызываем доменный метод добавления книги
var result = user.AddBookToCollection(request.BookId, request.CollectionName);
// Сохраняем изменения
await _userRepository.SaveChangesAsync(cancellationToken);
// Публикуем событие о добавлении книги в коллекцию
await _mediator.Publish(new BookAddedToCollectionEvent(
request.UserId,
request.BookId,
request.CollectionName), cancellationToken);
return Unit.Value;
}
} |
|
В этом примере четко видно разделение операций чтения и записи:
1. Поисковый запрос оптимизирован для быстрого извлечения и представления данных.
2. Команда работает с доменной моделью, обеспечивая соблюдение бизнес-правил.
3. Публикация событий позволяет другим компонентам системы реагировать на изменения.
Разбор сложного сценария: процесс оформления заказа
Рассмотрим более сложный пример — процесс оформления заказа в e-commerce системе. Этот процесс включает множество шагов и взаимодействие с различными частями системы.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
| // Команда создания заказа
public record CreateOrderCommand : IRequest<OrderCreatedResultDto>
{
public Guid CustomerId { get; init; }
public List<OrderItemDto> Items { get; init; } = new();
public AddressDto ShippingAddress { get; init; }
public string PaymentMethodId { get; init; }
}
// Обработчик создания заказа
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderCreatedResultDto>
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
private readonly IMediator _mediator;
// ... конструктор с внедрением зависимостей
public async Task<OrderCreatedResultDto> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
{
// Начало транзакции
using var transaction = await _orderRepository.BeginTransactionAsync();
try
{
// Проверка наличия и резервирование товаров
var inventoryCheck = await _inventoryService.CheckAndReserveInventoryAsync(
command.Items.Select(i => new InventoryItem(i.ProductId, i.Quantity)).ToList(),
cancellationToken);
if (!inventoryCheck.AllItemsAvailable)
throw new InsufficientInventoryException(inventoryCheck.UnavailableItems);
// Получение информации о ценах продуктов
var products = await _productRepository.GetProductsByIdsAsync(
command.Items.Select(i => i.ProductId).ToList(),
cancellationToken);
// Создание объекта заказа с применением бизнес-правил
var customer = await _customerRepository.GetByIdAsync(command.CustomerId, cancellationToken);
var order = new Order(customer);
foreach (var item in command.Items)
{
var product = products.FirstOrDefault(p => p.Id == item.ProductId);
if (product == null)
throw new ProductNotFoundException(item.ProductId);
order.AddItem(product, item.Quantity);
}
order.SetShippingAddress(
new Address(
command.ShippingAddress.Street,
command.ShippingAddress.City,
command.ShippingAddress.State,
command.ShippingAddress.PostalCode,
command.ShippingAddress.Country));
// Обработка платежа
var paymentResult = await _paymentService.ProcessPaymentAsync(
command.PaymentMethodId,
order.TotalAmount,
cancellationToken);
if (!paymentResult.Success)
throw new PaymentFailedException(paymentResult.ErrorMessage);
order.SetPaymentId(paymentResult.TransactionId);
// Сохранение заказа
await _orderRepository.AddAsync(order, cancellationToken);
await _orderRepository.SaveChangesAsync(cancellationToken);
// Публикация событий
await _mediator.Publish(new OrderCreatedEvent(order.Id), cancellationToken);
// Фиксация транзакции
await transaction.CommitAsync(cancellationToken);
return new OrderCreatedResultDto
{
OrderId = order.Id,
TotalAmount = order.TotalAmount,
EstimatedDeliveryDate = order.EstimatedDeliveryDate
};
}
catch
{
await transaction.RollbackAsync(cancellationToken);
throw;
}
}
} |
|
Данный пример демонстрирует несколько важных аспектов CQRS:
1. Работа с транзакциями для обеспечения атомарности операции.
2. Взаимодействие с несколькими репозиториями и сервисами.
3. Применение бизнес-правил через доменную модель.
4. Публикация событий для асинхронной обработки побочных эффектов.
Тестирование компонентов CQRS
Одним из главных преимуществ CQRS и MediatR является улучшенная тестируемость компонентов. Рассмотрим пример модульного теста для обработчика запроса
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| [Fact]
public async Task GetProductDetails_ForExistingProduct_ReturnsCorrectData()
{
// Arrange
var product = new Product
{
Id = 1,
Name = "Test Product",
Description = "Test Description",
Price = 29.99m,
CategoryId = 2
};
var mockRepository = new Mock<IProductRepository>();
mockRepository
.Setup(r => r.GetByIdAsync(It.Is<int>(id => id == 1), It.IsAny<CancellationToken>()))
.ReturnsAsync(product);
var handler = new GetProductDetailsQueryHandler(mockRepository.Object);
var query = new GetProductDetailsQuery(1);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.Equal(1, result.Id);
Assert.Equal("Test Product", result.Name);
Assert.Equal(29.99m, result.Price);
} |
|
Интеграционное тестирование медиатора с in-memory базой данных:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
| public class ProductCommandsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly IMediator _mediator;
public ProductCommandsIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Заменяем реальный DbContext на in-memory версию
var descriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase");
});
// Инициализация тестовых данных
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureCreated();
SeedTestData(db);
});
});
var scope = _factory.Services.CreateScope();
_mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
}
[Fact]
public async Task CreateProduct_ValidCommand_ReturnsProductId()
{
// Arrange
var command = new CreateProductCommand
{
Name = "New Product",
Price = 19.99m,
CategoryId = 1
};
// Act
var productId = await _mediator.Send(command);
// Assert
Assert.True(productId > 0);
// Verify product was created
var getQuery = new GetProductDetailsQuery(productId);
var product = await _mediator.Send(getQuery);
Assert.NotNull(product);
Assert.Equal("New Product", product.Name);
Assert.Equal(19.99m, product.Price);
}
private void SeedTestData(ApplicationDbContext context)
{
// Заполнение базы тестовыми данными
// ...
}
} |
|
Интеграция с API-шлюзами
В современных архитектурах CQRS часто используется вместе с API-шлюзами особенно в микросервисной среде. Рассмотрим пример конфигурации API-шлюза на основе Ocelot для работы с CQRS-сервисами:
JSON | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| {
"Routes": [
{
"DownstreamPathTemplate": "/api/products/{id}",
"DownstreamScheme": "https",
"DownstreamHostAndPorts": [
{
"Host": "query-service",
"Port": 443
}
],
"UpstreamPathTemplate": "/api/products/{id}",
"UpstreamHttpMethod": [ "Get" ],
"LoadBalancerOptions": {
"Type": "RoundRobin"
}
},
{
"DownstreamPathTemplate": "/api/products",
"DownstreamScheme": "https",
"DownstreamHostAndPorts": [
{
"Host": "command-service",
"Port": 443
}
],
"UpstreamPathTemplate": "/api/products",
"UpstreamHttpMethod": [ "Post", "Put" ],
"AuthenticationOptions": {
"AuthenticationProviderKey": "Bearer"
}
}
]
} |
|
Микросервисная реализация CQRS
CQRS идеально подходит для микросервисной архитектуры, позволяя разделить сервисы для чтения и записи. Пример архитектуры такого решения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| // Сервис команд - обработка записи (упрощенно)
public class ProductCommandService
{
private readonly IMediator _mediator;
private readonly IEventBus _eventBus;
public ProductCommandService(IMediator mediator, IEventBus eventBus)
{
_mediator = mediator;
_eventBus = eventBus;
}
public async Task<int> CreateProductAsync(CreateProductRequest request)
{
// Преобразование API-запроса в команду
var command = new CreateProductCommand(
request.Name,
request.Price,
request.CategoryId);
// Обработка команды
var productId = await _mediator.Send(command);
// Публикация события для синхронизации с сервисом запросов
await _eventBus.PublishAsync(new ProductCreatedIntegrationEvent(
productId,
request.Name,
request.Price,
request.CategoryId));
return productId;
}
}
// Сервис запросов - обработка событий и обновление моделей чтения
public class ProductQueryService
{
private readonly IProductReadRepository _repository;
public ProductQueryService(IProductReadRepository repository)
{
_repository = repository;
}
// Обработка событий интеграции
public async Task HandleProductCreatedEvent(ProductCreatedIntegrationEvent @event)
{
// Обновление базы данных для чтения
await _repository.CreateProductReadModelAsync(
@event.ProductId,
@event.Name,
@event.Price,
@event.CategoryId);
}
} |
|
Этот подход позволяет масштабировать сервисы чтения и записи независимо друг от друга, выбирая оптимальные технологии для каждого из них. Например, сервис команд может использовать реляционную БД для обеспечения транзакционности, а сервис запросов – документоориентированную БД для оптимизации чтения.
Критическая оценка и перспективы CQRS
Подводя итоги, стоит признать, что CQRS с MediatR – мощный архитектурный подход, решающий множество типичных проблем в сложных системах. Однако, как и любой инструмент, он не является универсальным решением для всех сценариев. Практика показывает, что разработчики часто сталкиваются с избыточной сложностью при внедрении CQRS в небольшие проекты или системы с простой бизнес-логикой. Распространённая ошибка – применять паттерн ко всей системе, когда он нужен лишь для отдельных компонентов. Важно помнить, что CQRS – это инструмент, решающий конкретные проблемы масштабирования и разделения ответственности, а не архитектурная самоцель.
Временная несогласованность между моделями чтения и записи может стать серьезным вызовом. В системах, где пользователи ожидают мгновенного отражения внесенных изменений, это может приводить к снижению удовлетворенности и доверия к приложению. Такие случаи требуют тщательного проектирования механизмов синхронизации и информирования пользователей о статусе их действий.
Интеграция с технологиями искусственного интеллекта открывает новые возможности. Например, предсказательные модели могут использоваться для оптимизации кэширования данных в моделях чтения, основываясь на паттернах использования и поведении пользователей.
Развитие реактивного программирования также влияет на реализацию CQRS. Библиотеки вроде System.Reactive и новые функциональные возможности C# позволяют более элегантно обрабатывать потоки событий и обновления модели чтения.
Говоря об альтернативах MediatR, стоит упомянуть несколько библиотек:
1. Brighter — расширяемый командный процессор с поддержкой очередей и отложенной обработки.
2. NServiceBus — полноценная шина сервисов, обеспечивающая надежную обработку сообщений.
3. Rebus — легковесная шина сообщений, хорошо интегрирующаяся с контейнерами внедрения зависимостей.
4. CAP (Client Actor Pattern) — реализует модель акторов для обработки команд и событий.
5. MassTransit — абстракция над шинами сообщений с богатой экосистемой.
Некоторые команды предпочитают создавать собственные облегченные реализации медиатора, что вполне обоснованно для простых случаев. Подход к выбору библиотеки должен определяться конкретными требованиями проекта, опытом команды и экосистемой, в которой функционирует приложение.
В конечном счете, успех применения CQRS с MediatR или альтернативными решениями зависит не столько от выбранных инструментов, сколько от глубокого понимания принципов, лежащих в основе этих архитектурных решений, и прагматичного подхода к их внедрению.
ASP.NET MVC 4,ASP.NET MVC 4.5 и ASP.NET MVC 5 большая ли разница между ними? Начал во всю осваивать технологию,теперь хочу с книжкой посидеть и вдумчиво перебрать всё то что... Оптимизация производительности C#.NET (Алгоритм, Многопоточность, Debug, Release, .Net Core, Net Native) Решил поделится своим небольшим опытом по оптимизации вычислений на C#.NET.
НЕ профи, палками не... Объясните на пальцах совместимость библиотек в .Net Core, .Net Framework, .Net Standart Изучаю .Net. Хочу написать некое серверное приложение (думаю что учеба лучше на реальном примере,... .net framework и .net core входят в состав .net? Какая там структура(в простом виде)? Реализация функции вычисления электронно-цифровой подписи RSA. Реализация функции проверки ЭЦП RSA Последовательность выполняемых действий включает следующие шаги.
1. Сформировать два простых числа... Реализация базы данных на С# без ADO.net и SQL серверов Здравствуйте уважаемые участники форума.
Мне была поставлена задача создать реализацию базы данных... Реализация чата на ASP.NET с использованием Long Polling Я брал код с http://easy4web.ru/?p=695.
Сам ничего не менял.
Короче, в чём автор статьи допустил... Разработка и создание алгоритмов решения заданий и их реализация в Visual Studio.Net Сама тема: "Разработка и создание алгоритмов решения задач и их реализация в Visual Studio.Net... [ASP.NET] IHttpModule, IHttpFilter вопросы, реализация. Задача есть некая CMS
1. все страницы генерируються динамически. то есть в проекте одна АСПХ... Реализация COM интерфейса в .NET для меня всегда было загадкой как имплементить com интерфейс у себя в программе для использования в... Реализация тестовых билетов ASP.NET: нужны исходники Здравствуйте. Есть какие - либо исходники простого теста на ASP.NET? Нужно от чего-то оттолкнуться,... ASP.net MVC4 Реализация видео галереи на сайте Хочу создать видео галерею на своем сайте, с возможностью редактирования и добавления нового. Не...
|