From bcd5a466a7a4420d9a2ddfa3548c21b4ac05d5b0 Mon Sep 17 00:00:00 2001
From: Dmitrii14 <117352471+Dmitrii14@users.noreply.github.com>
Date: Sun, 15 Mar 2026 21:00:05 +0400
Subject: [PATCH 1/5] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?=
=?UTF-8?q?=D0=BF=D0=BF=D0=B5=D1=80=D0=B2=D1=83=D1=8E=20=D0=BB=D0=B0=D0=B1?=
=?UTF-8?q?=D0=BE=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD=D1=83=D1=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Client.Wasm/Components/StudentCard.razor | 8 +-
Client.Wasm/Properties/launchSettings.json | 6 +-
Client.Wasm/wwwroot/appsettings.json | 2 +-
CloudDevelopment.sln | 18 +++
README.md | 143 +++---------------
.../Entities/SoftwareProject.cs | 57 +++++++
SoftwareProjects.Api/Program.cs | 32 ++++
.../Properties/launchSettings.json | 38 +++++
.../Services/ISoftwareProjectService.cs | 14 ++
.../Services/SoftwareProjectFaker.cs | 68 +++++++++
.../Services/SoftwareProjectService.cs | 73 +++++++++
.../SoftwareProjects.Api.csproj | 23 +++
.../appsettings.Development.json | 8 +
SoftwareProjects.Api/appsettings.json | 14 ++
.../SoftwareProjects.AppHost/AppHost.cs | 14 ++
.../Properties/launchSettings.json | 29 ++++
.../SoftwareProjects.AppHost.csproj | 23 +++
.../appsettings.Development.json | 8 +
.../SoftwareProjects.AppHost/appsettings.json | 9 ++
.../Extensions.cs | 127 ++++++++++++++++
.../SoftwareProjects.ServiceDefaults.csproj | 22 +++
21 files changed, 610 insertions(+), 126 deletions(-)
create mode 100644 SoftwareProjects.Api/Entities/SoftwareProject.cs
create mode 100644 SoftwareProjects.Api/Program.cs
create mode 100644 SoftwareProjects.Api/Properties/launchSettings.json
create mode 100644 SoftwareProjects.Api/Services/ISoftwareProjectService.cs
create mode 100644 SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
create mode 100644 SoftwareProjects.Api/Services/SoftwareProjectService.cs
create mode 100644 SoftwareProjects.Api/SoftwareProjects.Api.csproj
create mode 100644 SoftwareProjects.Api/appsettings.Development.json
create mode 100644 SoftwareProjects.Api/appsettings.json
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
create mode 100644 SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs
create mode 100644 SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..89936d11 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,10 +4,10 @@
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №1 "Кэширование"
+ Вариант №39 "Программный проект"
+ Выполнил Жидяев Дмитрий 6513
+ Ссылка на форк
diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json
index 0d824ea7..60120ec3 100644
--- a/Client.Wasm/Properties/launchSettings.json
+++ b/Client.Wasm/Properties/launchSettings.json
@@ -12,7 +12,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5127",
"environmentVariables": {
@@ -22,7 +22,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7282;http://localhost:5127",
"environmentVariables": {
@@ -31,7 +31,7 @@
},
"IIS Express": {
"commandName": "IISExpress",
- "launchBrowser": true,
+ "launchBrowser": false,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..8e1bc44d 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:5299/api/software-projects"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..7abfdb63 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.AppHost", "SoftwareProjects\SoftwareProjects.AppHost\SoftwareProjects.AppHost.csproj", "{EC51E772-B7D1-4185-86B3-9E0ACF79C521}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.ServiceDefaults", "SoftwareProjects\SoftwareProjects.ServiceDefaults\SoftwareProjects.ServiceDefaults.csproj", "{D568B20D-5F65-3DB5-3289-0CA101BC09FE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.Api", "SoftwareProjects.Api\SoftwareProjects.Api.csproj", "{D333DF66-6F24-76D8-CFA9-228C3368FF0D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +21,18 @@ Global
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EC51E772-B7D1-4185-86B3-9E0ACF79C521}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D568B20D-5F65-3DB5-3289-0CA101BC09FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/README.md b/README.md
index dcaa5eb7..9f28d5b2 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,35 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing)
+# Лабораторная работа №1 «Кэширование»
-## Задание
-### Цель
-Реализация проекта микросервисного бекенда.
+## Вариант №39 — «Программный проект»
-### Задачи
-* Реализация межсервисной коммуникации,
-* Изучение работы с брокерами сообщений,
-* Изучение архитектурных паттернов,
-* Изучение работы со средствами оркестрации на примере .NET Aspire,
-* Повторение основ работы с системами контроля версий,
-* Интеграционное тестирование.
+## Описание
-### Лабораторные работы
-
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов
-
-
-В рамках первой лабораторной работы необходимо:
-* Реализовать сервис генерации контрактов на основе Bogus,
-* Реализовать кеширование при помощи IDistributedCache и Redis,
-* Реализовать структурное логирование сервиса генерации,
-* Настроить оркестрацию Aspire.
-
-
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы
-
-
-В рамках второй лабораторной работы необходимо:
-* Настроить оркестрацию на запуск нескольких реплик сервиса генерации,
-* Реализовать апи гейтвей на основе Ocelot,
-* Имплементировать алгоритм балансировки нагрузки согласно варианту.
+Реализован сервис генерации данных о программных проектах с кэшированием ответов в Redis и оркестрацией через .NET Aspire
-
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда
-
+## Что реализовано
-В рамках третьей лабораторной работы необходимо:
-* Добавить в оркестрацию объектное хранилище,
-* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище,
-* Реализовать отправку генерируемых данных в файловый сервис посредством брокера,
-* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе.
+### Генерация данных (Bogus)
+- Класс `SoftwareProjectFaker` с `RuleFor` для каждого поля
+- Генерация ФИО менеджера с отчеством, образованным от мужского имени с учётом пола (окончания «ович/овна», «евич/евна»)
+- Локаль `ru` для русскоязычных имён
-
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud
-
-
-В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы:
-* Клиент - в хостинг через отдельный бакет Object Storage,
-* Сервис генерации - в Cloud Function,
-* Апи гейтвей - в Serverless Integration как API Gateway,
-* Брокер сообщений - в Message Queue,
-* Файловый сервис - в Cloud Function,
-* Объектное хранилище - в отдельный бакет Object Storage,
+### Кэширование (Redis + IDistributedCache)
+- Сервис `SoftwareProjectService` с интерфейсом `ISoftwareProjectService`
-
-
+### Структурное логирование
+- Логирование на английском языке через `ILogger`
+- Структурные параметры `{ProjectId}` для корреляции
+- Отдельные уровни: `Information` для успешных операций, `Warning` для ошибок кэша, `Error` для ошибок генерации
-## Задание. Общая часть
-**Обязательно**:
-* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview).
-* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview).
-* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus).
-* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs).
-* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация.
+### CORS
+- Разрешён только `GET`-запрос
+- Доверенные URL вынесены в `appsettings.json` (`TrustedOrigins`)
-**Факультативно**:
-* Перенос бекенда на облачную инфраструктуру Yandex Cloud
-
-Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью.
-Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю.
-
-По итогу работы в семестре должна получиться следующая информационная система:
-
-C4 диаграмма
-
-
-
-## Варианты заданий
-Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи.
-
-[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing)
-[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing)
-
-## Схема сдачи
-
-На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests).
-
-Общая схема:
-1. Сделать форк данного репозитория
-2. Выполнить задание
-3. Сделать PR в данный репозиторий
-4. Исправить замечания после code review
-5. Получить approve
-
-## Критерии оценивания
-
-Конкурентный принцип.
-Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки:
-1. Скорость разработки
-2. Качество разработки
-3. Полнота выполнения задания
-
-Быстрее делаете PR - у вас преимущество.
-Быстрее получаете Approve - у вас преимущество.
-Выполните нечто немного выходящее за рамки проекта - у вас преимущество.
-Не укладываетесь в дедлайн - получаете минимально возможный балл.
-
-### Шкала оценивания
-
-- **3 балла** за качество кода, из них:
- - 2 балла - базовая оценка
- - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов:
- - Реализация факультативного функционала
- - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл
-
-## Вопросы и обратная связь по курсу
-
-Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new).
-Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas).
+### Оркестрация (.NET Aspire)
+- Redis с RedisInsight
+- API сервис ждёт Redis (`WaitFor(cache)`)
+- Клиент WASM ждёт API сервис (`WaitFor(softwareProjectsApi)`)
+### API
+- Единственный эндпоинт: `GET /api/software-projects?id={id}`
+- Minimal API
diff --git a/SoftwareProjects.Api/Entities/SoftwareProject.cs b/SoftwareProjects.Api/Entities/SoftwareProject.cs
new file mode 100644
index 00000000..3c044b94
--- /dev/null
+++ b/SoftwareProjects.Api/Entities/SoftwareProject.cs
@@ -0,0 +1,57 @@
+namespace SoftwareProjects.Api.Entities;
+
+///
+/// Программный проект с информацией о бюджете, сроках и прогрессе
+///
+public class SoftwareProject
+{
+ ///
+ /// Идентификатор в системе
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// Название проекта
+ ///
+ public required string ProjectName { get; set; }
+
+ ///
+ /// Заказчик проекта
+ ///
+ public required string CustomerCompany { get; set; }
+
+ ///
+ /// Менеджер проекта (Фамилия Имя Отчество)
+ ///
+ public required string ProjectManager { get; set; }
+
+ ///
+ /// Дата начала
+ ///
+ public DateOnly StartDate { get; set; }
+
+ ///
+ /// Плановая дата завершения
+ ///
+ public DateOnly PlannedEndDate { get; set; }
+
+ ///
+ /// Фактическая дата завершения
+ ///
+ public DateOnly? ActualEndDate { get; set; }
+
+ ///
+ /// Бюджет
+ ///
+ public decimal Budget { get; set; }
+
+ ///
+ /// Фактические затраты
+ ///
+ public decimal ActualCosts { get; set; }
+
+ ///
+ /// Процент выполнения (0-100)
+ ///
+ public int CompletionPercentage { get; set; }
+}
diff --git a/SoftwareProjects.Api/Program.cs b/SoftwareProjects.Api/Program.cs
new file mode 100644
index 00000000..07767140
--- /dev/null
+++ b/SoftwareProjects.Api/Program.cs
@@ -0,0 +1,32 @@
+using SoftwareProjects.Api.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.AddRedisDistributedCache("cache");
+
+builder.Services.AddScoped();
+
+var trustedOrigins = builder.Configuration
+ .GetSection("TrustedOrigins")
+ .Get() ?? [];
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.WithOrigins(trustedOrigins)
+ .WithMethods("GET")
+ .AllowAnyHeader();
+ });
+});
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+app.UseCors();
+
+app.MapGet("/api/software-projects", async (int id, ISoftwareProjectService service) =>
+ Results.Ok(await service.GetById(id)));
+
+app.Run();
diff --git a/SoftwareProjects.Api/Properties/launchSettings.json b/SoftwareProjects.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..600c5c75
--- /dev/null
+++ b/SoftwareProjects.Api/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:20046",
+ "sslPort": 44320
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5299",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7166;http://localhost:5299",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Services/ISoftwareProjectService.cs b/SoftwareProjects.Api/Services/ISoftwareProjectService.cs
new file mode 100644
index 00000000..d3c3b338
--- /dev/null
+++ b/SoftwareProjects.Api/Services/ISoftwareProjectService.cs
@@ -0,0 +1,14 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Сервис получения программного проекта с поддержкой кэширования
+///
+public interface ISoftwareProjectService
+{
+ ///
+ /// Получает программный проект по идентификатору из кэша или генерирует новый
+ ///
+ public Task GetById(int id);
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
new file mode 100644
index 00000000..5aafe2cd
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
@@ -0,0 +1,68 @@
+using Bogus;
+using Bogus.DataSets;
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Статический генератор данных для программного проекта
+///
+public static class SoftwareProjectFaker
+{
+ private static readonly Faker _faker = new Faker("ru")
+ .RuleFor(p => p.Id, _ => 0)
+ .RuleFor(p => p.ProjectName, f =>
+ $"{f.Commerce.ProductName()} {f.Hacker.Abbreviation()}")
+ .RuleFor(p => p.CustomerCompany, f =>
+ f.Commerce.Department())
+ .RuleFor(p => p.ProjectManager, f =>
+ {
+ var gender = f.PickRandom();
+ var lastName = f.Name.LastName(gender);
+ var firstName = f.Name.FirstName(gender);
+ var patronymic = GeneratePatronymic(f, gender);
+ return $"{lastName} {firstName} {patronymic}";
+ })
+ .RuleFor(p => p.StartDate, f =>
+ DateOnly.FromDateTime(f.Date.Past(2)))
+ .RuleFor(p => p.PlannedEndDate, (f, p) =>
+ p.StartDate.AddDays(f.Random.Int(30, 365)))
+ .RuleFor(p => p.ActualEndDate, (f, p) =>
+ f.Random.Bool(0.4f)
+ ? p.StartDate.AddDays(f.Random.Int(30, 400))
+ : null)
+ .RuleFor(p => p.Budget, f =>
+ Math.Round(f.Finance.Amount(100_000, 10_000_000), 2))
+ .RuleFor(p => p.ActualCosts, (f, p) =>
+ Math.Round(p.Budget * f.Random.Decimal(0.3m, 1.2m), 2))
+ .RuleFor(p => p.CompletionPercentage, (f, p) =>
+ p.ActualEndDate.HasValue ? 100 : f.Random.Int(0, 99));
+
+ ///
+ /// Генерирует программный проект с указанным идентификатором
+ ///
+ public static SoftwareProject Generate(int id)
+ {
+ var project = _faker.Generate();
+ project.Id = id;
+ return project;
+ }
+
+ ///
+ /// Генерирует отчество на основе мужского имени с учётом пола менеджера
+ ///
+ private static string GeneratePatronymic(Faker faker, Name.Gender gender)
+ {
+ var maleName = faker.Name.FirstName(Name.Gender.Male);
+ var suffix = gender == Name.Gender.Male ? "ович" : "овна";
+ var softSuffix = gender == Name.Gender.Male ? "евич" : "евна";
+
+ if (maleName.EndsWith('а') || maleName.EndsWith('я'))
+ return maleName[..^1] + suffix;
+
+ if (maleName.EndsWith('ь'))
+ return maleName[..^1] + softSuffix;
+
+ return maleName + suffix;
+ }
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectService.cs b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
new file mode 100644
index 00000000..5356daca
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+using Microsoft.Extensions.Caching.Distributed;
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Реализация сервиса программных проектов с кэшированием через Redis
+///
+public class SoftwareProjectService(
+ IDistributedCache cache,
+ IConfiguration configuration,
+ ILogger logger) : ISoftwareProjectService
+{
+ private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
+
+ ///
+ /// Получает программный проект по идентификатору из кэша или генерирует новый
+ ///
+ public async Task GetById(int id)
+ {
+ var cacheKey = $"software-project-{id}";
+
+ try
+ {
+ var cached = await cache.GetStringAsync(cacheKey);
+
+ if (!string.IsNullOrEmpty(cached))
+ {
+ var deserialized = JsonSerializer.Deserialize(cached);
+
+ if (deserialized is not null)
+ {
+ logger.LogInformation("Project {ProjectId} retrieved from cache", id);
+ return deserialized;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to read project {ProjectId} from cache", id);
+ }
+
+ SoftwareProject project;
+
+ try
+ {
+ project = SoftwareProjectFaker.Generate(id);
+ logger.LogInformation("Project {ProjectId} generated successfully", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to generate project {ProjectId}", id);
+ throw;
+ }
+
+ try
+ {
+ var json = JsonSerializer.Serialize(project);
+ await cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_cacheExpirationMinutes)
+ });
+ logger.LogInformation("Project {ProjectId} saved to cache", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to write project {ProjectId} to cache", id);
+ }
+
+ return project;
+ }
+}
diff --git a/SoftwareProjects.Api/SoftwareProjects.Api.csproj b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
new file mode 100644
index 00000000..bef0eadb
--- /dev/null
+++ b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoftwareProjects.Api/appsettings.Development.json b/SoftwareProjects.Api/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/SoftwareProjects.Api/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/appsettings.json b/SoftwareProjects.Api/appsettings.json
new file mode 100644
index 00000000..125e7169
--- /dev/null
+++ b/SoftwareProjects.Api/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "TrustedOrigins": [
+ "http://localhost:5127",
+ "https://localhost:7282"
+ ],
+ "CacheExpirationMinutes": 5
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
new file mode 100644
index 00000000..61100ecf
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
@@ -0,0 +1,14 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var cache = builder.AddRedis("cache")
+ .WithRedisInsight(containerName: "credit-redis-insight");
+
+var softwareProjectsApi = builder.AddProject("softwareprojects-api")
+ .WithReference(cache)
+ .WaitFor(cache);
+
+builder.AddProject("client-wasm")
+ .WaitFor(softwareProjectsApi);
+
+
+builder.Build().Run();
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json b/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..f966aa44
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17036;http://localhost:15252",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21213",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22154"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15252",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19181",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20164"
+ }
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
new file mode 100644
index 00000000..1126c490
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ 8fca868f-2ed6-4417-82a1-5fb1ad8ee5f0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
new file mode 100644
index 00000000..31c092aa
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs b/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..b72c8753
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.ServiceDefaults/Extensions.cs
@@ -0,0 +1,127 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.ServiceDiscovery;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace Microsoft.Extensions.Hosting;
+
+// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
+// This project should be referenced by each service project in your solution.
+// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
+public static class Extensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.ConfigureOpenTelemetry();
+
+ builder.AddDefaultHealthChecks();
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
+
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
+
+ // Uncomment the following to restrict the allowed schemes for service discovery.
+ // builder.Services.Configure(options =>
+ // {
+ // options.AllowedSchemes = ["https"];
+ // });
+
+ return builder;
+ }
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddAspNetCoreInstrumentation(tracing =>
+ // Exclude health check requests from tracing
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
+ )
+ // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
+ //.AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj b/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj
new file mode 100644
index 00000000..1b6e209a
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.ServiceDefaults/SoftwareProjects.ServiceDefaults.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 9d113a4470bd88e5850e7f9648e29748d2d194f0 Mon Sep 17 00:00:00 2001
From: Dmitrii14 <117352471+Dmitrii14@users.noreply.github.com>
Date: Mon, 16 Mar 2026 15:01:53 +0400
Subject: [PATCH 2/5] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D0=BB=20=D0=B2=D1=81=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
SoftwareProjects.Api/Program.cs | 1 +
.../Services/ISoftwareProjectCacheService.cs | 19 ++++++
.../Services/SoftwareProjectCacheService.cs | 64 +++++++++++++++++++
.../Services/SoftwareProjectFaker.cs | 5 +-
.../Services/SoftwareProjectService.cs | 46 ++-----------
.../SoftwareProjects.AppHost/AppHost.cs | 1 -
6 files changed, 91 insertions(+), 45 deletions(-)
create mode 100644 SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs
create mode 100644 SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
diff --git a/SoftwareProjects.Api/Program.cs b/SoftwareProjects.Api/Program.cs
index 07767140..f9f7e353 100644
--- a/SoftwareProjects.Api/Program.cs
+++ b/SoftwareProjects.Api/Program.cs
@@ -5,6 +5,7 @@
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
+builder.Services.AddScoped();
builder.Services.AddScoped();
var trustedOrigins = builder.Configuration
diff --git a/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs b/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs
new file mode 100644
index 00000000..7488b1e3
--- /dev/null
+++ b/SoftwareProjects.Api/Services/ISoftwareProjectCacheService.cs
@@ -0,0 +1,19 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Служба кэширования программных проектов
+///
+public interface ISoftwareProjectCacheService
+{
+ ///
+ /// Получает программный проект из кэша по идентификатору
+ ///
+ public Task GetFromCache(int id);
+
+ ///
+ /// Сохраняет программный проект в кэш
+ ///
+ public Task SetToCache(int id, SoftwareProject project);
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
new file mode 100644
index 00000000..98590729
--- /dev/null
+++ b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+using Microsoft.Extensions.Caching.Distributed;
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Services;
+
+///
+/// Реализация службы кэширования программных проектов через Redis
+///
+public class SoftwareProjectCacheService(
+ IDistributedCache cache,
+ IConfiguration configuration,
+ ILogger logger) : ISoftwareProjectCacheService
+{
+ private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
+
+ ///
+ /// Получает программный проект из кэша по идентификатору
+ ///
+ public async Task GetFromCache(int id)
+ {
+ try
+ {
+ var cached = await cache.GetStringAsync($"software-project-{id}");
+
+ if (!string.IsNullOrEmpty(cached))
+ {
+ var deserialized = JsonSerializer.Deserialize(cached);
+
+ if (deserialized is not null)
+ {
+ logger.LogInformation("Project {ProjectId} retrieved from cache", id);
+ return deserialized;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to read project {ProjectId} from cache", id);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Сохраняет программный проект в кэш
+ ///
+ public async Task SetToCache(int id, SoftwareProject project)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(project);
+ await cache.SetStringAsync($"software-project-{id}", json, new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_cacheExpirationMinutes)
+ });
+ logger.LogInformation("Project {ProjectId} saved to cache", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to write project {ProjectId} to cache", id);
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
index 5aafe2cd..f60cab24 100644
--- a/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
+++ b/SoftwareProjects.Api/Services/SoftwareProjectFaker.cs
@@ -18,10 +18,7 @@ public static class SoftwareProjectFaker
.RuleFor(p => p.ProjectManager, f =>
{
var gender = f.PickRandom();
- var lastName = f.Name.LastName(gender);
- var firstName = f.Name.FirstName(gender);
- var patronymic = GeneratePatronymic(f, gender);
- return $"{lastName} {firstName} {patronymic}";
+ return $"{f.Name.LastName(gender)} {f.Name.FirstName(gender)} {GeneratePatronymic(f, gender)}";
})
.RuleFor(p => p.StartDate, f =>
DateOnly.FromDateTime(f.Date.Past(2)))
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectService.cs b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
index 5356daca..e19b80d7 100644
--- a/SoftwareProjects.Api/Services/SoftwareProjectService.cs
+++ b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
@@ -1,45 +1,23 @@
-using System.Text.Json;
-using Microsoft.Extensions.Caching.Distributed;
using SoftwareProjects.Api.Entities;
namespace SoftwareProjects.Api.Services;
///
-/// Реализация сервиса программных проектов с кэшированием через Redis
+/// Реализация сервиса программных проектов с кэшированием
///
public class SoftwareProjectService(
- IDistributedCache cache,
- IConfiguration configuration,
+ ISoftwareProjectCacheService cacheService,
ILogger logger) : ISoftwareProjectService
{
- private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
-
///
/// Получает программный проект по идентификатору из кэша или генерирует новый
///
public async Task GetById(int id)
{
- var cacheKey = $"software-project-{id}";
-
- try
- {
- var cached = await cache.GetStringAsync(cacheKey);
+ var cached = await cacheService.GetFromCache(id);
- if (!string.IsNullOrEmpty(cached))
- {
- var deserialized = JsonSerializer.Deserialize(cached);
-
- if (deserialized is not null)
- {
- logger.LogInformation("Project {ProjectId} retrieved from cache", id);
- return deserialized;
- }
- }
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "Failed to read project {ProjectId} from cache", id);
- }
+ if (cached is not null)
+ return cached;
SoftwareProject project;
@@ -54,19 +32,7 @@ public async Task GetById(int id)
throw;
}
- try
- {
- var json = JsonSerializer.Serialize(project);
- await cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions
- {
- AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_cacheExpirationMinutes)
- });
- logger.LogInformation("Project {ProjectId} saved to cache", id);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "Failed to write project {ProjectId} to cache", id);
- }
+ await cacheService.SetToCache(id, project);
return project;
}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
index 61100ecf..31fbb9ef 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
+++ b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
@@ -10,5 +10,4 @@
builder.AddProject("client-wasm")
.WaitFor(softwareProjectsApi);
-
builder.Build().Run();
From c3e05e1f1f874efe6608fcb9f7e34322e8697cd4 Mon Sep 17 00:00:00 2001
From: Dmitrii14 <117352471+Dmitrii14@users.noreply.github.com>
Date: Thu, 26 Mar 2026 18:55:16 +0400
Subject: [PATCH 3/5] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?=
=?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D1=83=D1=8E=20=D0=BB=D0=B0=D0=B1=D0=BE?=
=?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD=D1=83=D1=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Api.Gateway/Api.Gateway.csproj | 17 +++++++
.../WeightedRandomLoadBalancer.cs | 45 +++++++++++++++++++
Api.Gateway/Program.cs | 32 +++++++++++++
Api.Gateway/Properties/launchSettings.json | 38 ++++++++++++++++
Api.Gateway/appsettings.Development.json | 8 ++++
Api.Gateway/appsettings.json | 14 ++++++
Api.Gateway/ocelot.json | 20 +++++++++
Client.Wasm/Components/StudentCard.razor | 2 +-
Client.Wasm/wwwroot/appsettings.json | 2 +-
CloudDevelopment.sln | 6 +++
README.md | 22 +++++++++
SoftwareProjects.Api/Program.cs | 15 -------
.../Services/SoftwareProjectCacheService.cs | 4 +-
SoftwareProjects.Api/appsettings.json | 4 --
.../SoftwareProjects.AppHost/AppHost.cs | 14 ++++--
.../SoftwareProjects.AppHost.csproj | 1 +
16 files changed, 217 insertions(+), 27 deletions(-)
create mode 100644 Api.Gateway/Api.Gateway.csproj
create mode 100644 Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
create mode 100644 Api.Gateway/Program.cs
create mode 100644 Api.Gateway/Properties/launchSettings.json
create mode 100644 Api.Gateway/appsettings.Development.json
create mode 100644 Api.Gateway/appsettings.json
create mode 100644 Api.Gateway/ocelot.json
diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj
new file mode 100644
index 00000000..6ffae862
--- /dev/null
+++ b/Api.Gateway/Api.Gateway.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
new file mode 100644
index 00000000..555302fa
--- /dev/null
+++ b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
@@ -0,0 +1,45 @@
+using Ocelot.LoadBalancer.Interfaces;
+using Ocelot.Responses;
+using Ocelot.Values;
+using System.Collections.Generic;
+
+namespace Api.Gateway.LoadBalancers;
+
+///
+/// Балансировщик нагрузки на основе взвешенного случайного выбора (Weighted Random).
+/// Каждой реплике назначается вероятность выбора. При поступлении запроса
+/// реплика выбирается случайно с учётом заданных вероятностей.
+/// Веса задаются в appsettings.json в секции "WeightedRandomWeights".
+///
+public class WeightedRandomLoadBalancer(Func>> services, IConfiguration configuration) : ILoadBalancer
+{
+ private readonly double[] _cumulativeWeights = BuildCumulativeWeights(
+ configuration.GetSection("WeightedRandomWeights").Get() ?? [0.4, 0.3, 0.15, 0.1, 0.05]);
+
+ public string Type => nameof(WeightedRandomLoadBalancer);
+
+ public async Task> LeaseAsync(HttpContext httpContext)
+ {
+ var availableServices = await services();
+
+ if (availableServices.Count == 0)
+ throw new InvalidOperationException("No available downstream services");
+
+ var index = Array.BinarySearch(_cumulativeWeights, Random.Shared.NextDouble());
+ if (index < 0) index = ~index;
+
+ return new OkResponse(
+ availableServices[Math.Min(index, availableServices.Count - 1)].HostAndPort);
+ }
+
+ public void Release(ServiceHostAndPort hostAndPort) { }
+
+ private static double[] BuildCumulativeWeights(double[] weights)
+ {
+ var cumulative = new double[weights.Length];
+ cumulative[0] = weights[0];
+ for (var i = 1; i < weights.Length; i++)
+ cumulative[i] = cumulative[i - 1] + weights[i];
+ return cumulative;
+ }
+}
diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs
new file mode 100644
index 00000000..0997cb78
--- /dev/null
+++ b/Api.Gateway/Program.cs
@@ -0,0 +1,32 @@
+using Api.Gateway.LoadBalancers;
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+builder.Services.AddServiceDiscovery();
+builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
+builder.Services.AddOcelot()
+ .AddCustomLoadBalancer((sp, _, provider) =>
+ new WeightedRandomLoadBalancer(provider.GetAsync, sp.GetRequiredService()));
+
+var trustedOrigins = builder.Configuration
+ .GetSection("TrustedOrigins")
+ .Get() ?? [];
+
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.WithOrigins(trustedOrigins)
+ .WithMethods("GET")
+ .AllowAnyHeader();
+ });
+});
+
+var app = builder.Build();
+app.UseCors();
+app.MapDefaultEndpoints();
+await app.UseOcelot();
+app.Run();
diff --git a/Api.Gateway/Properties/launchSettings.json b/Api.Gateway/Properties/launchSettings.json
new file mode 100644
index 00000000..2f53e6a4
--- /dev/null
+++ b/Api.Gateway/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:64729",
+ "sslPort": 44359
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7081;http://localhost:5025",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Api.Gateway/appsettings.Development.json b/Api.Gateway/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/Api.Gateway/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Api.Gateway/appsettings.json b/Api.Gateway/appsettings.json
new file mode 100644
index 00000000..93d01ee7
--- /dev/null
+++ b/Api.Gateway/appsettings.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "TrustedOrigins": [
+ "http://localhost:5127",
+ "https://localhost:7282"
+ ],
+ "WeightedRandomWeights": [ 0.30, 0.25, 0.20, 0.15, 0.10 ]
+}
diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json
new file mode 100644
index 00000000..d2e65724
--- /dev/null
+++ b/Api.Gateway/ocelot.json
@@ -0,0 +1,20 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/software-projects",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/api/software-projects",
+ "DownstreamScheme": "https",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 5200 },
+ { "Host": "localhost", "Port": 5201 },
+ { "Host": "localhost", "Port": 5202 },
+ { "Host": "localhost", "Port": 5203 },
+ { "Host": "localhost", "Port": 5204 }
+ ],
+ "LoadBalancerOptions": {
+ "Type": "WeightedRandomLoadBalancer"
+ }
+ }
+ ]
+}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 89936d11..c919a3e7 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,7 +4,7 @@
- Номер №1 "Кэширование"
+ Номер №2 "Балансировка нагрузки"
Вариант №39 "Программный проект"
Выполнил Жидяев Дмитрий 6513
Ссылка на форк
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index 8e1bc44d..ad3d834b 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": "http://localhost:5299/api/software-projects"
+ "BaseAddress": "https://localhost:7081/software-projects"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index 7abfdb63..a7066ed1 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.ServiceDef
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.Api", "SoftwareProjects.Api\SoftwareProjects.Api.csproj", "{D333DF66-6F24-76D8-CFA9-228C3368FF0D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,6 +35,10 @@ Global
{D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D333DF66-6F24-76D8-CFA9-228C3368FF0D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/README.md b/README.md
index 9f28d5b2..b40378bf 100644
--- a/README.md
+++ b/README.md
@@ -33,3 +33,25 @@
### API
- Единственный эндпоинт: `GET /api/software-projects?id={id}`
- Minimal API
+
+---
+
+# Лабораторная работа №2 «Балансировка нагрузки»
+
+## Описание
+
+Реализован API Gateway на основе Ocelot с кастомным алгоритмом балансировки нагрузки Weighted Random
+
+## Что реализовано
+
+### Несколько реплик сервиса генерации
+- 5 реплик `SoftwareProjects.Api` на портах 5200–5204
+- Оркестрация через .NET Aspire (`AppHost.cs`)
+
+### API Gateway (Ocelot)
+- Маршрутизация запросов `/software-projects` → `/api/software-projects`
+- Конфигурация в `ocelot.json`
+
+### Балансировщик Weighted Random
+- Каждой реплике назначается вероятность выбора
+- При поступлении запроса реплика выбирается случайно с учётом заданных вероятностей
diff --git a/SoftwareProjects.Api/Program.cs b/SoftwareProjects.Api/Program.cs
index f9f7e353..66d56c0b 100644
--- a/SoftwareProjects.Api/Program.cs
+++ b/SoftwareProjects.Api/Program.cs
@@ -8,24 +8,9 @@
builder.Services.AddScoped();
builder.Services.AddScoped();
-var trustedOrigins = builder.Configuration
- .GetSection("TrustedOrigins")
- .Get() ?? [];
-
-builder.Services.AddCors(options =>
-{
- options.AddDefaultPolicy(policy =>
- {
- policy.WithOrigins(trustedOrigins)
- .WithMethods("GET")
- .AllowAnyHeader();
- });
-});
-
var app = builder.Build();
app.MapDefaultEndpoints();
-app.UseCors();
app.MapGet("/api/software-projects", async (int id, ISoftwareProjectService service) =>
Results.Ok(await service.GetById(id)));
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
index 98590729..e8716aaa 100644
--- a/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
+++ b/SoftwareProjects.Api/Services/SoftwareProjectCacheService.cs
@@ -1,6 +1,6 @@
-using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using SoftwareProjects.Api.Entities;
+using System.Text.Json;
namespace SoftwareProjects.Api.Services;
@@ -12,7 +12,7 @@ public class SoftwareProjectCacheService(
IConfiguration configuration,
ILogger logger) : ISoftwareProjectCacheService
{
- private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
+ private readonly int _cacheExpirationMinutes = configuration.GetValue("CacheExpirationMinutes", 5);
///
/// Получает программный проект из кэша по идентификатору
diff --git a/SoftwareProjects.Api/appsettings.json b/SoftwareProjects.Api/appsettings.json
index 125e7169..0ef4c3c3 100644
--- a/SoftwareProjects.Api/appsettings.json
+++ b/SoftwareProjects.Api/appsettings.json
@@ -6,9 +6,5 @@
}
},
"AllowedHosts": "*",
- "TrustedOrigins": [
- "http://localhost:5127",
- "https://localhost:7282"
- ],
"CacheExpirationMinutes": 5
}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
index 31fbb9ef..2a104390 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
+++ b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
@@ -3,11 +3,17 @@
var cache = builder.AddRedis("cache")
.WithRedisInsight(containerName: "credit-redis-insight");
-var softwareProjectsApi = builder.AddProject("softwareprojects-api")
- .WithReference(cache)
- .WaitFor(cache);
+var gateway = builder.AddProject("api-gateway");
+for (var i = 0; i < 5; i++)
+{
+ var softwareProjectsApi = builder.AddProject($"softwareprojects-api-{i}", launchProfileName: null)
+ .WithHttpsEndpoint(5200 + i)
+ .WithReference(cache)
+ .WaitFor(cache);
+ gateway.WaitFor(softwareProjectsApi);
+}
builder.AddProject("client-wasm")
- .WaitFor(softwareProjectsApi);
+ .WaitFor(gateway);
builder.Build().Run();
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
index 1126c490..88c39b1c 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
+++ b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
@@ -16,6 +16,7 @@
+
From b28b92de479a088ed5b92b6b127d0e7de946b3f2 Mon Sep 17 00:00:00 2001
From: Dmitrii14 <117352471+Dmitrii14@users.noreply.github.com>
Date: Fri, 27 Mar 2026 21:53:04 +0400
Subject: [PATCH 4/5] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?=
=?UTF-8?q?=D0=BB=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../LoadBalancers/WeightedRandomLoadBalancer.cs | 15 ++++++++++++---
1 file changed, 12 insertions(+), 3 deletions(-)
diff --git a/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
index 555302fa..fb39f531 100644
--- a/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
+++ b/Api.Gateway/LoadBalancers/WeightedRandomLoadBalancer.cs
@@ -1,7 +1,6 @@
using Ocelot.LoadBalancer.Interfaces;
using Ocelot.Responses;
using Ocelot.Values;
-using System.Collections.Generic;
namespace Api.Gateway.LoadBalancers;
@@ -11,6 +10,8 @@ namespace Api.Gateway.LoadBalancers;
/// реплика выбирается случайно с учётом заданных вероятностей.
/// Веса задаются в appsettings.json в секции "WeightedRandomWeights".
///
+/// Фабрика для получения списка доступных сервисов.
+/// Конфигурация приложения для чтения весов из секции "WeightedRandomWeights".
public class WeightedRandomLoadBalancer(Func>> services, IConfiguration configuration) : ILoadBalancer
{
private readonly double[] _cumulativeWeights = BuildCumulativeWeights(
@@ -34,12 +35,20 @@ public async Task> LeaseAsync(HttpContext httpConte
public void Release(ServiceHostAndPort hostAndPort) { }
+ ///
+ /// Строит массив кумулятивных весов на основе входных весов.
+ /// Каждый элемент результирующего массива равен сумме всех предыдущих весов включительно.
+ /// Используется для выбора реплики методом бинарного поиска по случайному числу.
+ ///
+ /// Массив весов для каждой реплики.
+ /// Массив кумулятивных весов.
private static double[] BuildCumulativeWeights(double[] weights)
{
+ var total = weights.Sum();
var cumulative = new double[weights.Length];
- cumulative[0] = weights[0];
+ cumulative[0] = weights[0] / total;
for (var i = 1; i < weights.Length; i++)
- cumulative[i] = cumulative[i - 1] + weights[i];
+ cumulative[i] = cumulative[i - 1] + weights[i] / total;
return cumulative;
}
}
From ba1cb8a756bcce1274fe2bc5ac49ccbcfaa90d5e Mon Sep 17 00:00:00 2001
From: Dmitrii14 <117352471+Dmitrii14@users.noreply.github.com>
Date: Wed, 29 Apr 2026 22:19:34 +0400
Subject: [PATCH 5/5] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20?=
=?UTF-8?q?=D1=82=D1=80=D0=B5=D1=82=D1=8C=D1=8E=20=D0=BB=D0=B0=D0=B1=D0=BE?=
=?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BD=D1=83=D1=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Client.Wasm/Components/StudentCard.razor | 2 +-
CloudDevelopment.sln | 12 ++
.../Controllers/SnsWebhookController.cs | 68 ++++++++
File.Service/Controllers/StorageController.cs | 61 +++++++
File.Service/File.Service.csproj | 22 +++
.../Messaging/SnsSubscriptionService.cs | 52 ++++++
File.Service/Program.cs | 49 ++++++
File.Service/Properties/launchSettings.json | 15 ++
File.Service/Storage/IObjectStorage.cs | 35 ++++
File.Service/Storage/S3ObjectStorage.cs | 126 ++++++++++++++
File.Service/appsettings.Development.json | 8 +
File.Service/appsettings.json | 9 +
README.md | 48 ++++++
.../Messaging/IProjectPublisher.cs | 16 ++
.../Messaging/SnsProjectPublisher.cs | 52 ++++++
SoftwareProjects.Api/Program.cs | 7 +
.../Services/SoftwareProjectService.cs | 13 +-
.../SoftwareProjects.Api.csproj | 8 +-
.../IntegrationTests.cs | 160 ++++++++++++++++++
.../SoftwareProjects.AppHost.Tests.csproj | 33 ++++
.../SoftwareProjects.AppHost/AppHost.cs | 37 +++-
.../softwareprojects-template.yaml | 59 +++++++
.../SoftwareProjects.AppHost.csproj | 8 +
.../SoftwareProjects.AppHost/appsettings.json | 3 +
24 files changed, 893 insertions(+), 10 deletions(-)
create mode 100644 File.Service/Controllers/SnsWebhookController.cs
create mode 100644 File.Service/Controllers/StorageController.cs
create mode 100644 File.Service/File.Service.csproj
create mode 100644 File.Service/Messaging/SnsSubscriptionService.cs
create mode 100644 File.Service/Program.cs
create mode 100644 File.Service/Properties/launchSettings.json
create mode 100644 File.Service/Storage/IObjectStorage.cs
create mode 100644 File.Service/Storage/S3ObjectStorage.cs
create mode 100644 File.Service/appsettings.Development.json
create mode 100644 File.Service/appsettings.json
create mode 100644 SoftwareProjects.Api/Messaging/IProjectPublisher.cs
create mode 100644 SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj
create mode 100644 SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index c919a3e7..a68453c9 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -4,7 +4,7 @@
- Номер №2 "Балансировка нагрузки"
+ Номер №3 "Интеграционное тестирование"
Вариант №39 "Программный проект"
Выполнил Жидяев Дмитрий 6513
Ссылка на форк
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index a7066ed1..2bcdcf65 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.Api", "Sof
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "File.Service", "File.Service\File.Service.csproj", "{DF944664-05C9-E01F-B262-C8B381C1EFE1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SoftwareProjects.AppHost.Tests", "SoftwareProjects\SoftwareProjects.AppHost.Tests\SoftwareProjects.AppHost.Tests.csproj", "{7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -39,6 +43,14 @@ Global
{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7B199D9A-546C-4B96-9B5C-E0B9F16B3E62}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/File.Service/Controllers/SnsWebhookController.cs b/File.Service/Controllers/SnsWebhookController.cs
new file mode 100644
index 00000000..b8c20ff5
--- /dev/null
+++ b/File.Service/Controllers/SnsWebhookController.cs
@@ -0,0 +1,68 @@
+using Amazon.SimpleNotificationService.Util;
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text;
+
+namespace File.Service.Controllers;
+
+///
+/// Контроллер вебхука, принимающий уведомления от SNS-топика
+///
+/// Служба объектного хранилища, в которое сохраняется тело уведомления
+/// Структурный логгер
+[ApiController]
+[Route("api/sns")]
+public class SnsWebhookController(IObjectStorage storage, ILogger logger) : ControllerBase
+{
+ ///
+ /// Принимает HTTP-запросы от SNS. Подтверждает подписку, если получено сообщение типа
+ /// SubscriptionConfirmation, иначе сохраняет полезную нагрузку в объектное хранилище
+ ///
+ /// 200 OK в любом случае — иначе SNS будет повторять отправку
+ [HttpPost]
+ [ProducesResponseType(200)]
+ public async Task Receive()
+ {
+ logger.LogInformation("SNS webhook invoked");
+ try
+ {
+ using var reader = new StreamReader(Request.Body, Encoding.UTF8);
+ var jsonContent = await reader.ReadToEndAsync();
+ var snsMessage = Message.ParseMessage(jsonContent);
+
+ if (snsMessage.Type == "SubscriptionConfirmation")
+ {
+ logger.LogInformation("SubscriptionConfirmation received");
+ using var httpClient = new HttpClient();
+ var builder = new UriBuilder(new Uri(snsMessage.SubscribeURL))
+ {
+ Scheme = "http",
+ Host = "localhost",
+ Port = 4566
+ };
+
+ var response = await httpClient.GetAsync(builder.Uri);
+ if (!response.IsSuccessStatusCode)
+ {
+ var body = await response.Content.ReadAsStringAsync();
+ throw new Exception($"SubscriptionConfirmation returned {response.StatusCode}: {body}");
+ }
+
+ logger.LogInformation("SNS subscription confirmed");
+ return Ok();
+ }
+
+ if (snsMessage.Type == "Notification")
+ {
+ await storage.UploadProject(snsMessage.MessageText);
+ logger.LogInformation("SNS notification successfully processed");
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Exception while processing SNS notification");
+ }
+
+ return Ok();
+ }
+}
diff --git a/File.Service/Controllers/StorageController.cs b/File.Service/Controllers/StorageController.cs
new file mode 100644
index 00000000..1169409a
--- /dev/null
+++ b/File.Service/Controllers/StorageController.cs
@@ -0,0 +1,61 @@
+using File.Service.Storage;
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json.Nodes;
+
+namespace File.Service.Controllers;
+
+///
+/// HTTP-контроллер для просмотра содержимого S3-бакета
+///
+/// Служба объектного хранилища
+/// Структурный логгер
+[ApiController]
+[Route("api/s3")]
+public class StorageController(IObjectStorage storage, ILogger logger) : ControllerBase
+{
+ ///
+ /// Возвращает список ключей всех файлов, лежащих в бакете
+ ///
+ /// Коллекция строковых ключей объектов в бакете
+ [HttpGet]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task>> List()
+ {
+ logger.LogInformation("Listing files in object storage");
+ try
+ {
+ var keys = await storage.ListProjects();
+ logger.LogInformation("Got {Count} keys from bucket", keys.Count);
+ return Ok(keys);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to list files");
+ return StatusCode(500, ex.Message);
+ }
+ }
+
+ ///
+ /// Возвращает JSON-содержимое файла из бакета по его ключу
+ ///
+ /// Ключ файла внутри бакета (например, software-project-42.json)
+ /// Десериализованный JSON-документ
+ [HttpGet("{key}")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task> Get(string key)
+ {
+ logger.LogInformation("Downloading file {Key} from object storage", key);
+ try
+ {
+ var node = await storage.DownloadProject(key);
+ return Ok(node);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to download file {Key}", key);
+ return StatusCode(500, ex.Message);
+ }
+ }
+}
diff --git a/File.Service/File.Service.csproj b/File.Service/File.Service.csproj
new file mode 100644
index 00000000..a475f300
--- /dev/null
+++ b/File.Service/File.Service.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/File.Service/Messaging/SnsSubscriptionService.cs b/File.Service/Messaging/SnsSubscriptionService.cs
new file mode 100644
index 00000000..e9858c85
--- /dev/null
+++ b/File.Service/Messaging/SnsSubscriptionService.cs
@@ -0,0 +1,52 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using System.Net;
+
+namespace File.Service.Messaging;
+
+///
+/// Служба, выполняющая HTTP-подписку File.Service на SNS-топик при старте приложения
+///
+/// Клиент Amazon SNS, разрешённый из DI
+/// Конфигурация приложения; содержит ARN топика и URL вебхука
+/// Структурный логгер
+public class SnsSubscriptionService(
+ IAmazonSimpleNotificationService snsClient,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ ///
+ /// ARN SNS-топика, на который подписывается сервис. Берётся из CloudFormation outputs (AWS:Resources:SNSTopicArn)
+ ///
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration");
+
+ ///
+ /// Публичный URL HTTP-эндпоинта, на который SNS будет присылать уведомления (контроллер SnsWebhookController)
+ ///
+ private readonly string _endpoint = configuration["AWS:Resources:SNSUrl"]
+ ?? throw new KeyNotFoundException("SNS subscriber URL was not found in configuration");
+
+ ///
+ /// Регистрирует HTTP-подписку на SNS-топик. Само подтверждение подписки выполняется
+ /// контроллером вебхука при получении сообщения типа SubscriptionConfirmation
+ ///
+ /// Задача, завершающаяся после получения ответа от SNS
+ public async Task SubscribeEndpoint()
+ {
+ logger.LogInformation("Subscribing endpoint {Endpoint} to SNS topic {Topic}", _endpoint, _topicArn);
+ var request = new SubscribeRequest
+ {
+ TopicArn = _topicArn,
+ Protocol = "http",
+ Endpoint = _endpoint,
+ ReturnSubscriptionArn = true
+ };
+
+ var response = await snsClient.SubscribeAsync(request);
+ if (response.HttpStatusCode != HttpStatusCode.OK)
+ logger.LogError("Failed to subscribe to SNS topic {Topic}: {Code}", _topicArn, response.HttpStatusCode);
+ else
+ logger.LogInformation("Subscription request for SNS topic {Topic} sent, awaiting confirmation", _topicArn);
+ }
+}
diff --git a/File.Service/Program.cs b/File.Service/Program.cs
new file mode 100644
index 00000000..ca607bd1
--- /dev/null
+++ b/File.Service/Program.cs
@@ -0,0 +1,49 @@
+using Amazon.S3;
+using Amazon.SimpleNotificationService;
+using File.Service.Messaging;
+using File.Service.Storage;
+using LocalStack.Client.Extensions;
+using System.Reflection;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ var assembly = Assembly.GetExecutingAssembly();
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assembly.GetName().Name}.xml");
+ if (System.IO.File.Exists(xmlPath))
+ options.IncludeXmlComments(xmlPath);
+});
+
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+builder.Services.AddAwsService();
+
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+using var scope = app.Services.CreateScope();
+
+var storage = scope.ServiceProvider.GetRequiredService();
+await storage.EnsureBucketExists();
+
+var subscription = scope.ServiceProvider.GetRequiredService();
+await subscription.SubscribeEndpoint();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.MapControllers();
+
+app.Run();
diff --git a/File.Service/Properties/launchSettings.json b/File.Service/Properties/launchSettings.json
new file mode 100644
index 00000000..29d026c4
--- /dev/null
+++ b/File.Service/Properties/launchSettings.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5300",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/File.Service/Storage/IObjectStorage.cs b/File.Service/Storage/IObjectStorage.cs
new file mode 100644
index 00000000..bfe2f5ff
--- /dev/null
+++ b/File.Service/Storage/IObjectStorage.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Контракт службы для работы с объектным хранилищем (S3-совместимым)
+///
+public interface IObjectStorage
+{
+ ///
+ /// Загружает JSON-документ в объектное хранилище. Имя файла формируется на основе поля id JSON-документа
+ ///
+ /// JSON-строка с данными программного проекта
+ /// true, если документ успешно загружен; иначе false
+ public Task UploadProject(string payload);
+
+ ///
+ /// Получает список ключей всех файлов, сохранённых в бакете
+ ///
+ /// Коллекция строковых ключей объектов
+ public Task> ListProjects();
+
+ ///
+ /// Скачивает указанный файл из бакета и десериализует его как JSON
+ ///
+ /// Ключ файла внутри бакета
+ /// Десериализованный с содержимым файла
+ public Task DownloadProject(string key);
+
+ ///
+ /// Создаёт целевой бакет в хранилище, если его ещё нет
+ ///
+ /// Задача, завершающаяся после проверки/создания бакета
+ public Task EnsureBucketExists();
+}
diff --git a/File.Service/Storage/S3ObjectStorage.cs b/File.Service/Storage/S3ObjectStorage.cs
new file mode 100644
index 00000000..ed979be7
--- /dev/null
+++ b/File.Service/Storage/S3ObjectStorage.cs
@@ -0,0 +1,126 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace File.Service.Storage;
+
+///
+/// Реализация поверх AWS-совместимого S3 (LocalStack)
+///
+/// Клиент AWS SDK для S3, разрешённый из DI
+/// Конфигурация приложения; используется для получения имени бакета
+/// Структурный логгер
+public class S3ObjectStorage(
+ IAmazonS3 client,
+ IConfiguration configuration,
+ ILogger logger) : IObjectStorage
+{
+ ///
+ /// Имя S3-бакета. Берётся из CloudFormation outputs (AWS:Resources:S3BucketName)
+ ///
+ private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"]
+ ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration");
+
+ ///
+ public async Task UploadProject(string payload)
+ {
+ var rootNode = JsonNode.Parse(payload) ?? throw new ArgumentException("Payload is not a valid JSON");
+ var id = rootNode["Id"]?.GetValue()
+ ?? rootNode["id"]?.GetValue()
+ ?? throw new ArgumentException("Payload JSON does not contain 'Id' field");
+
+ using var stream = new MemoryStream();
+ JsonSerializer.Serialize(stream, rootNode);
+ stream.Seek(0, SeekOrigin.Begin);
+
+ logger.LogInformation("Uploading software project {ProjectId} to bucket {Bucket}", id, _bucketName);
+ var request = new PutObjectRequest
+ {
+ BucketName = _bucketName,
+ Key = $"software-project-{id}.json",
+ InputStream = stream,
+ ContentType = "application/json"
+ };
+
+ var response = await client.PutObjectAsync(request);
+ if (response.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Failed to upload software project {ProjectId}: {Code}", id, response.HttpStatusCode);
+ return false;
+ }
+
+ logger.LogInformation("Software project {ProjectId} successfully uploaded to bucket {Bucket}", id, _bucketName);
+ return true;
+ }
+
+ ///
+ public async Task> ListProjects()
+ {
+ var keys = new List();
+ var request = new ListObjectsV2Request
+ {
+ BucketName = _bucketName,
+ Prefix = string.Empty
+ };
+
+ logger.LogInformation("Listing objects in bucket {Bucket}", _bucketName);
+ var paginator = client.Paginators.ListObjectsV2(request);
+ await foreach (var response in paginator.Responses)
+ {
+ if (response?.S3Objects is null)
+ {
+ logger.LogWarning("Received empty response page from bucket {Bucket}", _bucketName);
+ continue;
+ }
+
+ foreach (var obj in response.S3Objects)
+ if (obj is not null)
+ keys.Add(obj.Key);
+ }
+
+ return keys;
+ }
+
+ ///
+ public async Task DownloadProject(string key)
+ {
+ logger.LogInformation("Downloading object {Key} from bucket {Bucket}", key, _bucketName);
+
+ var request = new GetObjectRequest
+ {
+ BucketName = _bucketName,
+ Key = key
+ };
+
+ using var response = await client.GetObjectAsync(request);
+ if (response.HttpStatusCode != HttpStatusCode.OK)
+ {
+ logger.LogError("Failed to download {Key} from bucket {Bucket}: {Code}", key, _bucketName, response.HttpStatusCode);
+ throw new InvalidOperationException($"Error occurred downloading {key} - {response.HttpStatusCode}");
+ }
+
+ using var reader = new StreamReader(response.ResponseStream, Encoding.UTF8);
+ var content = await reader.ReadToEndAsync();
+ return JsonNode.Parse(content)
+ ?? throw new InvalidOperationException($"Object {key} contents are not a valid JSON");
+ }
+
+ ///
+ public async Task EnsureBucketExists()
+ {
+ logger.LogInformation("Ensuring bucket {Bucket} exists", _bucketName);
+ try
+ {
+ await client.EnsureBucketExistsAsync(_bucketName);
+ logger.LogInformation("Bucket {Bucket} is ready", _bucketName);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unhandled exception while ensuring bucket {Bucket}", _bucketName);
+ throw;
+ }
+ }
+}
diff --git a/File.Service/appsettings.Development.json b/File.Service/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/File.Service/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/File.Service/appsettings.json b/File.Service/appsettings.json
new file mode 100644
index 00000000..10f68b8c
--- /dev/null
+++ b/File.Service/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/README.md b/README.md
index b40378bf..f5a6d6be 100644
--- a/README.md
+++ b/README.md
@@ -55,3 +55,51 @@
### Балансировщик Weighted Random
- Каждой реплике назначается вероятность выбора
- При поступлении запроса реплика выбирается случайно с учётом заданных вероятностей
+
+---
+
+# Лабораторная работа №3 «Интеграционное тестирование»
+
+## Вариант: SNS + LocalStack
+
+## Описание
+
+В оркестрацию добавлены брокер сообщений **SNS** и объектное хранилище **S3**, которые
+поднимаются в эмуляторе **LocalStack**. Реализован файловый сервис, принимающий уведомления
+из SNS-топика и сохраняющий полезную нагрузку в S3-бакет в виде JSON-файлов. Реализованы
+интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе.
+
+## Что реализовано
+
+### Объектное хранилище и брокер (LocalStack)
+- В `SoftwareProjects.AppHost` добавлен контейнер `softwareprojects-localstack` через
+ `LocalStack.Aspire.Hosting`
+- Ресурсы (S3-бакет `softwareprojects-bucket` и SNS-топик `softwareprojects-topic`)
+ поднимаются в LocalStack из CloudFormation-шаблона
+ [`CloudFormation/softwareprojects-template.yaml`](SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml)
+- Имена и ARN ресурсов автоматически прокидываются в сервисы через `WithReference(awsResources)`
+
+### Публикация в SNS из API сервиса (`SoftwareProjects.Api`)
+- Интерфейс `IProjectPublisher` с реализацией `SnsProjectPublisher`
+- После генерации проекта `SoftwareProjectService` сериализует его в JSON и публикует в
+ SNS-топик через `IAmazonSimpleNotificationService`
+- LocalStack-клиент подключается через `LocalStack.Client.Extensions`
+
+### Файловый сервис (`File.Service`)
+- HTTP-подписчик SNS: контроллер `SnsWebhookController` принимает
+ `SubscriptionConfirmation` и `Notification` от SNS-топика, подтверждает подписку и
+ передаёт полезную нагрузку в объектное хранилище
+- Реализация объектного хранилища `S3ObjectStorage` поверх AWS SDK (LocalStack):
+ загрузка, листинг и скачивание файлов
+- Контроллер `StorageController` (`/api/s3`) для просмотра содержимого бакета
+- Подписка на SNS и `EnsureBucketExists` выполняются на старте приложения
+
+### Интеграционные тесты (`SoftwareProjects.AppHost.Tests`)
+Поднимают всю распределённую систему через `DistributedApplicationTestingBuilder` и проверяют:
+1. **`Pipeline_GatewayRequest_PersistsGeneratedProjectToS3`** — запрос через гейтвей доходит
+ до API, сгенерированный проект публикуется в SNS, файловый сервис сохраняет его в S3,
+ содержимое файла совпадает с ответом API
+2. **`Pipeline_MultipleRequests_AreAllListedInBucket`** — после нескольких запросов с
+ разными идентификаторами в бакете присутствуют все ожидаемые ключи
+3. **`Pipeline_RepeatedRequests_DoNotProduceDuplicateFiles`** — повторные запросы с тем же
+ идентификатором обслуживаются из Redis-кэша и не создают дубликатов файлов в S3
diff --git a/SoftwareProjects.Api/Messaging/IProjectPublisher.cs b/SoftwareProjects.Api/Messaging/IProjectPublisher.cs
new file mode 100644
index 00000000..c757b65d
--- /dev/null
+++ b/SoftwareProjects.Api/Messaging/IProjectPublisher.cs
@@ -0,0 +1,16 @@
+using SoftwareProjects.Api.Entities;
+
+namespace SoftwareProjects.Api.Messaging;
+
+///
+/// Интерфейс службы публикации сгенерированных программных проектов в брокер сообщений
+///
+public interface IProjectPublisher
+{
+ ///
+ /// Публикует сериализованное представление программного проекта в брокер
+ ///
+ /// Программный проект, который должен быть передан в файловый сервис
+ /// Задача, завершающаяся после публикации
+ public Task Publish(SoftwareProject project);
+}
diff --git a/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs b/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs
new file mode 100644
index 00000000..21384ef7
--- /dev/null
+++ b/SoftwareProjects.Api/Messaging/SnsProjectPublisher.cs
@@ -0,0 +1,52 @@
+using Amazon.SimpleNotificationService;
+using Amazon.SimpleNotificationService.Model;
+using SoftwareProjects.Api.Entities;
+using System.Net;
+using System.Text.Json;
+
+namespace SoftwareProjects.Api.Messaging;
+
+///
+/// Реализация , публикующая сообщение в SNS-топик AWS/LocalStack
+///
+/// Клиент Amazon SNS, разрешённый из DI
+/// Конфигурация приложения; используется для получения ARN целевого топика
+/// Структурный логгер
+public class SnsProjectPublisher(
+ IAmazonSimpleNotificationService snsClient,
+ IConfiguration configuration,
+ ILogger logger) : IProjectPublisher
+{
+ ///
+ /// ARN SNS-топика, в который публикуются сообщения. Берётся из CloudFormation outputs (AWS:Resources:SNSTopicArn)
+ ///
+ private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"]
+ ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration");
+
+ ///
+ /// Сериализует переданный программный проект в JSON и отправляет его в SNS-топик
+ ///
+ /// Программный проект для публикации
+ /// Задача, завершающаяся после получения ответа от SNS
+ public async Task Publish(SoftwareProject project)
+ {
+ try
+ {
+ var json = JsonSerializer.Serialize(project);
+ var request = new PublishRequest
+ {
+ Message = json,
+ TopicArn = _topicArn
+ };
+ var response = await snsClient.PublishAsync(request);
+ if (response.HttpStatusCode == HttpStatusCode.OK)
+ logger.LogInformation("Software project {ProjectId} was published to SNS topic", project.Id);
+ else
+ throw new Exception($"SNS returned {response.HttpStatusCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to publish software project {ProjectId} to SNS", project.Id);
+ }
+ }
+}
diff --git a/SoftwareProjects.Api/Program.cs b/SoftwareProjects.Api/Program.cs
index 66d56c0b..c44f7cf0 100644
--- a/SoftwareProjects.Api/Program.cs
+++ b/SoftwareProjects.Api/Program.cs
@@ -1,3 +1,6 @@
+using Amazon.SimpleNotificationService;
+using LocalStack.Client.Extensions;
+using SoftwareProjects.Api.Messaging;
using SoftwareProjects.Api.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -5,6 +8,10 @@
builder.AddServiceDefaults();
builder.AddRedisDistributedCache("cache");
+builder.Services.AddLocalStack(builder.Configuration);
+builder.Services.AddAwsService();
+
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/SoftwareProjects.Api/Services/SoftwareProjectService.cs b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
index e19b80d7..d52506f1 100644
--- a/SoftwareProjects.Api/Services/SoftwareProjectService.cs
+++ b/SoftwareProjects.Api/Services/SoftwareProjectService.cs
@@ -1,17 +1,25 @@
using SoftwareProjects.Api.Entities;
+using SoftwareProjects.Api.Messaging;
namespace SoftwareProjects.Api.Services;
///
-/// Реализация сервиса программных проектов с кэшированием
+/// Реализация сервиса программных проектов с кэшированием и публикацией событий о новых проектах в брокер
///
+/// Служба кэша Redis, через которую проверяется наличие проекта и сохраняется результат
+/// Служба публикации события о новом проекте в брокер сообщений (SNS)
+/// Структурный логгер
public class SoftwareProjectService(
ISoftwareProjectCacheService cacheService,
+ IProjectPublisher publisher,
ILogger logger) : ISoftwareProjectService
{
///
- /// Получает программный проект по идентификатору из кэша или генерирует новый
+ /// Получает программный проект по идентификатору. При промахе кэша генерирует новый,
+ /// публикует его в брокер для последующей сериализации в объектное хранилище и сохраняет в кэш
///
+ /// Идентификатор программного проекта
+ /// Готовый к выдаче программный проект
public async Task GetById(int id)
{
var cached = await cacheService.GetFromCache(id);
@@ -32,6 +40,7 @@ public async Task GetById(int id)
throw;
}
+ await publisher.Publish(project);
await cacheService.SetToCache(id, project);
return project;
diff --git a/SoftwareProjects.Api/SoftwareProjects.Api.csproj b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
index bef0eadb..22bfb219 100644
--- a/SoftwareProjects.Api/SoftwareProjects.Api.csproj
+++ b/SoftwareProjects.Api/SoftwareProjects.Api.csproj
@@ -10,14 +10,12 @@
-
-
-
-
-
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs b/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs
new file mode 100644
index 00000000..88b7d649
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost.Tests/IntegrationTests.cs
@@ -0,0 +1,160 @@
+using Aspire.Hosting;
+using Microsoft.Extensions.Logging;
+using SoftwareProjects.Api.Entities;
+using System.Text.Json;
+using Xunit.Abstractions;
+
+namespace SoftwareProjects.AppHost.Tests;
+
+///
+/// Интеграционные тесты, проверяющие совместную работу
+/// API-сервиса, кэша Redis, брокера SNS и файлового сервиса с S3-хранилищем (LocalStack)
+///
+/// Журнал юнит-тестов; пробрасывается в логирование Aspire-хоста
+public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime
+{
+ ///
+ /// Время, которое тесты ждут после HTTP-запроса к API,
+ /// чтобы сообщение успело пройти SNS → File.Service → S3
+ ///
+ private static readonly TimeSpan _propagationDelay = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Опции десериализации, совпадающие с тем, как ASP.NET сериализует ответы Minimal API
+ /// (camelCase свойства)
+ ///
+ private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
+
+ private DistributedApplication? _app;
+ private HttpClient? _gatewayClient;
+ private HttpClient? _fileServiceClient;
+
+
+ ///
+ public async Task InitializeAsync()
+ {
+ var cancellationToken = CancellationToken.None;
+ var builder = await DistributedApplicationTestingBuilder
+ .CreateAsync(cancellationToken);
+ builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
+ builder.Services.AddLogging(logging =>
+ {
+ logging.AddXUnit(output);
+ logging.SetMinimumLevel(LogLevel.Debug);
+ logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug);
+ logging.AddFilter("Aspire.Hosting", LogLevel.Debug);
+ });
+
+ _app = await builder.BuildAsync(cancellationToken);
+ await _app.StartAsync(cancellationToken);
+ _gatewayClient = _app!.CreateHttpClient("api-gateway", "http");
+ _fileServiceClient = _app!.CreateHttpClient("file-service", "http");
+ }
+
+ ///
+ /// Сценарий «End-to-end»: запрос на гейтвей → API генерирует и публикует проект в SNS →
+ /// File.Service сохраняет JSON в S3 → проверяем, что файл по ключу совпадает с ответом API
+ ///
+ [Fact]
+ public async Task Pipeline_GatewayRequest_PersistsGeneratedProjectToS3()
+ {
+ var id = Random.Shared.Next(1_000, 9_999);
+
+ using var gatewayResponse = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ gatewayResponse.EnsureSuccessStatusCode();
+ var apiProject = JsonSerializer.Deserialize(
+ await gatewayResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ await Task.Delay(_propagationDelay);
+
+ using var s3Response = await _fileServiceClient!.GetAsync($"/api/s3/software-project-{id}.json");
+ s3Response.EnsureSuccessStatusCode();
+ var s3Project = JsonSerializer.Deserialize(
+ await s3Response.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(apiProject);
+ Assert.NotNull(s3Project);
+ Assert.Equal(id, s3Project.Id);
+ Assert.Equivalent(apiProject, s3Project);
+ }
+
+ ///
+ /// Сценарий со списком: после нескольких запросов с разными id в бакете
+ /// должно появиться столько же файлов с ожидаемыми ключами
+ ///
+ [Fact]
+ public async Task Pipeline_MultipleRequests_AreAllListedInBucket()
+ {
+ var ids = new[]
+ {
+ Random.Shared.Next(10_000, 19_999),
+ Random.Shared.Next(20_000, 29_999),
+ Random.Shared.Next(30_000, 39_999)
+ };
+
+ foreach (var id in ids)
+ {
+ using var response = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ response.EnsureSuccessStatusCode();
+ }
+
+ await Task.Delay(_propagationDelay);
+
+ using var listResponse = await _fileServiceClient!.GetAsync("/api/s3");
+ listResponse.EnsureSuccessStatusCode();
+ var keys = JsonSerializer.Deserialize>(
+ await listResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(keys);
+ foreach (var id in ids)
+ Assert.Contains($"software-project-{id}.json", keys);
+ }
+
+ ///
+ /// Сценарий с кэшем: повторный запрос того же id должен попасть в Redis-кэш и
+ /// не приводить к повторной публикации в SNS, поэтому в S3 остаётся ровно одна версия файла
+ ///
+ [Fact]
+ public async Task Pipeline_RepeatedRequests_DoNotProduceDuplicateFiles()
+ {
+ var id = Random.Shared.Next(40_000, 49_999);
+
+ using var firstResponse = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ firstResponse.EnsureSuccessStatusCode();
+ var firstPayload = await firstResponse.Content.ReadAsStringAsync();
+ var firstProject = JsonSerializer.Deserialize(firstPayload, _jsonOptions);
+
+ await Task.Delay(_propagationDelay);
+
+ for (var i = 0; i < 3; i++)
+ {
+ using var repeated = await _gatewayClient!.GetAsync($"/software-projects?id={id}");
+ repeated.EnsureSuccessStatusCode();
+ var repeatedProject = JsonSerializer.Deserialize(
+ await repeated.Content.ReadAsStringAsync(), _jsonOptions);
+ Assert.NotNull(repeatedProject);
+ Assert.Equivalent(firstProject, repeatedProject);
+ }
+
+ await Task.Delay(_propagationDelay);
+
+ using var listResponse = await _fileServiceClient!.GetAsync("/api/s3");
+ listResponse.EnsureSuccessStatusCode();
+ var keys = JsonSerializer.Deserialize>(
+ await listResponse.Content.ReadAsStringAsync(), _jsonOptions);
+
+ Assert.NotNull(keys);
+ var duplicates = keys.Count(k => k == $"software-project-{id}.json");
+ Assert.Equal(1, duplicates);
+ }
+
+ ///
+ public async Task DisposeAsync()
+ {
+ if (_app is not null)
+ {
+ await _app.StopAsync();
+ await _app.DisposeAsync();
+ }
+ }
+}
diff --git a/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj b/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj
new file mode 100644
index 00000000..50f084a8
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost.Tests/SoftwareProjects.AppHost.Tests.csproj
@@ -0,0 +1,33 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
index 2a104390..d97ebb78 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
+++ b/SoftwareProjects/SoftwareProjects.AppHost/AppHost.cs
@@ -1,19 +1,52 @@
+using Amazon;
+using Aspire.Hosting.LocalStack.Container;
+
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
- .WithRedisInsight(containerName: "credit-redis-insight");
+ .WithRedisInsight(containerName: "softwareprojects-redis-insight");
var gateway = builder.AddProject("api-gateway");
+var awsConfig = builder.AddAWSSDKConfig()
+ .WithProfile("default")
+ .WithRegion(RegionEndpoint.EUCentral1);
+
+var localstack = builder
+ .AddLocalStack("softwareprojects-localstack", awsConfig: awsConfig, configureContainer: container =>
+ {
+ container.Lifetime = ContainerLifetime.Session;
+ container.DebugLevel = 1;
+ container.LogLevel = LocalStackLogLevel.Debug;
+ container.Port = 4566;
+ container.AdditionalEnvironmentVariables.Add("DEBUG", "1");
+ container.AdditionalEnvironmentVariables
+ .Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com");
+ });
+
+var awsResources = builder
+ .AddAWSCloudFormationTemplate("resources", "CloudFormation/softwareprojects-template.yaml", "softwareprojects")
+ .WithReference(awsConfig);
+
+var fileService = builder.AddProject("file-service")
+ .WithReference(awsResources)
+ .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5300/api/sns")
+ .WaitFor(awsResources);
+
for (var i = 0; i < 5; i++)
{
var softwareProjectsApi = builder.AddProject($"softwareprojects-api-{i}", launchProfileName: null)
.WithHttpsEndpoint(5200 + i)
.WithReference(cache)
- .WaitFor(cache);
+ .WithReference(awsResources)
+ .WaitFor(cache)
+ .WaitFor(awsResources);
gateway.WaitFor(softwareProjectsApi);
}
+
builder.AddProject("client-wasm")
.WaitFor(gateway);
+builder.UseLocalStack(localstack);
+
builder.Build().Run();
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml b/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml
new file mode 100644
index 00000000..d5487c24
--- /dev/null
+++ b/SoftwareProjects/SoftwareProjects.AppHost/CloudFormation/softwareprojects-template.yaml
@@ -0,0 +1,59 @@
+AWSTemplateFormatVersion: '2010-09-09'
+Description: 'Cloud formation template for software projects (SNS + LocalStack S3)'
+
+Parameters:
+ BucketName:
+ Type: String
+ Description: Name for the S3 bucket
+ Default: 'softwareprojects-bucket'
+
+ TopicName:
+ Type: String
+ Description: Name for the SNS topic
+ Default: 'softwareprojects-topic'
+
+Resources:
+ SoftwareProjectsBucket:
+ Type: AWS::S3::Bucket
+ Properties:
+ BucketName: !Ref BucketName
+ VersioningConfiguration:
+ Status: Suspended
+ Tags:
+ - Key: Name
+ Value: !Ref BucketName
+ - Key: Environment
+ Value: Lab3
+ PublicAccessBlockConfiguration:
+ BlockPublicAcls: true
+ BlockPublicPolicy: true
+ IgnorePublicAcls: true
+ RestrictPublicBuckets: true
+
+ SoftwareProjectsTopic:
+ Type: AWS::SNS::Topic
+ Properties:
+ TopicName: !Ref TopicName
+ DisplayName: !Ref TopicName
+ Tags:
+ - Key: Name
+ Value: !Ref TopicName
+ - Key: Environment
+ Value: Lab3
+
+Outputs:
+ S3BucketName:
+ Description: Name of the S3 bucket
+ Value: !Ref SoftwareProjectsBucket
+
+ S3BucketArn:
+ Description: ARN of the S3 bucket
+ Value: !GetAtt SoftwareProjectsBucket.Arn
+
+ SNSTopicName:
+ Description: Name of the SNS topic
+ Value: !GetAtt SoftwareProjectsTopic.TopicName
+
+ SNSTopicArn:
+ Description: ARN of the SNS topic
+ Value: !Ref SoftwareProjectsTopic
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
index 88c39b1c..028e6a82 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
+++ b/SoftwareProjects/SoftwareProjects.AppHost/SoftwareProjects.AppHost.csproj
@@ -13,12 +13,20 @@
+
+
+
+
+ Always
+
+
+
diff --git a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
index 31c092aa..a6b256bb 100644
--- a/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
+++ b/SoftwareProjects/SoftwareProjects.AppHost/appsettings.json
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
+ },
+ "LocalStack": {
+ "UseLocalStack": true
}
}