From 1aadc5dc72ca2fb3de145d533b8959f6e7695309 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Wed, 18 Mar 2026 12:52:52 +0400 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 после генерации | From 16887c94ff54377cfee79bb92d76be9417d251cb Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Sun, 17 May 2026 23:02:50 +0400 Subject: [PATCH 10/11] corrections 1 --- .../Controllers/VehicleController.cs | 6 +- ProjectApp.Api/Program.cs | 16 ++---- ProjectApp.Api/ProjectApp.Api.csproj | 1 + .../ISqsProducer.cs} | 6 +- .../SqsProducer.cs} | 8 +-- ProjectApp.Api/appsettings.json | 13 ++++- ProjectApp.AppHost/Program.cs | 8 +-- ProjectApp.AppHost/ProjectApp.AppHost.csproj | 7 ++- ProjectApp.FileService/Program.cs | 29 ++++++---- .../ProjectApp.FileService.csproj | 1 + .../ProjectApp.FileService.http | 6 -- .../Services/MinioStorageService.cs | 14 ++++- .../Services/SqsConsumerService.cs | 14 ++--- ProjectApp.FileService/appsettings.json | 13 ++++- ProjectApp.Tests/Fixtures/ServiceFixture.cs | 10 +++- .../VehicleIntegrationTests.cs | 57 +++++++++++++++++++ ProjectApp.Tests/ProjectApp.Tests.csproj | 1 + 17 files changed, 148 insertions(+), 62 deletions(-) rename ProjectApp.Api/Services/{SqsPublisher/ISqsPublisher.cs => SqsProducer/ISqsProducer.cs} (60%) rename ProjectApp.Api/Services/{SqsPublisher/SqsPublisher.cs => SqsProducer/SqsProducer.cs} (84%) delete mode 100644 ProjectApp.FileService/ProjectApp.FileService.http diff --git a/ProjectApp.Api/Controllers/VehicleController.cs b/ProjectApp.Api/Controllers/VehicleController.cs index f016da03..a19c0cf6 100644 --- a/ProjectApp.Api/Controllers/VehicleController.cs +++ b/ProjectApp.Api/Controllers/VehicleController.cs @@ -1,4 +1,4 @@ -using ProjectApp.Api.Services.SqsPublisher; +using ProjectApp.Api.Services.SqsProducer; using ProjectApp.Api.Services.VehicleGeneratorService; using ProjectApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; @@ -12,7 +12,7 @@ namespace ProjectApp.Api.Controllers; [ApiController] public class VehicleController( IVehicleGeneratorService vehicleService, - ISqsPublisher sqsPublisher, + ISqsProducer sqsProducer, ILogger logger) : ControllerBase { /// @@ -38,7 +38,7 @@ public async Task> GetById([FromRoute] int id, Cancellatio try { - await sqsPublisher.SendVehicleAsync(vehicle, cancellationToken); + await sqsProducer.SendVehicleAsync(vehicle, cancellationToken); } catch (Exception ex) { diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index 23b0a91b..8cc11e93 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -1,5 +1,6 @@ using Amazon.SQS; -using ProjectApp.Api.Services.SqsPublisher; +using LocalStack.Client.Extensions; +using ProjectApp.Api.Services.SqsProducer; using ProjectApp.Api.Services.VehicleGeneratorService; using ProjectApp.ServiceDefaults; @@ -9,17 +10,10 @@ 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.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj index 7f635d56..eeb35d8e 100644 --- a/ProjectApp.Api/ProjectApp.Api.csproj +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs similarity index 60% rename from ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs rename to ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs index 80e17e8c..e1e8e724 100644 --- a/ProjectApp.Api/Services/SqsPublisher/ISqsPublisher.cs +++ b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs @@ -1,11 +1,11 @@ using ProjectApp.Domain.Entities; -namespace ProjectApp.Api.Services.SqsPublisher; +namespace ProjectApp.Api.Services.SqsProducer; /// -/// Сервис публикации данных транспортного средства в очередь SQS +/// Сервис отправки данных транспортного средства в очередь SQS /// -public interface ISqsPublisher +public interface ISqsProducer { /// /// Отправляет данные транспортного средства в очередь diff --git a/ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs similarity index 84% rename from ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs rename to ProjectApp.Api/Services/SqsProducer/SqsProducer.cs index c2587596..047b0ed7 100644 --- a/ProjectApp.Api/Services/SqsPublisher/SqsPublisher.cs +++ b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs @@ -3,15 +3,15 @@ using Amazon.SQS.Model; using ProjectApp.Domain.Entities; -namespace ProjectApp.Api.Services.SqsPublisher; +namespace ProjectApp.Api.Services.SqsProducer; /// -/// Реализация публикации данных транспортного средства в SQS +/// Реализация отправки данных транспортного средства в SQS /// -public class SqsPublisher( +public class SqsProducer( IAmazonSQS sqsClient, IConfiguration configuration, - ILogger logger) : ISqsPublisher + ILogger logger) : ISqsProducer { private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue"; private string? _queueUrl; diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json index b5d5b64a..407e8dbf 100644 --- a/ProjectApp.Api/appsettings.json +++ b/ProjectApp.Api/appsettings.json @@ -9,8 +9,19 @@ "CacheSettings": { "ExpirationMinutes": 10 }, + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "test", + "AwsAccessKeySecret": "test", + "RegionName": "us-east-1" + }, + "Config": { + "LocalStackHost": "localhost", + "EdgePort": 9324 + } + }, "Sqs": { - "ServiceUrl": "http://localhost:9324", "QueueName": "vehicle-queue" } } diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 53315a76..dca08b53 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -3,17 +3,13 @@ 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 minio = builder.AddMinioContainer("minio"); var sqs = builder.AddContainer("sqs", "softwaremill/elasticmq-native") .WithHttpEndpoint(port: 9324, targetPort: 9324, name: "sqs"); var fileService = builder.AddProject("projectapp-fileservice") + .WithReference(minio) .WaitFor(minio) .WaitFor(sqs); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj index a4ea87fa..f5cc2982 100644 --- a/ProjectApp.AppHost/ProjectApp.AppHost.csproj +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -12,8 +12,9 @@ - - + + + diff --git a/ProjectApp.FileService/Program.cs b/ProjectApp.FileService/Program.cs index e8c31196..32cb739b 100644 --- a/ProjectApp.FileService/Program.cs +++ b/ProjectApp.FileService/Program.cs @@ -1,4 +1,5 @@ using Amazon.SQS; +using LocalStack.Client.Extensions; using Minio; using ProjectApp.FileService.Services; using ProjectApp.ServiceDefaults; @@ -7,21 +8,29 @@ 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.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); builder.Services.AddSingleton(sp => { var configuration = sp.GetRequiredService(); + + // When run via Aspire, WithReference(minio) sets ConnectionStrings:minio = "http://host:port" + // For local run without Aspire, fall back to Minio:Endpoint setting + string minioEndpoint; + var connectionString = configuration.GetConnectionString("minio"); + if (connectionString is not null) + { + var uri = new Uri(connectionString); + minioEndpoint = $"{uri.Host}:{uri.Port}"; + } + else + { + minioEndpoint = configuration["Minio:Endpoint"] ?? "localhost:9000"; + } + return new MinioClient() - .WithEndpoint(configuration["Minio:Endpoint"] ?? "localhost:9000") + .WithEndpoint(minioEndpoint) .WithCredentials( configuration["Minio:AccessKey"] ?? "minioadmin", configuration["Minio:SecretKey"] ?? "minioadmin") diff --git a/ProjectApp.FileService/ProjectApp.FileService.csproj b/ProjectApp.FileService/ProjectApp.FileService.csproj index 6b7abf20..1b024de7 100644 --- a/ProjectApp.FileService/ProjectApp.FileService.csproj +++ b/ProjectApp.FileService/ProjectApp.FileService.csproj @@ -8,6 +8,7 @@ + diff --git a/ProjectApp.FileService/ProjectApp.FileService.http b/ProjectApp.FileService/ProjectApp.FileService.http deleted file mode 100644 index 720619f3..00000000 --- a/ProjectApp.FileService/ProjectApp.FileService.http +++ /dev/null @@ -1,6 +0,0 @@ -@ProjectApp.FileService_HostAddress = http://localhost:5166 - -GET {{ProjectApp.FileService_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/ProjectApp.FileService/Services/MinioStorageService.cs b/ProjectApp.FileService/Services/MinioStorageService.cs index 387d88fc..62df2e71 100644 --- a/ProjectApp.FileService/Services/MinioStorageService.cs +++ b/ProjectApp.FileService/Services/MinioStorageService.cs @@ -38,9 +38,17 @@ await minioClient.MakeBucketAsync( public async Task SaveVehicleAsync(Vehicle vehicle, CancellationToken ct = default) { var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { WriteIndented = true }); - var bytes = Encoding.UTF8.GetBytes(json); + await SaveRawAsync(vehicle.Id, json, ct); + } + + /// + /// Сохраняет сырой JSON напрямую в MinIO без повторной сериализации + /// + public async Task SaveRawAsync(int id, string rawJson, CancellationToken ct = default) + { + var bytes = Encoding.UTF8.GetBytes(rawJson); using var stream = new MemoryStream(bytes); - var objectName = $"vehicle-{vehicle.Id}.json"; + var objectName = $"vehicle-{id}.json"; await minioClient.PutObjectAsync(new PutObjectArgs() .WithBucket(_bucketName) @@ -49,7 +57,7 @@ await minioClient.PutObjectAsync(new PutObjectArgs() .WithObjectSize(stream.Length) .WithContentType("application/json"), ct); - logger.LogInformation("Vehicle {Id} saved as {Object}", vehicle.Id, objectName); + logger.LogInformation("Vehicle {Id} saved as {Object}", id, objectName); } /// diff --git a/ProjectApp.FileService/Services/SqsConsumerService.cs b/ProjectApp.FileService/Services/SqsConsumerService.cs index 2dec8488..cfe2ba4b 100644 --- a/ProjectApp.FileService/Services/SqsConsumerService.cs +++ b/ProjectApp.FileService/Services/SqsConsumerService.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Amazon.SQS; using Amazon.SQS.Model; -using ProjectApp.Domain.Entities; namespace ProjectApp.FileService.Services; @@ -29,7 +28,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { QueueUrl = _queueUrl, MaxNumberOfMessages = 10, - WaitTimeSeconds = 5 + WaitTimeSeconds = 1 }, stoppingToken); foreach (var message in response.Messages) @@ -62,12 +61,11 @@ 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); - } + using var doc = JsonDocument.Parse(message.Body); + var id = doc.RootElement.GetProperty("Id").GetInt32(); + + await storageService.SaveRawAsync(id, message.Body, ct); + logger.LogInformation("Vehicle {Id} saved to MinIO from SQS message {MessageId}", id, message.MessageId); await sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); } diff --git a/ProjectApp.FileService/appsettings.json b/ProjectApp.FileService/appsettings.json index e220b82f..18af9f67 100644 --- a/ProjectApp.FileService/appsettings.json +++ b/ProjectApp.FileService/appsettings.json @@ -6,8 +6,19 @@ } }, "AllowedHosts": "*", + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "test", + "AwsAccessKeySecret": "test", + "RegionName": "us-east-1" + }, + "Config": { + "LocalStackHost": "localhost", + "EdgePort": 9324 + } + }, "Sqs": { - "ServiceUrl": "http://localhost:9324", "QueueName": "vehicle-queue" }, "Minio": { diff --git a/ProjectApp.Tests/Fixtures/ServiceFixture.cs b/ProjectApp.Tests/Fixtures/ServiceFixture.cs index 75755873..94d29827 100644 --- a/ProjectApp.Tests/Fixtures/ServiceFixture.cs +++ b/ProjectApp.Tests/Fixtures/ServiceFixture.cs @@ -1,7 +1,6 @@ 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; @@ -71,7 +70,7 @@ await Task.WhenAll( RedisConnection = await ConnectionMultiplexer.ConnectAsync(RedisConnectionString); var redisConnStr = RedisConnectionString; - var sqsUrl = SqsServiceUrl; + var sqsPort = _elasticMq.GetMappedPublicPort(9324); ApiFactory = new WebApplicationFactory() .WithWebHostBuilder(builder => @@ -81,7 +80,12 @@ await Task.WhenAll( config.AddInMemoryCollection(new Dictionary { ["ConnectionStrings:cache"] = redisConnStr, - ["Sqs:ServiceUrl"] = sqsUrl, + ["LocalStack:UseLocalStack"] = "true", + ["LocalStack:Session:AwsAccessKeyId"] = "test", + ["LocalStack:Session:AwsAccessKeySecret"] = "test", + ["LocalStack:Session:RegionName"] = "us-east-1", + ["LocalStack:Config:LocalStackHost"] = "localhost", + ["LocalStack:Config:EdgePort"] = sqsPort.ToString(), ["Sqs:QueueName"] = "vehicle-queue" }); }); diff --git a/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs index 58583372..7250ac11 100644 --- a/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs +++ b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs @@ -3,10 +3,13 @@ using System.Text.Json; using Amazon.SQS.Model; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Minio; using Minio.DataModel.Args; using ProjectApp.Domain.Entities; +using ProjectApp.FileService.Services; using ProjectApp.Tests.Fixtures; using Xunit; @@ -161,4 +164,58 @@ public async Task GetVehicle_WithInvalidId_ReturnsBadRequest() Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode); } + + /// + /// Проверяет, что объект из API совпадает с тем, что сохранено в объектном хранилище. + /// + [Fact] + public async Task EndToEnd_VehicleFlow_ApiResponseMatchesMinioStorage() + { + const int vehicleId = 88; + + var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Sqs:ServiceUrl"] = _fixture.SqsServiceUrl, + ["Sqs:QueueName"] = "vehicle-queue", + ["Minio:BucketName"] = "vehicles" + }) + .Build(); + + var minioStorage = new MinioStorageService( + _fixture.MinioClient, + config, + loggerFactory.CreateLogger()); + + await minioStorage.EnsureBucketExistsAsync(); + + var consumer = new SqsConsumerService( + _fixture.SqsClient, + minioStorage, + config, + loggerFactory.CreateLogger()); + + using var cts = new CancellationTokenSource(); + _ = consumer.StartAsync(cts.Token); + + var apiVehicle = await _httpClient.GetFromJsonAsync($"/api/vehicle/{vehicleId}"); + + await Task.Delay(4000); + + var savedVehicle = await minioStorage.GetVehicleAsync(vehicleId, CancellationToken.None); + + await cts.CancelAsync(); + await consumer.StopAsync(CancellationToken.None); + await Task.Delay(500); + + Assert.NotNull(apiVehicle); + Assert.NotNull(savedVehicle); + Assert.Equal(apiVehicle.Id, savedVehicle.Id); + Assert.Equal(apiVehicle.Vin, savedVehicle.Vin); + Assert.Equal(apiVehicle.Brand, savedVehicle.Brand); + Assert.Equal(apiVehicle.Model, savedVehicle.Model); + Assert.Equal(apiVehicle.Year, savedVehicle.Year); + } } diff --git a/ProjectApp.Tests/ProjectApp.Tests.csproj b/ProjectApp.Tests/ProjectApp.Tests.csproj index c3406106..71f58620 100644 --- a/ProjectApp.Tests/ProjectApp.Tests.csproj +++ b/ProjectApp.Tests/ProjectApp.Tests.csproj @@ -23,6 +23,7 @@ + From d22b2e60b7c6e83d3e88f391ae25caff3b949c15 Mon Sep 17 00:00:00 2001 From: MagGoldi Date: Thu, 21 May 2026 20:05:37 +0400 Subject: [PATCH 11/11] done fck lab4 --- .gitignore | 10 + Client.Wasm/Components/StudentCard.razor | 6 +- CloudDevelopment.sln | 14 +- ProjectApp.Api.Function/Handler.cs | 145 ++++++++ .../Models/FunctionModels.cs | 42 +++ ProjectApp.Api.Function/ModuleInit.cs | 42 +++ ProjectApp.Api.Function/Program.cs | 6 + .../ProjectApp.Api.Function.csproj | 15 + .../Services/VehicleFaker.cs | 1 + ProjectApp.Api.Function/VehicleProcessor.cs | 131 +++++++ ProjectApp.FileService.Function/Handler.cs | 129 +++++++ .../Models/QueueTriggerEvent.cs | 54 +++ .../ProjectApp.FileService.Function.csproj | 15 + README.md | 59 +++ cloud/api-gateway.yaml | 62 ++++ cloud/deploy.sh | 341 ++++++++++++++++++ cloud/env.example.sh | 31 ++ cloud/env.sh | 10 + 18 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 ProjectApp.Api.Function/Handler.cs create mode 100644 ProjectApp.Api.Function/Models/FunctionModels.cs create mode 100644 ProjectApp.Api.Function/ModuleInit.cs create mode 100644 ProjectApp.Api.Function/Program.cs create mode 100644 ProjectApp.Api.Function/ProjectApp.Api.Function.csproj create mode 100644 ProjectApp.Api.Function/Services/VehicleFaker.cs create mode 100644 ProjectApp.Api.Function/VehicleProcessor.cs create mode 100644 ProjectApp.FileService.Function/Handler.cs create mode 100644 ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs create mode 100644 ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj create mode 100644 cloud/api-gateway.yaml create mode 100755 cloud/deploy.sh create mode 100644 cloud/env.example.sh create mode 100644 cloud/env.sh diff --git a/.gitignore b/.gitignore index ce892922..f272ea46 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,13 @@ FodyWeavers.xsd *.msix *.msm *.msp + +vehicle-publish/ +publish/ +fileservice-publish/ + +*.zip + +# Yandex Cloud — артефакты сборки и деплоя +cloud/build/ +Client.Wasm/wwwroot/appsettings.Production.json \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 5ab9ea84..1598d77b 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,12 +1,12 @@ - Лабораторная работа + Лабораторная работа - Номер №2 "Балансировка нагрузки" + Номер №4 "Переход на облачную инфраструктуру" Вариант №29 "Транспортное средство" - Выполнена Нестеренко Андреем 6512 + Выполнена Нестеренко Андреем 6512 Ссылка на репозиторий diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index e1123f2c..fc0e6a1f 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 @@ -18,6 +18,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService", "P EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Tests", "ProjectApp.Tests\ProjectApp.Tests.csproj", "{38936714-96E7-4B0C-904A-0B01D8E96B2D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Api.Function", "ProjectApp.Api.Function\ProjectApp.Api.Function.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService.Function", "ProjectApp.FileService.Function\ProjectApp.FileService.Function.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -56,6 +60,14 @@ Global {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 + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api.Function/Handler.cs b/ProjectApp.Api.Function/Handler.cs new file mode 100644 index 00000000..fcf1c478 --- /dev/null +++ b/ProjectApp.Api.Function/Handler.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Runtime; +using Amazon.SQS; +using Amazon.SQS.Model; + +namespace ApiFunction; + +/// +/// Cloud Function генерации транспортного средства по идентификатору. +/// При наличии кэша в Object Storage возвращает сохранённые данные; +/// иначе генерирует новые и публикует их в Message Queue. +/// +public class Handler +{ + private static readonly Random _rng = Random.Shared; + private static readonly HttpClient _http = new(); + + private static readonly string[] _brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"]; + private static readonly string[] _models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"]; + private static readonly string[] _bodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"]; + private static readonly string[] _fuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"]; + private static readonly string[] _colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"]; + + private static readonly JsonSerializerOptions _camelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + /// + /// Точка входа Cloud Function. Принимает HTTP-запрос от API Gateway, + /// возвращает JSON транспортного средства с CORS-заголовками. + /// + /// Сериализованный запрос от API Gateway + public string FunctionHandler(string input) + { + FunctionRequest? req = null; + try { req = JsonSerializer.Deserialize(input ?? "{}"); } catch { } + + string? idRaw = null; + req?.PathParams?.TryGetValue("id", out idRaw); + if (string.IsNullOrEmpty(idRaw)) + { + var url = req?.Url ?? req?.Path ?? ""; + idRaw = url.Split('?')[0].Split('/', StringSplitOptions.RemoveEmptyEntries) + .LastOrDefault(p => !p.StartsWith('{')); + } + + if (!int.TryParse(idRaw, out var id) || id <= 0) + return Envelope(400, """{"error":"Identifier must be a positive number."}"""); + + var cached = TryGetCachedAsync(id).GetAwaiter().GetResult(); + if (cached != null) + return Envelope(200, cached); + + var vehicle = new + { + id, + vin = GenerateVin(), + brand = _brands[_rng.Next(_brands.Length)], + model = _models[_rng.Next(_models.Length)], + year = _rng.Next(2010, 2025), + bodyType = _bodyTypes[_rng.Next(_bodyTypes.Length)], + fuelType = _fuelTypes[_rng.Next(_fuelTypes.Length)], + color = _colors[_rng.Next(_colors.Length)], + mileage = Math.Round(_rng.NextDouble() * 200000, 1), + lastServiceDate = DateTime.UtcNow.AddDays(-_rng.Next(30, 730)).ToString("yyyy-MM-dd") + }; + + var json = JsonSerializer.Serialize(vehicle, _camelCase); + + Task.Run(() => TryPublishAsync(json)); + + return Envelope(200, json); + } + + private static string Envelope(int status, string body) => + JsonSerializer.Serialize(new + { + statusCode = status, + headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" + }, + body + }); + + private static string GenerateVin() + { + const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789"; + return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[_rng.Next(chars.Length)])); + } + + private static async Task TryGetCachedAsync(int id) + { + try + { + var bucket = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicle-data-store"; + var url = $"https://storage.yandexcloud.net/{bucket}/vehicle-{id}.json"; + var resp = await _http.GetAsync(url); + if (resp.IsSuccessStatusCode) + return await resp.Content.ReadAsStringAsync(); + } + catch { } + return null; + } + + private static async Task TryPublishAsync(string messageBody) + { + try + { + var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? ""; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return; + + var credentials = new BasicAWSCredentials(accessKey, secretKey); + var config = new AmazonSQSConfig + { + ServiceURL = "https://message-queue.api.cloud.yandex.net", + AuthenticationRegion = "ru-central1" + }; + using var sqs = new AmazonSQSClient(credentials, config); + await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = messageBody }); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}"); + } + } +} + +/// +/// Модель входящего запроса от Yandex API Gateway. +/// +public class FunctionRequest +{ + [JsonPropertyName("httpMethod")] public string HttpMethod { get; set; } = ""; + [JsonPropertyName("headers")] public Dictionary? Headers { get; set; } + [JsonPropertyName("path")] public string? Path { get; set; } + [JsonPropertyName("queryStringParameters")] public Dictionary? QueryStringParameters { get; set; } + [JsonPropertyName("pathParameters")] public Dictionary? PathParameters { get; set; } + [JsonPropertyName("pathParams")] public Dictionary? PathParams { get; set; } + [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("body")] public string? Body { get; set; } + [JsonPropertyName("isBase64Encoded")] public bool IsBase64Encoded { get; set; } +} diff --git a/ProjectApp.Api.Function/Models/FunctionModels.cs b/ProjectApp.Api.Function/Models/FunctionModels.cs new file mode 100644 index 00000000..c8a8ce2e --- /dev/null +++ b/ProjectApp.Api.Function/Models/FunctionModels.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace ApiFunction.Models; + +public class FunctionRequest +{ + [JsonPropertyName("httpMethod")] + public string HttpMethod { get; set; } = ""; + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("queryStringParameters")] + public Dictionary? QueryStringParameters { get; set; } + + [JsonPropertyName("pathParameters")] + public Dictionary? PathParameters { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("isBase64Encoded")] + public bool IsBase64Encoded { get; set; } +} + +public class FunctionResponse +{ + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("body")] + public string Body { get; set; } = ""; + + [JsonPropertyName("isBase64Encoded")] + public bool IsBase64Encoded { get; set; } +} diff --git a/ProjectApp.Api.Function/ModuleInit.cs b/ProjectApp.Api.Function/ModuleInit.cs new file mode 100644 index 00000000..d3090060 --- /dev/null +++ b/ProjectApp.Api.Function/ModuleInit.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace ApiFunction; + +/// +/// Инициализатор модуля — регистрирует обработчик AssemblyResolve до обращения к любому классу. +/// +public static class ModuleInit +{ + [ModuleInitializer] + public static void Initialize() + { + AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly; + } + + private static Assembly? ResolveAssembly(object? sender, ResolveEventArgs args) + { + try + { + var name = new AssemblyName(args.Name).Name; + if (string.IsNullOrEmpty(name)) return null; + + var assemblyDir = Path.GetDirectoryName(typeof(ModuleInit).Assembly.Location) ?? ""; + var dllPath = Path.Combine(assemblyDir, name + ".dll"); + + if (File.Exists(dllPath)) + { + Console.WriteLine($"[ModuleInit] Resolved {name} from {dllPath}"); + return Assembly.LoadFrom(dllPath); + } + + Console.WriteLine($"[ModuleInit] Could not resolve {name} (looked in {assemblyDir})"); + } + catch (Exception ex) + { + Console.WriteLine($"[ModuleInit] ResolveAssembly error: {ex.Message}"); + } + + return null; + } +} diff --git a/ProjectApp.Api.Function/Program.cs b/ProjectApp.Api.Function/Program.cs new file mode 100644 index 00000000..4a882f6d --- /dev/null +++ b/ProjectApp.Api.Function/Program.cs @@ -0,0 +1,6 @@ +namespace ApiFunction; + +public partial class Program +{ + public static void Main(string[] args) { } +} diff --git a/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj new file mode 100644 index 00000000..4acedef0 --- /dev/null +++ b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + ApiFunction + ApiFunction + + + + + + + diff --git a/ProjectApp.Api.Function/Services/VehicleFaker.cs b/ProjectApp.Api.Function/Services/VehicleFaker.cs new file mode 100644 index 00000000..eba87331 --- /dev/null +++ b/ProjectApp.Api.Function/Services/VehicleFaker.cs @@ -0,0 +1 @@ +namespace ApiFunction.Services; diff --git a/ProjectApp.Api.Function/VehicleProcessor.cs b/ProjectApp.Api.Function/VehicleProcessor.cs new file mode 100644 index 00000000..a503dac3 --- /dev/null +++ b/ProjectApp.Api.Function/VehicleProcessor.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ApiFunction.Models; + +namespace ApiFunction; + +/// +/// Бизнес-логика генерации и публикации транспортного средства. +/// +internal static class VehicleProcessor +{ + private static readonly Random Rng = Random.Shared; + private static readonly HttpClient Http = new(); + + private static readonly string[] Brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"]; + private static readonly string[] Models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"]; + private static readonly string[] BodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"]; + private static readonly string[] FuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"]; + private static readonly string[] Colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"]; + + public static async Task ProcessAsync(int id) + { + var vehicle = GenerateVehicle(id); + var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + _ = TryPublishToQueueAsync(json); + + return new FunctionResponse + { + StatusCode = 200, + Headers = new() + { + ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" + }, + Body = json + }; + } + + private static object GenerateVehicle(int id) => new + { + id, + vin = GenerateVin(), + brand = Brands[Rng.Next(Brands.Length)], + model = Models[Rng.Next(Models.Length)], + year = Rng.Next(2010, 2025), + bodyType = BodyTypes[Rng.Next(BodyTypes.Length)], + fuelType = FuelTypes[Rng.Next(FuelTypes.Length)], + color = Colors[Rng.Next(Colors.Length)], + mileage = Math.Round(Rng.NextDouble() * 200000, 1), + lastServiceDate = DateTime.UtcNow.AddDays(-Rng.Next(30, 730)).ToString("yyyy-MM-dd") + }; + + private static string GenerateVin() + { + const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789"; + return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[Rng.Next(chars.Length)])); + } + + private static async Task TryPublishToQueueAsync(string messageBody) + { + try + { + var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? ""; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return; + + var uri = new Uri(queueUrl); + var endpoint = $"{uri.Scheme}://{uri.Host}"; + var path = uri.AbsolutePath; + + var body = $"Action=SendMessage&MessageBody={Uri.EscapeDataString(messageBody)}&Version=2012-11-05"; + var now = DateTime.UtcNow; + var dateStamp = now.ToString("yyyyMMdd"); + var amzDate = now.ToString("yyyyMMddTHHmmssZ"); + + var payloadHash = Sha256Hash(body); + var headers = $"content-type:application/x-www-form-urlencoded\nhost:{uri.Host}\nx-amz-date:{amzDate}\n"; + var signedHeaders = "content-type;host;x-amz-date"; + var canonicalRequest = $"POST\n{path}\n\n{headers}\n{signedHeaders}\n{payloadHash}"; + + var region = "ru-central1"; + var service = "sqs"; + var credentialScope = $"{dateStamp}/{region}/{service}/aws4_request"; + var stringToSign = $"AWS4-HMAC-SHA256\n{amzDate}\n{credentialScope}\n{Sha256Hash(canonicalRequest)}"; + + var signingKey = GetSigningKey(secretKey, dateStamp, region, service); + var signature = HmacSha256Hex(signingKey, stringToSign); + + var authHeader = $"AWS4-HMAC-SHA256 Credential={accessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}"; + + var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}{path}") + { + Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded") + }; + request.Headers.TryAddWithoutValidation("Authorization", authHeader); + request.Headers.TryAddWithoutValidation("x-amz-date", amzDate); + + var resp = await Http.SendAsync(request); + if (!resp.IsSuccessStatusCode) + Console.WriteLine($"[WARN] SQS returned {resp.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}"); + } + } + + private static string Sha256Hash(string data) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(data)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static byte[] HmacSha256(byte[] key, string data) + => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data)); + + private static string HmacSha256Hex(byte[] key, string data) + => Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant(); + + private static byte[] GetSigningKey(string secret, string date, string region, string service) + { + var kSecret = Encoding.UTF8.GetBytes("AWS4" + secret); + var kDate = HmacSha256(kSecret, date); + var kRegion = HmacSha256(kDate, region); + var kService = HmacSha256(kRegion, service); + return HmacSha256(kService, "aws4_request"); + } +} diff --git a/ProjectApp.FileService.Function/Handler.cs b/ProjectApp.FileService.Function/Handler.cs new file mode 100644 index 00000000..60f6f7e3 --- /dev/null +++ b/ProjectApp.FileService.Function/Handler.cs @@ -0,0 +1,129 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace FileServiceFunction; + +/// +/// Cloud Function файлового сервиса. Получает пакет сообщений из Yandex Message Queue +/// и сохраняет данные каждого транспортного средства в Yandex Object Storage. +/// +public class Handler +{ + private readonly IAmazonS3 _s3Client; + private readonly string _bucketName; + + public Handler() + { + var endpoint = Environment.GetEnvironmentVariable("S3_ENDPOINT") + ?? "https://storage.yandexcloud.net"; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + _bucketName = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicles-storage"; + + var credentials = new BasicAWSCredentials(accessKey, secretKey); + var config = new AmazonS3Config + { + ServiceURL = endpoint, + ForcePathStyle = true + }; + _s3Client = new AmazonS3Client(credentials, config); + } + + /// + /// Точка входа Cloud Function. Обрабатывает пакет сообщений из очереди + /// и сохраняет каждое транспортное средство как JSON-файл в Object Storage. + /// + /// Сериализованное событие триггера Message Queue + public string FunctionHandler(string input) + { + QueueTriggerEvent? trigger = null; + try { trigger = JsonSerializer.Deserialize(input ?? "{}"); } catch { } + + var messages = trigger?.Messages ?? []; + foreach (var message in messages) + { + try { ProcessMessage(message); } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] {message.Details?.Message?.MessageId}: {ex.Message}"); + } + } + + return "{}"; + } + + private void ProcessMessage(QueueMessage message) + { + var rawBody = message.Details?.Message?.Body ?? ""; + + string json; + try + { + var decoded = Convert.FromBase64String(rawBody); + json = Encoding.UTF8.GetString(decoded); + } + catch + { + json = rawBody; + } + + using var doc = JsonDocument.Parse(json); + var idProp = doc.RootElement.TryGetProperty("id", out var idEl) ? idEl + : doc.RootElement.TryGetProperty("Id", out var idEl2) ? idEl2 : default; + var id = idProp.ValueKind == JsonValueKind.Number ? idProp.GetInt32() : 0; + var objectName = $"vehicle-{id}.json"; + + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + + _s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = _bucketName, + Key = objectName, + InputStream = stream, + ContentType = "application/json" + }).GetAwaiter().GetResult(); + + Console.WriteLine($"[INFO] Saved {objectName}"); + } +} + +public class QueueTriggerEvent +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public class QueueMessage +{ + [JsonPropertyName("event_metadata")] + public EventMetadata? EventMetadata { get; set; } + + [JsonPropertyName("details")] + public MessageDetails? Details { get; set; } +} + +public class EventMetadata +{ + [JsonPropertyName("event_id")] public string EventId { get; set; } = ""; + [JsonPropertyName("event_type")] public string EventType { get; set; } = ""; + [JsonPropertyName("created_at")] public string CreatedAt { get; set; } = ""; +} + +public class MessageDetails +{ + [JsonPropertyName("queue_id")] public string QueueId { get; set; } = ""; + [JsonPropertyName("message")] public SqsMessage? Message { get; set; } +} + +public class SqsMessage +{ + [JsonPropertyName("message_id")] public string MessageId { get; set; } = ""; + [JsonPropertyName("md5_of_body")] public string Md5OfBody { get; set; } = ""; + [JsonPropertyName("body")] public string Body { get; set; } = ""; + [JsonPropertyName("attributes")] public Dictionary? Attributes { get; set; } +} diff --git a/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs new file mode 100644 index 00000000..60e065d7 --- /dev/null +++ b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace FileServiceFunction.Models; + +public class QueueTriggerEvent +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public class QueueMessage +{ + [JsonPropertyName("event_metadata")] + public EventMetadata EventMetadata { get; set; } = new(); + + [JsonPropertyName("details")] + public MessageDetails Details { get; set; } = new(); +} + +public class EventMetadata +{ + [JsonPropertyName("event_id")] + public string EventId { get; set; } = ""; + + [JsonPropertyName("event_type")] + public string EventType { get; set; } = ""; + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = ""; +} + +public class MessageDetails +{ + [JsonPropertyName("queue_id")] + public string QueueId { get; set; } = ""; + + [JsonPropertyName("message")] + public SqsMessage Message { get; set; } = new(); +} + +public class SqsMessage +{ + [JsonPropertyName("message_id")] + public string MessageId { get; set; } = ""; + + [JsonPropertyName("md5_of_body")] + public string Md5OfBody { get; set; } = ""; + + [JsonPropertyName("body")] + public string Body { get; set; } = ""; + + [JsonPropertyName("attributes")] + public Dictionary? Attributes { get; set; } +} diff --git a/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj new file mode 100644 index 00000000..86e37129 --- /dev/null +++ b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + FileServiceFunction + FileServiceFunction + + + + + + + diff --git a/README.md b/README.md index be345a99..ae07a11f 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,62 @@ API-шлюз на основе Ocelot с кастомным алгоритмом | `GetVehicle_WithInvalidId_ReturnsBadRequest` | Запрос с невалидным ID (≤ 0) возвращает 400 Bad Request | | `MinioStorage_SavesAndRetrievesFile` | Запись и чтение JSON-файла из MinIO | | `Redis_CachesVehicleData` | Данные сохраняются в Redis после генерации | + +# Лабораторная работа №4 — «Переход на облачную инфраструктуру» + +## Описание + +Все сервисы перенесены в Yandex Cloud. Локальная инфраструктура (Aspire, Docker-контейнеры) заменена облачными managed-сервисами. Клиентское приложение размещено в Object Storage, сервисы генерации и обработки файлов развёрнуты как Cloud Functions, маршрутизация настроена через Serverless API Gateway, очередь сообщений перенесена в Yandex Message Queue, а объектное хранилище файлов — в отдельный бакет Object Storage. + +## Что реализовано + +### Клиент (`Client.Wasm`) → Object Storage +- Blazor WebAssembly собирается в Release с конфигурацией `appsettings.Production.json` +- Статические файлы загружаются в бакет `vehicles-client` +- Включён режим статического сайта с `index.html` по умолчанию +- `BaseAddress` клиента указывает на URL API Gateway + +### Сервис генерации (`ProjectApp.Api.Function`) → Cloud Function +- Проект `ProjectApp.Api.Function` — самодостаточная Cloud Function (без Aspire, без Redis) +- Точка входа: `ApiFunction.Handler`, метод `FunctionHandler(string input) : string` +- Runtime: `dotnet8`, память: 256 МБ, таймаут: 30 с +- Деплой через архив с исходным кодом (`.cs` + `.csproj`); YC компилирует на своей стороне +- Получает HTTP-запрос от API Gateway, парсит `id` из поля `pathParams` +- Генерирует транспортное средство случайным образом, публикует JSON в очередь через `AWSSDK.SQS` +- Возвращает JSON-ответ с CORS-заголовками в формате `{ statusCode, headers, body }` +- Конфигурация через переменные окружения: `SQS_QUEUE_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` + +### API Gateway → Serverless API Gateway +- OpenAPI 3.0 спецификация (`cloud/api-gateway.yaml`) +- Маршрут `GET /api/vehicle/{id}` интегрирован с Cloud Function через расширение `x-yc-apigateway-integration: type: cloud_functions` +- CORS настроен на уровне Gateway (`x-yc-apigateway: cors`) +- Балансировка нагрузки при масштабировании осуществляется платформой автоматически + +### Брокер сообщений → Yandex Message Queue +- Очередь `vehicle-queue` создаётся через boto3 (SQS-совместимый API) +- Endpoint: `https://message-queue.api.cloud.yandex.net`, регион `ru-central1` +- Триггер `vehicle-mq-trigger` связывает очередь с Cloud Function файлового сервиса (batch size 10, окно 10 с) + +### Файловый сервис (`ProjectApp.FileService.Function`) → Cloud Function +- Проект `ProjectApp.FileService.Function` — Cloud Function с триггером на Message Queue +- Точка входа: `FileServiceFunction.Handler`, метод `FunctionHandler(string input) : string` +- Runtime: `dotnet8`, память: 256 МБ, таймаут: 60 с +- Деплой через архив с исходным кодом; зависимость `AWSSDK.S3` устанавливается YC при сборке +- Получает пакет сообщений из очереди, декодирует тело (base64 или plain) и сохраняет в Object Storage +- Конфигурация через переменные окружения: `S3_ENDPOINT`, `S3_BUCKET`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` + +### Объектное хранилище → Object Storage +- Бакет `vehicle-data-store` — хранит JSON-файлы транспортных средств (`vehicle-{id}.json`) +- Бакет создаётся через boto3 от имени сервисного аккаунта (владелец — `vehicle-sa`) +- Доступ через `AWSSDK.S3` (YC Object Storage совместим с S3 API), endpoint: `https://storage.yandexcloud.net` + + +## Ресурсы + +| Ресурс | Значение | +|--------|----------| +| API Gateway URL | `https://d5djaicgufnt5rijrt0u.p8361f8z.apigw.yandexcloud.net` | +| Клиент | `http://vehicles-client.website.yandexcloud.net` | +| Очередь | `vehicle-queue` | +| Бакет файлов | `vehicle-data-store` | +| Бакет клиента | `vehicles-client` | \ No newline at end of file diff --git a/cloud/api-gateway.yaml b/cloud/api-gateway.yaml new file mode 100644 index 00000000..1f0576a4 --- /dev/null +++ b/cloud/api-gateway.yaml @@ -0,0 +1,62 @@ +openapi: "3.0.0" +info: + title: Vehicle API Gateway + version: "1.0" + +x-yc-apigateway: + cors: + origin: '*' + methods: '*' + allowedHeaders: 'Content-Type,Authorization' + +paths: + /api/vehicle/{id}: + get: + summary: Получить транспортное средство по идентификатору + operationId: getVehicleById + parameters: + - name: id + in: path + required: true + schema: + type: integer + x-yc-apigateway-integration: + type: cloud_functions + function_id: ${FUNCTION_ID} + service_account_id: ${SERVICE_ACCOUNT_ID} + tag: "$latest" + responses: + '200': + description: Транспортное средство успешно получено + content: + application/json: + schema: + $ref: '#/components/schemas/Vehicle' + '400': + description: Некорректный идентификатор + +components: + schemas: + Vehicle: + type: object + properties: + id: + type: integer + vin: + type: string + brand: + type: string + model: + type: string + year: + type: integer + bodyType: + type: string + fuelType: + type: string + color: + type: string + mileage: + type: number + lastServiceDate: + type: string diff --git a/cloud/deploy.sh b/cloud/deploy.sh new file mode 100755 index 00000000..41b9149e --- /dev/null +++ b/cloud/deploy.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +source "$SCRIPT_DIR/env.sh" + +BUILD_DIR="$SCRIPT_DIR/build" +mkdir -p "$BUILD_DIR" + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +ok() { echo -e "\033[1;32m[OK]\033[0m $*"; } +err() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; } + +command -v yc >/dev/null 2>&1 || err "yc CLI не установлен. Запустите: curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash" +command -v dotnet >/dev/null 2>&1 || err "dotnet SDK не найден" +command -v zip >/dev/null 2>&1 || err "zip не найден" + +log "Используем каталог: $YC_FOLDER_ID" + +# 1. Сервисный аккаунт +log "Создание сервисного аккаунта $SA_NAME..." +SA_ID=$(yc iam service-account list --folder-id "$YC_FOLDER_ID" \ + --format json | python3 -c " +import json,sys +data=json.load(sys.stdin) +sa=[x for x in data if x['name']=='$SA_NAME'] +print(sa[0]['id'] if sa else '') +") + +if [ -z "$SA_ID" ]; then + SA_ID=$(yc iam service-account create \ + --name "$SA_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + ok "Сервисный аккаунт создан: $SA_ID" +else + ok "Сервисный аккаунт уже существует: $SA_ID" +fi + +for ROLE in storage.admin ymq.admin serverless.functions.invoker; do + yc resource-manager folder add-access-binding "$YC_FOLDER_ID" \ + --role "$ROLE" \ + --subject "serviceAccount:$SA_ID" \ + --quiet 2>/dev/null || true +done +ok "Роли назначены сервисному аккаунту" + +KEY_FILE="$BUILD_DIR/sa-key.json" +if [ ! -f "$KEY_FILE" ]; then + yc iam access-key create --service-account-id "$SA_ID" --folder-id "$YC_FOLDER_ID" \ + --format json > "$BUILD_DIR/sa-access-key-raw.json" + ACCESS_KEY_ID=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['access_key']['key_id'])") + ACCESS_KEY_SECRET=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['secret'])") + echo "{\"key_id\":\"$ACCESS_KEY_ID\",\"secret\":\"$ACCESS_KEY_SECRET\"}" > "$KEY_FILE" + ok "IAM ключ создан" +else + ACCESS_KEY_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['key_id'])") + ACCESS_KEY_SECRET=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['secret'])") + ok "IAM ключ загружен из кэша" +fi + +# 2. Бакет для файлов транспортных средств +log "Создание бакета Object Storage: $STORAGE_BUCKET..." +python3 - </dev/null || ok "Бакет $CLIENT_BUCKET уже существует" + +yc storage bucket update --name "$CLIENT_BUCKET" \ + --website-settings '{"index":"index.html","error":"index.html"}' \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || true + +yc storage bucket set-https --name "$CLIENT_BUCKET" --folder-id "$YC_FOLDER_ID" 2>/dev/null || true +ok "Бакеты настроены" + +# 4. Message Queue +log "Создание Message Queue: $QUEUE_NAME..." +QUEUE_URL=$(python3 - </dev/null || ok "$API_FUNCTION_NAME уже существует" + +API_FUNCTION_ID=$(yc serverless function get "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + +yc serverless function version create \ + --function-name "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --runtime dotnet8 \ + --entrypoint "ApiFunction.Handler" \ + --memory 256m \ + --execution-timeout 30s \ + --source-path "$BUILD_DIR/api-function.zip" \ + --environment "SQS_ENDPOINT=https://message-queue.api.cloud.yandex.net" \ + --environment "SQS_QUEUE_URL=$QUEUE_URL" \ + --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \ + --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \ + --service-account-id "$SA_ID" + +yc serverless function allow-unauthenticated-invoke "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || true + +ok "Cloud Function $API_FUNCTION_NAME развёрнута: $API_FUNCTION_ID" + +# 7. Архив исходного кода vehicle-file-service +log "Подготовка исходного кода $FILE_FUNCTION_NAME..." +FILE_SRC_DIR="$ROOT_DIR/ProjectApp.FileService.Function" +rm -f "$BUILD_DIR/file-function.zip" +cd "$FILE_SRC_DIR" +zip -r "$BUILD_DIR/file-function.zip" \ + Handler.cs \ + ProjectApp.FileService.Function.csproj \ + -q +cd "$ROOT_DIR" +ok "Архив создан: $BUILD_DIR/file-function.zip ($(du -sh "$BUILD_DIR/file-function.zip" | cut -f1))" + +# 8. Развёртывание Cloud Function vehicle-file-service +log "Развёртывание Cloud Function $FILE_FUNCTION_NAME..." +yc serverless function create --name "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || ok "$FILE_FUNCTION_NAME уже существует" + +FILE_FUNCTION_ID=$(yc serverless function get "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + +yc serverless function version create \ + --function-name "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --runtime dotnet8 \ + --entrypoint "FileServiceFunction.Handler" \ + --memory 256m \ + --execution-timeout 60s \ + --source-path "$BUILD_DIR/file-function.zip" \ + --environment "S3_ENDPOINT=https://storage.yandexcloud.net" \ + --environment "S3_BUCKET=$STORAGE_BUCKET" \ + --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \ + --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \ + --service-account-id "$SA_ID" + +ok "Cloud Function $FILE_FUNCTION_NAME развёрнута: $FILE_FUNCTION_ID" + +# 9. Триггер Message Queue → vehicle-file-service +log "Создание триггера Message Queue → $FILE_FUNCTION_NAME..." +TRIGGER_EXISTS=$(yc serverless trigger list --folder-id "$YC_FOLDER_ID" --format json 2>/dev/null \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('yes' if any(x.get('name')=='vehicle-mq-trigger' for x in d) else '')" 2>/dev/null || echo "") + +if [ -z "$TRIGGER_EXISTS" ]; then + yc serverless trigger create message-queue \ + --name "vehicle-mq-trigger" \ + --folder-id "$YC_FOLDER_ID" \ + --queue "$QUEUE_URL" \ + --queue-service-account-id "$SA_ID" \ + --invoke-function-id "$FILE_FUNCTION_ID" \ + --invoke-function-service-account-id "$SA_ID" \ + --batch-size 10 \ + --batch-cutoff 10s 2>&1 | grep -v "^$" || ok "Триггер создан с предупреждением" + ok "Триггер создан" +else + ok "Триггер уже существует" +fi + +# 10. API Gateway +log "Развёртывание API Gateway $API_GATEWAY_NAME..." +GATEWAY_SPEC="$BUILD_DIR/api-gateway-rendered.yaml" +sed -e "s|\${FUNCTION_ID}|$API_FUNCTION_ID|g" \ + -e "s|\${SERVICE_ACCOUNT_ID}|$SA_ID|g" \ + "$SCRIPT_DIR/api-gateway.yaml" > "$GATEWAY_SPEC" + +GATEWAY_ID=$(yc serverless api-gateway list --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +gw=[x for x in data if x['name']=='$API_GATEWAY_NAME'] +print(gw[0]['id'] if gw else '') +") + +if [ -z "$GATEWAY_ID" ]; then + GATEWAY_ID=$(yc serverless api-gateway create \ + --name "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --spec "$GATEWAY_SPEC" \ + --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + ok "API Gateway создан: $GATEWAY_ID" +else + yc serverless api-gateway update "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --spec "$GATEWAY_SPEC" 2>/dev/null || true + ok "API Gateway обновлён: $GATEWAY_ID" +fi + +GATEWAY_DOMAIN=$(yc serverless api-gateway get "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))") + +API_GATEWAY_URL="https://$GATEWAY_DOMAIN" +ok "API Gateway URL: $API_GATEWAY_URL" + +# 11. Сборка и деплой клиентского приложения +log "Сборка Blazor WASM клиента..." +CLIENT_SETTINGS="$ROOT_DIR/Client.Wasm/wwwroot/appsettings.Production.json" +cat > "$CLIENT_SETTINGS" < "$BUILD_DIR/deployment-info.json" <