diff --git a/Client.Wasm/Client.Wasm.csproj b/Client.Wasm/Client.Wasm.csproj
index 0ba9f90c..40edaeee 100644
--- a/Client.Wasm/Client.Wasm.csproj
+++ b/Client.Wasm/Client.Wasm.csproj
@@ -16,6 +16,7 @@
+
diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor
index c646a839..20b06fa8 100644
--- a/Client.Wasm/Components/DataCard.razor
+++ b/Client.Wasm/Components/DataCard.razor
@@ -1,5 +1,5 @@
-@inject IConfiguration Configuration
-@inject HttpClient Client
+@using Client.Wasm
+@inject VehicleApiClient ApiClient
@@ -67,8 +67,7 @@
private async Task RequestNewData()
{
- var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress");
- Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { });
+ Value = await ApiClient.GetVehicleAsync(Id);
StateHasChanged();
}
}
diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor
index 661f1181..5ab9ea84 100644
--- a/Client.Wasm/Components/StudentCard.razor
+++ b/Client.Wasm/Components/StudentCard.razor
@@ -1,13 +1,13 @@
-
+
Лабораторная работа
- Номер №X "Название лабораторной"
- Вариант №Х "Название варианта"
- Выполнена Фамилией Именем 65ХХ
- Ссылка на форк
+ Номер №2 "Балансировка нагрузки"
+ Вариант №29 "Транспортное средство"
+ Выполнена Нестеренко Андреем 6512
+ Ссылка на репозиторий
diff --git a/Client.Wasm/Program.cs b/Client.Wasm/Program.cs
index a182a920..87c7cad9 100644
--- a/Client.Wasm/Program.cs
+++ b/Client.Wasm/Program.cs
@@ -9,7 +9,12 @@
builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
-builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddHttpClient(client =>
+{
+ var baseAddress = builder.Configuration["BaseAddress"]
+ ?? throw new InvalidOperationException("BaseAddress not found in configuration.");
+ client.BaseAddress = new Uri(baseAddress);
+});
builder.Services.AddBlazorise(options => { options.Immediate = true; })
.AddBootstrapProviders()
.AddFontAwesomeIcons();
diff --git a/Client.Wasm/VehicleApiClient.cs b/Client.Wasm/VehicleApiClient.cs
new file mode 100644
index 00000000..80fab7a9
--- /dev/null
+++ b/Client.Wasm/VehicleApiClient.cs
@@ -0,0 +1,12 @@
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+
+namespace Client.Wasm;
+
+public class VehicleApiClient(HttpClient httpClient)
+{
+ public async Task GetVehicleAsync(int id)
+ {
+ return await httpClient.GetFromJsonAsync($"api/vehicle/{id}");
+ }
+}
diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json
index d1fe7ab3..f1a09156 100644
--- a/Client.Wasm/wwwroot/appsettings.json
+++ b/Client.Wasm/wwwroot/appsettings.json
@@ -6,5 +6,5 @@
}
},
"AllowedHosts": "*",
- "BaseAddress": ""
+ "BaseAddress": "http://localhost:5200/"
}
diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln
index cb48241d..e1123f2c 100644
--- a/CloudDevelopment.sln
+++ b/CloudDevelopment.sln
@@ -1,10 +1,23 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
-VisualStudioVersion = 17.14.36811.4
+VisualStudioVersion = 17.13.35931.197
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}") = "ProjectApp.Api", "ProjectApp.Api\ProjectApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Domain", "ProjectApp.Domain\ProjectApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults", "ProjectApp.ServiceDefaults\ProjectApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "ProjectApp.AppHost\ProjectApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Gateway", "ProjectApp.Gateway\ProjectApp.Gateway.csproj", "{EBCDE049-6A3B-431E-ACBA-DD9C90898F49}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService", "ProjectApp.FileService\ProjectApp.FileService.csproj", "{FBAF0E91-832A-4594-9515-C9D42EA8A25B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Tests", "ProjectApp.Tests\ProjectApp.Tests.csproj", "{38936714-96E7-4B0C-904A-0B01D8E96B2D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -15,6 +28,34 @@ 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
+ {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/ProjectApp.Api/Controllers/VehicleController.cs b/ProjectApp.Api/Controllers/VehicleController.cs
new file mode 100644
index 00000000..f016da03
--- /dev/null
+++ b/ProjectApp.Api/Controllers/VehicleController.cs
@@ -0,0 +1,50 @@
+using ProjectApp.Api.Services.SqsPublisher;
+using ProjectApp.Api.Services.VehicleGeneratorService;
+using ProjectApp.Domain.Entities;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ProjectApp.Api.Controllers;
+
+///
+/// Контроллер для генерации и получения характеристик транспортных средств
+///
+[Route("api/[controller]")]
+[ApiController]
+public class VehicleController(
+ IVehicleGeneratorService vehicleService,
+ ISqsPublisher sqsPublisher,
+ ILogger logger) : ControllerBase
+{
+ ///
+ /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору
+ ///
+ /// Идентификатор транспортного средства
+ /// Токен отмены
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(Vehicle), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken)
+ {
+ logger.LogInformation("Request received for vehicle id {Id}", id);
+
+ if (id <= 0)
+ {
+ logger.LogWarning("Invalid vehicle id {Id} received", id);
+ return BadRequest("Identifier must be a positive number.");
+ }
+
+ var vehicle = await vehicleService.FetchByIdAsync(id, cancellationToken);
+
+ try
+ {
+ await sqsPublisher.SendVehicleAsync(vehicle, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to send vehicle {Id} to SQS", id);
+ }
+
+ return Ok(vehicle);
+ }
+}
diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs
new file mode 100644
index 00000000..23b0a91b
--- /dev/null
+++ b/ProjectApp.Api/Program.cs
@@ -0,0 +1,71 @@
+using Amazon.SQS;
+using ProjectApp.Api.Services.SqsPublisher;
+using ProjectApp.Api.Services.VehicleGeneratorService;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.AddRedisDistributedCache("cache");
+
+builder.Services.AddSingleton(sp =>
+{
+ var configuration = sp.GetRequiredService();
+ var sqsConfig = new AmazonSQSConfig
+ {
+ ServiceURL = configuration["Sqs:ServiceUrl"] ?? "http://localhost:9324"
+ };
+ return new AmazonSQSClient("test", "test", sqsConfig);
+});
+
+builder.Services.AddSingleton();
+
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+builder.Services.AddScoped(sp =>
+ ActivatorUtilities.CreateInstance(sp,
+ sp.GetRequiredService()));
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(options =>
+{
+ options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo
+ {
+ Title = "Vehicle Generator API"
+ });
+
+ var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml";
+ var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename);
+ if (File.Exists(xmlPath))
+ {
+ options.IncludeXmlComments(xmlPath);
+ }
+
+ var domainXmlPath = Path.Combine(AppContext.BaseDirectory, "ProjectApp.Domain.xml");
+ if (File.Exists(domainXmlPath))
+ {
+ options.IncludeXmlComments(domainXmlPath);
+ }
+});
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+if (!app.Environment.IsDevelopment())
+{
+ app.UseHttpsRedirection();
+}
+
+app.MapControllers();
+app.MapDefaultEndpoints();
+
+app.Run();
+
+public partial class Program;
\ No newline at end of file
diff --git a/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj
new file mode 100644
index 00000000..7f635d56
--- /dev/null
+++ b/ProjectApp.Api/ProjectApp.Api.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.Api/Properties/launchSettings.json b/ProjectApp.Api/Properties/launchSettings.json
new file mode 100644
index 00000000..bbe9ee66
--- /dev/null
+++ b/ProjectApp.Api/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:46825",
+ "sslPort": 44333
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5179",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7170;http://localhost:5179",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs b/ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs
new file mode 100644
index 00000000..80e17e8c
--- /dev/null
+++ b/ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs
@@ -0,0 +1,14 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.SqsPublisher;
+
+///
+/// Сервис публикации данных транспортного средства в очередь SQS
+///
+public interface ISqsPublisher
+{
+ ///
+ /// Отправляет данные транспортного средства в очередь
+ ///
+ Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default);
+}
diff --git a/ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs b/ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs
new file mode 100644
index 00000000..c2587596
--- /dev/null
+++ b/ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs
@@ -0,0 +1,45 @@
+using System.Text.Json;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.SqsPublisher;
+
+///
+/// Реализация публикации данных транспортного средства в SQS
+///
+public class SqsPublisher(
+ IAmazonSQS sqsClient,
+ IConfiguration configuration,
+ ILogger logger) : ISqsPublisher
+{
+ private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue";
+ private string? _queueUrl;
+
+ ///
+ /// Отправляет сериализованные данные транспортного средства в очередь SQS
+ ///
+ public async Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default)
+ {
+ var queueUrl = await GetQueueUrlAsync(cancellationToken);
+ var body = JsonSerializer.Serialize(vehicle);
+
+ await sqsClient.SendMessageAsync(new SendMessageRequest
+ {
+ QueueUrl = queueUrl,
+ MessageBody = body
+ }, cancellationToken);
+
+ logger.LogInformation("Vehicle {Id} sent to SQS", vehicle.Id);
+ }
+
+ private async Task GetQueueUrlAsync(CancellationToken ct)
+ {
+ if (_queueUrl != null)
+ return _queueUrl;
+
+ var response = await sqsClient.CreateQueueAsync(_queueName, ct);
+ _queueUrl = response.QueueUrl;
+ return _queueUrl;
+ }
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs
new file mode 100644
index 00000000..b1f5c1d9
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs
@@ -0,0 +1,67 @@
+using ProjectApp.Domain.Entities;
+using Microsoft.Extensions.Caching.Distributed;
+using System.Text.Json;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Декоратор над генератором транспортных средств с поддержкой кэширования через Redis
+///
+public class CachedVehicleGeneratorService(
+ IVehicleGeneratorService innerService,
+ IDistributedCache cache,
+ IConfiguration configuration,
+ ILogger logger) : IVehicleGeneratorService
+{
+ private readonly int _ttlMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10);
+
+ ///
+ /// Возвращает транспортное средство из кэша или генерирует новое и сохраняет в кэш
+ ///
+ public async Task FetchByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ var key = $"vehicle-{id}";
+
+ try
+ {
+ var raw = await cache.GetStringAsync(key, cancellationToken);
+ if (!string.IsNullOrEmpty(raw))
+ {
+ var vehicle = JsonSerializer.Deserialize(raw);
+ if (vehicle != null)
+ {
+ logger.LogInformation("Vehicle {Id} retrieved from cache", id);
+ return vehicle;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Cache read failed for vehicle {Id}. Falling back to generation.", id);
+ }
+
+ var generatedVehicle = await innerService.FetchByIdAsync(id, cancellationToken);
+
+ try
+ {
+ var opts = new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_ttlMinutes)
+ };
+
+ await cache.SetStringAsync(
+ key,
+ JsonSerializer.Serialize(generatedVehicle),
+ opts,
+ cancellationToken);
+
+ logger.LogInformation("Vehicle {Id} successfully stored in cache.", id);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to store vehicle {Id} in cache.", id);
+ }
+
+ return generatedVehicle;
+ }
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs
new file mode 100644
index 00000000..671ca786
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs
@@ -0,0 +1,14 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Интерфейс сервиса получения характеристик машины
+///
+public interface IVehicleGeneratorService
+{
+ ///
+ /// Запрашивает машину по ID
+ ///
+ public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default);
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs
new file mode 100644
index 00000000..82a79981
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs
@@ -0,0 +1,55 @@
+using Bogus;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Генератор тестовых данных транспортных средств на основе Bogus
+///
+public class VehicleFaker
+{
+ private static readonly string[] _fuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"];
+
+ private static readonly string[] _bodyTypes = ["Седан", "Хэтчбек", "Универсал", "Кроссовер", "Внедорожник", "Купе", "Минивэн", "Пикап"];
+
+ private static readonly Dictionary _brandModels = new()
+ {
+ ["Toyota"] = ["Camry", "Corolla", "RAV4", "Land Cruiser", "Yaris", "Highlander", "Prado"],
+ ["BMW"] = ["3 Series", "5 Series", "7 Series", "X3", "X5", "X6", "M4"],
+ ["Mercedes"] = ["C-Class", "E-Class", "S-Class", "GLE", "GLC", "A-Class", "CLA"],
+ ["Volkswagen"] = ["Passat", "Golf", "Tiguan", "Polo", "Touareg", "Jetta", "ID.4"],
+ ["Hyundai"] = ["Solaris", "Tucson", "Santa Fe", "Creta", "Elantra", "Sonata", "i30"],
+ ["Ford"] = ["Focus", "Mondeo", "Explorer", "Kuga", "Puma", "Mustang", "Ranger"],
+ ["Kia"] = ["Rio", "Sportage", "Sorento", "Ceed", "K5", "Seltos", "Stinger"],
+ ["Audi"] = ["A3", "A4", "A6", "Q3", "Q5", "Q7", "TT"],
+ ["Nissan"] = ["Qashqai", "X-Trail", "Altima", "Leaf", "Juke", "Murano", "Note"],
+ ["Renault"] = ["Logan", "Duster", "Sandero", "Megane", "Arkana", "Captur", "Laguna"],
+ ["Lada"] = ["Granta", "Vesta", "Largus", "Niva Travel", "XRAY", "4x4"],
+ ["Skoda"] = ["Octavia", "Superb", "Kodiaq", "Karoq", "Fabia", "Scala", "Kamiq"],
+ ["Mazda"] = ["Mazda3", "Mazda6", "CX-5", "CX-9", "MX-5", "CX-30"],
+ ["Honda"] = ["Civic", "Accord", "CR-V", "HR-V", "Jazz", "Pilot"],
+ ["Subaru"] = ["Outback", "Forester", "Impreza", "XV", "Legacy", "WRX"],
+ };
+
+ private readonly Faker _faker;
+
+ public VehicleFaker()
+ {
+ var currentYear = DateTime.Now.Year;
+
+ _faker = new Faker("ru")
+ .RuleFor(v => v.Id, f => f.IndexFaker + 1)
+ .RuleFor(v => v.Vin, f => f.Vehicle.Vin())
+ .RuleFor(v => v.Brand, f => f.PickRandom(_brandModels.Keys.ToArray()))
+ .RuleFor(v => v.Model, (f, v) => f.PickRandom(_brandModels[v.Brand]))
+ .RuleFor(v => v.Year, f => f.Random.Int(1984, currentYear))
+ .RuleFor(v => v.BodyType, f => f.PickRandom(_bodyTypes))
+ .RuleFor(v => v.FuelType, f => f.PickRandom(_fuelTypes))
+ .RuleFor(v => v.Color, f => f.Commerce.Color())
+ .RuleFor(v => v.Mileage, (f, v) => Math.Round(f.Random.Double(0, 500_000), 1))
+ .RuleFor(v => v.LastServiceDate, (f, v) =>
+ f.Date.BetweenDateOnly(new DateOnly(v.Year, 1, 1), DateOnly.FromDateTime(DateTime.Now)));
+ }
+
+ public Vehicle Generate() => _faker.Generate();
+}
diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs
new file mode 100644
index 00000000..edc42a36
--- /dev/null
+++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs
@@ -0,0 +1,22 @@
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.Api.Services.VehicleGeneratorService;
+
+///
+/// Сервис генерации данных транспортных средств на основе Bogus
+///
+public class VehicleGeneratorService(
+ VehicleFaker faker,
+ ILogger logger) : IVehicleGeneratorService
+{
+ ///
+ /// Генерирует новые данные транспортного средства для заданного идентификатора
+ ///
+ public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default)
+ {
+ logger.LogInformation("Generating new vehicle data for id {Id}", id);
+ var vehicle = faker.Generate();
+ vehicle.Id = id;
+ return Task.FromResult(vehicle);
+ }
+}
diff --git a/ProjectApp.Api/appsettings.Development.json b/ProjectApp.Api/appsettings.Development.json
new file mode 100644
index 00000000..b642d7aa
--- /dev/null
+++ b/ProjectApp.Api/appsettings.Development.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheSettings": {
+ "ExpirationMinutes": 10
+ }
+}
diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json
new file mode 100644
index 00000000..b5d5b64a
--- /dev/null
+++ b/ProjectApp.Api/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheSettings": {
+ "ExpirationMinutes": 10
+ },
+ "Sqs": {
+ "ServiceUrl": "http://localhost:9324",
+ "QueueName": "vehicle-queue"
+ }
+}
diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs
new file mode 100644
index 00000000..53315a76
--- /dev/null
+++ b/ProjectApp.AppHost/Program.cs
@@ -0,0 +1,38 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var redis = builder.AddRedis("cache")
+ .WithRedisCommander();
+
+var minio = builder.AddContainer("minio", "minio/minio")
+ .WithArgs("server", "/data", "--console-address", ":9001")
+ .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "minio-api")
+ .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "minio-console")
+ .WithEnvironment("MINIO_ROOT_USER", "minioadmin")
+ .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin");
+
+var sqs = builder.AddContainer("sqs", "softwaremill/elasticmq-native")
+ .WithHttpEndpoint(port: 9324, targetPort: 9324, name: "sqs");
+
+var fileService = builder.AddProject("projectapp-fileservice")
+ .WaitFor(minio)
+ .WaitFor(sqs);
+
+var gateway = builder.AddProject("projectapp-gateway")
+ .WithEndpoint("http", e => e.Port = 5200);
+
+for (var i = 0; i < 3; i++)
+{
+ var api = builder.AddProject($"projectapp-api-{i + 1}")
+ .WithReference(redis)
+ .WaitFor(redis)
+ .WaitFor(sqs)
+ .WithEndpoint("http", e => e.Port = 5180 + i)
+ .WithEndpoint("https", e => e.Port = 7170 + i);
+
+ gateway = gateway.WithReference(api).WaitFor(api);
+}
+
+builder.AddProject("client")
+ .WaitFor(gateway);
+
+builder.Build().Run();
diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
new file mode 100644
index 00000000..a4ea87fa
--- /dev/null
+++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ true
+ b8f3eae0-771a-4f3a-8df3-ef0a21b09b55
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.AppHost/Properties/launchSettings.json b/ProjectApp.AppHost/Properties/launchSettings.json
new file mode 100644
index 00000000..fa890ea9
--- /dev/null
+++ b/ProjectApp.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:17214;http://localhost:15105",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22273"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15105",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19190",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.AppHost/appsettings.Development.json b/ProjectApp.AppHost/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json
new file mode 100644
index 00000000..31c092aa
--- /dev/null
+++ b/ProjectApp.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs
new file mode 100644
index 00000000..250ec9a3
--- /dev/null
+++ b/ProjectApp.Domain/Entities/Vehicle.cs
@@ -0,0 +1,37 @@
+namespace ProjectApp.Domain.Entities;
+
+///
+/// Сущность транспортного средства
+///
+public class Vehicle
+{
+ /// Уникальный идентификатор транспортного средства в системе
+ public required int Id { get; set; }
+
+ /// VIN-номер транспортного средства
+ public required string Vin { get; set; }
+
+ /// Производитель транспортного средства
+ public required string Brand { get; set; }
+
+ /// Модель транспортного средства
+ public required string Model { get; set; }
+
+ /// Год выпуска транспортного средства
+ public int Year { get; set; }
+
+ /// Тип корпуса (кузова)
+ public required string BodyType { get; set; }
+
+ /// Тип используемого топлива
+ public required string FuelType { get; set; }
+
+ /// Цвет корпуса
+ public required string Color { get; set; }
+
+ /// Пробег в километрах
+ public double Mileage { get; set; }
+
+ /// Дата последнего техобслуживания
+ public DateOnly LastServiceDate { get; set; }
+}
diff --git a/ProjectApp.Domain/ProjectApp.Domain.csproj b/ProjectApp.Domain/ProjectApp.Domain.csproj
new file mode 100644
index 00000000..fcfb2654
--- /dev/null
+++ b/ProjectApp.Domain/ProjectApp.Domain.csproj
@@ -0,0 +1,11 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ $(NoWarn);1591
+
+
+
diff --git a/ProjectApp.FileService/Program.cs b/ProjectApp.FileService/Program.cs
new file mode 100644
index 00000000..e8c31196
--- /dev/null
+++ b/ProjectApp.FileService/Program.cs
@@ -0,0 +1,39 @@
+using Amazon.SQS;
+using Minio;
+using ProjectApp.FileService.Services;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddSingleton(sp =>
+{
+ var configuration = sp.GetRequiredService();
+ var sqsConfig = new AmazonSQSConfig
+ {
+ ServiceURL = configuration["Sqs:ServiceUrl"] ?? "http://localhost:9324"
+ };
+ return new AmazonSQSClient("test", "test", sqsConfig);
+});
+
+builder.Services.AddSingleton(sp =>
+{
+ var configuration = sp.GetRequiredService();
+ return new MinioClient()
+ .WithEndpoint(configuration["Minio:Endpoint"] ?? "localhost:9000")
+ .WithCredentials(
+ configuration["Minio:AccessKey"] ?? "minioadmin",
+ configuration["Minio:SecretKey"] ?? "minioadmin")
+ .WithSSL(false)
+ .Build();
+});
+
+builder.Services.AddSingleton();
+builder.Services.AddHostedService();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+app.Run();
diff --git a/ProjectApp.FileService/ProjectApp.FileService.csproj b/ProjectApp.FileService/ProjectApp.FileService.csproj
new file mode 100644
index 00000000..6b7abf20
--- /dev/null
+++ b/ProjectApp.FileService/ProjectApp.FileService.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.FileService/ProjectApp.FileService.http b/ProjectApp.FileService/ProjectApp.FileService.http
new file mode 100644
index 00000000..720619f3
--- /dev/null
+++ b/ProjectApp.FileService/ProjectApp.FileService.http
@@ -0,0 +1,6 @@
+@ProjectApp.FileService_HostAddress = http://localhost:5166
+
+GET {{ProjectApp.FileService_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/ProjectApp.FileService/Properties/launchSettings.json b/ProjectApp.FileService/Properties/launchSettings.json
new file mode 100644
index 00000000..2d1b79c6
--- /dev/null
+++ b/ProjectApp.FileService/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:17853",
+ "sslPort": 44303
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7072;http://localhost:5166",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.FileService/Services/MinioStorageService.cs b/ProjectApp.FileService/Services/MinioStorageService.cs
new file mode 100644
index 00000000..387d88fc
--- /dev/null
+++ b/ProjectApp.FileService/Services/MinioStorageService.cs
@@ -0,0 +1,71 @@
+using System.Text;
+using System.Text.Json;
+using Minio;
+using Minio.DataModel.Args;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.FileService.Services;
+
+///
+/// Сервис сохранения и чтения файлов транспортных средств в MinIO
+///
+public class MinioStorageService(
+ IMinioClient minioClient,
+ IConfiguration configuration,
+ ILogger logger)
+{
+ private readonly string _bucketName = configuration["Minio:BucketName"] ?? "vehicles";
+
+ ///
+ /// Создаёт бакет, если он ещё не существует
+ ///
+ public async Task EnsureBucketExistsAsync(CancellationToken ct = default)
+ {
+ var exists = await minioClient.BucketExistsAsync(
+ new BucketExistsArgs().WithBucket(_bucketName), ct);
+
+ if (!exists)
+ {
+ await minioClient.MakeBucketAsync(
+ new MakeBucketArgs().WithBucket(_bucketName), ct);
+ logger.LogInformation("Bucket {Bucket} created", _bucketName);
+ }
+ }
+
+ ///
+ /// Сохраняет данные транспортного средства в виде JSON-файла
+ ///
+ public async Task SaveVehicleAsync(Vehicle vehicle, CancellationToken ct = default)
+ {
+ var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { WriteIndented = true });
+ var bytes = Encoding.UTF8.GetBytes(json);
+ using var stream = new MemoryStream(bytes);
+ var objectName = $"vehicle-{vehicle.Id}.json";
+
+ await minioClient.PutObjectAsync(new PutObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(objectName)
+ .WithStreamData(stream)
+ .WithObjectSize(stream.Length)
+ .WithContentType("application/json"), ct);
+
+ logger.LogInformation("Vehicle {Id} saved as {Object}", vehicle.Id, objectName);
+ }
+
+ ///
+ /// Читает данные транспортного средства из MinIO по идентификатору
+ ///
+ public async Task GetVehicleAsync(int id, CancellationToken ct = default)
+ {
+ var objectName = $"vehicle-{id}.json";
+ using var ms = new MemoryStream();
+
+ await minioClient.GetObjectAsync(new GetObjectArgs()
+ .WithBucket(_bucketName)
+ .WithObject(objectName)
+ .WithCallbackStream(stream => stream.CopyTo(ms)), ct);
+
+ ms.Position = 0;
+ return await JsonSerializer.DeserializeAsync(ms, cancellationToken: ct);
+ }
+}
diff --git a/ProjectApp.FileService/Services/SqsConsumerService.cs b/ProjectApp.FileService/Services/SqsConsumerService.cs
new file mode 100644
index 00000000..2dec8488
--- /dev/null
+++ b/ProjectApp.FileService/Services/SqsConsumerService.cs
@@ -0,0 +1,79 @@
+using System.Text.Json;
+using Amazon.SQS;
+using Amazon.SQS.Model;
+using ProjectApp.Domain.Entities;
+
+namespace ProjectApp.FileService.Services;
+
+///
+/// Фоновый сервис для получения сообщений из SQS и сохранения данных в MinIO
+///
+public class SqsConsumerService(
+ IAmazonSQS sqsClient,
+ MinioStorageService storageService,
+ IConfiguration configuration,
+ ILogger logger) : BackgroundService
+{
+ private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue";
+ private string? _queueUrl;
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await InitializeAsync(stoppingToken);
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var response = await sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = _queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ }, stoppingToken);
+
+ foreach (var message in response.Messages)
+ {
+ await ProcessMessageAsync(message, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error polling SQS");
+ await Task.Delay(3000, stoppingToken);
+ }
+ }
+ }
+
+ private async Task InitializeAsync(CancellationToken ct)
+ {
+ var response = await sqsClient.CreateQueueAsync(_queueName, ct);
+ _queueUrl = response.QueueUrl;
+ logger.LogInformation("SQS queue {Queue} ready at {Url}", _queueName, _queueUrl);
+
+ await storageService.EnsureBucketExistsAsync(ct);
+ }
+
+ private async Task ProcessMessageAsync(Message message, CancellationToken ct)
+ {
+ try
+ {
+ var vehicle = JsonSerializer.Deserialize(message.Body);
+ if (vehicle != null)
+ {
+ await storageService.SaveVehicleAsync(vehicle, ct);
+ logger.LogInformation("Vehicle {Id} processed and saved to MinIO", vehicle.Id);
+ }
+
+ await sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId);
+ }
+ }
+}
diff --git a/ProjectApp.FileService/appsettings.Development.json b/ProjectApp.FileService/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.FileService/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.FileService/appsettings.json b/ProjectApp.FileService/appsettings.json
new file mode 100644
index 00000000..e220b82f
--- /dev/null
+++ b/ProjectApp.FileService/appsettings.json
@@ -0,0 +1,19 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Sqs": {
+ "ServiceUrl": "http://localhost:9324",
+ "QueueName": "vehicle-queue"
+ },
+ "Minio": {
+ "Endpoint": "localhost:9000",
+ "AccessKey": "minioadmin",
+ "SecretKey": "minioadmin",
+ "BucketName": "vehicles"
+ }
+}
diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs
new file mode 100644
index 00000000..ea8c48c0
--- /dev/null
+++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs
@@ -0,0 +1,54 @@
+using Ocelot.Configuration;
+using Ocelot.LoadBalancer.LoadBalancers;
+using Ocelot.Responses;
+using Ocelot.Values;
+
+namespace ProjectApp.Gateway.LoadBalancing;
+
+///
+/// Балансировщик нагрузки, распределяющий запросы на основе значения параметра id
+/// из строки запроса или пути. Один и тот же id всегда направляется на один и тот же хост.
+///
+public class QueryBasedLoadBalancer(DownstreamRoute route) : ILoadBalancer
+{
+ private readonly List _hosts = route.DownstreamAddresses
+ .Select(a => new ServiceHostAndPort(a.Host, a.Port))
+ .ToList();
+
+ ///
+ /// Тип используемого балансировщика нагрузки.
+ ///
+ public string Type => nameof(QueryBasedLoadBalancer);
+
+ ///
+ /// Выбирает downstream-хост на основе идентификатора из запроса.
+ ///
+ public Task> LeaseAsync(HttpContext httpContext)
+ {
+ if (_hosts.Count == 0)
+ return Task.FromResult>(
+ new ErrorResponse(
+ new ServicesAreEmptyError("No downstream hosts configured")));
+
+ var idRaw = httpContext.Request.Query["id"].FirstOrDefault()
+ ?? ExtractIdFromPath(httpContext.Request.Path);
+
+ var index = 0;
+ if (int.TryParse(idRaw, out var id))
+ index = Math.Abs(id) % _hosts.Count;
+
+ return Task.FromResult>(
+ new OkResponse(_hosts[index]));
+ }
+
+ ///
+ /// Освобождает ранее выданный хост (балансировщик без состояния).
+ ///
+ public void Release(ServiceHostAndPort hostAndPort) { }
+
+ private static string? ExtractIdFromPath(PathString path)
+ {
+ var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ return segments?.LastOrDefault();
+ }
+}
diff --git a/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs
new file mode 100644
index 00000000..ebef4b1c
--- /dev/null
+++ b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs
@@ -0,0 +1,5 @@
+using Ocelot.Errors;
+
+namespace ProjectApp.Gateway.LoadBalancing;
+
+public class ServicesAreEmptyError(string message) : Error(message, OcelotErrorCode.UnknownError, 503);
diff --git a/ProjectApp.Gateway/Program.cs b/ProjectApp.Gateway/Program.cs
new file mode 100644
index 00000000..f42be308
--- /dev/null
+++ b/ProjectApp.Gateway/Program.cs
@@ -0,0 +1,37 @@
+using Ocelot.DependencyInjection;
+using Ocelot.Middleware;
+using ProjectApp.Gateway.LoadBalancing;
+using ProjectApp.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Configuration
+ .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true)
+ .AddJsonFile($"ocelot.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
+ .AddEnvironmentVariables();
+
+var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? ["http://localhost:5127"];
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.WithOrigins(allowedOrigins)
+ .WithMethods("GET")
+ .WithHeaders("Content-Type");
+ });
+});
+
+builder.Services
+ .AddOcelot(builder.Configuration)
+ .AddCustomLoadBalancer((route, _) => new QueryBasedLoadBalancer(route));
+
+var app = builder.Build();
+
+app.UseCors();
+app.MapDefaultEndpoints();
+
+await app.UseOcelot();
+
+app.Run();
diff --git a/ProjectApp.Gateway/ProjectApp.Gateway.csproj b/ProjectApp.Gateway/ProjectApp.Gateway.csproj
new file mode 100644
index 00000000..afa35b75
--- /dev/null
+++ b/ProjectApp.Gateway/ProjectApp.Gateway.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json
new file mode 100644
index 00000000..595cd1bd
--- /dev/null
+++ b/ProjectApp.Gateway/Properties/launchSettings.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:64048",
+ "sslPort": 44366
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5130",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7286;http://localhost:5130",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/ProjectApp.Gateway/appsettings.Development.json b/ProjectApp.Gateway/appsettings.Development.json
new file mode 100644
index 00000000..0c208ae9
--- /dev/null
+++ b/ProjectApp.Gateway/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/ProjectApp.Gateway/appsettings.json b/ProjectApp.Gateway/appsettings.json
new file mode 100644
index 00000000..5860f55c
--- /dev/null
+++ b/ProjectApp.Gateway/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Ocelot": "Information"
+ }
+ },
+ "AllowedHosts": "*",
+ "AllowedOrigins": [
+ "http://localhost:5127"
+ ]
+}
diff --git a/ProjectApp.Gateway/ocelot.json b/ProjectApp.Gateway/ocelot.json
new file mode 100644
index 00000000..be45093c
--- /dev/null
+++ b/ProjectApp.Gateway/ocelot.json
@@ -0,0 +1,21 @@
+{
+ "Routes": [
+ {
+ "UpstreamPathTemplate": "/api/vehicle/{id}",
+ "UpstreamHttpMethod": [ "GET" ],
+ "DownstreamPathTemplate": "/api/vehicle/{id}",
+ "DownstreamScheme": "http",
+ "DownstreamHostAndPorts": [
+ { "Host": "localhost", "Port": 5180 },
+ { "Host": "localhost", "Port": 5181 },
+ { "Host": "localhost", "Port": 5182 }
+ ],
+ "LoadBalancerOptions": {
+ "Type": "QueryBasedLoadBalancer"
+ }
+ }
+ ],
+ "GlobalConfiguration": {
+ "BaseUrl": "http://localhost:5200"
+ }
+}
diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs
new file mode 100644
index 00000000..9f167125
--- /dev/null
+++ b/ProjectApp.ServiceDefaults/Extensions.cs
@@ -0,0 +1,104 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics.HealthChecks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+
+namespace ProjectApp.ServiceDefaults;
+
+ ///
+ /// Вспомогательный класс для настройки общих сервисов (логи, метрики, проверки состояния)
+ ///
+public static class Extensions
+{
+ ///
+ /// Регистрация базовой конфигурации для микросервисов
+ ///
+ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
+ {
+ builder.ConfigureOpenTelemetry();
+ builder.AddDefaultHealthChecks();
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
+ {
+ http.AddStandardResilienceHandler();
+ http.AddServiceDiscovery();
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Подключение и настройка OpenTelemetry (логирование и сбор метрик)
+ ///
+ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation();
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation();
+ });
+
+ builder.AddOpenTelemetryExporters();
+
+ return builder;
+ }
+
+ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+
+ if (useOtlpExporter)
+ {
+ builder.Services.AddOpenTelemetry().UseOtlpExporter();
+ }
+
+ return builder;
+ }
+
+ ///
+ /// Базовые проверки сервиса (health checks)
+ ///
+ public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ ///
+ /// Маппинг endpoints для мониторинга доступности
+ ///
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks("/health");
+ app.MapHealthChecks("/alive", new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj
new file mode 100644
index 00000000..bf9f33a7
--- /dev/null
+++ b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ProjectApp.Tests/Fixtures/ServiceFixture.cs b/ProjectApp.Tests/Fixtures/ServiceFixture.cs
new file mode 100644
index 00000000..75755873
--- /dev/null
+++ b/ProjectApp.Tests/Fixtures/ServiceFixture.cs
@@ -0,0 +1,129 @@
+using Amazon.SQS;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Minio;
+using StackExchange.Redis;
+using Xunit;
+
+namespace ProjectApp.Tests.Fixtures;
+
+///
+/// Фикстура для интеграционных тестов — поднимает контейнеры Redis, MinIO и ElasticMQ
+///
+public class ServiceFixture : IAsyncLifetime
+{
+ private IContainer _redis = null!;
+ private IContainer _minio = null!;
+ private IContainer _elasticMq = null!;
+
+ public WebApplicationFactory ApiFactory { get; private set; } = null!;
+ public IAmazonSQS SqsClient { get; private set; } = null!;
+ public IMinioClient MinioClient { get; private set; } = null!;
+ public IConnectionMultiplexer RedisConnection { get; private set; } = null!;
+
+ public string SqsServiceUrl => $"http://localhost:{_elasticMq.GetMappedPublicPort(9324)}";
+ public string MinioEndpoint => $"localhost:{_minio.GetMappedPublicPort(9000)}";
+ public string RedisConnectionString => $"localhost:{_redis.GetMappedPublicPort(6379)},abortConnect=false";
+
+ public async Task InitializeAsync()
+ {
+ _redis = new ContainerBuilder()
+ .WithImage("redis:7-alpine")
+ .WithPortBinding(6379, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379))
+ .Build();
+
+ _minio = new ContainerBuilder()
+ .WithImage("minio/minio")
+ .WithCommand("server", "/data")
+ .WithEnvironment("MINIO_ROOT_USER", "minioadmin")
+ .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin")
+ .WithPortBinding(9000, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9000))
+ .Build();
+
+ _elasticMq = new ContainerBuilder()
+ .WithImage("softwaremill/elasticmq-native")
+ .WithPortBinding(9324, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9324))
+ .Build();
+
+ await Task.WhenAll(
+ _redis.StartAsync(),
+ _minio.StartAsync(),
+ _elasticMq.StartAsync());
+
+ SqsClient = new AmazonSQSClient("test", "test", new AmazonSQSConfig
+ {
+ ServiceURL = SqsServiceUrl
+ });
+
+ MinioClient = new Minio.MinioClient()
+ .WithEndpoint(MinioEndpoint)
+ .WithCredentials("minioadmin", "minioadmin")
+ .WithSSL(false)
+ .Build();
+
+ RedisConnection = await ConnectionMultiplexer.ConnectAsync(RedisConnectionString);
+
+ var redisConnStr = RedisConnectionString;
+ var sqsUrl = SqsServiceUrl;
+
+ ApiFactory = new WebApplicationFactory()
+ .WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, config) =>
+ {
+ config.AddInMemoryCollection(new Dictionary
+ {
+ ["ConnectionStrings:cache"] = redisConnStr,
+ ["Sqs:ServiceUrl"] = sqsUrl,
+ ["Sqs:QueueName"] = "vehicle-queue"
+ });
+ });
+
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton(
+ ConnectionMultiplexer.Connect(redisConnStr));
+ });
+ });
+ }
+
+ ///
+ /// Сброс состояния между тестами: очистка Redis и SQS
+ ///
+ public async Task ResetAsync()
+ {
+ var db = RedisConnection.GetDatabase();
+ await db.ExecuteAsync("FLUSHDB");
+
+ try
+ {
+ var queueUrl = (await SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl;
+ await SqsClient.PurgeQueueAsync(new Amazon.SQS.Model.PurgeQueueRequest
+ {
+ QueueUrl = queueUrl
+ });
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"[WARN] Failed to purge SQS queue during reset: {ex.Message}");
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ RedisConnection?.Dispose();
+ ApiFactory?.Dispose();
+
+ await Task.WhenAll(
+ _redis.DisposeAsync().AsTask(),
+ _minio.DisposeAsync().AsTask(),
+ _elasticMq.DisposeAsync().AsTask());
+ }
+}
diff --git a/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs
new file mode 100644
index 00000000..58583372
--- /dev/null
+++ b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs
@@ -0,0 +1,164 @@
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Amazon.SQS.Model;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.DependencyInjection;
+using Minio;
+using Minio.DataModel.Args;
+using ProjectApp.Domain.Entities;
+using ProjectApp.Tests.Fixtures;
+using Xunit;
+
+namespace ProjectApp.Tests.IntegrationTests;
+
+///
+/// Интеграционные тесты для проверки совместной работы сервисов
+///
+public class VehicleIntegrationTests : IClassFixture, IAsyncLifetime
+{
+ private readonly ServiceFixture _fixture;
+ private readonly HttpClient _httpClient;
+
+ public VehicleIntegrationTests(ServiceFixture fixture)
+ {
+ _fixture = fixture;
+ _httpClient = fixture.ApiFactory.CreateClient();
+ }
+
+ public async Task InitializeAsync()
+ {
+ await _fixture.ResetAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+
+ [Fact]
+ public async Task GetVehicle_ReturnsValidData()
+ {
+ var response = await _httpClient.GetAsync("/api/vehicle/1");
+
+ response.EnsureSuccessStatusCode();
+ var vehicle = await response.Content.ReadFromJsonAsync();
+
+ Assert.NotNull(vehicle);
+ Assert.Equal(1, vehicle.Id);
+ Assert.False(string.IsNullOrEmpty(vehicle.Vin));
+ Assert.False(string.IsNullOrEmpty(vehicle.Brand));
+ Assert.False(string.IsNullOrEmpty(vehicle.Model));
+ Assert.InRange(vehicle.Year, 1984, DateTime.Now.Year);
+ Assert.True(vehicle.Mileage >= 0);
+ }
+
+ [Fact]
+ public async Task GetVehicle_SecondRequest_ReturnsCachedData()
+ {
+ var first = await _httpClient.GetFromJsonAsync("/api/vehicle/42");
+ var second = await _httpClient.GetFromJsonAsync("/api/vehicle/42");
+
+ Assert.NotNull(first);
+ Assert.NotNull(second);
+ Assert.Equal(first.Vin, second.Vin);
+ Assert.Equal(first.Brand, second.Brand);
+ Assert.Equal(first.Model, second.Model);
+ Assert.Equal(first.Year, second.Year);
+ Assert.Equal(first.Mileage, second.Mileage);
+ }
+
+ [Fact]
+ public async Task GetVehicle_PublishesMessageToSqs()
+ {
+ await _httpClient.GetAsync("/api/vehicle/7");
+
+ await Task.Delay(700);
+
+ var queueUrl = (await _fixture.SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl;
+
+ var messages = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest
+ {
+ QueueUrl = queueUrl,
+ MaxNumberOfMessages = 10,
+ WaitTimeSeconds = 5
+ });
+
+ Assert.NotEmpty(messages.Messages);
+
+ var vehicle = JsonSerializer.Deserialize(messages.Messages[0].Body);
+ Assert.NotNull(vehicle);
+ Assert.Equal(7, vehicle.Id);
+ }
+
+ [Fact]
+ public async Task MinioStorage_SavesAndRetrievesFile()
+ {
+ var minio = _fixture.MinioClient;
+ var bucketName = "test-vehicles";
+
+ var exists = await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucketName));
+ if (!exists)
+ await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucketName));
+
+ var vehicle = new Vehicle
+ {
+ Id = 99,
+ Vin = "TEST12345678VIN01",
+ Brand = "Toyota",
+ Model = "Camry",
+ Year = 2020,
+ BodyType = "Седан",
+ FuelType = "Бензин",
+ Color = "white",
+ Mileage = 15000,
+ LastServiceDate = new DateOnly(2023, 6, 15)
+ };
+
+ var json = JsonSerializer.Serialize(vehicle);
+ var bytes = Encoding.UTF8.GetBytes(json);
+ using var uploadStream = new MemoryStream(bytes);
+
+ await minio.PutObjectAsync(new PutObjectArgs()
+ .WithBucket(bucketName)
+ .WithObject("vehicle-99.json")
+ .WithStreamData(uploadStream)
+ .WithObjectSize(uploadStream.Length)
+ .WithContentType("application/json"));
+
+ using var downloadStream = new MemoryStream();
+ await minio.GetObjectAsync(new GetObjectArgs()
+ .WithBucket(bucketName)
+ .WithObject("vehicle-99.json")
+ .WithCallbackStream(s => s.CopyTo(downloadStream)));
+
+ downloadStream.Position = 0;
+ var loaded = await JsonSerializer.DeserializeAsync(downloadStream);
+
+ Assert.NotNull(loaded);
+ Assert.Equal(vehicle.Id, loaded.Id);
+ Assert.Equal(vehicle.Vin, loaded.Vin);
+ Assert.Equal(vehicle.Brand, loaded.Brand);
+ }
+
+ [Fact]
+ public async Task Redis_CachesVehicleData()
+ {
+ await _httpClient.GetAsync("/api/vehicle/55");
+
+ using var scope = _fixture.ApiFactory.Services.CreateScope();
+ var cache = scope.ServiceProvider.GetRequiredService();
+ var cached = await cache.GetStringAsync("vehicle-55");
+
+ Assert.NotNull(cached);
+
+ var vehicle = JsonSerializer.Deserialize(cached);
+ Assert.NotNull(vehicle);
+ Assert.Equal(55, vehicle.Id);
+ }
+
+ [Fact]
+ public async Task GetVehicle_WithInvalidId_ReturnsBadRequest()
+ {
+ var response = await _httpClient.GetAsync("/api/vehicle/-1");
+
+ Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
+ }
+}
diff --git a/ProjectApp.Tests/ProjectApp.Tests.csproj b/ProjectApp.Tests/ProjectApp.Tests.csproj
new file mode 100644
index 00000000..c3406106
--- /dev/null
+++ b/ProjectApp.Tests/ProjectApp.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index dcaa5eb7..be345a99 100644
--- a/README.md
+++ b/README.md
@@ -1,128 +1,106 @@
-# Современные технологии разработки программного обеспечения
-[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing)
-
-## Задание
-### Цель
-Реализация проекта микросервисного бекенда.
-
-### Задачи
-* Реализация межсервисной коммуникации,
-* Изучение работы с брокерами сообщений,
-* Изучение архитектурных паттернов,
-* Изучение работы со средствами оркестрации на примере .NET Aspire,
-* Повторение основ работы с системами контроля версий,
-* Интеграционное тестирование.
-
-### Лабораторные работы
-
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов
-
-
-В рамках первой лабораторной работы необходимо:
-* Реализовать сервис генерации контрактов на основе Bogus,
-* Реализовать кеширование при помощи IDistributedCache и Redis,
-* Реализовать структурное логирование сервиса генерации,
-* Настроить оркестрацию Aspire.
-
-
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы
-
-
-В рамках второй лабораторной работы необходимо:
-* Настроить оркестрацию на запуск нескольких реплик сервиса генерации,
-* Реализовать апи гейтвей на основе Ocelot,
-* Имплементировать алгоритм балансировки нагрузки согласно варианту.
-
-
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда
-
-
-В рамках третьей лабораторной работы необходимо:
-* Добавить в оркестрацию объектное хранилище,
-* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище,
-* Реализовать отправку генерируемых данных в файловый сервис посредством брокера,
-* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе.
-
-
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud
-
-
-В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы:
-* Клиент - в хостинг через отдельный бакет Object Storage,
-* Сервис генерации - в Cloud Function,
-* Апи гейтвей - в Serverless Integration как API Gateway,
-* Брокер сообщений - в Message Queue,
-* Файловый сервис - в Cloud Function,
-* Объектное хранилище - в отдельный бакет Object Storage,
-
-
-
-
-## Задание. Общая часть
-**Обязательно**:
-* Реализация серверной части на [.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 с информацией о задании, скриншоты приложения и прочая информация.
-
-**Факультативно**:
-* Перенос бекенда на облачную инфраструктуру 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).
+# Лабораторная работа №1 — «Кэширование»
+**Вариант:** №29 — «Транспортное средство»
+**Балансировка:** Query Based
+**Брокер:** SQS
+**Хостинг S3:** Minio
+**Выполнил:** Нестеренко Андрей, 6512
+
+## Что реализовано
+
+- Генерация сущности «Транспортное средство» через Bogus.
+- Кэширование результатов генерации через IDistributedCache (Redis) с TTL 10 минут.
+- Структурное логирование запросов и результатов генерации.
+- Оркестрация сервисов через .NET Aspire.
+- REST endpoint: `GET /api/vehicle/{id}`.
+
+# Лабораторная работа №2 — «Балансировка нагрузки»
+
+## Описание
+
+API-шлюз на основе Ocelot с кастомным алгоритмом балансировки нагрузки Query Based. Клиентские запросы распределяются по репликам сервиса генерации на основе параметра `id` из строки запроса.
+
+## Что реализовано
+
+### Репликация сервиса генерации
+- Aspire AppHost запускает 3 реплики `ProjectApp.Api` на портах 5180, 5181, 5182
+- Гейтвей ожидает готовности всех реплик перед стартом (`.WaitFor()`)
+
+### API Gateway (Ocelot)
+- Проект `ProjectApp.Gateway` — единая точка входа для клиента
+- Маршрутизация настроена через `ocelot.json`
+- CORS-политика вынесена в конфигурацию (`AllowedOrigins`)
+
+### Кастомный балансировщик — `QueryBasedLoadBalancer`
+- Реализует интерфейс `ILoadBalancer` из Ocelot
+- Алгоритм: `index = id % N`, где `N` — число реплик
+- При отсутствии параметра `id` запрос направляется на первую реплику
+
+## Характеристики генерируемого транспортного средства
+
+1. Идентификатор в системе — `int`
+2. VIN-номер — `string`
+3. Производитель — `string`
+4. Модель — `string`
+5. Год выпуска — `int`
+6. Тип корпуса — `string`
+7. Тип топлива — `string`
+8. Цвет корпуса — `string`
+9. Пробег — `double`
+10. Дата последнего техобслуживания — `DateOnly`
+
+## Правила генерации
+
+- VIN-номер: берётся из раздела Vehicle (Bogus).
+- Производитель: случайно выбирается из фиксированного списка популярных марок.
+- Модель: выбирается из набора моделей, соответствующих производителю.
+- Год выпуска: от 1984 до текущего года включительно.
+- Тип корпуса: берётся из раздела Vehicle (Bogus).
+- Тип топлива: берётся из раздела Vehicle (Bogus); выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ).
+- Цвет корпуса: берётся из раздела Commerce (Bogus).
+- Пробег: от 0 до 500 000 км, не может быть меньше нуля.
+- Дата последнего техобслуживания: не ранее 1 января года выпуска и не позже сегодняшней даты.
+
+# Лабораторная работа №3 — «Интеграционное тестирование»
+
+## Описание
+
+Добавлен файловый сервис, объектное хранилище MinIO и очередь сообщений SQS (ElasticMQ). Написаны интеграционные тесты для проверки совместной работы всех компонентов.
+
+## Что реализовано
+
+### Объектное хранилище (MinIO)
+- В оркестрацию Aspire добавлен контейнер MinIO (`minio/minio`)
+- MinIO доступен на порту 9000 (API) и 9001 (Console)
+- Учётные данные по умолчанию: `minioadmin` / `minioadmin`
+- Бакет `vehicles` создаётся автоматически при старте файлового сервиса
+
+### Очередь сообщений (SQS → ElasticMQ)
+- В оркестрацию добавлен контейнер ElasticMQ (`softwaremill/elasticmq-native`) — легковесная SQS-совместимая очередь
+- ElasticMQ доступен на порту 9324
+- Очередь `vehicle-queue` создаётся автоматически
+
+### Файловый сервис (`ProjectApp.FileService`)
+- `SqsConsumerService` — фоновый сервис (`BackgroundService`), который опрашивает очередь SQS
+- `MinioStorageService` — сохраняет десериализованные данные транспортного средства в MinIO как JSON-файлы
+- Файлы хранятся в бакете `vehicles` с именами вида `vehicle-{id}.json`
+
+### Отправка данных через SQS
+- `SqsPublisher` в проекте `ProjectApp.Api` отправляет JSON-сериализованные данные транспортного средства в очередь после генерации
+- Ошибки отправки в SQS не блокируют ответ клиенту
+
+### Интеграционные тесты (`ProjectApp.Tests`)
+- Фикстура `ServiceFixture` поднимает контейнеры Redis, MinIO и ElasticMQ через Testcontainers
+- `WebApplicationFactory` запускает API с подменёнными подключениями к тестовым контейнерам
+- Между тестами выполняется сброс состояния (flush Redis, purge SQS)
+
+### Список тестов
+
+| Тест | Что проверяет |
+|------|---------------|
+| `GetVehicle_ReturnsValidData` | Генерация транспортного средства возвращает корректные поля |
+| `GetVehicle_SecondRequest_ReturnsCachedData` | Повторный запрос с тем же ID возвращает закэшированные данные |
+| `GetVehicle_PublishesMessageToSqs` | После генерации сообщение попадает в очередь SQS |
+| `GetVehicle_WithInvalidId_ReturnsBadRequest` | Запрос с невалидным ID (≤ 0) возвращает 400 Bad Request |
+| `MinioStorage_SavesAndRetrievesFile` | Запись и чтение JSON-файла из MinIO |
+| `Redis_CachesVehicleData` | Данные сохраняются в Redis после генерации |