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