From f20b777b757bdd6c2fcec023bd8aa707b750899d Mon Sep 17 00:00:00 2001 From: THO LE LOC Date: Wed, 11 Mar 2026 14:52:44 +0400 Subject: [PATCH 01/10] add lab_1 --- Client.Wasm/Components/DataCard.razor | 18 +-- Client.Wasm/Components/StudentCard.razor | 8 +- Client.Wasm/wwwroot/appsettings.json | 4 +- CloudDevelopment.sln | 18 +++ Vehicle.Api/Cache/IVehicleCache.cs | 53 ++++++++ Vehicle.Api/Cache/RedisVehicleCache.cs | 98 ++++++++++++++ Vehicle.Api/Controllers/VehiclesController.cs | 57 ++++++++ Vehicle.Api/Entities/VehicleEntity.cs | 69 ++++++++++ Vehicle.Api/Generation/VehicleGenerator.cs | 39 ++++++ Vehicle.Api/Program.cs | 54 ++++++++ Vehicle.Api/Properties/launchSettings.json | 41 ++++++ Vehicle.Api/Services/VehicleService.cs | 125 +++++++++++++++++ Vehicle.Api/Vehicle.Api.csproj | 19 +++ Vehicle.Api/Vehicle.Api.http | 7 + Vehicle.Api/appsettings.Development.json | 8 ++ Vehicle.Api/appsettings.json | 9 ++ Vehicle.AppHost/AppHost.cs | 25 ++++ .../Properties/launchSettings.json | 29 ++++ Vehicle.AppHost/Vehicle.AppHost.csproj | 23 ++++ Vehicle.AppHost/appsettings.Development.json | 8 ++ Vehicle.AppHost/appsettings.json | 9 ++ Vehicle.ServiceDefaults/Extensions.cs | 127 ++++++++++++++++++ Vehicle.ServiceDefaults/GlobalSuppressions.cs | 8 ++ .../Vehicle.ServiceDefaults.csproj | 22 +++ 24 files changed, 863 insertions(+), 15 deletions(-) create mode 100644 Vehicle.Api/Cache/IVehicleCache.cs create mode 100644 Vehicle.Api/Cache/RedisVehicleCache.cs create mode 100644 Vehicle.Api/Controllers/VehiclesController.cs create mode 100644 Vehicle.Api/Entities/VehicleEntity.cs create mode 100644 Vehicle.Api/Generation/VehicleGenerator.cs create mode 100644 Vehicle.Api/Program.cs create mode 100644 Vehicle.Api/Properties/launchSettings.json create mode 100644 Vehicle.Api/Services/VehicleService.cs create mode 100644 Vehicle.Api/Vehicle.Api.csproj create mode 100644 Vehicle.Api/Vehicle.Api.http create mode 100644 Vehicle.Api/appsettings.Development.json create mode 100644 Vehicle.Api/appsettings.json create mode 100644 Vehicle.AppHost/AppHost.cs create mode 100644 Vehicle.AppHost/Properties/launchSettings.json create mode 100644 Vehicle.AppHost/Vehicle.AppHost.csproj create mode 100644 Vehicle.AppHost/appsettings.Development.json create mode 100644 Vehicle.AppHost/appsettings.json create mode 100644 Vehicle.ServiceDefaults/Extensions.cs create mode 100644 Vehicle.ServiceDefaults/GlobalSuppressions.cs create mode 100644 Vehicle.ServiceDefaults/Vehicle.ServiceDefaults.csproj diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..5a3d387e 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -1,4 +1,4 @@ -@inject IConfiguration Configuration +@inject IConfiguration Configuration @inject HttpClient Client @@ -7,16 +7,16 @@ Характеристики текущего объекта - +
- + # Характеристика Значение - + - @if(Value is null) + @if (Value is null) { 1 @@ -30,7 +30,7 @@ foreach (var property in array) { - @(Array.IndexOf(array, property)+1) + @(Array.IndexOf(array, property) + 1) @property.Key @property.Value?.ToString() @@ -40,7 +40,7 @@
- + Запросить новый объект @@ -51,7 +51,7 @@ Идентификатор нового объекта: - + @@ -71,4 +71,4 @@ Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { }); StateHasChanged(); } -} +} \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..caddd992 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер: №1 Кеширование + Вариант: №44 Транспортное средство + Выполнена: Ле Лок Тхо 6513 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..4444c8a5 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Information", @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "https://localhost:7096/api/Vehicles" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..750831ff 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.AppHost", "Vehicle.AppHost\Vehicle.AppHost.csproj", "{54CA0405-7EEA-4371-B7FC-2C28DAE084E4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.ServiceDefaults", "Vehicle.ServiceDefaults\Vehicle.ServiceDefaults.csproj", "{FB9B2348-1D87-4A32-A4F7-ECFEBE8D694C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.Api", "Vehicle.Api\Vehicle.Api.csproj", "{A07A69BA-7257-47D7-9A72-96305089AAC1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +21,18 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {54CA0405-7EEA-4371-B7FC-2C28DAE084E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54CA0405-7EEA-4371-B7FC-2C28DAE084E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54CA0405-7EEA-4371-B7FC-2C28DAE084E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54CA0405-7EEA-4371-B7FC-2C28DAE084E4}.Release|Any CPU.Build.0 = Release|Any CPU + {FB9B2348-1D87-4A32-A4F7-ECFEBE8D694C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB9B2348-1D87-4A32-A4F7-ECFEBE8D694C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB9B2348-1D87-4A32-A4F7-ECFEBE8D694C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB9B2348-1D87-4A32-A4F7-ECFEBE8D694C}.Release|Any CPU.Build.0 = Release|Any CPU + {A07A69BA-7257-47D7-9A72-96305089AAC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A07A69BA-7257-47D7-9A72-96305089AAC1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A07A69BA-7257-47D7-9A72-96305089AAC1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A07A69BA-7257-47D7-9A72-96305089AAC1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Vehicle.Api/Cache/IVehicleCache.cs b/Vehicle.Api/Cache/IVehicleCache.cs new file mode 100644 index 00000000..18b894a6 --- /dev/null +++ b/Vehicle.Api/Cache/IVehicleCache.cs @@ -0,0 +1,53 @@ +using Vehicle.Api.Entities; + +namespace Vehicle.Api.Cache; + +/// +/// Интерфейс для работы с кэшем транспортных средств. +/// +public interface IVehicleCache +{ + /// + /// Получает список объектов из кэша по ключу. + /// + /// Ключ кэша. + /// Токен отмены операции. + /// Список объектов или null, если данных нет. + public Task?> GetAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Сохраняет список объектов в кэш. + /// + /// Ключ кэша. + /// Список объектов. + /// Время хранения в кэше. + /// Токен отмены операции. + public Task SetAsync( + string key, + IReadOnlyList vehicles, + TimeSpan ttl, + CancellationToken cancellationToken = default); + + /// + /// Получает один объект из кэша по ключу. + /// + /// Ключ кэша. + /// Токен отмены операции. + /// Объект или null, если данных нет. + public Task GetOneAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Сохраняет один объект в кэш. + /// + /// Ключ кэша. + /// Объект транспортного средства. + /// Время хранения в кэше. + /// Токен отмены операции. + public Task SetOneAsync( + string key, + VehicleEntity vehicle, + TimeSpan ttl, + CancellationToken cancellationToken = default); +} + + diff --git a/Vehicle.Api/Cache/RedisVehicleCache.cs b/Vehicle.Api/Cache/RedisVehicleCache.cs new file mode 100644 index 00000000..0e12ba2f --- /dev/null +++ b/Vehicle.Api/Cache/RedisVehicleCache.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Vehicle.Api.Entities; + +namespace Vehicle.Api.Cache; + +/// +/// Класс для работы с Redis-кэшем транспортных средств. +/// +public class RedisVehicleCache(IDistributedCache cache) : IVehicleCache +{ + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly IDistributedCache _cache = cache; + + /// + /// Получает список объектов из кэша по ключу. + /// + /// Ключ кэша. + /// Токен отмены операции. + /// Список объектов или null, если данных нет. + public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) + { + var json = await _cache.GetStringAsync(key, cancellationToken); + + if (string.IsNullOrWhiteSpace(json)) + return null; + + return JsonSerializer.Deserialize>(json, _jsonOptions); + } + + /// + /// Сохраняет список объектов в кэш. + /// + /// Ключ кэша. + /// Список объектов. + /// Время хранения в кэше. + /// Токен отмены операции. + public async Task SetAsync( + string key, + IReadOnlyList vehicles, + TimeSpan ttl, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(vehicles, _jsonOptions); + + await _cache.SetStringAsync( + key, + json, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }, + cancellationToken); + } + + /// + /// Получает один объект из кэша по ключу. + /// + /// Ключ кэша. + /// Токен отмены операции. + /// Объект или null, если данных нет. + public async Task GetOneAsync(string key, CancellationToken cancellationToken = default) + { + var json = await _cache.GetStringAsync(key, cancellationToken); + + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonSerializer.Deserialize(json, _jsonOptions); + } + + /// + /// Сохраняет один объект в кэш. + /// + /// Ключ кэша. + /// Объект транспортного средства. + /// Время хранения в кэше. + /// Токен отмены операции. + public async Task SetOneAsync( + string key, + VehicleEntity vehicle, + TimeSpan ttl, + CancellationToken cancellationToken = default) + { + var json = JsonSerializer.Serialize(vehicle, _jsonOptions); + + await _cache.SetStringAsync( + key, + json, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }, + cancellationToken); + } +} diff --git a/Vehicle.Api/Controllers/VehiclesController.cs b/Vehicle.Api/Controllers/VehiclesController.cs new file mode 100644 index 00000000..80190209 --- /dev/null +++ b/Vehicle.Api/Controllers/VehiclesController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading; +using Vehicle.Api.Entities; +using Vehicle.Api.Services; + +namespace Vehicle.Api.Controllers; + +/// +/// Контроллер для управления транспортными средствами +/// +[ApiController] +[Route("api/[controller]")] +public class VehiclesController(VehicleService vehicleService) : ControllerBase +{ + private readonly VehicleService _vehicleService = vehicleService; + + /// + /// Создаёт указанное количество случайных транспортных средств + /// + /// Количество генерируемых записей (должно быть больше 0) + /// Токен для отмены запроса + /// Список сгенерированных транспортных средств + [HttpGet("generate")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Generate([FromQuery] int? count, CancellationToken cancellationToken = default) + { + if (count is null || count <= 0) + { + return BadRequest("count must be >= 0"); + } + + var vehicles = await _vehicleService.GenerateAsync(count.Value, cancellationToken); + return Ok(vehicles); + } + + /// + /// Возвращает транспортное средство по его идентификатору + /// + /// Идентификатор транспортного средства (должен быть больше 0) + /// Токен для отмены запроса + /// Информация о найденном транспортном средстве + [HttpGet] + [ProducesResponseType(typeof(VehicleEntity), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task GetById([FromQuery] int? id, CancellationToken cancellationToken = default) + { + if (id is null || id <= 0) + { + return BadRequest("id must be > 0"); + } + + var vehicle = await _vehicleService.GetByIdAsync(id.Value, cancellationToken); + return Ok(vehicle); + } + +} \ No newline at end of file diff --git a/Vehicle.Api/Entities/VehicleEntity.cs b/Vehicle.Api/Entities/VehicleEntity.cs new file mode 100644 index 00000000..37b9ca7a --- /dev/null +++ b/Vehicle.Api/Entities/VehicleEntity.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace Vehicle.Api.Entities; + +/// +/// Генератор записи о транспортных средствах +/// +public class VehicleEntity +{ + /// + /// Идентификатор в системе + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Идентификационный номер транспортного средства (VIN-номер) + /// + [JsonPropertyName("vin")] + public string Vin { get; set; } = string.Empty; + + /// + /// Производитель + /// + [JsonPropertyName("manufacturer")] + public string Manufacturer { get; set; } = string.Empty; + + /// + /// Модель + /// + [JsonPropertyName("model")] + public string Model { get; set; } = string.Empty; + + /// + /// Год выпуска + /// + [JsonPropertyName("year")] + public int Year { get; set; } + + /// + /// Тип корпуса + /// + [JsonPropertyName("bodyType")] + public string BodyType { get; set; } = string.Empty; + + /// + /// Тип топлива + /// + [JsonPropertyName("fuelType")] + public string FuelType { get; set; } = string.Empty; + + /// + /// Цвет корпуса + /// + [JsonPropertyName("color")] + public string Color { get; set; } = string.Empty; + + /// + /// Пробег + /// + [JsonPropertyName("mileage")] + public double Mileage { get; set; } + + /// + /// Дата последнего техобслуживания + /// + [JsonPropertyName("lastServiceDate")] + public DateOnly LastServiceDate { get; set; } +} \ No newline at end of file diff --git a/Vehicle.Api/Generation/VehicleGenerator.cs b/Vehicle.Api/Generation/VehicleGenerator.cs new file mode 100644 index 00000000..c1073355 --- /dev/null +++ b/Vehicle.Api/Generation/VehicleGenerator.cs @@ -0,0 +1,39 @@ +using Bogus; +using Vehicle.Api.Entities; + +namespace Vehicle.Api.Generation; + +/// +/// Сервис генерации тестовых данных транспортного средства. +/// +public class VehicleGenerator +{ + private static readonly Faker _faker = new Faker("ru") + .RuleFor(v => v.Id, f => f.IndexFaker + 1) + .RuleFor(v => v.Vin, f => f.Vehicle.Vin()) + .RuleFor(v => v.Manufacturer, f => f.Vehicle.Manufacturer()) + .RuleFor(v => v.Model, f => f.Vehicle.Model()) + .RuleFor(v => v.Year, f => f.Date.Past(20).Year) + .RuleFor(v => v.BodyType, f => f.Vehicle.Type()) + .RuleFor(v => v.FuelType, f => f.Vehicle.Fuel()) + .RuleFor(v => v.Color, f => f.Commerce.Color()) + .RuleFor(v => v.Mileage, f => Math.Round(f.Random.Double(0, 400000), 2)) + .RuleFor(v => v.LastServiceDate, (f, item) => + { + var productionDate = new DateTime(item.Year, 1, 1); + var serviceDate = f.Date.Between(productionDate, DateTime.Today); + return DateOnly.FromDateTime(serviceDate); + }); + + /// + /// Генерирует транспортное средство по заданному id + /// + /// Идентификатор транспортного средства + /// Сгенерированный объект транспортного средства + public VehicleEntity Generate(int id) + { + var vehicle = _faker.Generate(); + vehicle.Id = id; + return vehicle; + } +} diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs new file mode 100644 index 00000000..cfa072ec --- /dev/null +++ b/Vehicle.Api/Program.cs @@ -0,0 +1,54 @@ +using Vehicle.Api.Cache; +using Vehicle.Api.Generation; +using Vehicle.Api.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddCors(options => +{ + options.AddPolicy("ClientCors", policy => + { + policy + .WithOrigins( + "http://localhost:7282", + "https://localhost:7282", + "http://localhost:5127", + "https://localhost:5127") + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = builder.Configuration.GetConnectionString("redis"); + options.InstanceName = "vehicle-api"; +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.UseCors("ClientCors"); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Vehicle.Api/Properties/launchSettings.json b/Vehicle.Api/Properties/launchSettings.json new file mode 100644 index 00000000..2c71ad7d --- /dev/null +++ b/Vehicle.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:37132", + "sslPort": 44319 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5152", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7096;http://localhost:5152", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Vehicle.Api/Services/VehicleService.cs b/Vehicle.Api/Services/VehicleService.cs new file mode 100644 index 00000000..26df30ce --- /dev/null +++ b/Vehicle.Api/Services/VehicleService.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using Vehicle.Api.Cache; +using Vehicle.Api.Entities; +using Vehicle.Api.Generation; + +namespace Vehicle.Api.Services; + +/// +/// Сервис для работы с транспортными средствами (генерация и получение по ID) +/// +public class VehicleService( + VehicleGenerator generator, + IVehicleCache vehicleCache, + ILogger logger) +{ + private readonly VehicleGenerator _generator = generator; + private readonly IVehicleCache _vehicleCache = vehicleCache; + private readonly ILogger _logger = logger; + + /// + /// Генерирует указанное количество транспортных средств и кэширует результат + /// + /// Количество транспортных средств для генерации + /// Токен отмены операции + /// Список сгенерированных транспортных средств + public async Task> GenerateAsync( + int count, + CancellationToken cancellationToken = default) + { + var cacheKey = $"vehicles:{count}"; + var stopwatch = Stopwatch.StartNew(); + + var cachedVehicles = await _vehicleCache.GetAsync(cacheKey, cancellationToken); + + if (cachedVehicles is not null) + { + stopwatch.Stop(); + + _logger.LogInformation( + "Cache hit for key {CacheKey}. Returned {Count} vehicles in {ElapsedMs} ms", + cacheKey, + cachedVehicles.Count, + stopwatch.ElapsedMilliseconds); + + return cachedVehicles; + } + + _logger.LogInformation("Cache miss for key {CacheKey}", cacheKey); + + var vehicles = new List(count); + + for (var i = 1; i <= count; i++) + { + vehicles.Add(_generator.Generate(i)); + } + + await _vehicleCache.SetAsync( + cacheKey, + vehicles, + TimeSpan.FromMinutes(5), + cancellationToken); + + stopwatch.Stop(); + + _logger.LogInformation( + "Generated {Count} vehicles and cached them with key {CacheKey} in {ElapsedMs} ms", + count, + cacheKey, + stopwatch.ElapsedMilliseconds); + + return vehicles; + } + + /// + /// Получает транспортное средство по ID (из кэша или генерирует новое) + /// + /// Идентификатор транспортного средства + /// Токен отмены операции + /// Транспортное средство с указанным ID + public async Task GetByIdAsync( + int id, + CancellationToken cancellationToken = default) + { + var cacheKey = $"vehicle:{id}"; + var stopwatch = Stopwatch.StartNew(); + + var cachedVehicle = await _vehicleCache.GetOneAsync(cacheKey, cancellationToken); + + if (cachedVehicle is not null) + { + stopwatch.Stop(); + + _logger.LogInformation( + "Cache hit for key {CacheKey}. Returned vehicle with id {Id} in {ElapsedMs} ms", + cacheKey, + id, + stopwatch.ElapsedMilliseconds); + + return cachedVehicle; + } + + _logger.LogInformation( + "Cache miss for key {CacheKey}. Generating vehicle with id {Id}", + cacheKey, + id); + + var vehicle = _generator.Generate(id); + + await _vehicleCache.SetOneAsync( + cacheKey, + vehicle, + TimeSpan.FromMinutes(5), + cancellationToken); + + stopwatch.Stop(); + + _logger.LogInformation( + "Generated vehicle with id {Id} and cached it with key {CacheKey} in {ElapsedMs} ms", + id, + cacheKey, + stopwatch.ElapsedMilliseconds); + + return vehicle; + } +} \ No newline at end of file diff --git a/Vehicle.Api/Vehicle.Api.csproj b/Vehicle.Api/Vehicle.Api.csproj new file mode 100644 index 00000000..b3b4132c --- /dev/null +++ b/Vehicle.Api/Vehicle.Api.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/Vehicle.Api/Vehicle.Api.http b/Vehicle.Api/Vehicle.Api.http new file mode 100644 index 00000000..e5265743 --- /dev/null +++ b/Vehicle.Api/Vehicle.Api.http @@ -0,0 +1,7 @@ +@Vehicle.Api_HostAddress = https://localhost:5152 + +GET {{Vehicle.Api_HostAddress}}/api/Vehicles/generate?id=1 +Accept: application/json + +### + diff --git a/Vehicle.Api/appsettings.Development.json b/Vehicle.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Vehicle.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Vehicle.Api/appsettings.json b/Vehicle.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/Vehicle.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs new file mode 100644 index 00000000..ce136196 --- /dev/null +++ b/Vehicle.AppHost/AppHost.cs @@ -0,0 +1,25 @@ +//var builder = DistributedApplication.CreateBuilder(args); + +//var redis = builder.AddRedis("redis"); + +//builder.AddProject("vehicle-api") +// .WithReference(redis) +// .WaitFor(redis); + +//builder.Build().Run(); + +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis").WithRedisCommander(); + +var api = builder.AddProject("vehicle-api") + .WithReference(redis) + .WaitFor(redis); + +builder.AddProject("client") + .WaitFor(api); + +builder.Build().Run(); + + + diff --git a/Vehicle.AppHost/Properties/launchSettings.json b/Vehicle.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..b14ecbd3 --- /dev/null +++ b/Vehicle.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:17080;http://localhost:15018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21071", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22124" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15018", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19200", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20199" + } + } + } +} diff --git a/Vehicle.AppHost/Vehicle.AppHost.csproj b/Vehicle.AppHost/Vehicle.AppHost.csproj new file mode 100644 index 00000000..d93d8c7a --- /dev/null +++ b/Vehicle.AppHost/Vehicle.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net8.0 + enable + enable + 62bfc3f6-9e4a-4858-934b-e6b0454e609a + + + + + + + + + + + + + diff --git a/Vehicle.AppHost/appsettings.Development.json b/Vehicle.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Vehicle.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Vehicle.AppHost/appsettings.json b/Vehicle.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/Vehicle.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/Vehicle.ServiceDefaults/Extensions.cs b/Vehicle.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..34c8cd19 --- /dev/null +++ b/Vehicle.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/Vehicle.ServiceDefaults/GlobalSuppressions.cs b/Vehicle.ServiceDefaults/GlobalSuppressions.cs new file mode 100644 index 00000000..b9cdf0c8 --- /dev/null +++ b/Vehicle.ServiceDefaults/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Style", "IDE0130:Namespace does not match folder structure", Justification = "", Scope = "namespace", Target = "~N:Microsoft.Extensions.Hosting")] diff --git a/Vehicle.ServiceDefaults/Vehicle.ServiceDefaults.csproj b/Vehicle.ServiceDefaults/Vehicle.ServiceDefaults.csproj new file mode 100644 index 00000000..1b6e209a --- /dev/null +++ b/Vehicle.ServiceDefaults/Vehicle.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + From 9214ff2cd3d626164f405017f3f5ae5901bf9599 Mon Sep 17 00:00:00 2001 From: THO LE LOC Date: Fri, 13 Mar 2026 05:09:25 +0400 Subject: [PATCH 02/10] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vehicle.Api/Cache/IVehicleCache.cs | 21 ------ Vehicle.Api/Cache/RedisVehicleCache.cs | 52 +------------ Vehicle.Api/Controllers/VehiclesController.cs | 24 +----- Vehicle.Api/Services/VehicleService.cs | 75 ++----------------- Vehicle.Api/Vehicle.Api.http | 4 +- Vehicle.AppHost/AppHost.cs | 17 +---- 6 files changed, 16 insertions(+), 177 deletions(-) diff --git a/Vehicle.Api/Cache/IVehicleCache.cs b/Vehicle.Api/Cache/IVehicleCache.cs index 18b894a6..7f48f7e5 100644 --- a/Vehicle.Api/Cache/IVehicleCache.cs +++ b/Vehicle.Api/Cache/IVehicleCache.cs @@ -7,27 +7,6 @@ namespace Vehicle.Api.Cache; /// public interface IVehicleCache { - /// - /// Получает список объектов из кэша по ключу. - /// - /// Ключ кэша. - /// Токен отмены операции. - /// Список объектов или null, если данных нет. - public Task?> GetAsync(string key, CancellationToken cancellationToken = default); - - /// - /// Сохраняет список объектов в кэш. - /// - /// Ключ кэша. - /// Список объектов. - /// Время хранения в кэше. - /// Токен отмены операции. - public Task SetAsync( - string key, - IReadOnlyList vehicles, - TimeSpan ttl, - CancellationToken cancellationToken = default); - /// /// Получает один объект из кэша по ключу. /// diff --git a/Vehicle.Api/Cache/RedisVehicleCache.cs b/Vehicle.Api/Cache/RedisVehicleCache.cs index 0e12ba2f..f5e88470 100644 --- a/Vehicle.Api/Cache/RedisVehicleCache.cs +++ b/Vehicle.Api/Cache/RedisVehicleCache.cs @@ -9,50 +9,6 @@ namespace Vehicle.Api.Cache; /// public class RedisVehicleCache(IDistributedCache cache) : IVehicleCache { - private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); - private readonly IDistributedCache _cache = cache; - - /// - /// Получает список объектов из кэша по ключу. - /// - /// Ключ кэша. - /// Токен отмены операции. - /// Список объектов или null, если данных нет. - public async Task?> GetAsync(string key, CancellationToken cancellationToken = default) - { - var json = await _cache.GetStringAsync(key, cancellationToken); - - if (string.IsNullOrWhiteSpace(json)) - return null; - - return JsonSerializer.Deserialize>(json, _jsonOptions); - } - - /// - /// Сохраняет список объектов в кэш. - /// - /// Ключ кэша. - /// Список объектов. - /// Время хранения в кэше. - /// Токен отмены операции. - public async Task SetAsync( - string key, - IReadOnlyList vehicles, - TimeSpan ttl, - CancellationToken cancellationToken = default) - { - var json = JsonSerializer.Serialize(vehicles, _jsonOptions); - - await _cache.SetStringAsync( - key, - json, - new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = ttl - }, - cancellationToken); - } - /// /// Получает один объект из кэша по ключу. /// @@ -61,14 +17,14 @@ await _cache.SetStringAsync( /// Объект или null, если данных нет. public async Task GetOneAsync(string key, CancellationToken cancellationToken = default) { - var json = await _cache.GetStringAsync(key, cancellationToken); + var json = await cache.GetStringAsync(key, cancellationToken); if (string.IsNullOrWhiteSpace(json)) { return null; } - return JsonSerializer.Deserialize(json, _jsonOptions); + return JsonSerializer.Deserialize(json); } /// @@ -84,9 +40,9 @@ public async Task SetOneAsync( TimeSpan ttl, CancellationToken cancellationToken = default) { - var json = JsonSerializer.Serialize(vehicle, _jsonOptions); + var json = JsonSerializer.Serialize(vehicle); - await _cache.SetStringAsync( + await cache.SetStringAsync( key, json, new DistributedCacheEntryOptions diff --git a/Vehicle.Api/Controllers/VehiclesController.cs b/Vehicle.Api/Controllers/VehiclesController.cs index 80190209..71f11341 100644 --- a/Vehicle.Api/Controllers/VehiclesController.cs +++ b/Vehicle.Api/Controllers/VehiclesController.cs @@ -12,28 +12,6 @@ namespace Vehicle.Api.Controllers; [Route("api/[controller]")] public class VehiclesController(VehicleService vehicleService) : ControllerBase { - private readonly VehicleService _vehicleService = vehicleService; - - /// - /// Создаёт указанное количество случайных транспортных средств - /// - /// Количество генерируемых записей (должно быть больше 0) - /// Токен для отмены запроса - /// Список сгенерированных транспортных средств - [HttpGet("generate")] - [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task Generate([FromQuery] int? count, CancellationToken cancellationToken = default) - { - if (count is null || count <= 0) - { - return BadRequest("count must be >= 0"); - } - - var vehicles = await _vehicleService.GenerateAsync(count.Value, cancellationToken); - return Ok(vehicles); - } - /// /// Возвращает транспортное средство по его идентификатору /// @@ -50,7 +28,7 @@ public async Task GetById([FromQuery] int? id, CancellationToken return BadRequest("id must be > 0"); } - var vehicle = await _vehicleService.GetByIdAsync(id.Value, cancellationToken); + var vehicle = await vehicleService.GetByIdAsync(id.Value, cancellationToken); return Ok(vehicle); } diff --git a/Vehicle.Api/Services/VehicleService.cs b/Vehicle.Api/Services/VehicleService.cs index 26df30ce..d73de6ec 100644 --- a/Vehicle.Api/Services/VehicleService.cs +++ b/Vehicle.Api/Services/VehicleService.cs @@ -8,69 +8,8 @@ namespace Vehicle.Api.Services; /// /// Сервис для работы с транспортными средствами (генерация и получение по ID) /// -public class VehicleService( - VehicleGenerator generator, - IVehicleCache vehicleCache, - ILogger logger) +public class VehicleService(VehicleGenerator generator, IVehicleCache vehicleCache, ILogger logger) { - private readonly VehicleGenerator _generator = generator; - private readonly IVehicleCache _vehicleCache = vehicleCache; - private readonly ILogger _logger = logger; - - /// - /// Генерирует указанное количество транспортных средств и кэширует результат - /// - /// Количество транспортных средств для генерации - /// Токен отмены операции - /// Список сгенерированных транспортных средств - public async Task> GenerateAsync( - int count, - CancellationToken cancellationToken = default) - { - var cacheKey = $"vehicles:{count}"; - var stopwatch = Stopwatch.StartNew(); - - var cachedVehicles = await _vehicleCache.GetAsync(cacheKey, cancellationToken); - - if (cachedVehicles is not null) - { - stopwatch.Stop(); - - _logger.LogInformation( - "Cache hit for key {CacheKey}. Returned {Count} vehicles in {ElapsedMs} ms", - cacheKey, - cachedVehicles.Count, - stopwatch.ElapsedMilliseconds); - - return cachedVehicles; - } - - _logger.LogInformation("Cache miss for key {CacheKey}", cacheKey); - - var vehicles = new List(count); - - for (var i = 1; i <= count; i++) - { - vehicles.Add(_generator.Generate(i)); - } - - await _vehicleCache.SetAsync( - cacheKey, - vehicles, - TimeSpan.FromMinutes(5), - cancellationToken); - - stopwatch.Stop(); - - _logger.LogInformation( - "Generated {Count} vehicles and cached them with key {CacheKey} in {ElapsedMs} ms", - count, - cacheKey, - stopwatch.ElapsedMilliseconds); - - return vehicles; - } - /// /// Получает транспортное средство по ID (из кэша или генерирует новое) /// @@ -84,13 +23,13 @@ public async Task GetByIdAsync( var cacheKey = $"vehicle:{id}"; var stopwatch = Stopwatch.StartNew(); - var cachedVehicle = await _vehicleCache.GetOneAsync(cacheKey, cancellationToken); + var cachedVehicle = await vehicleCache.GetOneAsync(cacheKey, cancellationToken); if (cachedVehicle is not null) { stopwatch.Stop(); - _logger.LogInformation( + logger.LogInformation( "Cache hit for key {CacheKey}. Returned vehicle with id {Id} in {ElapsedMs} ms", cacheKey, id, @@ -99,14 +38,14 @@ public async Task GetByIdAsync( return cachedVehicle; } - _logger.LogInformation( + logger.LogInformation( "Cache miss for key {CacheKey}. Generating vehicle with id {Id}", cacheKey, id); - var vehicle = _generator.Generate(id); + var vehicle = generator.Generate(id); - await _vehicleCache.SetOneAsync( + await vehicleCache.SetOneAsync( cacheKey, vehicle, TimeSpan.FromMinutes(5), @@ -114,7 +53,7 @@ await _vehicleCache.SetOneAsync( stopwatch.Stop(); - _logger.LogInformation( + logger.LogInformation( "Generated vehicle with id {Id} and cached it with key {CacheKey} in {ElapsedMs} ms", id, cacheKey, diff --git a/Vehicle.Api/Vehicle.Api.http b/Vehicle.Api/Vehicle.Api.http index e5265743..9bdc1c04 100644 --- a/Vehicle.Api/Vehicle.Api.http +++ b/Vehicle.Api/Vehicle.Api.http @@ -1,6 +1,6 @@ -@Vehicle.Api_HostAddress = https://localhost:5152 +@Vehicle.Api_HostAddress = https://localhost:7096 -GET {{Vehicle.Api_HostAddress}}/api/Vehicles/generate?id=1 +GET {{Vehicle.Api_HostAddress}}/api/Vehicles?id=5 Accept: application/json ### diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs index ce136196..33d7a7cf 100644 --- a/Vehicle.AppHost/AppHost.cs +++ b/Vehicle.AppHost/AppHost.cs @@ -1,14 +1,4 @@ -//var builder = DistributedApplication.CreateBuilder(args); - -//var redis = builder.AddRedis("redis"); - -//builder.AddProject("vehicle-api") -// .WithReference(redis) -// .WaitFor(redis); - -//builder.Build().Run(); - -var builder = DistributedApplication.CreateBuilder(args); +var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("redis").WithRedisCommander(); @@ -19,7 +9,4 @@ builder.AddProject("client") .WaitFor(api); -builder.Build().Run(); - - - +builder.Build().Run(); \ No newline at end of file From 3ddea97b393ec6bd93e3d42793396cdd1632d2dd Mon Sep 17 00:00:00 2001 From: THO LE LOC Date: Fri, 13 Mar 2026 19:36:58 +0400 Subject: [PATCH 03/10] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20=D0=BA=D1=8D=D1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vehicle.Api/Controllers/VehiclesController.cs | 1 - Vehicle.Api/Program.cs | 16 ++--- Vehicle.Api/Services/VehicleService.cs | 65 ++++++++++++++++--- Vehicle.Api/Vehicle.Api.csproj | 4 +- Vehicle.AppHost/Vehicle.AppHost.csproj | 6 +- 5 files changed, 65 insertions(+), 27 deletions(-) diff --git a/Vehicle.Api/Controllers/VehiclesController.cs b/Vehicle.Api/Controllers/VehiclesController.cs index 71f11341..8d24a8a7 100644 --- a/Vehicle.Api/Controllers/VehiclesController.cs +++ b/Vehicle.Api/Controllers/VehiclesController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using System.Threading; using Vehicle.Api.Entities; using Vehicle.Api.Services; diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs index cfa072ec..5e443b69 100644 --- a/Vehicle.Api/Program.cs +++ b/Vehicle.Api/Program.cs @@ -11,13 +11,9 @@ options.AddPolicy("ClientCors", policy => { policy - .WithOrigins( - "http://localhost:7282", - "https://localhost:7282", - "http://localhost:5127", - "https://localhost:5127") - .AllowAnyHeader() - .AllowAnyMethod(); + .AllowAnyOrigin() + .WithMethods("GET") + .WithHeaders("Content-Type"); }); }); @@ -29,11 +25,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddStackExchangeRedisCache(options => -{ - options.Configuration = builder.Configuration.GetConnectionString("redis"); - options.InstanceName = "vehicle-api"; -}); +builder.AddRedisDistributedCache("redis"); var app = builder.Build(); diff --git a/Vehicle.Api/Services/VehicleService.cs b/Vehicle.Api/Services/VehicleService.cs index d73de6ec..bed4cdcf 100644 --- a/Vehicle.Api/Services/VehicleService.cs +++ b/Vehicle.Api/Services/VehicleService.cs @@ -23,7 +23,10 @@ public async Task GetByIdAsync( var cacheKey = $"vehicle:{id}"; var stopwatch = Stopwatch.StartNew(); - var cachedVehicle = await vehicleCache.GetOneAsync(cacheKey, cancellationToken); + var cachedVehicle = await TryReadCacheAsync( + () => vehicleCache.GetOneAsync(cacheKey, cancellationToken), + "Failed to read vehicle from cache. Key: {CacheKey}", + cacheKey); if (cachedVehicle is not null) { @@ -45,20 +48,64 @@ public async Task GetByIdAsync( var vehicle = generator.Generate(id); - await vehicleCache.SetOneAsync( - cacheKey, - vehicle, - TimeSpan.FromMinutes(5), - cancellationToken); + await TryWriteCacheAsync( + () => vehicleCache.SetOneAsync( + cacheKey, + vehicle, + TimeSpan.FromMinutes(5), + cancellationToken), + "Failed to write vehicle to cache. Key: {CacheKey}", + cacheKey); stopwatch.Stop(); logger.LogInformation( - "Generated vehicle with id {Id} and cached it with key {CacheKey} in {ElapsedMs} ms", + "Generated vehicle with id {Id} in {ElapsedMs} ms", id, - cacheKey, stopwatch.ElapsedMilliseconds); return vehicle; } -} \ No newline at end of file + + /// + /// Безопасно читает данные из кэша. + /// + private async Task TryReadCacheAsync( + Func> action, + string warningMessage, + string cacheKey) + { + try + { + return await action(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, warningMessage, cacheKey); + return default; + } + } + + /// + /// Безопасно записывает данные в кэш. + /// + private async Task TryWriteCacheAsync( + Func action, + string warningMessage, + string cacheKey) + { + try + { + await action(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, warningMessage, cacheKey); + } + } + +} + + + + diff --git a/Vehicle.Api/Vehicle.Api.csproj b/Vehicle.Api/Vehicle.Api.csproj index b3b4132c..ff3ec65b 100644 --- a/Vehicle.Api/Vehicle.Api.csproj +++ b/Vehicle.Api/Vehicle.Api.csproj @@ -8,7 +8,7 @@ - + @@ -16,4 +16,4 @@ - + \ No newline at end of file diff --git a/Vehicle.AppHost/Vehicle.AppHost.csproj b/Vehicle.AppHost/Vehicle.AppHost.csproj index d93d8c7a..280f5b4b 100644 --- a/Vehicle.AppHost/Vehicle.AppHost.csproj +++ b/Vehicle.AppHost/Vehicle.AppHost.csproj @@ -1,6 +1,6 @@ - + Exe @@ -11,8 +11,8 @@ - - + + From 81703511eac4505230f031cc4bfc71bfedb19bd9 Mon Sep 17 00:00:00 2001 From: THO LE LOC Date: Wed, 18 Mar 2026 00:42:33 +0400 Subject: [PATCH 04/10] clean up code --- Vehicle.Api/Cache/RedisVehicleCache.cs | 6 +++--- Vehicle.Api/Generation/VehicleGenerator.cs | 2 +- Vehicle.Api/Services/VehicleService.cs | 7 +------ Vehicle.Api/Vehicle.Api.http | 3 +-- Vehicle.ServiceDefaults/Extensions.cs | 1 - 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/Vehicle.Api/Cache/RedisVehicleCache.cs b/Vehicle.Api/Cache/RedisVehicleCache.cs index f5e88470..d2a21e49 100644 --- a/Vehicle.Api/Cache/RedisVehicleCache.cs +++ b/Vehicle.Api/Cache/RedisVehicleCache.cs @@ -1,5 +1,5 @@ -using System.Text.Json; -using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; using Vehicle.Api.Entities; namespace Vehicle.Api.Cache; @@ -51,4 +51,4 @@ await cache.SetStringAsync( }, cancellationToken); } -} +} \ No newline at end of file diff --git a/Vehicle.Api/Generation/VehicleGenerator.cs b/Vehicle.Api/Generation/VehicleGenerator.cs index c1073355..6b2d1bf8 100644 --- a/Vehicle.Api/Generation/VehicleGenerator.cs +++ b/Vehicle.Api/Generation/VehicleGenerator.cs @@ -36,4 +36,4 @@ public VehicleEntity Generate(int id) vehicle.Id = id; return vehicle; } -} +} \ No newline at end of file diff --git a/Vehicle.Api/Services/VehicleService.cs b/Vehicle.Api/Services/VehicleService.cs index bed4cdcf..e883eacb 100644 --- a/Vehicle.Api/Services/VehicleService.cs +++ b/Vehicle.Api/Services/VehicleService.cs @@ -103,9 +103,4 @@ private async Task TryWriteCacheAsync( logger.LogWarning(ex, warningMessage, cacheKey); } } - -} - - - - +} \ No newline at end of file diff --git a/Vehicle.Api/Vehicle.Api.http b/Vehicle.Api/Vehicle.Api.http index 9bdc1c04..69f05d0f 100644 --- a/Vehicle.Api/Vehicle.Api.http +++ b/Vehicle.Api/Vehicle.Api.http @@ -3,5 +3,4 @@ GET {{Vehicle.Api_HostAddress}}/api/Vehicles?id=5 Accept: application/json -### - +### \ No newline at end of file diff --git a/Vehicle.ServiceDefaults/Extensions.cs b/Vehicle.ServiceDefaults/Extensions.cs index 34c8cd19..7b3daf44 100644 --- a/Vehicle.ServiceDefaults/Extensions.cs +++ b/Vehicle.ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; From 0e827d4f020da9e565f9d42d861456ff38eec198 Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Wed, 1 Apr 2026 02:59:15 +0400 Subject: [PATCH 05/10] Add lab2 --- Client.Wasm/Components/StudentCard.razor | 2 +- CloudDevelopment.sln | 6 + Vehicle.Api/Program.cs | 20 +++ Vehicle.AppHost/AppHost.cs | 32 ++++- Vehicle.AppHost/Vehicle.AppHost.csproj | 1 + Vehicle.AppHost/appsettings.json | 8 +- .../WeightedRandomLoadBalancer.cs | 118 ++++++++++++++++++ Vehicle.Gateway/Program.cs | 41 ++++++ .../Properties/launchSettings.json | 36 ++++++ Vehicle.Gateway/Vehicle.Gateway.csproj | 17 +++ Vehicle.Gateway/appsettings.Development.json | 8 ++ Vehicle.Gateway/appsettings.json | 21 ++++ Vehicle.Gateway/ocelot.json | 38 ++++++ 13 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs create mode 100644 Vehicle.Gateway/Program.cs create mode 100644 Vehicle.Gateway/Properties/launchSettings.json create mode 100644 Vehicle.Gateway/Vehicle.Gateway.csproj create mode 100644 Vehicle.Gateway/appsettings.Development.json create mode 100644 Vehicle.Gateway/appsettings.json create mode 100644 Vehicle.Gateway/ocelot.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index caddd992..5d311dbd 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер: №1 Кеширование + Номер: №2 Балансировка нагрузки Вариант: №44 Транспортное средство Выполнена: Ле Лок Тхо 6513 Ссылка на форк diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 750831ff..dd2450ac 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.ServiceDefaults", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.Api", "Vehicle.Api\Vehicle.Api.csproj", "{A07A69BA-7257-47D7-9A72-96305089AAC1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.Gateway", "Vehicle.Gateway\Vehicle.Gateway.csproj", "{6A946D9E-A3D9-4345-929B-1415379D9102}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {A07A69BA-7257-47D7-9A72-96305089AAC1}.Debug|Any CPU.Build.0 = Debug|Any CPU {A07A69BA-7257-47D7-9A72-96305089AAC1}.Release|Any CPU.ActiveCfg = Release|Any CPU {A07A69BA-7257-47D7-9A72-96305089AAC1}.Release|Any CPU.Build.0 = Release|Any CPU + {6A946D9E-A3D9-4345-929B-1415379D9102}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A946D9E-A3D9-4345-929B-1415379D9102}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A946D9E-A3D9-4345-929B-1415379D9102}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A946D9E-A3D9-4345-929B-1415379D9102}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs index 5e443b69..a4ab84b4 100644 --- a/Vehicle.Api/Program.cs +++ b/Vehicle.Api/Program.cs @@ -41,6 +41,26 @@ app.UseCors("ClientCors"); +app.MapGet("/", () => +{ + var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; + + return Results.Ok(new + { + service = "Vehicle.Api", + status = "ok", + instanceId, + message = "Vehicle API is running" + }); +}); + app.MapControllers(); +app.Use(async (context, next) => +{ + var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; + context.Response.Headers["X-Instance-Id"] = instanceId; + await next(); +}); + app.Run(); \ No newline at end of file diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs index 33d7a7cf..4164ddfc 100644 --- a/Vehicle.AppHost/AppHost.cs +++ b/Vehicle.AppHost/AppHost.cs @@ -1,12 +1,34 @@ -var builder = DistributedApplication.CreateBuilder(args); +using Microsoft.Extensions.Configuration; -var redis = builder.AddRedis("redis").WithRedisCommander(); +var builder = DistributedApplication.CreateBuilder(args); -var api = builder.AddProject("vehicle-api") - .WithReference(redis) +var apiPorts = builder.Configuration.GetSection("ApiService:Ports").Get() + ?? throw new InvalidOperationException("ApiService:Ports is not configured."); + +var gatewayPort = builder.Configuration.GetValue("Gateway:Port") + ?? throw new InvalidOperationException("Gateway:Port is not configured."); + +var redis = builder.AddRedis("redis") + .WithRedisCommander(); + +var gateway = builder.AddProject("vehicle-gateway") + .WithHttpsEndpoint(port: gatewayPort, name: "vehicle-gateway-lb") .WaitFor(redis); +for (var i = 0; i < apiPorts.Length; i++) +{ + var httpsPort = apiPorts[i]; + var instanceName = $"vehicle-api-{i + 1}"; + var api = builder.AddProject($"vehicle-api-{i + 1}", launchProfileName: null) + .WithReference(redis) + .WithHttpsEndpoint(port: httpsPort, name: instanceName) + .WithEnvironment("INSTANCE_ID", instanceName) + .WaitFor(redis); + + gateway.WaitFor(api); +} + builder.AddProject("client") - .WaitFor(api); + .WaitFor(gateway); builder.Build().Run(); \ No newline at end of file diff --git a/Vehicle.AppHost/Vehicle.AppHost.csproj b/Vehicle.AppHost/Vehicle.AppHost.csproj index 280f5b4b..18a7b545 100644 --- a/Vehicle.AppHost/Vehicle.AppHost.csproj +++ b/Vehicle.AppHost/Vehicle.AppHost.csproj @@ -18,6 +18,7 @@ + diff --git a/Vehicle.AppHost/appsettings.json b/Vehicle.AppHost/appsettings.json index 31c092aa..79ba158e 100644 --- a/Vehicle.AppHost/appsettings.json +++ b/Vehicle.AppHost/appsettings.json @@ -1,9 +1,15 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "ApiService": { + "Ports": [ 7101, 7102, 7103, 7104, 7105 ] + }, + "Gateway": { + "Port": 7200 } } diff --git a/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs new file mode 100644 index 00000000..e0083158 --- /dev/null +++ b/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -0,0 +1,118 @@ +using Ocelot.LoadBalancer.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Vehicle.Gateway.LoadBalancing; + +/// +/// Кастомный балансировщик нагрузки для Ocelot. +/// Реализует алгоритм Weighted Random через расширенный пул реплик: чем больше вес реплики, тем чаще она попадает в пул выбора. +/// +/// +/// Создаёт балансировщик и загружает веса реплик из конфигурации. +/// +/// Конфигурация приложения. +/// Функция получения доступных downstream-сервисов. +public sealed class WeightedRandomLoadBalancer(IConfiguration configuration, Func>> getServices) : ILoadBalancer +{ + private readonly Dictionary<(string Host, int Port), int> _weights = ReadWeights(configuration); + + /// + /// Имя балансировщика. + /// + public string Type => nameof(WeightedRandomLoadBalancer); + + /// + /// Выбирает downstream-сервис для текущего запроса. + /// + /// HTTP-контекст запроса. + /// Выбранный адрес downstream-сервиса. + public async Task> LeaseAsync(HttpContext _) + { + var services = await getServices(); + + if (services.Count == 0) + { + return new ErrorResponse( + new UnableToFindLoadBalancerError("No downstream services available")); + } + + var selectionPool = BuildSelectionPool(services); + + if (selectionPool.Count == 0) + { + return new ErrorResponse( + new UnableToFindLoadBalancerError("No services were added to the weighted selection pool")); + } + + var randomIndex = Random.Shared.Next(selectionPool.Count); + var selected = selectionPool[randomIndex]; + + return new OkResponse(selected); + } + + /// + /// Освобождение ресурса + /// + /// Адрес downstream-сервиса. + public void Release(ServiceHostAndPort _) { } + + /// + /// Строит расширенный пул выбора: каждая реплика добавляется в список столько раз, сколько равен её вес. + /// + /// Список доступных downstream-сервисов. + /// Расширенный пул для случайного выбора. + private List BuildSelectionPool(IEnumerable services) + { + var pool = new List(); + + foreach (var service in services) + { + var key = ( + service.HostAndPort.DownstreamHost.ToLowerInvariant(), + service.HostAndPort.DownstreamPort + ); + + var weight = _weights.GetValueOrDefault(key, 1); + weight = Math.Max(weight, 1); + + for (var i = 0; i < weight; i++) + { + pool.Add(service.HostAndPort); + } + } + + return pool; + } + + /// + /// Читает веса реплик из секции WeightedRandom:Replicas. + /// + /// Конфигурация приложения. + /// Словарь весов реплик. + private static Dictionary<(string Host, int Port), int> ReadWeights(IConfiguration configuration) + { + var result = new Dictionary<(string Host, int Port), int>(); + + foreach (var item in configuration.GetSection("WeightedRandom:Replicas").GetChildren()) + { + var endpoint = item.Key; + var separatorIndex = endpoint.LastIndexOf(':'); + + if (separatorIndex <= 0 || separatorIndex >= endpoint.Length - 1) + continue; + + var host = endpoint[..separatorIndex].Trim().ToLowerInvariant(); + var portText = endpoint[(separatorIndex + 1)..].Trim(); + + if (!int.TryParse(portText, out var port)) + continue; + if (!int.TryParse(item.Value, out var weight)) + continue; + + result[(host, port)] = Math.Max(weight, 1); + } + return result; + } +} \ No newline at end of file diff --git a/Vehicle.Gateway/Program.cs b/Vehicle.Gateway/Program.cs new file mode 100644 index 00000000..1b2eab7d --- /dev/null +++ b/Vehicle.Gateway/Program.cs @@ -0,0 +1,41 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Vehicle.Gateway.LoadBalancing; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration + .SetBasePath(builder.Environment.ContentRootPath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((serviceProvider, _, discoveryProvider) => + { + var configuration = serviceProvider.GetRequiredService(); + + if (discoveryProvider is null) + { + throw new InvalidOperationException("Ocelot service discovery provider is not available."); + } + + return new WeightedRandomLoadBalancer( + configuration, + discoveryProvider.GetAsync); + }); + +var app = builder.Build(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "Vehicle.Gateway", + status = "ok", + message = "Gateway is running" +})); + +await app.UseOcelot(); +await app.RunAsync(); \ No newline at end of file diff --git a/Vehicle.Gateway/Properties/launchSettings.json b/Vehicle.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..306a69d3 --- /dev/null +++ b/Vehicle.Gateway/Properties/launchSettings.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:46271", + "sslPort": 44324 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Vehicle.Gateway/Vehicle.Gateway.csproj b/Vehicle.Gateway/Vehicle.Gateway.csproj new file mode 100644 index 00000000..095b1747 --- /dev/null +++ b/Vehicle.Gateway/Vehicle.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Vehicle.Gateway/appsettings.Development.json b/Vehicle.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Vehicle.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Vehicle.Gateway/appsettings.json b/Vehicle.Gateway/appsettings.json new file mode 100644 index 00000000..bd7bb94d --- /dev/null +++ b/Vehicle.Gateway/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Information" + } + }, + + "AllowedHosts": "*", + + "WeightedRandom": { + "Replicas": { + "localhost:7101": 5, + "localhost:7102": 4, + "localhost:7103": 3, + "localhost:7104": 2, + "localhost:7105": 1 + } + } +} \ No newline at end of file diff --git a/Vehicle.Gateway/ocelot.json b/Vehicle.Gateway/ocelot.json new file mode 100644 index 00000000..bfd3ded8 --- /dev/null +++ b/Vehicle.Gateway/ocelot.json @@ -0,0 +1,38 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/gateway/Vehicles", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/api/Vehicles", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7101 + }, + { + "Host": "localhost", + "Port": 7102 + }, + { + "Host": "localhost", + "Port": 7103 + }, + { + "Host": "localhost", + "Port": 7104 + }, + { + "Host": "localhost", + "Port": 7105 + } + ], + "LoadBalancerOptions": { + "Type": "WeightedRandomLoadBalancer" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:7200" + } +} \ No newline at end of file From 51585e8447570af92e7e2a668da225ffba82301d Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Sat, 4 Apr 2026 01:23:29 +0400 Subject: [PATCH 06/10] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vehicle.Api/Program.cs | 13 ------ .../WeightedRandomLoadBalancer.cs | 40 +++++++++---------- Vehicle.Gateway/Program.cs | 20 +++++++--- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs index a4ab84b4..88bd9978 100644 --- a/Vehicle.Api/Program.cs +++ b/Vehicle.Api/Program.cs @@ -6,17 +6,6 @@ builder.AddServiceDefaults(); -builder.Services.AddCors(options => -{ - options.AddPolicy("ClientCors", policy => - { - policy - .AllowAnyOrigin() - .WithMethods("GET") - .WithHeaders("Content-Type"); - }); -}); - builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -39,8 +28,6 @@ app.UseHttpsRedirection(); -app.UseCors("ClientCors"); - app.MapGet("/", () => { var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; diff --git a/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs index e0083158..59004b81 100644 --- a/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs +++ b/Vehicle.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -93,26 +93,24 @@ private List BuildSelectionPool(IEnumerable service /// Словарь весов реплик. private static Dictionary<(string Host, int Port), int> ReadWeights(IConfiguration configuration) { - var result = new Dictionary<(string Host, int Port), int>(); - - foreach (var item in configuration.GetSection("WeightedRandom:Replicas").GetChildren()) - { - var endpoint = item.Key; - var separatorIndex = endpoint.LastIndexOf(':'); - - if (separatorIndex <= 0 || separatorIndex >= endpoint.Length - 1) - continue; - - var host = endpoint[..separatorIndex].Trim().ToLowerInvariant(); - var portText = endpoint[(separatorIndex + 1)..].Trim(); - - if (!int.TryParse(portText, out var port)) - continue; - if (!int.TryParse(item.Value, out var weight)) - continue; - - result[(host, port)] = Math.Max(weight, 1); - } - return result; + return configuration + .GetSection("WeightedRandom:Replicas") + .GetChildren() + .Where(item => !string.IsNullOrWhiteSpace(item.Key) && int.TryParse(item.Value, out _)) + .ToDictionary( + item => + { + var parts = item.Key.Split(':', 2, StringSplitOptions.TrimEntries); + + if (parts.Length != 2 || !int.TryParse(parts[1], out var port)) + { + throw new InvalidOperationException( + $"Invalid replica endpoint format: '{item.Key}'. Expected format: host:port"); + } + + return (parts[0].ToLowerInvariant(), port); + }, + item => Math.Max(int.Parse(item.Value!), 1) + ); } } \ No newline at end of file diff --git a/Vehicle.Gateway/Program.cs b/Vehicle.Gateway/Program.cs index 1b2eab7d..d6bba55a 100644 --- a/Vehicle.Gateway/Program.cs +++ b/Vehicle.Gateway/Program.cs @@ -6,6 +6,17 @@ builder.AddServiceDefaults(); +builder.Services.AddCors(options => +{ + options.AddPolicy("GatewayCors", policy => + { + policy + .AllowAnyOrigin() + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + builder.Configuration .SetBasePath(builder.Environment.ContentRootPath) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) @@ -30,12 +41,9 @@ var app = builder.Build(); -app.MapGet("/", () => Results.Ok(new -{ - service = "Vehicle.Gateway", - status = "ok", - message = "Gateway is running" -})); +app.MapDefaultEndpoints(); + +app.UseCors("GatewayCors"); await app.UseOcelot(); await app.RunAsync(); \ No newline at end of file From b8fdd027dc11632f428e316318afd3e1c4b2d75a Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Sat, 2 May 2026 07:24:24 +0400 Subject: [PATCH 07/10] Add lab 3 integration testing and event sink --- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 12 + Vehicle.Api/Messaging/IProducerService.cs | 17 ++ Vehicle.Api/Messaging/SnsOptions.cs | 12 + Vehicle.Api/Messaging/SnsPublisherService.cs | 53 ++++ Vehicle.Api/Program.cs | 44 +++- Vehicle.Api/Properties/launchSettings.json | 2 +- Vehicle.Api/Services/VehicleService.cs | 67 +++--- Vehicle.Api/Vehicle.Api.csproj | 1 + Vehicle.AppHost.Tests/IntegrationTests.cs | 226 ++++++++++++++++++ .../Vehicle.AppHost.Tests.csproj | 33 +++ Vehicle.AppHost/AppHost.cs | 79 +++++- Vehicle.AppHost/Vehicle.AppHost.csproj | 1 + Vehicle.AppHost/appsettings.json | 19 ++ .../Controllers/S3StorageController.cs | 63 +++++ .../Controllers/SnsSubscriberController.cs | 92 +++++++ .../Messaging/SnsSubscriptionService.cs | 68 ++++++ Vehicle.EventSink/Program.cs | 146 +++++++++++ .../Properties/launchSettings.json | 68 ++++++ Vehicle.EventSink/Storage/IS3Service.cs | 31 +++ Vehicle.EventSink/Storage/S3MinioService.cs | 173 ++++++++++++++ Vehicle.EventSink/Vehicle.EventSink.csproj | 20 ++ .../appsettings.Development.json | 8 + Vehicle.EventSink/appsettings.json | 21 ++ 24 files changed, 1210 insertions(+), 48 deletions(-) create mode 100644 Vehicle.Api/Messaging/IProducerService.cs create mode 100644 Vehicle.Api/Messaging/SnsOptions.cs create mode 100644 Vehicle.Api/Messaging/SnsPublisherService.cs create mode 100644 Vehicle.AppHost.Tests/IntegrationTests.cs create mode 100644 Vehicle.AppHost.Tests/Vehicle.AppHost.Tests.csproj create mode 100644 Vehicle.EventSink/Controllers/S3StorageController.cs create mode 100644 Vehicle.EventSink/Controllers/SnsSubscriberController.cs create mode 100644 Vehicle.EventSink/Messaging/SnsSubscriptionService.cs create mode 100644 Vehicle.EventSink/Program.cs create mode 100644 Vehicle.EventSink/Properties/launchSettings.json create mode 100644 Vehicle.EventSink/Storage/IS3Service.cs create mode 100644 Vehicle.EventSink/Storage/S3MinioService.cs create mode 100644 Vehicle.EventSink/Vehicle.EventSink.csproj create mode 100644 Vehicle.EventSink/appsettings.Development.json create mode 100644 Vehicle.EventSink/appsettings.json diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 4444c8a5..81ec88f6 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7096/api/Vehicles" + "BaseAddress": "https://localhost:7200/gateway/Vehicles" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index dd2450ac..0dd682ad 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.Api", "Vehicle.Api\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.Gateway", "Vehicle.Gateway\Vehicle.Gateway.csproj", "{6A946D9E-A3D9-4345-929B-1415379D9102}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.EventSink", "Vehicle.EventSink\Vehicle.EventSink.csproj", "{167FB7E9-87E2-4CCA-9682-4AFB88CB056F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Vehicle.AppHost.Tests", "Vehicle.AppHost.Tests\Vehicle.AppHost.Tests.csproj", "{1CF24179-B133-42DA-B577-9044C89F2A13}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +43,14 @@ Global {6A946D9E-A3D9-4345-929B-1415379D9102}.Debug|Any CPU.Build.0 = Debug|Any CPU {6A946D9E-A3D9-4345-929B-1415379D9102}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A946D9E-A3D9-4345-929B-1415379D9102}.Release|Any CPU.Build.0 = Release|Any CPU + {167FB7E9-87E2-4CCA-9682-4AFB88CB056F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {167FB7E9-87E2-4CCA-9682-4AFB88CB056F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {167FB7E9-87E2-4CCA-9682-4AFB88CB056F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {167FB7E9-87E2-4CCA-9682-4AFB88CB056F}.Release|Any CPU.Build.0 = Release|Any CPU + {1CF24179-B133-42DA-B577-9044C89F2A13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CF24179-B133-42DA-B577-9044C89F2A13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CF24179-B133-42DA-B577-9044C89F2A13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CF24179-B133-42DA-B577-9044C89F2A13}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Vehicle.Api/Messaging/IProducerService.cs b/Vehicle.Api/Messaging/IProducerService.cs new file mode 100644 index 00000000..8e7fbbe0 --- /dev/null +++ b/Vehicle.Api/Messaging/IProducerService.cs @@ -0,0 +1,17 @@ +using Vehicle.Api.Entities; + +namespace Vehicle.Api.Messaging; + +/// +/// Интерфейс службы для отправки генерируемых транспортных средств в брокер сообщений. +/// +public interface IProducerService +{ + /// + /// Асинхронно отправляет сообщение, содержащее информацию о транспортном средстве. + /// + /// Объект транспортного средства, который необходимо отправить. + /// Токен отмены операции. + /// Задача, представляющая асинхронную операцию отправки. + public Task SendMessageAsync(VehicleEntity vehicle, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Vehicle.Api/Messaging/SnsOptions.cs b/Vehicle.Api/Messaging/SnsOptions.cs new file mode 100644 index 00000000..d5fb91a2 --- /dev/null +++ b/Vehicle.Api/Messaging/SnsOptions.cs @@ -0,0 +1,12 @@ +namespace Vehicle.Api.Messaging; + +/// +/// Настройки SNS для отправки сообщений в брокер. +/// +public class SnsOptions +{ + /// + /// ARN SNS topic. + /// + public string TopicArn { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Vehicle.Api/Messaging/SnsPublisherService.cs b/Vehicle.Api/Messaging/SnsPublisherService.cs new file mode 100644 index 00000000..a734047e --- /dev/null +++ b/Vehicle.Api/Messaging/SnsPublisherService.cs @@ -0,0 +1,53 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Microsoft.Extensions.Options; +using System.Net; +using System.Text.Json; +using Vehicle.Api.Entities; + +namespace Vehicle.Api.Messaging; + +/// +/// Служба для отправки сообщений в SNS. +/// +/// Клиент SNS. +/// Настройки SNS. +/// Логгер. +public class SnsPublisherService(IAmazonSimpleNotificationService client, IOptions options, ILogger logger) : IProducerService +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + private readonly string _topicArn = !string.IsNullOrWhiteSpace(options.Value.TopicArn) + ? options.Value.TopicArn + : throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + /// + public async Task SendMessageAsync(VehicleEntity vehicle, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(vehicle, _jsonOptions); + + var request = new PublishRequest + { + Message = json, + TopicArn = _topicArn + }; + + var response = await client.PublishAsync(request, cancellationToken); + + if (response.HttpStatusCode == HttpStatusCode.OK) + { + logger.LogInformation("Vehicle {VehicleId} was sent to file service via SNS", vehicle.Id); + } + else + { + throw new Exception($"SNS returned {response.HttpStatusCode}"); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Unable to send vehicle through SNS topic"); + } + } +} \ No newline at end of file diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs index 88bd9978..c444feee 100644 --- a/Vehicle.Api/Program.cs +++ b/Vehicle.Api/Program.cs @@ -1,6 +1,9 @@ using Vehicle.Api.Cache; using Vehicle.Api.Generation; using Vehicle.Api.Services; +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Vehicle.Api.Messaging; var builder = WebApplication.CreateBuilder(args); @@ -14,6 +17,33 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure(options => +{ + options.TopicArn = + builder.Configuration["AWS:Resources:SNSTopicArn"] + ?? "arn:aws:sns:us-east-1:000000000000:vehicle-generated"; +}); + +builder.Services.AddSingleton(serviceProvider => +{ + var configuration = serviceProvider.GetRequiredService(); + + var serviceUrl = configuration["AWS:ServiceUrl"] ?? "http://localhost:4566"; + var region = configuration["AWS:Region"] ?? "us-east-1"; + + var config = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = region + }; + + return new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + config); +}); + +builder.Services.AddSingleton(); + builder.AddRedisDistributedCache("redis"); var app = builder.Build(); @@ -28,6 +58,13 @@ app.UseHttpsRedirection(); +app.Use(async (context, next) => +{ + var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; + context.Response.Headers["X-Instance-Id"] = instanceId; + await next(); +}); + app.MapGet("/", () => { var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; @@ -43,11 +80,4 @@ app.MapControllers(); -app.Use(async (context, next) => -{ - var instanceId = Environment.GetEnvironmentVariable("INSTANCE_ID") ?? "vehicle-api-unknown"; - context.Response.Headers["X-Instance-Id"] = instanceId; - await next(); -}); - app.Run(); \ No newline at end of file diff --git a/Vehicle.Api/Properties/launchSettings.json b/Vehicle.Api/Properties/launchSettings.json index 2c71ad7d..5df10d29 100644 --- a/Vehicle.Api/Properties/launchSettings.json +++ b/Vehicle.Api/Properties/launchSettings.json @@ -7,7 +7,7 @@ "applicationUrl": "http://localhost:37132", "sslPort": 44319 } - }, + }, "profiles": { "http": { "commandName": "Project", diff --git a/Vehicle.Api/Services/VehicleService.cs b/Vehicle.Api/Services/VehicleService.cs index e883eacb..cd00f2ef 100644 --- a/Vehicle.Api/Services/VehicleService.cs +++ b/Vehicle.Api/Services/VehicleService.cs @@ -2,13 +2,14 @@ using Vehicle.Api.Cache; using Vehicle.Api.Entities; using Vehicle.Api.Generation; +using Vehicle.Api.Messaging; namespace Vehicle.Api.Services; /// /// Сервис для работы с транспортными средствами (генерация и получение по ID) /// -public class VehicleService(VehicleGenerator generator, IVehicleCache vehicleCache, ILogger logger) +public class VehicleService(VehicleGenerator generator, IVehicleCache vehicleCache, IProducerService producerService, ILogger logger) { /// /// Получает транспортное средство по ID (из кэша или генерирует новое) @@ -16,53 +17,36 @@ public class VehicleService(VehicleGenerator generator, IVehicleCache vehicleCac /// Идентификатор транспортного средства /// Токен отмены операции /// Транспортное средство с указанным ID - public async Task GetByIdAsync( - int id, - CancellationToken cancellationToken = default) + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { var cacheKey = $"vehicle:{id}"; var stopwatch = Stopwatch.StartNew(); var cachedVehicle = await TryReadCacheAsync( - () => vehicleCache.GetOneAsync(cacheKey, cancellationToken), - "Failed to read vehicle from cache. Key: {CacheKey}", - cacheKey); + () => vehicleCache.GetOneAsync(cacheKey, cancellationToken), "Failed to read vehicle from cache. Key: {CacheKey}", cacheKey); if (cachedVehicle is not null) { stopwatch.Stop(); - - logger.LogInformation( - "Cache hit for key {CacheKey}. Returned vehicle with id {Id} in {ElapsedMs} ms", - cacheKey, - id, - stopwatch.ElapsedMilliseconds); - + logger.LogInformation("Cache hit for key {CacheKey}. Returned vehicle with id {Id} in {ElapsedMs} ms", cacheKey, id, stopwatch.ElapsedMilliseconds); return cachedVehicle; } - logger.LogInformation( - "Cache miss for key {CacheKey}. Generating vehicle with id {Id}", - cacheKey, - id); + logger.LogInformation("Cache miss for key {CacheKey}. Generating vehicle with id {Id}", cacheKey, id); var vehicle = generator.Generate(id); await TryWriteCacheAsync( - () => vehicleCache.SetOneAsync( - cacheKey, - vehicle, - TimeSpan.FromMinutes(5), - cancellationToken), - "Failed to write vehicle to cache. Key: {CacheKey}", - cacheKey); + () => vehicleCache.SetOneAsync(cacheKey, vehicle, TimeSpan.FromMinutes(5), cancellationToken), + "Failed to write vehicle to cache. Key: {CacheKey}", cacheKey); + + await TryPublishAsync( + () => producerService.SendMessageAsync(vehicle, cancellationToken), + "Failed to publish generated vehicle to SNS. Vehicle id: {VehicleId}", id); stopwatch.Stop(); - logger.LogInformation( - "Generated vehicle with id {Id} in {ElapsedMs} ms", - id, - stopwatch.ElapsedMilliseconds); + logger.LogInformation("Generated vehicle with id {Id} in {ElapsedMs} ms", id, stopwatch.ElapsedMilliseconds); return vehicle; } @@ -70,10 +54,7 @@ await TryWriteCacheAsync( /// /// Безопасно читает данные из кэша. /// - private async Task TryReadCacheAsync( - Func> action, - string warningMessage, - string cacheKey) + private async Task TryReadCacheAsync(Func> action, string warningMessage, string cacheKey) { try { @@ -89,10 +70,7 @@ await TryWriteCacheAsync( /// /// Безопасно записывает данные в кэш. /// - private async Task TryWriteCacheAsync( - Func action, - string warningMessage, - string cacheKey) + private async Task TryWriteCacheAsync(Func action, string warningMessage, string cacheKey) { try { @@ -103,4 +81,19 @@ private async Task TryWriteCacheAsync( logger.LogWarning(ex, warningMessage, cacheKey); } } + + /// + /// Безопасно публикует данные о сгенерированном транспортном средстве в брокер сообщений. + /// + private async Task TryPublishAsync(Func action, string warningMessage, int vehicleId) + { + try + { + await action(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, warningMessage, vehicleId); + } + } } \ No newline at end of file diff --git a/Vehicle.Api/Vehicle.Api.csproj b/Vehicle.Api/Vehicle.Api.csproj index ff3ec65b..c07609d7 100644 --- a/Vehicle.Api/Vehicle.Api.csproj +++ b/Vehicle.Api/Vehicle.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/Vehicle.AppHost.Tests/IntegrationTests.cs b/Vehicle.AppHost.Tests/IntegrationTests.cs new file mode 100644 index 00000000..4e10ad00 --- /dev/null +++ b/Vehicle.AppHost.Tests/IntegrationTests.cs @@ -0,0 +1,226 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Vehicle.Api.Entities; +using Xunit.Abstractions; + +namespace Vehicle.AppHost.Tests; + +/// +/// Интеграционные тесты для проверки микросервисного пайплайна Vehicle. +/// +/// Служба журналирования юнит-тестов. +public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + + private static readonly JsonSerializerOptions _jsonOptions = + new(JsonSerializerDefaults.Web); + + /// + public async Task InitializeAsync() + { + var cancellationToken = CancellationToken.None; + + _builder = await DistributedApplicationTestingBuilder + .CreateAsync(cancellationToken); + + _builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + + _builder.Services.AddLogging(logging => + { + logging.AddXUnit(output); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); + logging.AddFilter("Aspire.Hosting", LogLevel.Debug); + }); + } + + /// + /// Проверяет, что вызов гейтвея: + /// + /// В ответ отправляет сгенерированное транспортное средство. + /// Отправляет данные через SNS в Vehicle.EventSink. + /// Сериализует транспортное средство в JSON-файл и сохраняет его в Minio. + /// Проверяет, что данные из API и объектного хранилища идентичны. + /// + /// + /// Запускаемый профиль окружения. + [Theory] + [InlineData("SNS+MinioS3")] + public async Task TestPipeline(string envName) + { + var cancellationToken = CancellationToken.None; + + _builder!.Environment.EnvironmentName = envName; + + _app = await _builder.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + using var sinkClient = _app.CreateHttpClient( + "vehicle-event-sink", + "event-sink-http"); + + // Ждем, пока Vehicle.EventSink и Minio начнут отвечать. + await WaitForEventSinkAsync( + sinkClient, + TimeSpan.FromSeconds(60), + cancellationToken); + + // Даем SNS subscription время подтвердиться после старта EventSink. + await Task.Delay(TimeSpan.FromSeconds(20), cancellationToken); + + var id = Random.Shared.Next(100_000, 999_999); + + using var gatewayClient = _app.CreateHttpClient( + "vehicle-gateway", + "vehicle-gateway-lb"); + + using var gatewayResponse = await gatewayClient.GetAsync( + $"/gateway/Vehicles?id={id}", + cancellationToken); + + var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(cancellationToken); + + Assert.True( + gatewayResponse.IsSuccessStatusCode, + $"Gateway returned {(int)gatewayResponse.StatusCode}: {gatewayContent}"); + + var apiVehicle = JsonSerializer.Deserialize( + gatewayContent, + _jsonOptions); + + Assert.NotNull(apiVehicle); + Assert.Equal(id, apiVehicle.Id); + + var vehicleFileName = await WaitForVehicleFileAsync( + sinkClient, + id, + TimeSpan.FromSeconds(90), + + output, cancellationToken); + + Assert.False( + string.IsNullOrWhiteSpace(vehicleFileName), + $"File vehicle_{id}_*.json was not found in Minio"); + + using var s3Response = await sinkClient.GetAsync( + $"/api/s3/{vehicleFileName}", + cancellationToken); + + var s3Content = await s3Response.Content.ReadAsStringAsync(cancellationToken); + + Assert.True( + s3Response.IsSuccessStatusCode, + $"EventSink returned {(int)s3Response.StatusCode}: {s3Content}"); + + var s3Vehicle = JsonSerializer.Deserialize( + s3Content, + _jsonOptions); + + Assert.NotNull(s3Vehicle); + Assert.Equal(id, s3Vehicle.Id); + Assert.Equivalent(apiVehicle, s3Vehicle); + } + + /// + /// Ждет, пока Vehicle.EventSink начнет отвечать на запросы к /api/s3. + /// + private static async Task WaitForEventSinkAsync( + HttpClient sinkClient, + TimeSpan timeout, + CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + var lastResponse = string.Empty; + + while (DateTimeOffset.UtcNow < deadline) + { + try + { + using var response = await sinkClient.GetAsync( + "/api/s3", + cancellationToken); + + lastResponse = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.IsSuccessStatusCode) + { + return; + } + } + catch (Exception ex) + { + lastResponse = ex.Message; + } + + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + } + + throw new TimeoutException( + $"Vehicle.EventSink did not become ready in time. Last response: {lastResponse}"); + } + + /// + /// Ждет появления JSON-файла vehicle_{id}_*.json в Minio. + /// + private static async Task WaitForVehicleFileAsync( + HttpClient sinkClient, + int vehicleId, + TimeSpan timeout, + ITestOutputHelper output, CancellationToken cancellationToken) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + var expectedPrefix = $"vehicle_{vehicleId}_"; + var lastFileList = string.Empty; + + while (DateTimeOffset.UtcNow < deadline) + { + using var listResponse = await sinkClient.GetAsync( + "/api/s3", + cancellationToken); + + lastFileList = await listResponse.Content.ReadAsStringAsync(cancellationToken); + + if (listResponse.IsSuccessStatusCode) + { + var vehicleList = JsonSerializer.Deserialize>( + lastFileList, + _jsonOptions); + + var vehicleFileName = vehicleList? + .FirstOrDefault(file => file.StartsWith( + expectedPrefix, + StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(vehicleFileName)) + { + return vehicleFileName; + } + } + + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + } + + output.WriteLine( + $"File with prefix {expectedPrefix} was not found. Last file list: {lastFileList}"); + + return null; + } + + /// + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + if (_builder is not null) + { + await _builder.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/Vehicle.AppHost.Tests/Vehicle.AppHost.Tests.csproj b/Vehicle.AppHost.Tests/Vehicle.AppHost.Tests.csproj new file mode 100644 index 00000000..dab859c8 --- /dev/null +++ b/Vehicle.AppHost.Tests/Vehicle.AppHost.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs index 4164ddfc..97433d06 100644 --- a/Vehicle.AppHost/AppHost.cs +++ b/Vehicle.AppHost/AppHost.cs @@ -8,9 +8,75 @@ var gatewayPort = builder.Configuration.GetValue("Gateway:Port") ?? throw new InvalidOperationException("Gateway:Port is not configured."); +var localStackPort = builder.Configuration.GetValue("LocalStack:Port") + ?? throw new InvalidOperationException("LocalStack:Port is not configured."); + +var snsServiceUrl = builder.Configuration["LocalStack:ServiceUrl"] + ?? throw new InvalidOperationException("LocalStack:ServiceUrl is not configured."); + +var snsTopicArn = builder.Configuration["SNS:TopicArn"] + ?? throw new InvalidOperationException("SNS:TopicArn is not configured."); + +var snsEndpointUrl = builder.Configuration["SNS:EndpointUrl"] + ?? throw new InvalidOperationException("SNS:EndpointUrl is not configured."); + +var minioApiPort = builder.Configuration.GetValue("Minio:ApiPort") + ?? throw new InvalidOperationException("Minio:ApiPort is not configured."); + +var minioConsolePort = builder.Configuration.GetValue("Minio:ConsolePort") + ?? throw new InvalidOperationException("Minio:ConsolePort is not configured."); + +var minioEndpoint = builder.Configuration["Minio:Endpoint"] + ?? throw new InvalidOperationException("Minio:Endpoint is not configured."); + +var minioAccessKey = builder.Configuration["Minio:AccessKey"] + ?? throw new InvalidOperationException("Minio:AccessKey is not configured."); + +var minioSecretKey = builder.Configuration["Minio:SecretKey"] + ?? throw new InvalidOperationException("Minio:SecretKey is not configured."); + +var minioBucketName = builder.Configuration["Minio:BucketName"] + ?? throw new InvalidOperationException("Minio:BucketName is not configured."); + var redis = builder.AddRedis("redis") .WithRedisCommander(); +var localstack = builder.AddContainer("localstack", "localstack/localstack:3") + .WithEnvironment("SERVICES", "sns") + .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") + .WithEnvironment("DEBUG", "1") + .WithEnvironment("HOSTNAME_EXTERNAL", "host.docker.internal") + .WithContainerRuntimeArgs("--add-host", "host.docker.internal:host-gateway") + .WithHttpEndpoint(port: localStackPort, targetPort: 4566, name: "edge"); + +var minio = builder.AddContainer("minio", "minio/minio") + .WithEnvironment("MINIO_ROOT_USER", minioAccessKey) + .WithEnvironment("MINIO_ROOT_PASSWORD", minioSecretKey) + .WithArgs("server", "/data", "--console-address", ":9001") + .WithHttpEndpoint(port: minioApiPort, targetPort: 9000, name: "s3") + .WithHttpEndpoint(port: minioConsolePort, targetPort: 9001, name: "console"); + +var eventSink = builder.AddProject( + "vehicle-event-sink", + launchProfileName: null) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") + .WithEnvironment("ASPNETCORE_URLS", "http://0.0.0.0:5303") + .WithHttpEndpoint( + port: 5303, + targetPort: 5303, + name: "event-sink-http", + isProxied: false) + .WithEnvironment("AWS__Region", "us-east-1") + .WithEnvironment("AWS__ServiceUrl", snsServiceUrl) + .WithEnvironment("AWS__Resources__SNSTopicArn", snsTopicArn) + .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5303/api/sns") + .WithEnvironment("AWS__Resources__MinioEndpoint", "localhost:9000") + .WithEnvironment("AWS__Resources__MinioAccessKey", "minioadmin") + .WithEnvironment("AWS__Resources__MinioSecretKey", "minioadmin") + .WithEnvironment("AWS__Resources__MinioBucketName", "vehicle-files") + .WaitFor(localstack) + .WaitFor(minio); + var gateway = builder.AddProject("vehicle-gateway") .WithHttpsEndpoint(port: gatewayPort, name: "vehicle-gateway-lb") .WaitFor(redis); @@ -19,11 +85,20 @@ { var httpsPort = apiPorts[i]; var instanceName = $"vehicle-api-{i + 1}"; - var api = builder.AddProject($"vehicle-api-{i + 1}", launchProfileName: null) + + var api = builder.AddProject( + $"vehicle-api-{i + 1}", + launchProfileName: null) .WithReference(redis) .WithHttpsEndpoint(port: httpsPort, name: instanceName) + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithEnvironment("INSTANCE_ID", instanceName) - .WaitFor(redis); + .WithEnvironment("AWS__Region", "us-east-1") + .WithEnvironment("AWS__ServiceUrl", snsServiceUrl) + .WithEnvironment("AWS__Resources__SNSTopicArn", snsTopicArn) + .WaitFor(redis) + .WaitFor(localstack) + .WaitFor(eventSink); gateway.WaitFor(api); } diff --git a/Vehicle.AppHost/Vehicle.AppHost.csproj b/Vehicle.AppHost/Vehicle.AppHost.csproj index 18a7b545..51ea15d2 100644 --- a/Vehicle.AppHost/Vehicle.AppHost.csproj +++ b/Vehicle.AppHost/Vehicle.AppHost.csproj @@ -18,6 +18,7 @@ + diff --git a/Vehicle.AppHost/appsettings.json b/Vehicle.AppHost/appsettings.json index 79ba158e..f15b84a9 100644 --- a/Vehicle.AppHost/appsettings.json +++ b/Vehicle.AppHost/appsettings.json @@ -11,5 +11,24 @@ }, "Gateway": { "Port": 7200 + }, + "LocalStack": { + "Port": 4566, + "ServiceUrl": "http://localhost:4566" + }, + "SNS": { + "TopicArn": "arn:aws:sns:us-east-1:000000000000:vehicle-generated", + "EndpointUrl": "http://host.docker.internal:5303/api/sns" + }, + "EventSink": { + "Port": 5303 + }, + "Minio": { + "ApiPort": 9000, + "ConsolePort": 9001, + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "BucketName": "vehicle-files" } } diff --git a/Vehicle.EventSink/Controllers/S3StorageController.cs b/Vehicle.EventSink/Controllers/S3StorageController.cs new file mode 100644 index 00000000..5bd82302 --- /dev/null +++ b/Vehicle.EventSink/Controllers/S3StorageController.cs @@ -0,0 +1,63 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; +using Vehicle.EventSink.Storage; + +namespace Vehicle.EventSink.Controllers; + +/// +/// Контроллер для взаимодействия с S3-совместимым хранилищем Minio. +/// +/// Служба для работы с S3. +/// Логгер. +[Route("api/s3")] +[ApiController] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Получает список файлов, сохраненных в Minio. + /// + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> ListFiles() + { + logger.LogInformation("Method {Method} of {Controller} was called", nameof(ListFiles), nameof(S3StorageController)); + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Got a list of {Count} files from bucket", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during {Method} of {Controller}", nameof(ListFiles), nameof(S3StorageController)); + return BadRequest(ex.Message); + } + } + + /// + /// Получает JSON-файл из Minio по ключу. + /// + /// Ключ файла. + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> GetFile(string key) + { + logger.LogInformation("Method {Method} of {Controller} was called", nameof(GetFile), nameof(S3StorageController)); + + try + { + var node = await s3Service.DownloadFile(key); + + logger.LogInformation("Received JSON of {Size} bytes", Encoding.UTF8.GetByteCount(node.ToJsonString())); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during {Method} of {Controller}", nameof(GetFile), nameof(S3StorageController)); + return BadRequest(ex.Message); + } + } +} diff --git a/Vehicle.EventSink/Controllers/SnsSubscriberController.cs b/Vehicle.EventSink/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..942d9cf3 --- /dev/null +++ b/Vehicle.EventSink/Controllers/SnsSubscriberController.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; +using Vehicle.EventSink.Storage; + +namespace Vehicle.EventSink.Controllers; + +/// +/// Контроллер для приема сообщений от SNS. +/// +/// Служба для работы с S3. +/// Логгер. +[ApiController] +[Route("api/sns")] +public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(200)] + public async Task ReceiveMessage() + { + logger.LogInformation("SNS webhook was called"); + + try + { + using var reader = new StreamReader(Request.Body, Encoding.UTF8); + var jsonContent = await reader.ReadToEndAsync(); + + var rootNode = JsonNode.Parse(jsonContent) ?? throw new ArgumentException("SNS message is not valid JSON"); + + var messageType = rootNode["Type"]?.GetValue(); + + if (messageType == "SubscriptionConfirmation") + { + logger.LogInformation("SubscriptionConfirmation was received"); + + var subscribeUrl = rootNode["SubscribeURL"]?.GetValue(); + + if (string.IsNullOrWhiteSpace(subscribeUrl)) + { + throw new ArgumentException("SubscribeURL was not found in SNS message"); + } + + using var httpClient = new HttpClient(); + + var builder = new UriBuilder(new Uri(subscribeUrl)) + { + Scheme = "http", + Host = "localhost", + Port = 4566 + }; + + var response = await httpClient.GetAsync(builder.Uri); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + throw new Exception($"SubscriptionConfirmation returned {response.StatusCode}: {body}"); + } + + logger.LogInformation("Subscription was successfully confirmed"); + return Ok(); + } + + if (messageType == "Notification") + { + var messageText = rootNode["Message"]?.GetValue(); + + if (string.IsNullOrWhiteSpace(messageText)) + { + throw new ArgumentException("Message field was not found in SNS notification"); + } + + var uploaded = await s3Service.UploadFile(messageText); + + if (uploaded) + { + logger.LogInformation("Notification was successfully processed"); + } + else + { + logger.LogWarning("Notification was received, but file was not uploaded"); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred while processing SNS notification"); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/Vehicle.EventSink/Messaging/SnsSubscriptionService.cs b/Vehicle.EventSink/Messaging/SnsSubscriptionService.cs new file mode 100644 index 00000000..ace2b5c9 --- /dev/null +++ b/Vehicle.EventSink/Messaging/SnsSubscriptionService.cs @@ -0,0 +1,68 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace Vehicle.EventSink.Messaging; + +/// +/// Служба для подписки файлового сервиса на SNS topic при старте приложения. +/// +/// Клиент SNS. +/// Конфигурация. +/// Логгер. +public class SnsSubscriptionService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) +{ + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + /// + /// Делает попытку подписаться на SNS topic. + /// + public async Task SubscribeEndpoint() + { + logger.LogInformation("Sending subscribe request for {Topic}", _topicArn); + + await EnsureTopicExists(); + + var endpoint = configuration["AWS:Resources:SNSUrl"] + ?? throw new KeyNotFoundException("SNS endpoint URL was not found in configuration"); + + var request = new SubscribeRequest + { + TopicArn = _topicArn, + Protocol = "http", + Endpoint = endpoint, + ReturnSubscriptionArn = true + }; + + var response = await snsClient.SubscribeAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to subscribe to {Topic}", _topicArn); + } + else + { + logger.LogInformation( + "Subscription request for {Topic} is successful, waiting for confirmation", + _topicArn); + } + } + + /// + /// Создает SNS topic при необходимости. + /// Для LocalStack операция CreateTopic является безопасной и идемпотентной. + /// + private async Task EnsureTopicExists() + { + var topicName = _topicArn.Split(':').Last(); + + logger.LogInformation("Ensuring SNS topic {TopicName} exists", topicName); + + await snsClient.CreateTopicAsync( + new CreateTopicRequest + { + Name = topicName + }); + } +} \ No newline at end of file diff --git a/Vehicle.EventSink/Program.cs b/Vehicle.EventSink/Program.cs new file mode 100644 index 00000000..2d363013 --- /dev/null +++ b/Vehicle.EventSink/Program.cs @@ -0,0 +1,146 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Minio; +using Vehicle.EventSink.Messaging; +using Vehicle.EventSink.Storage; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(CreateSnsClient); +builder.Services.AddSingleton(CreateMinioClient); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +LogConfiguration(app); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapGet("/", () => Results.Ok(new +{ + service = "Vehicle.EventSink", + status = "ok", + message = "Vehicle EventSink is running" +})); + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Lifetime.ApplicationStarted.Register(() => +{ + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(5)); + await InitializeEventSinkAsync(app); + }); +}); + +await app.RunAsync(); + +static IAmazonSimpleNotificationService CreateSnsClient(IServiceProvider serviceProvider) +{ + var configuration = serviceProvider.GetRequiredService(); + + return new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + new AmazonSimpleNotificationServiceConfig + { + ServiceURL = Required(configuration, "AWS:ServiceUrl"), + AuthenticationRegion = Required(configuration, "AWS:Region") + }); +} + +static IMinioClient CreateMinioClient(IServiceProvider serviceProvider) +{ + var configuration = serviceProvider.GetRequiredService(); + + var endpoint = Required(configuration, "AWS:Resources:MinioEndpoint") + .Replace("http://", string.Empty) + .Replace("https://", string.Empty); + + return new MinioClient() + .WithEndpoint(endpoint) + .WithCredentials( + Required(configuration, "AWS:Resources:MinioAccessKey"), + Required(configuration, "AWS:Resources:MinioSecretKey")) + .WithSSL(false) + .Build(); +} + +static async Task InitializeEventSinkAsync(WebApplication app) +{ + var logger = app.Services + .GetRequiredService() + .CreateLogger("Vehicle.EventSink.Startup"); + + const int maxAttempts = 5; + var retryDelay = TimeSpan.FromSeconds(3); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + logger.LogInformation( + "Initializing Vehicle.EventSink. Attempt {Attempt}/{MaxAttempts}", + attempt, + maxAttempts); + + using var scope = app.Services.CreateScope(); + + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + + var subscriptionService = scope.ServiceProvider.GetRequiredService(); + await subscriptionService.SubscribeEndpoint(); + + logger.LogInformation("Vehicle.EventSink was initialized successfully"); + return; + } + catch (Exception ex) + { + if (attempt == maxAttempts) + { + logger.LogError( + ex, + "Vehicle.EventSink initialization failed after all attempts"); + } + else + { + logger.LogWarning( + ex, + "Vehicle.EventSink is not ready yet. Retry attempt {Attempt}/{MaxAttempts}", + attempt, + maxAttempts); + + await Task.Delay(retryDelay); + } + } + } +} + +static string Required(IConfiguration configuration, string key) +{ + return configuration[key] + ?? throw new KeyNotFoundException($"{key} was not found in configuration"); +} + +static void LogConfiguration(WebApplication app) +{ + app.Logger.LogInformation("AWS Region: {Region}", app.Configuration["AWS:Region"]); + app.Logger.LogInformation("SNS Topic ARN: {TopicArn}", app.Configuration["AWS:Resources:SNSTopicArn"]); + app.Logger.LogInformation("SNS endpoint URL: {SnsUrl}", app.Configuration["AWS:Resources:SNSUrl"]); + app.Logger.LogInformation("Minio endpoint: {MinioEndpoint}", app.Configuration["AWS:Resources:MinioEndpoint"]); + app.Logger.LogInformation("Minio bucket: {Bucket}", app.Configuration["AWS:Resources:MinioBucketName"]); +} \ No newline at end of file diff --git a/Vehicle.EventSink/Properties/launchSettings.json b/Vehicle.EventSink/Properties/launchSettings.json new file mode 100644 index 00000000..7b35eb45 --- /dev/null +++ b/Vehicle.EventSink/Properties/launchSettings.json @@ -0,0 +1,68 @@ +//{ +// "$schema": "http://json.schemastore.org/launchsettings.json", +// "iisSettings": { +// "windowsAuthentication": false, +// "anonymousAuthentication": true, +// "iisExpress": { +// "applicationUrl": "http://localhost:20857", +// "sslPort": 44368 +// } +// }, +// "profiles": { +// "http": { +// "commandName": "Project", +// "dotnetRunMessages": true, +// "launchBrowser": true, +// "launchUrl": "swagger", +// "applicationUrl": "http://localhost:5262", +// "environmentVariables": { +// "ASPNETCORE_ENVIRONMENT": "Development" +// } +// }, +// "https": { +// "commandName": "Project", +// "dotnetRunMessages": true, +// "launchBrowser": true, +// "launchUrl": "swagger", +// "applicationUrl": "https://localhost:7031;http://localhost:5262", +// "environmentVariables": { +// "ASPNETCORE_ENVIRONMENT": "Development" +// } +// }, +// "IIS Express": { +// "commandName": "IISExpress", +// "launchBrowser": true, +// "launchUrl": "swagger", +// "environmentVariables": { +// "ASPNETCORE_ENVIRONMENT": "Development" +// } +// } +// } +//} + + +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": true, + //"launchUrl": "swagger", + "applicationUrl": "http://localhost:5303", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "https": { + "commandName": "Project", + "launchBrowser": true, + //"launchUrl": "swagger", + "applicationUrl": "https://localhost:7301;http://localhost:5303", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + } + } +} \ No newline at end of file diff --git a/Vehicle.EventSink/Storage/IS3Service.cs b/Vehicle.EventSink/Storage/IS3Service.cs new file mode 100644 index 00000000..856b1041 --- /dev/null +++ b/Vehicle.EventSink/Storage/IS3Service.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; + +namespace Vehicle.EventSink.Storage; + +/// +/// Интерфейс службы для работы с файлами в объектном хранилище. +/// +public interface IS3Service +{ + /// + /// Отправляет JSON-файл в объектное хранилище. + /// + /// Строковое представление JSON-файла. + public Task UploadFile(string fileData); + + /// + /// Получает список всех файлов из хранилища. + /// + public Task> GetFileList(); + + /// + /// Получает JSON-файл из хранилища. + /// + /// Ключ файла в бакете. + public Task DownloadFile(string filePath); + + /// + /// Создает бакет при необходимости. + /// + public Task EnsureBucketExists(); +} diff --git a/Vehicle.EventSink/Storage/S3MinioService.cs b/Vehicle.EventSink/Storage/S3MinioService.cs new file mode 100644 index 00000000..fbbfe935 --- /dev/null +++ b/Vehicle.EventSink/Storage/S3MinioService.cs @@ -0,0 +1,173 @@ +using Minio; +using Minio.DataModel.Args; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace Vehicle.EventSink.Storage; + +/// +/// Служба для работы с файлами в объектном хранилище Minio. +/// +/// Minio-клиент. +/// Конфигурация. +/// Логгер. +public class S3MinioService(IMinioClient client, IConfiguration configuration, ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"] + ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + + var request = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithPrefix("") + .WithRecursive(true); + + logger.LogInformation("Began listing files in bucket {Bucket}", _bucketName); + + var responseList = client.ListObjectsEnumAsync(request); + + await foreach (var response in responseList) + { + list.Add(response.Key); + } + + return list; + } + + /// + public async Task UploadFile(string fileData) + { + var rootNode = JsonNode.Parse(fileData) + ?? throw new ArgumentException("Passed string is not a valid JSON"); + + var id = rootNode["id"]?.GetValue() + ?? rootNode["Id"]?.GetValue() + ?? throw new ArgumentException("Passed JSON has invalid structure: field 'id' was not found"); + + var bytes = Encoding.UTF8.GetBytes(fileData); + + using var stream = new MemoryStream(bytes); + stream.Seek(0, SeekOrigin.Begin); + + var objectName = $"vehicle_{id}_{DateTimeOffset.UtcNow:yyyyMMdd_HHmmss_fff}.json"; + + logger.LogInformation( + "Began uploading vehicle {VehicleId} to bucket {Bucket}", + id, + _bucketName); + + var request = new PutObjectArgs() + .WithBucket(_bucketName) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithObject(objectName) + .WithContentType("application/json"); + + var response = await client.PutObjectAsync(request); + + if (response.ResponseStatusCode != HttpStatusCode.OK) + { + logger.LogError( + "Failed to upload vehicle {VehicleId}: {StatusCode}", + id, + response.ResponseStatusCode); + + return false; + } + + logger.LogInformation( + "Finished uploading vehicle {VehicleId} to bucket {Bucket} as {ObjectName}", + id, + _bucketName, + objectName); + + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation( + "Began downloading {File} from bucket {Bucket}", + key, + _bucketName); + + try + { + var memoryStream = new MemoryStream(); + + var request = new GetObjectArgs() + .WithBucket(_bucketName) + .WithObject(key) + .WithCallbackStream(async (stream, cancellationToken) => + { + await stream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Seek(0, SeekOrigin.Begin); + }); + + var response = await client.GetObjectAsync(request); + + if (response is null) + { + logger.LogError("Failed to download {File}", key); + throw new InvalidOperationException($"Error occurred downloading {key}: object is null"); + } + + using var reader = new StreamReader(memoryStream, Encoding.UTF8); + var content = await reader.ReadToEndAsync(); + + return JsonNode.Parse(content) + ?? throw new InvalidOperationException("Downloaded document is not a valid JSON"); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Exception occurred during downloading file {File}", + key); + + throw; + } + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether bucket {Bucket} exists", _bucketName); + + try + { + var request = new BucketExistsArgs() + .WithBucket(_bucketName); + + var exists = await client.BucketExistsAsync(request); + + if (!exists) + { + logger.LogInformation("Creating bucket {Bucket}", _bucketName); + + var createRequest = new MakeBucketArgs() + .WithBucket(_bucketName); + + await client.MakeBucketAsync(createRequest); + return; + } + + logger.LogInformation("Bucket {Bucket} already exists", _bucketName); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Unhandled exception occurred during bucket {Bucket} check", + _bucketName); + + throw; + } + } +} diff --git a/Vehicle.EventSink/Vehicle.EventSink.csproj b/Vehicle.EventSink/Vehicle.EventSink.csproj new file mode 100644 index 00000000..03908412 --- /dev/null +++ b/Vehicle.EventSink/Vehicle.EventSink.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + d9196e3d-e011-40da-8419-ada6548d1b1e + + + + + + + + + + + + + diff --git a/Vehicle.EventSink/appsettings.Development.json b/Vehicle.EventSink/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Vehicle.EventSink/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Vehicle.EventSink/appsettings.json b/Vehicle.EventSink/appsettings.json new file mode 100644 index 00000000..dddc4546 --- /dev/null +++ b/Vehicle.EventSink/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AWS": { + "Region": "us-east-1", + "ServiceUrl": "http://localhost:4566", + "Resources": { + "SNSTopicArn": "arn:aws:sns:us-east-1:000000000000:vehicle-generated", + "SNSUrl": "http://host.docker.internal:5303/api/sns", + "MinioEndpoint": "localhost:9000", + "MinioAccessKey": "minioadmin", + "MinioSecretKey": "minioadmin", + "MinioBucketName": "vehicle-files" + } + } +} \ No newline at end of file From 0cf990f76a32ab0d0b0e78b10a3dcce55bc726c6 Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Sat, 2 May 2026 07:53:17 +0400 Subject: [PATCH 08/10] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20Student=20Card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 5d311dbd..65edf46b 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер: №2 Балансировка нагрузки + Номер: №3 Интеграционное тестирование Вариант: №44 Транспортное средство Выполнена: Ле Лок Тхо 6513 Ссылка на форк From d1bcd26f63b80c99a0399b35463c2c7ee1bc78ef Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Mon, 4 May 2026 19:10:27 +0400 Subject: [PATCH 09/10] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5,=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vehicle.Api/Vehicle.Api.http | 4 +- Vehicle.AppHost.Tests/IntegrationTests.cs | 233 ++++++++++-------- Vehicle.AppHost/AppHost.cs | 103 ++++---- Vehicle.AppHost/appsettings.json | 3 +- .../Extensions/ServiceExtensions.cs | 146 +++++++++++ Vehicle.EventSink/Program.cs | 119 +-------- .../Properties/launchSettings.json | 47 +--- 7 files changed, 336 insertions(+), 319 deletions(-) create mode 100644 Vehicle.EventSink/Extensions/ServiceExtensions.cs diff --git a/Vehicle.Api/Vehicle.Api.http b/Vehicle.Api/Vehicle.Api.http index 69f05d0f..e26d412e 100644 --- a/Vehicle.Api/Vehicle.Api.http +++ b/Vehicle.Api/Vehicle.Api.http @@ -1,6 +1,6 @@ -@Vehicle.Api_HostAddress = https://localhost:7096 +@Vehicle.Api_HostAddress = https://localhost:7200 -GET {{Vehicle.Api_HostAddress}}/api/Vehicles?id=5 +GET {{Vehicle.Api_HostAddress}}/gateway/Vehicles?id=2 Accept: application/json ### \ No newline at end of file diff --git a/Vehicle.AppHost.Tests/IntegrationTests.cs b/Vehicle.AppHost.Tests/IntegrationTests.cs index 4e10ad00..d3ded1ea 100644 --- a/Vehicle.AppHost.Tests/IntegrationTests.cs +++ b/Vehicle.AppHost.Tests/IntegrationTests.cs @@ -7,18 +7,21 @@ namespace Vehicle.AppHost.Tests; /// -/// Интеграционные тесты для проверки микросервисного пайплайна Vehicle. +/// Интеграционные тесты для проверки микросервисного пайплайна Vehicle: /// -/// Служба журналирования юнит-тестов. +/// Объект для вывода логов теста в xUnit. public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime { + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private IDistributedApplicationTestingBuilder? _builder; private DistributedApplication? _app; + private HttpClient? _gatewayClient; + private HttpClient? _eventSinkClient; - private static readonly JsonSerializerOptions _jsonOptions = - new(JsonSerializerDefaults.Web); - - /// + /// + /// Инициализирует тестовое распределенное приложение Aspire, запускает все сервисы и подготавливает HTTP-клиенты. + /// public async Task InitializeAsync() { var cancellationToken = CancellationToken.None; @@ -35,51 +38,43 @@ public async Task InitializeAsync() logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); logging.AddFilter("Aspire.Hosting", LogLevel.Debug); }); - } - - /// - /// Проверяет, что вызов гейтвея: - /// - /// В ответ отправляет сгенерированное транспортное средство. - /// Отправляет данные через SNS в Vehicle.EventSink. - /// Сериализует транспортное средство в JSON-файл и сохраняет его в Minio. - /// Проверяет, что данные из API и объектного хранилища идентичны. - /// - /// - /// Запускаемый профиль окружения. - [Theory] - [InlineData("SNS+MinioS3")] - public async Task TestPipeline(string envName) - { - var cancellationToken = CancellationToken.None; - - _builder!.Environment.EnvironmentName = envName; _app = await _builder.BuildAsync(cancellationToken); await _app.StartAsync(cancellationToken); - using var sinkClient = _app.CreateHttpClient( + _gatewayClient = _app.CreateHttpClient( + "vehicle-gateway", + "vehicle-gateway-lb"); + + _eventSinkClient = _app.CreateHttpClient( "vehicle-event-sink", "event-sink-http"); - // Ждем, пока Vehicle.EventSink и Minio начнут отвечать. - await WaitForEventSinkAsync( - sinkClient, + await WaitUntilEventSinkIsReadyAsync( + _eventSinkClient, TimeSpan.FromSeconds(60), cancellationToken); - // Даем SNS subscription время подтвердиться после старта EventSink. await Task.Delay(TimeSpan.FromSeconds(20), cancellationToken); + } - var id = Random.Shared.Next(100_000, 999_999); + /// + /// Основной сценарий: запрос через Gateway генерирует транспортное средство, публикует его через SNS, а Vehicle.EventSink сохраняет JSON-файл в Minio. + /// + [Fact] + public async Task TestPipeline() + { + var cancellationToken = CancellationToken.None; - using var gatewayClient = _app.CreateHttpClient( - "vehicle-gateway", - "vehicle-gateway-lb"); + var gatewayClient = _gatewayClient + ?? throw new InvalidOperationException("Gateway client was not initialized."); - using var gatewayResponse = await gatewayClient.GetAsync( - $"/gateway/Vehicles?id={id}", - cancellationToken); + var eventSinkClient = _eventSinkClient + ?? throw new InvalidOperationException("EventSink client was not initialized."); + + var id = Random.Shared.Next(100_000, 999_999); + + using var gatewayResponse = await gatewayClient.GetAsync($"/gateway/Vehicles?id={id}", cancellationToken); var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(cancellationToken); @@ -87,131 +82,175 @@ await WaitForEventSinkAsync( gatewayResponse.IsSuccessStatusCode, $"Gateway returned {(int)gatewayResponse.StatusCode}: {gatewayContent}"); - var apiVehicle = JsonSerializer.Deserialize( - gatewayContent, - _jsonOptions); + var apiVehicle = JsonSerializer.Deserialize(gatewayContent, _jsonOptions); Assert.NotNull(apiVehicle); Assert.Equal(id, apiVehicle.Id); - var vehicleFileName = await WaitForVehicleFileAsync( - sinkClient, - id, - TimeSpan.FromSeconds(90), - - output, cancellationToken); - - Assert.False( - string.IsNullOrWhiteSpace(vehicleFileName), - $"File vehicle_{id}_*.json was not found in Minio"); + var vehicleFileName = await WaitUntilVehicleFileAppearsAsync(eventSinkClient, id, TimeSpan.FromSeconds(90), cancellationToken); - using var s3Response = await sinkClient.GetAsync( - $"/api/s3/{vehicleFileName}", - cancellationToken); + using var s3Response = await eventSinkClient.GetAsync($"/api/s3/{vehicleFileName}", cancellationToken); var s3Content = await s3Response.Content.ReadAsStringAsync(cancellationToken); Assert.True( - s3Response.IsSuccessStatusCode, + s3Response.IsSuccessStatusCode, $"EventSink returned {(int)s3Response.StatusCode}: {s3Content}"); - var s3Vehicle = JsonSerializer.Deserialize( - s3Content, - _jsonOptions); + var s3Vehicle = JsonSerializer.Deserialize(s3Content, _jsonOptions); Assert.NotNull(s3Vehicle); Assert.Equal(id, s3Vehicle.Id); Assert.Equivalent(apiVehicle, s3Vehicle); } + /// + /// Дополнительный важный сценарий: повторный запрос с тем же id возвращается из кэша + /// и не создает второй файл в Minio для того же транспортного средства. + /// + [Fact] + public async Task RepeatedRequest_DoesNotCreateDuplicateFileInMinio() + { + var cancellationToken = CancellationToken.None; + + var gatewayClient = _gatewayClient + ?? throw new InvalidOperationException("Gateway client was not initialized."); + + var eventSinkClient = _eventSinkClient + ?? throw new InvalidOperationException("EventSink client was not initialized."); + + var id = Random.Shared.Next(100_000, 999_999); + + using var firstResponse = await gatewayClient.GetAsync($"/gateway/Vehicles?id={id}", cancellationToken); + + var firstContent = await firstResponse.Content.ReadAsStringAsync(cancellationToken); + + Assert.True( + firstResponse.IsSuccessStatusCode, + $"First gateway request failed: {firstContent}"); + + var firstVehicle = JsonSerializer.Deserialize(firstContent, _jsonOptions); + + Assert.NotNull(firstVehicle); + + var firstFileName = await WaitUntilVehicleFileAppearsAsync(eventSinkClient, id, TimeSpan.FromSeconds(90), cancellationToken); + + using var secondResponse = await gatewayClient.GetAsync($"/gateway/Vehicles?id={id}", cancellationToken); + + var secondContent = await secondResponse.Content.ReadAsStringAsync(cancellationToken); + + Assert.True( + secondResponse.IsSuccessStatusCode, + $"Second gateway request failed: {secondContent}"); + + var secondVehicle = JsonSerializer.Deserialize(secondContent, _jsonOptions); + + Assert.NotNull(secondVehicle); + Assert.Equivalent(firstVehicle, secondVehicle); + + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + + var files = await GetFileListAsync(eventSinkClient, cancellationToken); + + var filesForCurrentId = files + .Where(file => file.StartsWith($"vehicle_{id}_", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + Assert.Single(filesForCurrentId); + Assert.Equal(firstFileName, filesForCurrentId[0]); + } + /// /// Ждет, пока Vehicle.EventSink начнет отвечать на запросы к /api/s3. /// - private static async Task WaitForEventSinkAsync( - HttpClient sinkClient, - TimeSpan timeout, - CancellationToken cancellationToken) + private static async Task WaitUntilEventSinkIsReadyAsync(HttpClient eventSinkClient, TimeSpan timeout, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow.Add(timeout); + Exception? lastException = null; var lastResponse = string.Empty; while (DateTimeOffset.UtcNow < deadline) { try { - using var response = await sinkClient.GetAsync( - "/api/s3", - cancellationToken); + using var response = await eventSinkClient.GetAsync("/api/s3", cancellationToken); lastResponse = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) - { return; - } } catch (Exception ex) { - lastResponse = ex.Message; + lastException = ex; } await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); } - throw new TimeoutException( - $"Vehicle.EventSink did not become ready in time. Last response: {lastResponse}"); + throw new TimeoutException($"Vehicle.EventSink did not become ready within {timeout.TotalSeconds} seconds. Last response: {lastResponse}", lastException); } /// - /// Ждет появления JSON-файла vehicle_{id}_*.json в Minio. + /// Ожидает появления JSON-файла vehicle_{id}_*.json в Minio. /// - private static async Task WaitForVehicleFileAsync( - HttpClient sinkClient, - int vehicleId, - TimeSpan timeout, - ITestOutputHelper output, CancellationToken cancellationToken) + private static async Task WaitUntilVehicleFileAppearsAsync(HttpClient eventSinkClient, int vehicleId, TimeSpan timeout, CancellationToken cancellationToken) { var deadline = DateTimeOffset.UtcNow.Add(timeout); var expectedPrefix = $"vehicle_{vehicleId}_"; + + Exception? lastException = null; var lastFileList = string.Empty; while (DateTimeOffset.UtcNow < deadline) { - using var listResponse = await sinkClient.GetAsync( - "/api/s3", - cancellationToken); + try + { + var files = await GetFileListAsync(eventSinkClient, cancellationToken); + lastFileList = JsonSerializer.Serialize(files, _jsonOptions); - lastFileList = await listResponse.Content.ReadAsStringAsync(cancellationToken); + var matchingFile = files.FirstOrDefault(file => file.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase)); - if (listResponse.IsSuccessStatusCode) + if (!string.IsNullOrWhiteSpace(matchingFile)) + return matchingFile; + } + catch (Exception ex) { - var vehicleList = JsonSerializer.Deserialize>( - lastFileList, - _jsonOptions); - - var vehicleFileName = vehicleList? - .FirstOrDefault(file => file.StartsWith( - expectedPrefix, - StringComparison.OrdinalIgnoreCase)); - - if (!string.IsNullOrWhiteSpace(vehicleFileName)) - { - return vehicleFileName; - } + lastException = ex; } await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); } - output.WriteLine( - $"File with prefix {expectedPrefix} was not found. Last file list: {lastFileList}"); + throw new TimeoutException( + $"File with prefix '{expectedPrefix}' was not found in Minio within {timeout.TotalSeconds} seconds. Last file list: {lastFileList}", + lastException); + } + + /// + /// Получает список файлов из Minio через Vehicle.EventSink. + /// + private static async Task> GetFileListAsync(HttpClient eventSinkClient, CancellationToken cancellationToken) + { + using var listResponse = await eventSinkClient.GetAsync("/api/s3", cancellationToken); - return null; + var listContent = await listResponse.Content.ReadAsStringAsync(cancellationToken); + + Assert.True( + listResponse.IsSuccessStatusCode, + $"EventSink returned {(int)listResponse.StatusCode}: {listContent}"); + + return JsonSerializer.Deserialize>(listContent, _jsonOptions) ?? []; } - /// + /// + /// Останавливает приложение и освобождает ресурсы тестовой среды. + /// public async Task DisposeAsync() { + _gatewayClient?.Dispose(); + _eventSinkClient?.Dispose(); + if (_app is not null) { await _app.StopAsync(); diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs index 97433d06..2a57d24b 100644 --- a/Vehicle.AppHost/AppHost.cs +++ b/Vehicle.AppHost/AppHost.cs @@ -2,50 +2,33 @@ var builder = DistributedApplication.CreateBuilder(args); -var apiPorts = builder.Configuration.GetSection("ApiService:Ports").Get() - ?? throw new InvalidOperationException("ApiService:Ports is not configured."); +const string awsRegion = "us-east-1"; -var gatewayPort = builder.Configuration.GetValue("Gateway:Port") - ?? throw new InvalidOperationException("Gateway:Port is not configured."); +var apiPorts = RequiredIntArray("ApiService:Ports"); +var gatewayPort = RequiredInt("Gateway:Port"); -var localStackPort = builder.Configuration.GetValue("LocalStack:Port") - ?? throw new InvalidOperationException("LocalStack:Port is not configured."); +var localStackPort = RequiredInt("LocalStack:Port"); +var localStackServiceUrl = RequiredString("LocalStack:ServiceUrl"); -var snsServiceUrl = builder.Configuration["LocalStack:ServiceUrl"] - ?? throw new InvalidOperationException("LocalStack:ServiceUrl is not configured."); +var snsTopicArn = RequiredString("SNS:TopicArn"); +var snsEndpointUrl = RequiredString("SNS:EndpointUrl"); -var snsTopicArn = builder.Configuration["SNS:TopicArn"] - ?? throw new InvalidOperationException("SNS:TopicArn is not configured."); +var eventSinkPort = RequiredInt("EventSink:Port"); +var eventSinkUrls = RequiredString("EventSink:Urls"); -var snsEndpointUrl = builder.Configuration["SNS:EndpointUrl"] - ?? throw new InvalidOperationException("SNS:EndpointUrl is not configured."); - -var minioApiPort = builder.Configuration.GetValue("Minio:ApiPort") - ?? throw new InvalidOperationException("Minio:ApiPort is not configured."); - -var minioConsolePort = builder.Configuration.GetValue("Minio:ConsolePort") - ?? throw new InvalidOperationException("Minio:ConsolePort is not configured."); - -var minioEndpoint = builder.Configuration["Minio:Endpoint"] - ?? throw new InvalidOperationException("Minio:Endpoint is not configured."); - -var minioAccessKey = builder.Configuration["Minio:AccessKey"] - ?? throw new InvalidOperationException("Minio:AccessKey is not configured."); - -var minioSecretKey = builder.Configuration["Minio:SecretKey"] - ?? throw new InvalidOperationException("Minio:SecretKey is not configured."); - -var minioBucketName = builder.Configuration["Minio:BucketName"] - ?? throw new InvalidOperationException("Minio:BucketName is not configured."); +var minioApiPort = RequiredInt("Minio:ApiPort"); +var minioConsolePort = RequiredInt("Minio:ConsolePort"); +var minioEndpoint = RequiredString("Minio:Endpoint"); +var minioAccessKey = RequiredString("Minio:AccessKey"); +var minioSecretKey = RequiredString("Minio:SecretKey"); +var minioBucketName = RequiredString("Minio:BucketName"); var redis = builder.AddRedis("redis") .WithRedisCommander(); var localstack = builder.AddContainer("localstack", "localstack/localstack:3") .WithEnvironment("SERVICES", "sns") - .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") - .WithEnvironment("DEBUG", "1") - .WithEnvironment("HOSTNAME_EXTERNAL", "host.docker.internal") + .WithEnvironment("AWS_DEFAULT_REGION", awsRegion) .WithContainerRuntimeArgs("--add-host", "host.docker.internal:host-gateway") .WithHttpEndpoint(port: localStackPort, targetPort: 4566, name: "edge"); @@ -56,24 +39,18 @@ .WithHttpEndpoint(port: minioApiPort, targetPort: 9000, name: "s3") .WithHttpEndpoint(port: minioConsolePort, targetPort: 9001, name: "console"); -var eventSink = builder.AddProject( - "vehicle-event-sink", - launchProfileName: null) +var eventSink = builder.AddProject("vehicle-event-sink", launchProfileName: null) .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") - .WithEnvironment("ASPNETCORE_URLS", "http://0.0.0.0:5303") - .WithHttpEndpoint( - port: 5303, - targetPort: 5303, - name: "event-sink-http", - isProxied: false) - .WithEnvironment("AWS__Region", "us-east-1") - .WithEnvironment("AWS__ServiceUrl", snsServiceUrl) + .WithEnvironment("ASPNETCORE_URLS", eventSinkUrls) + .WithHttpEndpoint(port: eventSinkPort, targetPort: eventSinkPort, name: "event-sink-http", isProxied: false) + .WithEnvironment("AWS__Region", awsRegion) + .WithEnvironment("AWS__ServiceUrl", localStackServiceUrl) .WithEnvironment("AWS__Resources__SNSTopicArn", snsTopicArn) - .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5303/api/sns") - .WithEnvironment("AWS__Resources__MinioEndpoint", "localhost:9000") - .WithEnvironment("AWS__Resources__MinioAccessKey", "minioadmin") - .WithEnvironment("AWS__Resources__MinioSecretKey", "minioadmin") - .WithEnvironment("AWS__Resources__MinioBucketName", "vehicle-files") + .WithEnvironment("AWS__Resources__SNSUrl", snsEndpointUrl) + .WithEnvironment("AWS__Resources__MinioEndpoint", minioEndpoint) + .WithEnvironment("AWS__Resources__MinioAccessKey", minioAccessKey) + .WithEnvironment("AWS__Resources__MinioSecretKey", minioSecretKey) + .WithEnvironment("AWS__Resources__MinioBucketName", minioBucketName) .WaitFor(localstack) .WaitFor(minio); @@ -83,18 +60,15 @@ for (var i = 0; i < apiPorts.Length; i++) { - var httpsPort = apiPorts[i]; var instanceName = $"vehicle-api-{i + 1}"; - var api = builder.AddProject( - $"vehicle-api-{i + 1}", - launchProfileName: null) + var api = builder.AddProject(instanceName, launchProfileName: null) .WithReference(redis) - .WithHttpsEndpoint(port: httpsPort, name: instanceName) + .WithHttpsEndpoint(port: apiPorts[i], name: instanceName) .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithEnvironment("INSTANCE_ID", instanceName) - .WithEnvironment("AWS__Region", "us-east-1") - .WithEnvironment("AWS__ServiceUrl", snsServiceUrl) + .WithEnvironment("AWS__Region", awsRegion) + .WithEnvironment("AWS__ServiceUrl", localStackServiceUrl) .WithEnvironment("AWS__Resources__SNSTopicArn", snsTopicArn) .WaitFor(redis) .WaitFor(localstack) @@ -106,4 +80,19 @@ builder.AddProject("client") .WaitFor(gateway); -builder.Build().Run(); \ No newline at end of file +builder.Build().Run(); + +string RequiredString(string key) +{ + return builder.Configuration[key] ?? throw new InvalidOperationException($"{key} is not configured."); +} + +int RequiredInt(string key) +{ + return builder.Configuration.GetValue(key) ?? throw new InvalidOperationException($"{key} is not configured."); +} + +int[] RequiredIntArray(string key) +{ + return builder.Configuration.GetSection(key).Get() ?? throw new InvalidOperationException($"{key} is not configured."); +} \ No newline at end of file diff --git a/Vehicle.AppHost/appsettings.json b/Vehicle.AppHost/appsettings.json index f15b84a9..d2451823 100644 --- a/Vehicle.AppHost/appsettings.json +++ b/Vehicle.AppHost/appsettings.json @@ -21,7 +21,8 @@ "EndpointUrl": "http://host.docker.internal:5303/api/sns" }, "EventSink": { - "Port": 5303 + "Port": 5303, + "Urls": "http://0.0.0.0:5303" }, "Minio": { "ApiPort": 9000, diff --git a/Vehicle.EventSink/Extensions/ServiceExtensions.cs b/Vehicle.EventSink/Extensions/ServiceExtensions.cs new file mode 100644 index 00000000..44439d1f --- /dev/null +++ b/Vehicle.EventSink/Extensions/ServiceExtensions.cs @@ -0,0 +1,146 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Minio; +using Vehicle.EventSink.Messaging; +using Vehicle.EventSink.Storage; + +namespace Vehicle.EventSink.Extensions; + +/// +/// Extension methods for configuring Vehicle.EventSink. +/// +public static class ServiceExtensions +{ + /// + /// Registers SNS, Minio and EventSink application services. + /// + public static WebApplicationBuilder AddEventSinkServices(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(CreateSnsClient); + builder.Services.AddSingleton(CreateMinioClient); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + return builder; + } + + /// + /// Starts delayed initialization after HTTP endpoint is ready. + /// + public static WebApplication UseEventSinkStartup(this WebApplication app) + { + LogConfiguration(app); + + var stoppingToken = app.Lifetime.ApplicationStopping; + + app.Lifetime.ApplicationStarted.Register(() => + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + await InitializeEventSinkAsync(app, stoppingToken); + } + catch (OperationCanceledException) { } + } + , stoppingToken); + }); + + return app; + } + + private static IAmazonSimpleNotificationService CreateSnsClient(IServiceProvider serviceProvider) + { + var configuration = serviceProvider.GetRequiredService(); + + return new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + new AmazonSimpleNotificationServiceConfig + { + ServiceURL = Required(configuration, "AWS:ServiceUrl"), + AuthenticationRegion = Required(configuration, "AWS:Region") + }); + } + + private static IMinioClient CreateMinioClient(IServiceProvider serviceProvider) + { + var configuration = serviceProvider.GetRequiredService(); + + var endpoint = Required(configuration, "AWS:Resources:MinioEndpoint") + .Replace("http://", string.Empty) + .Replace("https://", string.Empty); + + return new MinioClient() + .WithEndpoint(endpoint) + .WithCredentials( + Required(configuration, "AWS:Resources:MinioAccessKey"), + Required(configuration, "AWS:Resources:MinioSecretKey")) + .WithSSL(false) + .Build(); + } + + private static async Task InitializeEventSinkAsync( + WebApplication app, + CancellationToken cancellationToken) + { + var logger = app.Services + .GetRequiredService() + .CreateLogger("Vehicle.EventSink.Startup"); + + const int maxAttempts = 5; + var retryDelay = TimeSpan.FromSeconds(3); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + + logger.LogInformation("Initializing Vehicle.EventSink. Attempt {Attempt}/{MaxAttempts}", attempt, maxAttempts); + + using var scope = app.Services.CreateScope(); + + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + + var subscriptionService = scope.ServiceProvider.GetRequiredService(); + await subscriptionService.SubscribeEndpoint(); + + logger.LogInformation("Vehicle.EventSink was initialized successfully"); + return; + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + if (attempt == maxAttempts) + { + logger.LogError(ex, "Vehicle.EventSink initialization failed after all attempts"); + } + else + { + logger.LogWarning(ex, "Vehicle.EventSink is not ready yet. Retry attempt {Attempt}/{MaxAttempts}", attempt, maxAttempts); + await Task.Delay(retryDelay, cancellationToken); + } + } + } + } + + private static string Required(IConfiguration configuration, string key) + { + return configuration[key] ?? throw new KeyNotFoundException($"{key} was not found in configuration"); + } + + private static void LogConfiguration(WebApplication app) + { + app.Logger.LogInformation("AWS Region: {Region}", app.Configuration["AWS:Region"]); + app.Logger.LogInformation("SNS Topic ARN: {TopicArn}", app.Configuration["AWS:Resources:SNSTopicArn"]); + app.Logger.LogInformation("SNS endpoint URL: {SnsUrl}", app.Configuration["AWS:Resources:SNSUrl"]); + app.Logger.LogInformation("Minio endpoint: {MinioEndpoint}", app.Configuration["AWS:Resources:MinioEndpoint"]); + app.Logger.LogInformation("Minio bucket: {Bucket}", app.Configuration["AWS:Resources:MinioBucketName"]); + } +} diff --git a/Vehicle.EventSink/Program.cs b/Vehicle.EventSink/Program.cs index 2d363013..dfb202a9 100644 --- a/Vehicle.EventSink/Program.cs +++ b/Vehicle.EventSink/Program.cs @@ -1,8 +1,4 @@ -using Amazon.Runtime; -using Amazon.SimpleNotificationService; -using Minio; -using Vehicle.EventSink.Messaging; -using Vehicle.EventSink.Storage; +using Vehicle.EventSink.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -12,15 +8,11 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddSingleton(CreateSnsClient); -builder.Services.AddSingleton(CreateMinioClient); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.AddEventSinkServices(); var app = builder.Build(); -LogConfiguration(app); +app.UseEventSinkStartup(); if (app.Environment.IsDevelopment()) { @@ -38,109 +30,4 @@ app.MapControllers(); app.MapDefaultEndpoints(); -app.Lifetime.ApplicationStarted.Register(() => -{ - _ = Task.Run(async () => - { - await Task.Delay(TimeSpan.FromSeconds(5)); - await InitializeEventSinkAsync(app); - }); -}); - await app.RunAsync(); - -static IAmazonSimpleNotificationService CreateSnsClient(IServiceProvider serviceProvider) -{ - var configuration = serviceProvider.GetRequiredService(); - - return new AmazonSimpleNotificationServiceClient( - new BasicAWSCredentials("test", "test"), - new AmazonSimpleNotificationServiceConfig - { - ServiceURL = Required(configuration, "AWS:ServiceUrl"), - AuthenticationRegion = Required(configuration, "AWS:Region") - }); -} - -static IMinioClient CreateMinioClient(IServiceProvider serviceProvider) -{ - var configuration = serviceProvider.GetRequiredService(); - - var endpoint = Required(configuration, "AWS:Resources:MinioEndpoint") - .Replace("http://", string.Empty) - .Replace("https://", string.Empty); - - return new MinioClient() - .WithEndpoint(endpoint) - .WithCredentials( - Required(configuration, "AWS:Resources:MinioAccessKey"), - Required(configuration, "AWS:Resources:MinioSecretKey")) - .WithSSL(false) - .Build(); -} - -static async Task InitializeEventSinkAsync(WebApplication app) -{ - var logger = app.Services - .GetRequiredService() - .CreateLogger("Vehicle.EventSink.Startup"); - - const int maxAttempts = 5; - var retryDelay = TimeSpan.FromSeconds(3); - - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { - logger.LogInformation( - "Initializing Vehicle.EventSink. Attempt {Attempt}/{MaxAttempts}", - attempt, - maxAttempts); - - using var scope = app.Services.CreateScope(); - - var s3Service = scope.ServiceProvider.GetRequiredService(); - await s3Service.EnsureBucketExists(); - - var subscriptionService = scope.ServiceProvider.GetRequiredService(); - await subscriptionService.SubscribeEndpoint(); - - logger.LogInformation("Vehicle.EventSink was initialized successfully"); - return; - } - catch (Exception ex) - { - if (attempt == maxAttempts) - { - logger.LogError( - ex, - "Vehicle.EventSink initialization failed after all attempts"); - } - else - { - logger.LogWarning( - ex, - "Vehicle.EventSink is not ready yet. Retry attempt {Attempt}/{MaxAttempts}", - attempt, - maxAttempts); - - await Task.Delay(retryDelay); - } - } - } -} - -static string Required(IConfiguration configuration, string key) -{ - return configuration[key] - ?? throw new KeyNotFoundException($"{key} was not found in configuration"); -} - -static void LogConfiguration(WebApplication app) -{ - app.Logger.LogInformation("AWS Region: {Region}", app.Configuration["AWS:Region"]); - app.Logger.LogInformation("SNS Topic ARN: {TopicArn}", app.Configuration["AWS:Resources:SNSTopicArn"]); - app.Logger.LogInformation("SNS endpoint URL: {SnsUrl}", app.Configuration["AWS:Resources:SNSUrl"]); - app.Logger.LogInformation("Minio endpoint: {MinioEndpoint}", app.Configuration["AWS:Resources:MinioEndpoint"]); - app.Logger.LogInformation("Minio bucket: {Bucket}", app.Configuration["AWS:Resources:MinioBucketName"]); -} \ No newline at end of file diff --git a/Vehicle.EventSink/Properties/launchSettings.json b/Vehicle.EventSink/Properties/launchSettings.json index 7b35eb45..d22f148c 100644 --- a/Vehicle.EventSink/Properties/launchSettings.json +++ b/Vehicle.EventSink/Properties/launchSettings.json @@ -1,53 +1,9 @@ -//{ -// "$schema": "http://json.schemastore.org/launchsettings.json", -// "iisSettings": { -// "windowsAuthentication": false, -// "anonymousAuthentication": true, -// "iisExpress": { -// "applicationUrl": "http://localhost:20857", -// "sslPort": 44368 -// } -// }, -// "profiles": { -// "http": { -// "commandName": "Project", -// "dotnetRunMessages": true, -// "launchBrowser": true, -// "launchUrl": "swagger", -// "applicationUrl": "http://localhost:5262", -// "environmentVariables": { -// "ASPNETCORE_ENVIRONMENT": "Development" -// } -// }, -// "https": { -// "commandName": "Project", -// "dotnetRunMessages": true, -// "launchBrowser": true, -// "launchUrl": "swagger", -// "applicationUrl": "https://localhost:7031;http://localhost:5262", -// "environmentVariables": { -// "ASPNETCORE_ENVIRONMENT": "Development" -// } -// }, -// "IIS Express": { -// "commandName": "IISExpress", -// "launchBrowser": true, -// "launchUrl": "swagger", -// "environmentVariables": { -// "ASPNETCORE_ENVIRONMENT": "Development" -// } -// } -// } -//} - - -{ +{ "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "launchBrowser": true, - //"launchUrl": "swagger", "applicationUrl": "http://localhost:5303", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -57,7 +13,6 @@ "https": { "commandName": "Project", "launchBrowser": true, - //"launchUrl": "swagger", "applicationUrl": "https://localhost:7301;http://localhost:5303", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From 39ba192b5f093a1b20a5dc60f17bbcabbfe3f1ed Mon Sep 17 00:00:00 2001 From: lelocthoVN Date: Wed, 6 May 2026 00:58:01 +0400 Subject: [PATCH 10/10] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Vehicle.Api/Program.cs | 8 +-- Vehicle.Api/Vehicle.Api.http | 2 +- Vehicle.AppHost.Tests/IntegrationTests.cs | 62 +++++++------------ Vehicle.AppHost/AppHost.cs | 47 +++++--------- .../Extensions/ConfigExtensions.cs | 33 ++++++++++ 5 files changed, 75 insertions(+), 77 deletions(-) create mode 100644 Vehicle.AppHost/Extensions/ConfigExtensions.cs diff --git a/Vehicle.Api/Program.cs b/Vehicle.Api/Program.cs index c444feee..c85bc39a 100644 --- a/Vehicle.Api/Program.cs +++ b/Vehicle.Api/Program.cs @@ -1,9 +1,9 @@ -using Vehicle.Api.Cache; -using Vehicle.Api.Generation; -using Vehicle.Api.Services; -using Amazon.Runtime; +using Amazon.Runtime; using Amazon.SimpleNotificationService; +using Vehicle.Api.Cache; +using Vehicle.Api.Generation; using Vehicle.Api.Messaging; +using Vehicle.Api.Services; var builder = WebApplication.CreateBuilder(args); diff --git a/Vehicle.Api/Vehicle.Api.http b/Vehicle.Api/Vehicle.Api.http index e26d412e..fcc0e9f8 100644 --- a/Vehicle.Api/Vehicle.Api.http +++ b/Vehicle.Api/Vehicle.Api.http @@ -1,6 +1,6 @@ @Vehicle.Api_HostAddress = https://localhost:7200 -GET {{Vehicle.Api_HostAddress}}/gateway/Vehicles?id=2 +GET {{Vehicle.Api_HostAddress}}/gateway/Vehicles?id=3 Accept: application/json ### \ No newline at end of file diff --git a/Vehicle.AppHost.Tests/IntegrationTests.cs b/Vehicle.AppHost.Tests/IntegrationTests.cs index d3ded1ea..0dabd346 100644 --- a/Vehicle.AppHost.Tests/IntegrationTests.cs +++ b/Vehicle.AppHost.Tests/IntegrationTests.cs @@ -13,11 +13,10 @@ namespace Vehicle.AppHost.Tests; public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime { private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); - - private IDistributedApplicationTestingBuilder? _builder; private DistributedApplication? _app; - private HttpClient? _gatewayClient; - private HttpClient? _eventSinkClient; + private DistributedApplication App => _app ?? throw new InvalidOperationException("Test application was not initialized."); + private HttpClient CreateGatewayClient() => App.CreateHttpClient("vehicle-gateway", "vehicle-gateway-lb"); + private HttpClient CreateEventSinkClient() => App.CreateHttpClient("vehicle-event-sink", "event-sink-http"); /// /// Инициализирует тестовое распределенное приложение Aspire, запускает все сервисы и подготавливает HTTP-клиенты. @@ -26,12 +25,11 @@ public async Task InitializeAsync() { var cancellationToken = CancellationToken.None; - _builder = await DistributedApplicationTestingBuilder - .CreateAsync(cancellationToken); + var builder = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); - _builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; - _builder.Services.AddLogging(logging => + builder.Services.AddLogging(logging => { logging.AddXUnit(output); logging.SetMinimumLevel(LogLevel.Debug); @@ -39,19 +37,13 @@ public async Task InitializeAsync() logging.AddFilter("Aspire.Hosting", LogLevel.Debug); }); - _app = await _builder.BuildAsync(cancellationToken); + _app = await builder.BuildAsync(cancellationToken); await _app.StartAsync(cancellationToken); - _gatewayClient = _app.CreateHttpClient( - "vehicle-gateway", - "vehicle-gateway-lb"); - - _eventSinkClient = _app.CreateHttpClient( - "vehicle-event-sink", - "event-sink-http"); + var eventSinkClient = CreateEventSinkClient(); await WaitUntilEventSinkIsReadyAsync( - _eventSinkClient, + eventSinkClient, TimeSpan.FromSeconds(60), cancellationToken); @@ -66,13 +58,11 @@ public async Task TestPipeline() { var cancellationToken = CancellationToken.None; - var gatewayClient = _gatewayClient - ?? throw new InvalidOperationException("Gateway client was not initialized."); + var gatewayClient = CreateGatewayClient(); - var eventSinkClient = _eventSinkClient - ?? throw new InvalidOperationException("EventSink client was not initialized."); + var eventSinkClient = CreateEventSinkClient(); - var id = Random.Shared.Next(100_000, 999_999); + var id = Random.Shared.Next(100, 100_000); using var gatewayResponse = await gatewayClient.GetAsync($"/gateway/Vehicles?id={id}", cancellationToken); @@ -94,7 +84,7 @@ public async Task TestPipeline() var s3Content = await s3Response.Content.ReadAsStringAsync(cancellationToken); Assert.True( - s3Response.IsSuccessStatusCode, + s3Response.IsSuccessStatusCode, $"EventSink returned {(int)s3Response.StatusCode}: {s3Content}"); var s3Vehicle = JsonSerializer.Deserialize(s3Content, _jsonOptions); @@ -113,20 +103,18 @@ public async Task RepeatedRequest_DoesNotCreateDuplicateFileInMinio() { var cancellationToken = CancellationToken.None; - var gatewayClient = _gatewayClient - ?? throw new InvalidOperationException("Gateway client was not initialized."); + var gatewayClient = CreateGatewayClient(); - var eventSinkClient = _eventSinkClient - ?? throw new InvalidOperationException("EventSink client was not initialized."); + var eventSinkClient = CreateEventSinkClient(); - var id = Random.Shared.Next(100_000, 999_999); + var id = Random.Shared.Next(100, 100_000); using var firstResponse = await gatewayClient.GetAsync($"/gateway/Vehicles?id={id}", cancellationToken); var firstContent = await firstResponse.Content.ReadAsStringAsync(cancellationToken); Assert.True( - firstResponse.IsSuccessStatusCode, + firstResponse.IsSuccessStatusCode, $"First gateway request failed: {firstContent}"); var firstVehicle = JsonSerializer.Deserialize(firstContent, _jsonOptions); @@ -140,7 +128,7 @@ public async Task RepeatedRequest_DoesNotCreateDuplicateFileInMinio() var secondContent = await secondResponse.Content.ReadAsStringAsync(cancellationToken); Assert.True( - secondResponse.IsSuccessStatusCode, + secondResponse.IsSuccessStatusCode, $"Second gateway request failed: {secondContent}"); var secondVehicle = JsonSerializer.Deserialize(secondContent, _jsonOptions); @@ -161,7 +149,7 @@ public async Task RepeatedRequest_DoesNotCreateDuplicateFileInMinio() } /// - /// Ждет, пока Vehicle.EventSink начнет отвечать на запросы к /api/s3. + /// Ждет, пока Vehicle.EventSink начнет отвечать на запросы к /api/s3 /// private static async Task WaitUntilEventSinkIsReadyAsync(HttpClient eventSinkClient, TimeSpan timeout, CancellationToken cancellationToken) { @@ -192,7 +180,7 @@ private static async Task WaitUntilEventSinkIsReadyAsync(HttpClient eventSinkCli } /// - /// Ожидает появления JSON-файла vehicle_{id}_*.json в Minio. + /// Ожидает появления JSON-файла vehicle_{id}_*.json в Minio /// private static async Task WaitUntilVehicleFileAppearsAsync(HttpClient eventSinkClient, int vehicleId, TimeSpan timeout, CancellationToken cancellationToken) { @@ -228,7 +216,7 @@ private static async Task WaitUntilVehicleFileAppearsAsync(HttpClient ev } /// - /// Получает список файлов из Minio через Vehicle.EventSink. + /// Получает список файлов из Minio через Vehicle.EventSink /// private static async Task> GetFileListAsync(HttpClient eventSinkClient, CancellationToken cancellationToken) { @@ -248,18 +236,10 @@ private static async Task> GetFileListAsync(HttpClient eventSinkCli /// public async Task DisposeAsync() { - _gatewayClient?.Dispose(); - _eventSinkClient?.Dispose(); - if (_app is not null) { await _app.StopAsync(); await _app.DisposeAsync(); } - - if (_builder is not null) - { - await _builder.DisposeAsync(); - } } } \ No newline at end of file diff --git a/Vehicle.AppHost/AppHost.cs b/Vehicle.AppHost/AppHost.cs index 2a57d24b..f0076a51 100644 --- a/Vehicle.AppHost/AppHost.cs +++ b/Vehicle.AppHost/AppHost.cs @@ -1,27 +1,27 @@ -using Microsoft.Extensions.Configuration; +using Vehicle.AppHost.Extensions; var builder = DistributedApplication.CreateBuilder(args); const string awsRegion = "us-east-1"; -var apiPorts = RequiredIntArray("ApiService:Ports"); -var gatewayPort = RequiredInt("Gateway:Port"); +var apiPorts = builder.Configuration.GetRequiredIntArray("ApiService:Ports"); +var gatewayPort = builder.Configuration.GetRequiredInt("Gateway:Port"); -var localStackPort = RequiredInt("LocalStack:Port"); -var localStackServiceUrl = RequiredString("LocalStack:ServiceUrl"); +var localStackPort = builder.Configuration.GetRequiredInt("LocalStack:Port"); +var localStackServiceUrl = builder.Configuration.GetRequiredString("LocalStack:ServiceUrl"); -var snsTopicArn = RequiredString("SNS:TopicArn"); -var snsEndpointUrl = RequiredString("SNS:EndpointUrl"); +var snsTopicArn = builder.Configuration.GetRequiredString("SNS:TopicArn"); +var snsEndpointUrl = builder.Configuration.GetRequiredString("SNS:EndpointUrl"); -var eventSinkPort = RequiredInt("EventSink:Port"); -var eventSinkUrls = RequiredString("EventSink:Urls"); +var eventSinkPort = builder.Configuration.GetRequiredInt("EventSink:Port"); +var eventSinkUrls = builder.Configuration.GetRequiredString("EventSink:Urls"); -var minioApiPort = RequiredInt("Minio:ApiPort"); -var minioConsolePort = RequiredInt("Minio:ConsolePort"); -var minioEndpoint = RequiredString("Minio:Endpoint"); -var minioAccessKey = RequiredString("Minio:AccessKey"); -var minioSecretKey = RequiredString("Minio:SecretKey"); -var minioBucketName = RequiredString("Minio:BucketName"); +var minioApiPort = builder.Configuration.GetRequiredInt("Minio:ApiPort"); +var minioConsolePort = builder.Configuration.GetRequiredInt("Minio:ConsolePort"); +var minioEndpoint = builder.Configuration.GetRequiredString("Minio:Endpoint"); +var minioAccessKey = builder.Configuration.GetRequiredString("Minio:AccessKey"); +var minioSecretKey = builder.Configuration.GetRequiredString("Minio:SecretKey"); +var minioBucketName = builder.Configuration.GetRequiredString("Minio:BucketName"); var redis = builder.AddRedis("redis") .WithRedisCommander(); @@ -80,19 +80,4 @@ builder.AddProject("client") .WaitFor(gateway); -builder.Build().Run(); - -string RequiredString(string key) -{ - return builder.Configuration[key] ?? throw new InvalidOperationException($"{key} is not configured."); -} - -int RequiredInt(string key) -{ - return builder.Configuration.GetValue(key) ?? throw new InvalidOperationException($"{key} is not configured."); -} - -int[] RequiredIntArray(string key) -{ - return builder.Configuration.GetSection(key).Get() ?? throw new InvalidOperationException($"{key} is not configured."); -} \ No newline at end of file +builder.Build().Run(); \ No newline at end of file diff --git a/Vehicle.AppHost/Extensions/ConfigExtensions.cs b/Vehicle.AppHost/Extensions/ConfigExtensions.cs new file mode 100644 index 00000000..c78a11c0 --- /dev/null +++ b/Vehicle.AppHost/Extensions/ConfigExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Configuration; + +namespace Vehicle.AppHost.Extensions; + +/// +/// Методы расширения для безопасного чтения обязательных значений конфигурации +/// +public static class ConfigurationExtensions +{ + /// + /// Получает обязательное строковое значение из конфигурации + /// + public static string GetRequiredString(this IConfiguration configuration, string key) + { + return configuration[key] ?? throw new InvalidOperationException($"{key} is not configured."); + } + + /// + /// Получает обязательное целочисленное значение из конфигурации + /// + public static int GetRequiredInt(this IConfiguration configuration, string key) + { + return configuration.GetValue(key) ?? throw new InvalidOperationException($"{key} is not configured."); + } + + /// + /// Получает обязательный массив целых чисел из конфигурации + /// + public static int[] GetRequiredIntArray(this IConfiguration configuration, string key) + { + return configuration.GetSection(key).Get() ?? throw new InvalidOperationException($"{key} is not configured."); + } +} \ No newline at end of file