From 1aadc5dc72ca2fb3de145d533b8959f6e7695309 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Wed, 18 Mar 2026 12:52:52 +0400 Subject: [PATCH 1/9] done --- Client.Wasm/Client.Wasm.csproj | 1 + Client.Wasm/Components/DataCard.razor | 7 +- Client.Wasm/Components/StudentCard.razor | 10 +- Client.Wasm/Program.cs | 7 +- Client.Wasm/VehicleApiClient.cs | 12 ++ Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 27 ++- .../Controllers/VehicleController.cs | 29 +++ ProjectApp.Api/Program.cs | 66 +++++++ ProjectApp.Api/ProjectApp.Api.csproj | 23 +++ ProjectApp.Api/Properties/launchSettings.json | 41 +++++ .../IVehicleGeneratorService.cs | 14 ++ .../VehicleGeneratorService/VehicleFaker.cs | 41 +++++ .../VehicleGeneratorService.cs | 76 ++++++++ ProjectApp.Api/appsettings.Development.json | 12 ++ ProjectApp.Api/appsettings.json | 15 ++ ProjectApp.AppHost/Program.cs | 15 ++ ProjectApp.AppHost/ProjectApp.AppHost.csproj | 24 +++ .../Properties/launchSettings.json | 29 +++ .../appsettings.Development.json | 8 + ProjectApp.AppHost/appsettings.json | 9 + ProjectApp.Domain/Entities/Vehicle.cs | 18 ++ ProjectApp.Domain/ProjectApp.Domain.csproj | 11 ++ ProjectApp.ServiceDefaults/Extensions.cs | 104 +++++++++++ .../ProjectApp.ServiceDefaults.csproj | 20 +++ README.md | 168 +++++------------- 26 files changed, 648 insertions(+), 141 deletions(-) create mode 100644 Client.Wasm/VehicleApiClient.cs create mode 100644 ProjectApp.Api/Controllers/VehicleController.cs create mode 100644 ProjectApp.Api/Program.cs create mode 100644 ProjectApp.Api/ProjectApp.Api.csproj create mode 100644 ProjectApp.Api/Properties/launchSettings.json create mode 100644 ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs create mode 100644 ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs create mode 100644 ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs create mode 100644 ProjectApp.Api/appsettings.Development.json create mode 100644 ProjectApp.Api/appsettings.json create mode 100644 ProjectApp.AppHost/Program.cs create mode 100644 ProjectApp.AppHost/ProjectApp.AppHost.csproj create mode 100644 ProjectApp.AppHost/Properties/launchSettings.json create mode 100644 ProjectApp.AppHost/appsettings.Development.json create mode 100644 ProjectApp.AppHost/appsettings.json create mode 100644 ProjectApp.Domain/Entities/Vehicle.cs create mode 100644 ProjectApp.Domain/ProjectApp.Domain.csproj create mode 100644 ProjectApp.ServiceDefaults/Extensions.cs create mode 100644 ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj 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..faf6c115 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,13 @@ - + Лабораторная работа - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "Кэширование" + Вариант №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..c95e6fd0 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5179/" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..7e69b888 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,17 @@ - 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +22,22 @@ 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 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..f8f950d0 --- /dev/null +++ b/ProjectApp.Api/Controllers/VehicleController.cs @@ -0,0 +1,29 @@ +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, ILogger logger) : ControllerBase +{ + /// + /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору + /// + /// Идентификатор машины + /// Токен отмены + [HttpGet("{id}")] + public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken) + { + logger.LogInformation("Request received for vehicle id {Id}", id); + + var vehicle = await vehicleService.FetchByIdAsync(id, cancellationToken); + + return Ok(vehicle); + } +} diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs new file mode 100644 index 00000000..0b024e68 --- /dev/null +++ b/ProjectApp.Api/Program.cs @@ -0,0 +1,66 @@ +using ProjectApp.Api.Services.VehicleGeneratorService; +using ProjectApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddRedisDistributedCache("cache"); + +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.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.UseCors(); +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ 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..2aea6015 --- /dev/null +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -0,0 +1,23 @@ + + + + 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/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..1db9a5c0 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -0,0 +1,41 @@ +using Bogus; +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +public class VehicleFaker +{ + private readonly Faker _faker; + + public VehicleFaker() + { + var fuelTypes = new[] { "Бензин", "Дизель", "Электро", "Гибрид", "Газ" }; + + _faker = new Faker("ru") + .RuleFor(v => v.Id, f => f.IndexFaker + 1) + .RuleFor(v => v.Brand, f => f.PickRandom( + "Toyota", "BMW", "Mercedes", "Volkswagen", "Hyundai", + "Ford", "Kia", "Audi", "Nissan", "Renault")) + .RuleFor(v => v.Model, (f, v) => f.PickRandom( + "Comfort", "Sport", "Elite", "Plus", "Pro", + "Active", "Max", "Base", "Premium", "Line")) + .RuleFor(v => v.RegistrationNumber, f => + { + var letters = "АВЕКМНОРСТУХ"; + var letter1 = letters[f.Random.Int(0, letters.Length - 1)]; + var digits = f.Random.Int(100, 999); + var letter2 = letters[f.Random.Int(0, letters.Length - 1)]; + var letter3 = letters[f.Random.Int(0, letters.Length - 1)]; + var region = f.Random.Int(10, 199); + return $"{letter1}{digits}{letter2}{letter3} {region}"; + }) + .RuleFor(v => v.OwnerName, f => f.Name.FullName()) + .RuleFor(v => v.Year, f => f.Random.Int(1984, 2026)) + .RuleFor(v => v.EngineVolume, f => Math.Round(f.Random.Decimal(0.8m, 6.0m), 1)) + .RuleFor(v => v.Mileage, f => f.Random.Int(0, 500000)) + .RuleFor(v => v.FuelType, f => f.PickRandom(fuelTypes)) + .RuleFor(v => v.Price, f => Math.Round(f.Random.Decimal(100000m, 10000000m), 0)); + } + + 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..311a49c9 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs @@ -0,0 +1,76 @@ +using ProjectApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +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); + } +} + +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) + { + // отлавливаем падение при недоступном Redis + 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/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..56fded7d --- /dev/null +++ b/ProjectApp.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AllowedOrigins": [ + "http://localhost:5127" + ], + "CacheSettings": { + "ExpirationMinutes": 10 + } +} diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs new file mode 100644 index 00000000..13bf1f02 --- /dev/null +++ b/ProjectApp.AppHost/Program.cs @@ -0,0 +1,15 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("cache") + .WithRedisCommander(); + +var api = builder.AddProject("projectapp-api") + .WithReference(redis) + .WaitFor(redis) + .WithEndpoint("http", e => e.Port = 5179); + +builder.AddProject("client") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj new file mode 100644 index 00000000..7704751d --- /dev/null +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + 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..18c773e8 --- /dev/null +++ b/ProjectApp.Domain/Entities/Vehicle.cs @@ -0,0 +1,18 @@ +namespace ProjectApp.Domain.Entities; + +/// +/// Сущность транспортного средства +/// +public class Vehicle +{ + public required int Id { get; set; } + public required string Brand { get; set; } + public required string Model { get; set; } + public required string RegistrationNumber { get; set; } + public required string OwnerName { get; set; } + public int Year { get; set; } + public decimal EngineVolume { get; set; } + public int Mileage { get; set; } + public required string FuelType { get; set; } + public decimal Price { 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.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/README.md b/README.md index dcaa5eb7..c868a66a 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,40 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](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 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](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}`. + +## Характеристики генерируемого транспортного средства + +1. Идентификатор в системе — `int` +2. Марка — `string` +3. Модель — `string` +4. Регистрационный номер — `string` +5. Владелец — `string` +6. Год выпуска — `int` +7. Объём двигателя (л) — `decimal` +8. Пробег (км) — `int` +9. Тип топлива — `string` +10. Стоимость (₽) — `decimal` + +## Правила генерации + +- Марка: случайно выбирается из фиксированного списка популярных производителей. +- Модель: случайная комплектация из набора суффиксов. +- Регистрационный номер: форматируется по схеме `А000АА 000` с кириллическими буквами. +- Владелец: полное имя (Name.FullName). +- Год выпуска: от 1984 до 2026. +- Объём двигателя: от 0.8 до 6.0 литра с округлением до 1 знака. +- Пробег: от 0 до 500 000 км. +- Тип топлива: выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ). +- Стоимость: от 100 000 до 10 000 000 ₽. From b6f3c708b8ae56dabd7981a913537b2174cc73e3 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Wed, 18 Mar 2026 13:00:58 +0400 Subject: [PATCH 2/9] commit because im disgrace --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c868a66a..ddced879 100644 --- a/README.md +++ b/README.md @@ -38,3 +38,4 @@ - Пробег: от 0 до 500 000 км. - Тип топлива: выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ). - Стоимость: от 100 000 до 10 000 000 ₽. + From 125caae0a533b06b50d44f82d1dc8974a41378f2 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Tue, 24 Mar 2026 12:06:24 +0400 Subject: [PATCH 3/9] corrections --- .../Controllers/VehicleController.cs | 7 ++- .../CachedVehicleGeneratorService.cs | 61 +++++++++++++++++++ .../VehicleGeneratorService/VehicleFaker.cs | 35 +++++++---- .../VehicleGeneratorService.cs | 60 ------------------ ProjectApp.AppHost/Program.cs | 1 - ProjectApp.Domain/Entities/Vehicle.cs | 19 ++++++ 6 files changed, 106 insertions(+), 77 deletions(-) create mode 100644 ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs diff --git a/ProjectApp.Api/Controllers/VehicleController.cs b/ProjectApp.Api/Controllers/VehicleController.cs index f8f950d0..9989ac47 100644 --- a/ProjectApp.Api/Controllers/VehicleController.cs +++ b/ProjectApp.Api/Controllers/VehicleController.cs @@ -7,7 +7,6 @@ namespace ProjectApp.Api.Controllers; /// /// Контроллер для генерации и получения характеристик транспортных средств /// - [Route("api/[controller]")] [ApiController] public class VehicleController(IVehicleGeneratorService vehicleService, ILogger logger) : ControllerBase @@ -15,9 +14,11 @@ public class VehicleController(IVehicleGeneratorService vehicleService, ILogger< /// /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору /// - /// Идентификатор машины + /// Идентификатор транспортного средства /// Токен отмены - [HttpGet("{id}")] + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(Vehicle), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken) { logger.LogInformation("Request received for vehicle id {Id}", id); diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs new file mode 100644 index 00000000..b1df1600 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs @@ -0,0 +1,61 @@ +using ProjectApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +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/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs index 1db9a5c0..70ffb82e 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -5,27 +5,36 @@ namespace ProjectApp.Api.Services.VehicleGeneratorService; public class VehicleFaker { + private static readonly string[] FuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; + + private static readonly string[] Brands = + [ + "Toyota", "BMW", "Mercedes", "Volkswagen", "Hyundai", + "Ford", "Kia", "Audi", "Nissan", "Renault" + ]; + + private static readonly string[] Trims = + [ + "Comfort", "Sport", "Elite", "Plus", "Pro", + "Active", "Max", "Base", "Premium", "Line" + ]; + + private const string RegistrationLetters = "АВЕКМНОРСТУХ"; + private readonly Faker _faker; public VehicleFaker() { - var fuelTypes = new[] { "Бензин", "Дизель", "Электро", "Гибрид", "Газ" }; - _faker = new Faker("ru") .RuleFor(v => v.Id, f => f.IndexFaker + 1) - .RuleFor(v => v.Brand, f => f.PickRandom( - "Toyota", "BMW", "Mercedes", "Volkswagen", "Hyundai", - "Ford", "Kia", "Audi", "Nissan", "Renault")) - .RuleFor(v => v.Model, (f, v) => f.PickRandom( - "Comfort", "Sport", "Elite", "Plus", "Pro", - "Active", "Max", "Base", "Premium", "Line")) + .RuleFor(v => v.Brand, f => f.PickRandom(Brands)) + .RuleFor(v => v.Model, f => f.PickRandom(Trims)) .RuleFor(v => v.RegistrationNumber, f => { - var letters = "АВЕКМНОРСТУХ"; - var letter1 = letters[f.Random.Int(0, letters.Length - 1)]; + var letter1 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; var digits = f.Random.Int(100, 999); - var letter2 = letters[f.Random.Int(0, letters.Length - 1)]; - var letter3 = letters[f.Random.Int(0, letters.Length - 1)]; + var letter2 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; + var letter3 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; var region = f.Random.Int(10, 199); return $"{letter1}{digits}{letter2}{letter3} {region}"; }) @@ -33,7 +42,7 @@ public VehicleFaker() .RuleFor(v => v.Year, f => f.Random.Int(1984, 2026)) .RuleFor(v => v.EngineVolume, f => Math.Round(f.Random.Decimal(0.8m, 6.0m), 1)) .RuleFor(v => v.Mileage, f => f.Random.Int(0, 500000)) - .RuleFor(v => v.FuelType, f => f.PickRandom(fuelTypes)) + .RuleFor(v => v.FuelType, f => f.PickRandom(FuelTypes)) .RuleFor(v => v.Price, f => Math.Round(f.Random.Decimal(100000m, 10000000m), 0)); } diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs index 311a49c9..645f7b49 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs @@ -1,6 +1,4 @@ using ProjectApp.Domain.Entities; -using Microsoft.Extensions.Caching.Distributed; -using System.Text.Json; namespace ProjectApp.Api.Services.VehicleGeneratorService; @@ -16,61 +14,3 @@ public Task FetchByIdAsync(int id, CancellationToken cancellationToken return Task.FromResult(vehicle); } } - -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) - { - // отлавливаем падение при недоступном Redis - 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.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 13bf1f02..f086b3e6 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -9,7 +9,6 @@ .WithEndpoint("http", e => e.Port = 5179); builder.AddProject("client") - .WithReference(api) .WaitFor(api); builder.Build().Run(); diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs index 18c773e8..67fa639d 100644 --- a/ProjectApp.Domain/Entities/Vehicle.cs +++ b/ProjectApp.Domain/Entities/Vehicle.cs @@ -5,14 +5,33 @@ namespace ProjectApp.Domain.Entities; /// public class Vehicle { + /// Уникальный идентификатор транспортного средства в системе public required int Id { get; set; } + + /// Марка (производитель) транспортного средства public required string Brand { get; set; } + + /// Модель (комплектация) транспортного средства public required string Model { get; set; } + + /// Государственный регистрационный номер public required string RegistrationNumber { get; set; } + + /// Полное имя владельца транспортного средства public required string OwnerName { get; set; } + + /// Год выпуска транспортного средства public int Year { get; set; } + + /// Объём двигателя в литрах public decimal EngineVolume { get; set; } + + /// Пробег в километрах public int Mileage { get; set; } + + /// Тип используемого топлива public required string FuelType { get; set; } + + /// Рыночная стоимость транспортного средства в рублях public decimal Price { get; set; } } From ff3b0e0a665e9e5728fbf884ed33c2a98b8631c5 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Wed, 25 Mar 2026 12:15:59 +0400 Subject: [PATCH 4/9] brand-model dictionary for more realistic data generation --- .../VehicleGeneratorService/VehicleFaker.cs | 45 +++++++++++-------- ProjectApp.Domain/Entities/Vehicle.cs | 2 +- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs index 70ffb82e..6aa39754 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -7,17 +7,24 @@ public class VehicleFaker { private static readonly string[] FuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; - private static readonly string[] Brands = - [ - "Toyota", "BMW", "Mercedes", "Volkswagen", "Hyundai", - "Ford", "Kia", "Audi", "Nissan", "Renault" - ]; - - private static readonly string[] Trims = - [ - "Comfort", "Sport", "Elite", "Plus", "Pro", - "Active", "Max", "Base", "Premium", "Line" - ]; + 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 const string RegistrationLetters = "АВЕКМНОРСТУХ"; @@ -27,23 +34,23 @@ public VehicleFaker() { _faker = new Faker("ru") .RuleFor(v => v.Id, f => f.IndexFaker + 1) - .RuleFor(v => v.Brand, f => f.PickRandom(Brands)) - .RuleFor(v => v.Model, f => f.PickRandom(Trims)) + .RuleFor(v => v.Brand, f => f.PickRandom(BrandModels.Keys.ToArray())) + .RuleFor(v => v.Model, (f, v) => f.PickRandom(BrandModels[v.Brand])) .RuleFor(v => v.RegistrationNumber, f => { - var letter1 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; + var l1 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; var digits = f.Random.Int(100, 999); - var letter2 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; - var letter3 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; + var l2 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; + var l3 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; var region = f.Random.Int(10, 199); - return $"{letter1}{digits}{letter2}{letter3} {region}"; + return $"{l1}{digits}{l2}{l3} {region}"; }) .RuleFor(v => v.OwnerName, f => f.Name.FullName()) .RuleFor(v => v.Year, f => f.Random.Int(1984, 2026)) .RuleFor(v => v.EngineVolume, f => Math.Round(f.Random.Decimal(0.8m, 6.0m), 1)) - .RuleFor(v => v.Mileage, f => f.Random.Int(0, 500000)) + .RuleFor(v => v.Mileage, f => f.Random.Int(0, 500_000)) .RuleFor(v => v.FuelType, f => f.PickRandom(FuelTypes)) - .RuleFor(v => v.Price, f => Math.Round(f.Random.Decimal(100000m, 10000000m), 0)); + .RuleFor(v => v.Price, f => Math.Round(f.Random.Decimal(100_000m, 10_000_000m), 0)); } public Vehicle Generate() => _faker.Generate(); diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs index 67fa639d..d8d74e85 100644 --- a/ProjectApp.Domain/Entities/Vehicle.cs +++ b/ProjectApp.Domain/Entities/Vehicle.cs @@ -11,7 +11,7 @@ public class Vehicle /// Марка (производитель) транспортного средства public required string Brand { get; set; } - /// Модель (комплектация) транспортного средства + /// Модель транспортного средства public required string Model { get; set; } /// Государственный регистрационный номер From bc7294b7ddaca0616e473a4a0ecef5201831afb7 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Sun, 29 Mar 2026 16:03:10 +0400 Subject: [PATCH 5/9] corrections 2 --- .../VehicleGeneratorService/VehicleFaker.cs | 70 +++++++++---------- ProjectApp.Domain/Entities/Vehicle.cs | 28 ++++---- README.md | 37 +++++----- 3 files changed, 67 insertions(+), 68 deletions(-) diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs index 6aa39754..55e9d193 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -5,52 +5,52 @@ namespace ProjectApp.Api.Services.VehicleGeneratorService; public class VehicleFaker { - private static readonly string[] FuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; + private static readonly string[] _fuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; - private static readonly Dictionary BrandModels = new() + 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"], + ["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 const string RegistrationLetters = "АВЕКМНОРСТУХ"; - 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.Brand, f => f.PickRandom(BrandModels.Keys.ToArray())) - .RuleFor(v => v.Model, (f, v) => f.PickRandom(BrandModels[v.Brand])) - .RuleFor(v => v.RegistrationNumber, f => + .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) => { - var l1 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; - var digits = f.Random.Int(100, 999); - var l2 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; - var l3 = RegistrationLetters[f.Random.Int(0, RegistrationLetters.Length - 1)]; - var region = f.Random.Int(10, 199); - return $"{l1}{digits}{l2}{l3} {region}"; - }) - .RuleFor(v => v.OwnerName, f => f.Name.FullName()) - .RuleFor(v => v.Year, f => f.Random.Int(1984, 2026)) - .RuleFor(v => v.EngineVolume, f => Math.Round(f.Random.Decimal(0.8m, 6.0m), 1)) - .RuleFor(v => v.Mileage, f => f.Random.Int(0, 500_000)) - .RuleFor(v => v.FuelType, f => f.PickRandom(FuelTypes)) - .RuleFor(v => v.Price, f => Math.Round(f.Random.Decimal(100_000m, 10_000_000m), 0)); + var minDate = new DateOnly(v.Year, 1, 1); + var maxDate = DateOnly.FromDateTime(DateTime.Now); + var range = maxDate.DayNumber - minDate.DayNumber; + return minDate.AddDays(f.Random.Int(0, range)); + }); } public Vehicle Generate() => _faker.Generate(); diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs index d8d74e85..250ec9a3 100644 --- a/ProjectApp.Domain/Entities/Vehicle.cs +++ b/ProjectApp.Domain/Entities/Vehicle.cs @@ -8,30 +8,30 @@ 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 required string RegistrationNumber { get; set; } - - /// Полное имя владельца транспортного средства - public required string OwnerName { get; set; } - /// Год выпуска транспортного средства public int Year { get; set; } - /// Объём двигателя в литрах - public decimal EngineVolume { get; set; } - - /// Пробег в километрах - public int Mileage { get; set; } + /// Тип корпуса (кузова) + public required string BodyType { get; set; } /// Тип используемого топлива public required string FuelType { get; set; } - /// Рыночная стоимость транспортного средства в рублях - public decimal Price { get; set; } + /// Цвет корпуса + public required string Color { get; set; } + + /// Пробег в километрах + public double Mileage { get; set; } + + /// Дата последнего техобслуживания + public DateOnly LastServiceDate { get; set; } } diff --git a/README.md b/README.md index ddced879..8497b667 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,24 @@ ## Характеристики генерируемого транспортного средства 1. Идентификатор в системе — `int` -2. Марка — `string` -3. Модель — `string` -4. Регистрационный номер — `string` -5. Владелец — `string` -6. Год выпуска — `int` -7. Объём двигателя (л) — `decimal` -8. Пробег (км) — `int` -9. Тип топлива — `string` -10. Стоимость (₽) — `decimal` +2. VIN-номер — `string` +3. Производитель — `string` +4. Модель — `string` +5. Год выпуска — `int` +6. Тип корпуса — `string` +7. Тип топлива — `string` +8. Цвет корпуса — `string` +9. Пробег — `double` +10. Дата последнего техобслуживания — `DateOnly` ## Правила генерации -- Марка: случайно выбирается из фиксированного списка популярных производителей. -- Модель: случайная комплектация из набора суффиксов. -- Регистрационный номер: форматируется по схеме `А000АА 000` с кириллическими буквами. -- Владелец: полное имя (Name.FullName). -- Год выпуска: от 1984 до 2026. -- Объём двигателя: от 0.8 до 6.0 литра с округлением до 1 знака. -- Пробег: от 0 до 500 000 км. -- Тип топлива: выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ). -- Стоимость: от 100 000 до 10 000 000 ₽. - +- VIN-номер: берётся из раздела Vehicle (Bogus). +- Производитель: случайно выбирается из фиксированного списка популярных марок. +- Модель: выбирается из набора моделей, соответствующих производителю. +- Год выпуска: от 1984 до текущего года включительно. +- Тип корпуса: берётся из раздела Vehicle (Bogus). +- Тип топлива: берётся из раздела Vehicle (Bogus); выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ). +- Цвет корпуса: берётся из раздела Commerce (Bogus). +- Пробег: от 0 до 500 000 км, не может быть меньше нуля. +- Дата последнего техобслуживания: не ранее 1 января года выпуска и не позже сегодняшней даты. From fa3147df3b742b7e749e477c3d860ff47f4ddf6c Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Sat, 4 Apr 2026 11:24:22 +0400 Subject: [PATCH 6/9] correction 3 --- .../Services/VehicleGeneratorService/VehicleFaker.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs index 55e9d193..14ba6a2e 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -45,12 +45,7 @@ public VehicleFaker() .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) => - { - var minDate = new DateOnly(v.Year, 1, 1); - var maxDate = DateOnly.FromDateTime(DateTime.Now); - var range = maxDate.DayNumber - minDate.DayNumber; - return minDate.AddDays(f.Random.Int(0, range)); - }); + f.Date.BetweenDateOnly(new DateOnly(v.Year, 1, 1), DateOnly.FromDateTime(DateTime.Now))); } public Vehicle Generate() => _faker.Generate(); From 1a380e951af1c026d5eae1e24f23823fdb33b8cd Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Sat, 4 Apr 2026 19:00:40 +0400 Subject: [PATCH 7/9] done lab 2 --- Client.Wasm/Components/StudentCard.razor | 2 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 8 ++- ProjectApp.Api/Program.cs | 13 +---- ProjectApp.Api/appsettings.json | 3 -- ProjectApp.AppHost/Program.cs | 19 +++++-- ProjectApp.AppHost/ProjectApp.AppHost.csproj | 1 + .../LoadBalancing/QueryBasedLoadBalancer.cs | 49 +++++++++++++++++++ .../QueryBasedLoadBalancerFactory.cs | 19 +++++++ .../LoadBalancing/ServicesAreEmptyError.cs | 5 ++ ProjectApp.Gateway/Program.cs | 37 ++++++++++++++ ProjectApp.Gateway/ProjectApp.Gateway.csproj | 23 +++++++++ ProjectApp.Gateway/ProjectApp.Gateway.http | 6 +++ .../Properties/launchSettings.json | 41 ++++++++++++++++ .../appsettings.Development.json | 8 +++ ProjectApp.Gateway/appsettings.json | 13 +++++ ProjectApp.Gateway/ocelot.json | 21 ++++++++ README.md | 22 +++++++++ 18 files changed, 270 insertions(+), 22 deletions(-) create mode 100644 ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs create mode 100644 ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs create mode 100644 ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs create mode 100644 ProjectApp.Gateway/Program.cs create mode 100644 ProjectApp.Gateway/ProjectApp.Gateway.csproj create mode 100644 ProjectApp.Gateway/ProjectApp.Gateway.http create mode 100644 ProjectApp.Gateway/Properties/launchSettings.json create mode 100644 ProjectApp.Gateway/appsettings.Development.json create mode 100644 ProjectApp.Gateway/appsettings.json create mode 100644 ProjectApp.Gateway/ocelot.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index faf6c115..5ab9ea84 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №1 "Кэширование" + Номер №2 "Балансировка нагрузки" Вариант №29 "Транспортное средство" Выполнена Нестеренко Андреем 6512 Ссылка на репозиторий diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index c95e6fd0..f1a09156 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "http://localhost:5179/" + "BaseAddress": "http://localhost:5200/" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 7e69b888..fca405c3 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.13.35931.197 MinimumVisualStudioVersion = 10.0.40219.1 @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults" 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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,10 @@ Global {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index 0b024e68..bffcbf0a 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -7,16 +7,7 @@ builder.AddRedisDistributedCache("cache"); -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.AddSingleton(); builder.Services.AddScoped(); @@ -59,7 +50,7 @@ { app.UseHttpsRedirection(); } -app.UseCors(); + app.MapControllers(); app.MapDefaultEndpoints(); diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json index 56fded7d..b642d7aa 100644 --- a/ProjectApp.Api/appsettings.json +++ b/ProjectApp.Api/appsettings.json @@ -6,9 +6,6 @@ } }, "AllowedHosts": "*", - "AllowedOrigins": [ - "http://localhost:5127" - ], "CacheSettings": { "ExpirationMinutes": 10 } diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index f086b3e6..835b66e0 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -3,12 +3,21 @@ var redis = builder.AddRedis("cache") .WithRedisCommander(); -var api = builder.AddProject("projectapp-api") - .WithReference(redis) - .WaitFor(redis) - .WithEndpoint("http", e => e.Port = 5179); +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) + .WithEndpoint("http", e => e.Port = 5180 + i) + .WithEndpoint("https", e => e.Port = 7170 + i); + + gateway = gateway.WithReference(api).WaitFor(api); +} builder.AddProject("client") - .WaitFor(api); + .WaitFor(gateway); builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj index 7704751d..e38d6aa8 100644 --- a/ProjectApp.AppHost/ProjectApp.AppHost.csproj +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -19,6 +19,7 @@ + diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..765f0539 --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs @@ -0,0 +1,49 @@ +using Ocelot.Configuration; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ProjectApp.Gateway.LoadBalancing; + +public class QueryBasedLoadBalancer(DownstreamRoute route) : ILoadBalancer +{ + private readonly List _hosts = route.DownstreamAddresses + .Select(a => new ServiceHostAndPort(a.Host, a.Port)) + .ToList(); + + public QueryBasedLoadBalancer(IEnumerable services, DownstreamRoute route) + : this(route) + { + } + + /// + /// Тип используемого балансировщика нагрузки. + /// + public string Type => nameof(QueryBasedLoadBalancer); + + 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); + + int 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/QueryBasedLoadBalancerFactory.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs new file mode 100644 index 00000000..38b2bd17 --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs @@ -0,0 +1,19 @@ +using Ocelot.Configuration; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.ServiceDiscovery.Providers; +using Ocelot.Values; + +namespace ProjectApp.Gateway.LoadBalancing; + +public class QueryBasedLoadBalancerFactory : ILoadBalancerFactory +{ + public const string LoadBalancerType = "QueryBased"; + + public Response Get( + DownstreamRoute route, + ServiceProviderConfiguration serviceProviderConfiguration) + { + return new OkResponse(new QueryBasedLoadBalancer(route)); + } +} 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/ProjectApp.Gateway.http b/ProjectApp.Gateway/ProjectApp.Gateway.http new file mode 100644 index 00000000..e4e587aa --- /dev/null +++ b/ProjectApp.Gateway/ProjectApp.Gateway.http @@ -0,0 +1,6 @@ +@ProjectApp.Gateway_HostAddress = http://localhost:5130 + +GET {{ProjectApp.Gateway_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..27317ec1 --- /dev/null +++ b/ProjectApp.Gateway/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$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": true, + "launchUrl": "weatherforecast", + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "weatherforecast", + "applicationUrl": "https://localhost:7286;http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "weatherforecast", + "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/README.md b/README.md index 8497b667..9f84b6b8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,28 @@ - Оркестрация сервисов через .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` From c825cec49fba6e46d44872fff7025059d942a080 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Tue, 14 Apr 2026 14:45:28 +0400 Subject: [PATCH 8/9] corrections 1 --- .../LoadBalancing/QueryBasedLoadBalancer.cs | 17 +++++++++++------ .../QueryBasedLoadBalancerFactory.cs | 19 ------------------- ProjectApp.Gateway/ProjectApp.Gateway.http | 6 ------ .../Properties/launchSettings.json | 11 ++++------- 4 files changed, 15 insertions(+), 38 deletions(-) delete mode 100644 ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs delete mode 100644 ProjectApp.Gateway/ProjectApp.Gateway.http diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs index 765f0539..ea8c48c0 100644 --- a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs +++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs @@ -5,22 +5,24 @@ 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 QueryBasedLoadBalancer(IEnumerable services, DownstreamRoute route) - : this(route) - { - } - /// /// Тип используемого балансировщика нагрузки. /// public string Type => nameof(QueryBasedLoadBalancer); + /// + /// Выбирает downstream-хост на основе идентификатора из запроса. + /// public Task> LeaseAsync(HttpContext httpContext) { if (_hosts.Count == 0) @@ -31,7 +33,7 @@ public Task> LeaseAsync(HttpContext httpContext) var idRaw = httpContext.Request.Query["id"].FirstOrDefault() ?? ExtractIdFromPath(httpContext.Request.Path); - int index = 0; + var index = 0; if (int.TryParse(idRaw, out var id)) index = Math.Abs(id) % _hosts.Count; @@ -39,6 +41,9 @@ public Task> LeaseAsync(HttpContext httpContext) new OkResponse(_hosts[index])); } + /// + /// Освобождает ранее выданный хост (балансировщик без состояния). + /// public void Release(ServiceHostAndPort hostAndPort) { } private static string? ExtractIdFromPath(PathString path) diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs deleted file mode 100644 index 38b2bd17..00000000 --- a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancerFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Ocelot.Configuration; -using Ocelot.LoadBalancer.LoadBalancers; -using Ocelot.Responses; -using Ocelot.ServiceDiscovery.Providers; -using Ocelot.Values; - -namespace ProjectApp.Gateway.LoadBalancing; - -public class QueryBasedLoadBalancerFactory : ILoadBalancerFactory -{ - public const string LoadBalancerType = "QueryBased"; - - public Response Get( - DownstreamRoute route, - ServiceProviderConfiguration serviceProviderConfiguration) - { - return new OkResponse(new QueryBasedLoadBalancer(route)); - } -} diff --git a/ProjectApp.Gateway/ProjectApp.Gateway.http b/ProjectApp.Gateway/ProjectApp.Gateway.http deleted file mode 100644 index e4e587aa..00000000 --- a/ProjectApp.Gateway/ProjectApp.Gateway.http +++ /dev/null @@ -1,6 +0,0 @@ -@ProjectApp.Gateway_HostAddress = http://localhost:5130 - -GET {{ProjectApp.Gateway_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json index 27317ec1..595cd1bd 100644 --- a/ProjectApp.Gateway/Properties/launchSettings.json +++ b/ProjectApp.Gateway/Properties/launchSettings.json @@ -1,4 +1,4 @@ -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, @@ -12,8 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchBrowser": false, "applicationUrl": "http://localhost:5130", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -22,8 +21,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchBrowser": false, "applicationUrl": "https://localhost:7286;http://localhost:5130", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -31,8 +29,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "weatherforecast", + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } From a3706f5c30bc153f566b3ad89aedd6d4b66f7931 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Sat, 18 Apr 2026 13:14:38 +0400 Subject: [PATCH 9/9] done lab 3 --- CloudDevelopment.sln | 12 ++ .../Controllers/VehicleController.cs | 22 ++- ProjectApp.Api/Program.cs | 20 ++- ProjectApp.Api/ProjectApp.Api.csproj | 1 + .../Services/SqsPublisher/ISqsPublisher.cs | 14 ++ .../Services/SqsPublisher/SqsPublisher.cs | 45 +++++ .../CachedVehicleGeneratorService.cs | 6 + .../VehicleGeneratorService/VehicleFaker.cs | 3 + .../VehicleGeneratorService.cs | 6 + ProjectApp.Api/appsettings.json | 4 + ProjectApp.AppHost/Program.cs | 15 ++ ProjectApp.AppHost/ProjectApp.AppHost.csproj | 1 + ProjectApp.FileService/Program.cs | 39 +++++ .../ProjectApp.FileService.csproj | 19 ++ .../ProjectApp.FileService.http | 6 + .../Properties/launchSettings.json | 41 +++++ .../Services/MinioStorageService.cs | 71 ++++++++ .../Services/SqsConsumerService.cs | 79 +++++++++ .../appsettings.Development.json | 8 + ProjectApp.FileService/appsettings.json | 19 ++ ProjectApp.Tests/Fixtures/ServiceFixture.cs | 129 ++++++++++++++ .../VehicleIntegrationTests.cs | 164 ++++++++++++++++++ ProjectApp.Tests/ProjectApp.Tests.csproj | 28 +++ README.md | 44 +++++ 24 files changed, 792 insertions(+), 4 deletions(-) create mode 100644 ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs create mode 100644 ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs create mode 100644 ProjectApp.FileService/Program.cs create mode 100644 ProjectApp.FileService/ProjectApp.FileService.csproj create mode 100644 ProjectApp.FileService/ProjectApp.FileService.http create mode 100644 ProjectApp.FileService/Properties/launchSettings.json create mode 100644 ProjectApp.FileService/Services/MinioStorageService.cs create mode 100644 ProjectApp.FileService/Services/SqsConsumerService.cs create mode 100644 ProjectApp.FileService/appsettings.Development.json create mode 100644 ProjectApp.FileService/appsettings.json create mode 100644 ProjectApp.Tests/Fixtures/ServiceFixture.cs create mode 100644 ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs create mode 100644 ProjectApp.Tests/ProjectApp.Tests.csproj diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index fca405c3..e1123f2c 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -14,6 +14,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "Proje 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 @@ -44,6 +48,14 @@ Global {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 index 9989ac47..f016da03 100644 --- a/ProjectApp.Api/Controllers/VehicleController.cs +++ b/ProjectApp.Api/Controllers/VehicleController.cs @@ -1,3 +1,4 @@ +using ProjectApp.Api.Services.SqsPublisher; using ProjectApp.Api.Services.VehicleGeneratorService; using ProjectApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; @@ -9,7 +10,10 @@ namespace ProjectApp.Api.Controllers; /// [Route("api/[controller]")] [ApiController] -public class VehicleController(IVehicleGeneratorService vehicleService, ILogger logger) : ControllerBase +public class VehicleController( + IVehicleGeneratorService vehicleService, + ISqsPublisher sqsPublisher, + ILogger logger) : ControllerBase { /// /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору @@ -18,13 +22,29 @@ public class VehicleController(IVehicleGeneratorService vehicleService, ILogger< /// Токен отмены [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 index bffcbf0a..23b0a91b 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -1,3 +1,5 @@ +using Amazon.SQS; +using ProjectApp.Api.Services.SqsPublisher; using ProjectApp.Api.Services.VehicleGeneratorService; using ProjectApp.ServiceDefaults; @@ -7,12 +9,22 @@ 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, +builder.Services.AddScoped(sp => + ActivatorUtilities.CreateInstance(sp, sp.GetRequiredService())); builder.Services.AddControllers(); @@ -54,4 +66,6 @@ app.MapControllers(); app.MapDefaultEndpoints(); -app.Run(); \ No newline at end of file +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 index 2aea6015..7f635d56 100644 --- a/ProjectApp.Api/ProjectApp.Api.csproj +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -10,6 +10,7 @@ + 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 index b1df1600..b1f5c1d9 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs @@ -4,6 +4,9 @@ namespace ProjectApp.Api.Services.VehicleGeneratorService; +/// +/// Декоратор над генератором транспортных средств с поддержкой кэширования через Redis +/// public class CachedVehicleGeneratorService( IVehicleGeneratorService innerService, IDistributedCache cache, @@ -12,6 +15,9 @@ public class CachedVehicleGeneratorService( { private readonly int _ttlMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + /// + /// Возвращает транспортное средство из кэша или генерирует новое и сохраняет в кэш + /// public async Task FetchByIdAsync(int id, CancellationToken cancellationToken = default) { var key = $"vehicle-{id}"; diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs index 14ba6a2e..82a79981 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -3,6 +3,9 @@ namespace ProjectApp.Api.Services.VehicleGeneratorService; +/// +/// Генератор тестовых данных транспортных средств на основе Bogus +/// public class VehicleFaker { private static readonly string[] _fuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs index 645f7b49..edc42a36 100644 --- a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs @@ -2,10 +2,16 @@ 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); diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json index b642d7aa..b5d5b64a 100644 --- a/ProjectApp.Api/appsettings.json +++ b/ProjectApp.Api/appsettings.json @@ -8,5 +8,9 @@ "AllowedHosts": "*", "CacheSettings": { "ExpirationMinutes": 10 + }, + "Sqs": { + "ServiceUrl": "http://localhost:9324", + "QueueName": "vehicle-queue" } } diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 835b66e0..53315a76 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -3,6 +3,20 @@ 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); @@ -11,6 +25,7 @@ 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); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj index e38d6aa8..a4ea87fa 100644 --- a/ProjectApp.AppHost/ProjectApp.AppHost.csproj +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -19,6 +19,7 @@ + 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.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 9f84b6b8..be345a99 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,47 @@ API-шлюз на основе Ocelot с кастомным алгоритмом - Цвет корпуса: берётся из раздела 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 после генерации |