From 8d67c74a0289ba68325df53baa810f0ff1a373f2 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Wed, 25 Feb 2026 18:16:02 +0400 Subject: [PATCH 01/26] Lab1 --- Client.Wasm/Components/StudentCard.razor | 8 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 22 ++- .../Cache/InventoryCache.cs | 49 +++++++ .../Controllers/InventoryControler.cs | 25 ++++ .../Inventory.ApiService/Entity/Product.cs | 57 ++++++++ .../Generation/Generator.cs | 42 ++++++ .../Inventory.ApiService.csproj | 20 +++ .../Inventory.ApiService/Program.cs | 41 ++++++ .../Properties/launchSettings.json | 23 ++++ .../appsettings.Development.json | 8 ++ .../Inventory.ApiService/appsettings.json | 9 ++ InventoryManager/Inventory.AppHost/AppHost.cs | 15 ++ .../Inventory.AppHost.csproj | 20 +++ .../Properties/launchSettings.json | 31 +++++ .../appsettings.Development.json | 8 ++ .../Inventory.AppHost/appsettings.json | 9 ++ .../Inventory.ServiceDefaults/Extensions.cs | 128 ++++++++++++++++++ .../Inventory.ServiceDefaults.csproj | 22 +++ 19 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs create mode 100644 InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs create mode 100644 InventoryManager/Inventory.ApiService/Entity/Product.cs create mode 100644 InventoryManager/Inventory.ApiService/Generation/Generator.cs create mode 100644 InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj create mode 100644 InventoryManager/Inventory.ApiService/Program.cs create mode 100644 InventoryManager/Inventory.ApiService/Properties/launchSettings.json create mode 100644 InventoryManager/Inventory.ApiService/appsettings.Development.json create mode 100644 InventoryManager/Inventory.ApiService/appsettings.json create mode 100644 InventoryManager/Inventory.AppHost/AppHost.cs create mode 100644 InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj create mode 100644 InventoryManager/Inventory.AppHost/Properties/launchSettings.json create mode 100644 InventoryManager/Inventory.AppHost/appsettings.Development.json create mode 100644 InventoryManager/Inventory.AppHost/appsettings.json create mode 100644 InventoryManager/Inventory.ServiceDefaults/Extensions.cs create mode 100644 InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..e73eaa3b 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 Кеширование + Вариант №45 Товар на кладке + Выполнена Ле Хань Хоанг 6513 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 4dda7c04..68034c99 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/land-plot" + "BaseAddress": "https://localhost:7266/api/inventory" } \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..bc2f2501 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,16 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.3.11520.95 d18.3 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}") = "Inventory.ApiService", "InventoryManager\Inventory.ApiService\Inventory.ApiService.csproj", "{E32F4311-CD79-A2FA-0DBA-55DAD48F6377}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.AppHost", "InventoryManager\Inventory.AppHost\Inventory.AppHost.csproj", "{AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.ServiceDefaults", "InventoryManager\Inventory.ServiceDefaults\Inventory.ServiceDefaults.csproj", "{E302BFA1-84FC-63A7-EA3A-7872A83042B9}" +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 + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs new file mode 100644 index 00000000..55425f1b --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Generation; +using Microsoft.Extensions.Caching.Distributed; + +namespace Inventory.ApiService.Cache; + +public class InventoryCache +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly IDistributedCache _cache; + private readonly Generator _generator; + private readonly ILogger _logger; + + public InventoryCache(IDistributedCache cache, Generator generator, ILogger logger) + { + _cache = cache; + _generator = generator; + _logger = logger; + } + + public async Task> GetAsync(int count, int? seed, CancellationToken ct) + { + var cacheKey = $"inventory:count={count}:seed={(seed?.ToString() ?? "null")}"; + + var cached = await _cache.GetStringAsync(cacheKey, ct); + + if (cached is not null) + { + _logger.LogInformation("Inventory cache HIT {CacheKey}", cacheKey); + return JsonSerializer.Deserialize>(cached, _jsonOptions) ?? new List(); + } + + _logger.LogInformation("Inventory cache MISS {CacheKey}. Generating {Count} items.", cacheKey, count); + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var data = Generator.Generate(count, seed); + + sw.Stop(); + + _logger.LogInformation("Generated inventory {Count} items in {ElapsedMs}ms", data.Count, sw.ElapsedMilliseconds); + + var json = JsonSerializer.Serialize(data, _jsonOptions); + + await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct); + + return data; + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs new file mode 100644 index 00000000..9f1491d1 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Cache; + +namespace Inventory.ApiService.Controllers; + +[ApiController] +[Route("api/inventory")] +public class InventoryController(InventoryCache _cache) : ControllerBase +{ + [HttpGet] + public async Task> Get(int? id) + { + var index = id ?? 0; + + if (index < 0) + return BadRequest("id must be >= 0"); + + var data = await _cache.GetAsync(100, seed: 1, CancellationToken.None); + + var product = data.ElementAtOrDefault(index); + + return product is not null? Ok(product): NotFound(); + } +} diff --git a/InventoryManager/Inventory.ApiService/Entity/Product.cs b/InventoryManager/Inventory.ApiService/Entity/Product.cs new file mode 100644 index 00000000..e06a44a0 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Entity/Product.cs @@ -0,0 +1,57 @@ +namespace Inventory.ApiService.Entity; + +/// +/// Класс, представляющий товар на складе +/// +public class Product +{ + /// + /// Идентификатор в системе + /// + public int Id { get; set; } + + /// + /// Наименование товара + /// + public string NameProduct { get; set; } = string.Empty; + + /// + /// Категория товара + /// + public string Category { get; set; } = string.Empty; + + /// + /// Количество на складе + /// + public int Quantity { get; set; } + + /// + /// Цена за единицу товара + /// + public decimal Price { get; set; } + + /// + /// Вес единицы товара + /// + public double Weight { get; set; } + + /// + /// Габариты единицы товара + /// + public string Dimension { get; set; } = string.Empty; + + /// + /// Товар хрупкий + /// + public bool IsFragile { get; set; } + + /// + /// Дата последней поставки + /// + public DateOnly LastDeliveryDate { get; set; } + + /// + /// Дата следующей поставки + /// + public DateOnly NextDeliveryDate { get; set; } +} diff --git a/InventoryManager/Inventory.ApiService/Generation/Generator.cs b/InventoryManager/Inventory.ApiService/Generation/Generator.cs new file mode 100644 index 00000000..c4fe21d3 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Generation/Generator.cs @@ -0,0 +1,42 @@ +using Bogus; +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Generation; + +public class Generator +{ + public static List Generate(int count, int? seed = null) + { + if (seed.HasValue) + Randomizer.Seed = new Random(seed.Value); + + var faker = new Faker() + .RuleFor(x => x.Id, f => f.IndexFaker + 1) + .RuleFor(x => x.NameProduct, f => f.Commerce.ProductName()) + .RuleFor(x => x.Category, f => f.Commerce.Categories(1)[0]) + .RuleFor(x => x.Quantity, f => f.Random.Int(0, 1000)) + .RuleFor(x => x.Price, f => Math.Round(f.Random.Decimal(1, 10000), 2)) + .RuleFor(x => x.Weight, f => Math.Round(f.Random.Double(0.1, 100), 2)) + .RuleFor(x => x.Dimension, f => + { + var a = f.Random.Int(1, 200); + var b = f.Random.Int(1, 200); + var c = f.Random.Int(1, 200); + return $"{a}×{b}×{c} cm"; + }) + .RuleFor(x => x.IsFragile, f => f.Random.Bool()) + .RuleFor(x => x.LastDeliveryDate, f => + { + var date = f.Date.Past(2); + return DateOnly.FromDateTime(date); + }) + .RuleFor(x => x.NextDeliveryDate, (f, item) => + { + var lastDate = item.LastDeliveryDate.ToDateTime(TimeOnly.MinValue); + var nextDate = f.Date.Between(lastDate, lastDate.AddMonths(6)); + return DateOnly.FromDateTime(nextDate); + }); + + return faker.Generate(count); + } +} diff --git a/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj new file mode 100644 index 00000000..296b39dc --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs new file mode 100644 index 00000000..4b0a3650 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -0,0 +1,41 @@ +using Inventory.ApiService.Cache; +using Inventory.ApiService.Generation; +using Inventory.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +// Cache +builder.AddRedisDistributedCache("cache"); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// CORS +builder.Services.AddCors(options => +{ + options.AddPolicy("client", policy => + { + policy.AllowAnyOrigin() + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseCors("client"); + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Properties/launchSettings.json b/InventoryManager/Inventory.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000..49be98fd --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7266;http://localhost:5339", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.ApiService/appsettings.Development.json b/InventoryManager/Inventory.ApiService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.ApiService/appsettings.json b/InventoryManager/Inventory.ApiService/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/InventoryManager/Inventory.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs new file mode 100644 index 00000000..8316a372 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -0,0 +1,15 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("cache"); + +var api = builder.AddProject("apiservice") + .WithReference(cache) + .WaitFor(cache); + + +var client = builder.AddProject("client-wasm") + .WithExternalHttpEndpoints() + .WithHttpHealthCheck("/health") + .WaitFor(api); + +builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj new file mode 100644 index 00000000..103b4405 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + c2ca5822-3bae-42fc-9a94-9efbb7235036 + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.AppHost/Properties/launchSettings.json b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..7532a920 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17276;http://localhost:15241", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21117", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23036", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22056" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15241", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19037", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18161", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20000" + } + } + } +} diff --git a/InventoryManager/Inventory.AppHost/appsettings.Development.json b/InventoryManager/Inventory.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.AppHost/appsettings.json b/InventoryManager/Inventory.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/InventoryManager/Inventory.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.ServiceDefaults/Extensions.cs b/InventoryManager/Inventory.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..61905e5f --- /dev/null +++ b/InventoryManager/Inventory.ServiceDefaults/Extensions.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Inventory.ServiceDefaults; + +// 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/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj b/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj new file mode 100644 index 00000000..eeb71e38 --- /dev/null +++ b/InventoryManager/Inventory.ServiceDefaults/Inventory.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + From 64130a5688bf6ba06097c3108d809af29a200109 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Wed, 25 Feb 2026 18:18:27 +0400 Subject: [PATCH 02/26] small fix --- 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 e73eaa3b..3831cdb7 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -5,7 +5,7 @@ Номер №1 Кеширование - Вариант №45 Товар на кладке + Вариант №45 Товар на складке Выполнена Ле Хань Хоанг 6513 Ссылка на форк From 69e71ee62f67d1f620b2a5bfe161de0cf9243ec8 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Wed, 25 Feb 2026 18:24:09 +0400 Subject: [PATCH 03/26] done --- 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 3831cdb7..c524c990 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -5,7 +5,7 @@ Номер №1 Кеширование - Вариант №45 Товар на складке + Вариант №45 Товар на складе Выполнена Ле Хань Хоанг 6513 Ссылка на форк From c25c998c4dbad0a4b8ce0982052d6bdeaac89b3e Mon Sep 17 00:00:00 2001 From: Khanh Hoang <114916103+vieKH@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:25:36 +0400 Subject: [PATCH 04/26] Update README.md --- README.md | 132 +++--------------------------------------------------- 1 file changed, 5 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index dcaa5eb7..3d3cdd1b 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,6 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
- -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +## Ле Хань Хоанг 6513 +## Вариант 45 - Товар на складке +## Лабораторная работа №1 - Кэширование +image +image From 6b54ce1d10bdd2ad9b64b53b2eff086d33bedf56 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Thu, 26 Feb 2026 12:27:45 +0400 Subject: [PATCH 05/26] fix index --- InventoryManager/Inventory.ApiService/Generation/Generator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InventoryManager/Inventory.ApiService/Generation/Generator.cs b/InventoryManager/Inventory.ApiService/Generation/Generator.cs index c4fe21d3..948572e1 100644 --- a/InventoryManager/Inventory.ApiService/Generation/Generator.cs +++ b/InventoryManager/Inventory.ApiService/Generation/Generator.cs @@ -11,7 +11,7 @@ public static List Generate(int count, int? seed = null) Randomizer.Seed = new Random(seed.Value); var faker = new Faker() - .RuleFor(x => x.Id, f => f.IndexFaker + 1) + .RuleFor(x => x.Id, f => f.IndexFaker) .RuleFor(x => x.NameProduct, f => f.Commerce.ProductName()) .RuleFor(x => x.Category, f => f.Commerce.Categories(1)[0]) .RuleFor(x => x.Quantity, f => f.Random.Int(0, 1000)) From 8863dd55772c155d107b2ed84f21b8a24efe9aeb Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Tue, 3 Mar 2026 00:48:44 +0400 Subject: [PATCH 06/26] Fix --- .../Cache/IInventoryCache.cs | 17 ++++ .../Cache/InventoryCache.cs | 87 ++++++++++++------- .../Controllers/InventoryControler.cs | 34 +++++--- .../Generation/Generator.cs | 69 +++++++-------- .../Inventory.ApiService/Program.cs | 2 +- .../Inventory.ApiService/appsettings.json | 5 +- InventoryManager/Inventory.AppHost/AppHost.cs | 4 +- 7 files changed, 137 insertions(+), 81 deletions(-) create mode 100644 InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs diff --git a/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs new file mode 100644 index 00000000..974af8cd --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Cache/IInventoryCache.cs @@ -0,0 +1,17 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Cache; + +/// +/// Интерфейс сервиса для получения продукта с использованием кэширования. +/// +public interface IInventoryCache +{ + /// + /// Возвращает продукт по идентификатору из кэша или генерирует его при отсутствии в кэше. + /// + /// Идентификатор продукта + /// Токен отмены операции + /// Экземпляр продукта + public Task GetAsync(int id, CancellationToken ct); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs index 55425f1b..621cf288 100644 --- a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs +++ b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs @@ -4,46 +4,73 @@ using Microsoft.Extensions.Caching.Distributed; namespace Inventory.ApiService.Cache; - -public class InventoryCache +/// +/// Реализация сервиса кэширования для получения продукта. +/// Сначала пытается получить данные из кэша, при отсутствии — генерирует продукт и сохраняет его в кэш. +/// +/// Сервис распределённого кэширования +/// Конфигурация приложения +/// Логгер для записи событий +/// Генератор +public class InventoryCache(IDistributedCache _cache, IConfiguration _configuration, ILogger _logger, Generator _generator) : IInventoryCache { - private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); - private readonly IDistributedCache _cache; - private readonly Generator _generator; - private readonly ILogger _logger; - - public InventoryCache(IDistributedCache cache, Generator generator, ILogger logger) + /// + /// Возвращает продукт по идентификатору. + /// При наличии в кэше возвращает сохранённые данные, иначе генерирует новый объект и сохраняет его в кэш + /// + /// Идентификатор продукта + /// Токен отмены операции + /// + public async Task GetAsync(int id, CancellationToken ct) { - _cache = cache; - _generator = generator; - _logger = logger; - } + var cacheKey = $"inventory-{id}"; + _logger.LogInformation("Try get product {Id} from cache", id); - public async Task> GetAsync(int count, int? seed, CancellationToken ct) - { - var cacheKey = $"inventory:count={count}:seed={(seed?.ToString() ?? "null")}"; + var cachedData = await _cache.GetStringAsync(cacheKey, ct); - var cached = await _cache.GetStringAsync(cacheKey, ct); - if (cached is not null) + if (!string.IsNullOrEmpty(cachedData)) { - _logger.LogInformation("Inventory cache HIT {CacheKey}", cacheKey); - return JsonSerializer.Deserialize>(cached, _jsonOptions) ?? new List(); - } - - _logger.LogInformation("Inventory cache MISS {CacheKey}. Generating {Count} items.", cacheKey, count); - - var sw = System.Diagnostics.Stopwatch.StartNew(); - var data = Generator.Generate(count, seed); + try + { + var cachedProduct = JsonSerializer.Deserialize(cachedData); + if (cachedProduct is not null) + { + _logger.LogInformation("Cache HIT for product {Id}", id); + return cachedProduct; + } - sw.Stop(); + _logger.LogWarning("Cache HIT but deserialize returned null for product {Id}", id); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Deserialize failed for product {Id}. Continue without cache.", id); + } + } - _logger.LogInformation("Generated inventory {Count} items in {ElapsedMs}ms", data.Count, sw.ElapsedMilliseconds); + _logger.LogInformation("Cache MISS for product {Id}. Generating.", id); + var product = _generator.Generate(id); - var json = JsonSerializer.Serialize(data, _jsonOptions); + try + { + var expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 5); + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(expirationMinutes) + }; - await _cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct); + await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), options, ct); + _logger.LogInformation("Product {Id} saved to cache", id); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache WRITE failed for {Id}. Continue without cache.", id); + } - return data; + return product; } } \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs index 9f1491d1..8619b82f 100644 --- a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -1,25 +1,33 @@ using Microsoft.AspNetCore.Mvc; using Inventory.ApiService.Entity; using Inventory.ApiService.Cache; +using Inventory.ApiService.Generation; namespace Inventory.ApiService.Controllers; +/// +/// Контроллер для обработки запросов, связанных с продуктами +/// +/// Сервис кэширования продуктов [ApiController] -[Route("api/inventory")] -public class InventoryController(InventoryCache _cache) : ControllerBase +[Route("api/[controller]")] +public class InventoryController(IInventoryCache cache) : ControllerBase { + /// + /// Обрабатывает GET-запрос на получение продукта по идентификатору + /// + /// Идентификатор продукта + /// Токен отмены операции + /// Объект продукта или ошибка 400 при некорректном идентификаторе [HttpGet] - public async Task> Get(int? id) + [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Get([FromQuery] int? id, CancellationToken ct) { - var index = id ?? 0; + if (id is null || id < 0) + return BadRequest("id is required and must be >= 0"); - if (index < 0) - return BadRequest("id must be >= 0"); - - var data = await _cache.GetAsync(100, seed: 1, CancellationToken.None); - - var product = data.ElementAtOrDefault(index); - - return product is not null? Ok(product): NotFound(); + var product = await cache.GetAsync(id.Value, ct); + return Ok(product); } -} +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Generation/Generator.cs b/InventoryManager/Inventory.ApiService/Generation/Generator.cs index 948572e1..6d1942d3 100644 --- a/InventoryManager/Inventory.ApiService/Generation/Generator.cs +++ b/InventoryManager/Inventory.ApiService/Generation/Generator.cs @@ -2,41 +2,42 @@ using Inventory.ApiService.Entity; namespace Inventory.ApiService.Generation; - +/// +/// Сервис генерации тестовых данных продукта.Использует библиотеку Bogus для создания случайных значений. +/// public class Generator { - public static List Generate(int count, int? seed = null) - { - if (seed.HasValue) - Randomizer.Seed = new Random(seed.Value); + private static readonly Faker _faker = new Faker() + .RuleFor(x => x.NameProduct, f => f.Commerce.ProductName()) + .RuleFor(x => x.Category, f => f.Commerce.Categories(1)[0]) + .RuleFor(x => x.Quantity, f => f.Random.Int(0, 1000)) + .RuleFor(x => x.Price, f => Math.Round(f.Random.Decimal(1, 10000), 2)) + .RuleFor(x => x.Weight, f => Math.Round(f.Random.Double(0.1, 100), 2)) + .RuleFor(x => x.Dimension, f => + { + var a = f.Random.Int(1, 200); + var b = f.Random.Int(1, 200); + var c = f.Random.Int(1, 200); + return $"{a}×{b}×{c} cm"; + }) + .RuleFor(x => x.IsFragile, f => f.Random.Bool()) + .RuleFor(x => x.LastDeliveryDate, f => DateOnly.FromDateTime(f.Date.Past(2))) + .RuleFor(x => x.NextDeliveryDate, (f, item) => + { + var lastDate = item.LastDeliveryDate.ToDateTime(TimeOnly.MinValue); + var nextDate = f.Date.Between(lastDate, lastDate.AddMonths(6)); + return DateOnly.FromDateTime(nextDate); + }); - var faker = new Faker() - .RuleFor(x => x.Id, f => f.IndexFaker) - .RuleFor(x => x.NameProduct, f => f.Commerce.ProductName()) - .RuleFor(x => x.Category, f => f.Commerce.Categories(1)[0]) - .RuleFor(x => x.Quantity, f => f.Random.Int(0, 1000)) - .RuleFor(x => x.Price, f => Math.Round(f.Random.Decimal(1, 10000), 2)) - .RuleFor(x => x.Weight, f => Math.Round(f.Random.Double(0.1, 100), 2)) - .RuleFor(x => x.Dimension, f => - { - var a = f.Random.Int(1, 200); - var b = f.Random.Int(1, 200); - var c = f.Random.Int(1, 200); - return $"{a}×{b}×{c} cm"; - }) - .RuleFor(x => x.IsFragile, f => f.Random.Bool()) - .RuleFor(x => x.LastDeliveryDate, f => - { - var date = f.Date.Past(2); - return DateOnly.FromDateTime(date); - }) - .RuleFor(x => x.NextDeliveryDate, (f, item) => - { - var lastDate = item.LastDeliveryDate.ToDateTime(TimeOnly.MinValue); - var nextDate = f.Date.Between(lastDate, lastDate.AddMonths(6)); - return DateOnly.FromDateTime(nextDate); - }); - - return faker.Generate(count); + /// + /// Генерирует продукт по заданному идентификатору. + /// + /// Идентификатор продукта + /// Сгенерированный объект продукта + public Product Generate(int id) + { + var product = _faker.Generate(); + product.Id = id; + return product; } -} +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index 4b0a3650..a8b185b8 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -26,7 +26,7 @@ // DI builder.Services.AddSingleton(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/InventoryManager/Inventory.ApiService/appsettings.json b/InventoryManager/Inventory.ApiService/appsettings.json index 10f68b8c..82896777 100644 --- a/InventoryManager/Inventory.ApiService/appsettings.json +++ b/InventoryManager/Inventory.ApiService/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "CacheSettings": { + "ExpirationMinutes": 5 + }, + "AllowedHosts": "*" } diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 8316a372..90e0b792 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,12 +1,12 @@ var builder = DistributedApplication.CreateBuilder(args); -var cache = builder.AddRedis("cache"); +var cache = builder.AddRedis("cache") + .WithRedisCommander(); var api = builder.AddProject("apiservice") .WithReference(cache) .WaitFor(cache); - var client = builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WithHttpHealthCheck("/health") From 0f7fe10cc5ac27d173de3feb73f95cd3a3fd5ca8 Mon Sep 17 00:00:00 2001 From: Khanh Hoang <114916103+vieKH@users.noreply.github.com> Date: Tue, 3 Mar 2026 00:52:03 +0400 Subject: [PATCH 07/26] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d3cdd1b..90071f06 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ ## Ле Хань Хоанг 6513 ## Вариант 45 - Товар на складке ## Лабораторная работа №1 - Кэширование -image -image +image + +image From 77b704ff1f62537ef2ad5dfe4cf6b775868fac76 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Tue, 3 Mar 2026 13:50:37 +0400 Subject: [PATCH 08/26] Fix code style and logic in inventoryCache.cs --- .../Cache/InventoryCache.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs index 621cf288..648dd3b9 100644 --- a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs +++ b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs @@ -12,8 +12,13 @@ namespace Inventory.ApiService.Cache; /// Конфигурация приложения /// Логгер для записи событий /// Генератор -public class InventoryCache(IDistributedCache _cache, IConfiguration _configuration, ILogger _logger, Generator _generator) : IInventoryCache +public class InventoryCache(IDistributedCache cache, IConfiguration configuration, ILogger logger,Generator generator) : IInventoryCache { + private readonly IDistributedCache _cache = cache; + private readonly IConfiguration _configuration = configuration; + private readonly ILogger _logger = logger; + private readonly Generator _generator = generator; + /// /// Возвращает продукт по идентификатору. /// При наличии в кэше возвращает сохранённые данные, иначе генерирует новый объект и сохраняет его в кэш @@ -26,8 +31,16 @@ public async Task GetAsync(int id, CancellationToken ct) var cacheKey = $"inventory-{id}"; _logger.LogInformation("Try get product {Id} from cache", id); - var cachedData = await _cache.GetStringAsync(cacheKey, ct); + string? cachedData = null; + try + { + cachedData = await _cache.GetStringAsync(cacheKey, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache READ failed for {Id}. Continue without cache.", id); + } if (!string.IsNullOrEmpty(cachedData)) { From 1fba50a2b49ec3ce192f283bcc6c6685b9cadb94 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Tue, 3 Mar 2026 14:22:27 +0400 Subject: [PATCH 09/26] final fix --- .../Cache/InventoryCache.cs | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs index 648dd3b9..32ecb454 100644 --- a/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs +++ b/InventoryManager/Inventory.ApiService/Cache/InventoryCache.cs @@ -8,17 +8,12 @@ namespace Inventory.ApiService.Cache; /// Реализация сервиса кэширования для получения продукта. /// Сначала пытается получить данные из кэша, при отсутствии — генерирует продукт и сохраняет его в кэш. /// -/// Сервис распределённого кэширования -/// Конфигурация приложения -/// Логгер для записи событий -/// Генератор +/// Сервис распределённого кэширования +/// Конфигурация приложения +/// Логгер для записи событий +/// Генератор public class InventoryCache(IDistributedCache cache, IConfiguration configuration, ILogger logger,Generator generator) : IInventoryCache { - private readonly IDistributedCache _cache = cache; - private readonly IConfiguration _configuration = configuration; - private readonly ILogger _logger = logger; - private readonly Generator _generator = generator; - /// /// Возвращает продукт по идентификатору. /// При наличии в кэше возвращает сохранённые данные, иначе генерирует новый объект и сохраняет его в кэш @@ -29,17 +24,17 @@ public class InventoryCache(IDistributedCache cache, IConfiguration configuratio public async Task GetAsync(int id, CancellationToken ct) { var cacheKey = $"inventory-{id}"; - _logger.LogInformation("Try get product {Id} from cache", id); + logger.LogInformation("Try get product {Id} from cache", id); string? cachedData = null; try { - cachedData = await _cache.GetStringAsync(cacheKey, ct); + cachedData = await cache.GetStringAsync(cacheKey, ct); } catch (Exception ex) { - _logger.LogWarning(ex, "Cache READ failed for {Id}. Continue without cache.", id); + logger.LogWarning(ex, "Cache READ failed for {Id}. Continue without cache.", id); } if (!string.IsNullOrEmpty(cachedData)) @@ -49,31 +44,31 @@ public async Task GetAsync(int id, CancellationToken ct) var cachedProduct = JsonSerializer.Deserialize(cachedData); if (cachedProduct is not null) { - _logger.LogInformation("Cache HIT for product {Id}", id); + logger.LogInformation("Cache HIT for product {Id}", id); return cachedProduct; } - _logger.LogWarning("Cache HIT but deserialize returned null for product {Id}", id); + logger.LogWarning("Cache HIT but deserialize returned null for product {Id}", id); } catch (Exception ex) { - _logger.LogWarning(ex, "Deserialize failed for product {Id}. Continue without cache.", id); + logger.LogWarning(ex, "Deserialize failed for product {Id}. Continue without cache.", id); } } - _logger.LogInformation("Cache MISS for product {Id}. Generating.", id); - var product = _generator.Generate(id); + logger.LogInformation("Cache MISS for product {Id}. Generating.", id); + var product = generator.Generate(id); try { - var expirationMinutes = _configuration.GetValue("CacheSettings:ExpirationMinutes", 5); + var expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 5); var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(expirationMinutes) }; - await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), options, ct); - _logger.LogInformation("Product {Id} saved to cache", id); + await cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(product), options, ct); + logger.LogInformation("Product {Id} saved to cache", id); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -81,7 +76,7 @@ public async Task GetAsync(int id, CancellationToken ct) } catch (Exception ex) { - _logger.LogWarning(ex, "Cache WRITE failed for {Id}. Continue without cache.", id); + logger.LogWarning(ex, "Cache WRITE failed for {Id}. Continue without cache.", id); } return product; From 77e43ceb224984c55de5c663e3c49e0b963b0980 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 8 Mar 2026 02:01:16 +0400 Subject: [PATCH 10/26] Base Ocelot --- CloudDevelopment.sln | 8 ++- .../Controllers/InventoryControler.cs | 1 - .../Inventory.Gateway.csproj | 17 +++++ .../LoadBalancer/RandomBalance.cs | 58 +++++++++++++++++ InventoryManager/Inventory.Gateway/Program.cs | 44 +++++++++++++ .../Properties/launchSettings.json | 23 +++++++ .../appsettings.Development.json | 8 +++ .../Inventory.Gateway/appsettings.json | 9 +++ .../Inventory.Gateway/ocelot.json | 62 +++++++++++++++++++ 9 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj create mode 100644 InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs create mode 100644 InventoryManager/Inventory.Gateway/Program.cs create mode 100644 InventoryManager/Inventory.Gateway/Properties/launchSettings.json create mode 100644 InventoryManager/Inventory.Gateway/appsettings.Development.json create mode 100644 InventoryManager/Inventory.Gateway/appsettings.json create mode 100644 InventoryManager/Inventory.Gateway/ocelot.json diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index bc2f2501..660d6982 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.3.11520.95 d18.3 +VisualStudioVersion = 18.3.11520.95 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.AppHost", "Invent EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.ServiceDefaults", "InventoryManager\Inventory.ServiceDefaults\Inventory.ServiceDefaults.csproj", "{E302BFA1-84FC-63A7-EA3A-7872A83042B9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.Gateway", "InventoryManager\Inventory.Gateway\Inventory.Gateway.csproj", "{103A57B1-1180-645E-9B47-C67CDA2CD513}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs index 8619b82f..f7c8db0a 100644 --- a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Inventory.ApiService.Entity; using Inventory.ApiService.Cache; -using Inventory.ApiService.Generation; namespace Inventory.ApiService.Controllers; diff --git a/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj b/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj new file mode 100644 index 00000000..37bef241 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Inventory.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs new file mode 100644 index 00000000..df882421 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs @@ -0,0 +1,58 @@ +using Ocelot.DownstreamRouteFinder.Finder; +using Ocelot.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Inventory.Gateway.LoadBalancer; + +public class RandomSelector(ILogger logger, List instances) : ILoadBalancer +{ + public string Type => "RandomSelector"; + + private static int ParseRequestId(HttpContext context) + { + var raw = context.Request.Query["id"].FirstOrDefault(); + + return int.TryParse(raw, out var value) ? value : -1; + } + + private Service SelectInstance() + { + var index = Random.Shared.Next(instances.Count); + + return instances[index]; + } + + public Task> LeaseAsync(HttpContext context) + { + if (instances.Count == 0) + { + return Task.FromResult>( + new ErrorResponse( + new UnableToFindDownstreamRouteError( + context.Request.Path, + context.Request.Method + ) + ) + ); + } + + var requestId = ParseRequestId(context); + + var selected = SelectInstance(); + + logger.LogInformation( + "Gateway selected instance {Host}:{Port} for request id {Id}", + selected.HostAndPort.DownstreamHost, + selected.HostAndPort.DownstreamPort, + requestId + ); + + return Task.FromResult>( + new OkResponse(selected.HostAndPort) + ); + } + + public void Release(ServiceHostAndPort hostAndPort){ } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Program.cs b/InventoryManager/Inventory.Gateway/Program.cs new file mode 100644 index 00000000..94f8ac43 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Program.cs @@ -0,0 +1,44 @@ +using Inventory.Gateway.LoadBalancer; +using Inventory.ServiceDefaults; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration + .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +builder.Services + .AddOcelot() + .AddCustomLoadBalancer((provider, route, discovery) => + { + var log = provider.GetRequiredService>(); + + var downstream = discovery + .GetAsync() + .GetAwaiter() + .GetResult() + .ToList(); + + return new RandomSelector(log, downstream); + }); + +builder.Services.AddCors(policy => +{ + policy.AddPolicy("ClientPolicy", cfg => + { + cfg.AllowAnyOrigin() + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + +var app = builder.Build(); + +app.UseCors("ClientPolicy"); + +await app.UseOcelot(); + +app.Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Properties/launchSettings.json b/InventoryManager/Inventory.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..f7b5a5b7 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7288;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.Gateway/appsettings.Development.json b/InventoryManager/Inventory.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.Gateway/appsettings.json b/InventoryManager/Inventory.Gateway/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/InventoryManager/Inventory.Gateway/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json new file mode 100644 index 00000000..85cde092 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -0,0 +1,62 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/inventory", + "UpstreamHttpMethod": [ "GET" ], + + "DownstreamPathTemplate": "/inventory", + "DownstreamScheme": "https", + + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + } + ], + + "LoadBalancerOptions": { + "Type": "RandomSelector" + } + }, + { + "UpstreamPathTemplate": "/health", + "UpstreamHttpMethod": [ "GET" ], + "Priority": 1, + + "DownstreamPathTemplate": "/health", + "DownstreamScheme": "https", + + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + } + ], + + "LoadBalancerOptions": { + "Type": "RandomSelector" + } + } + ], + + "GlobalConfiguration": { + "BaseUrl": "https://localhost:5000", + "RequestIdKey": "Gateway-Request-Id" + } +} \ No newline at end of file From e6a49500f89e83f51e598b0ef004eb9bc9f0ac5d Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 8 Mar 2026 10:03:46 +0400 Subject: [PATCH 11/26] Add balancer --- .../LoadBalancer/RandomBalance.cs | 58 ------------------- .../LoadBalancer/WeightedRandom.cs | 47 +++++++++++++++ InventoryManager/Inventory.Gateway/Program.cs | 20 +++---- .../Inventory.Gateway/ocelot.json | 4 +- 4 files changed, 56 insertions(+), 73 deletions(-) delete mode 100644 InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs create mode 100644 InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs deleted file mode 100644 index df882421..00000000 --- a/InventoryManager/Inventory.Gateway/LoadBalancer/RandomBalance.cs +++ /dev/null @@ -1,58 +0,0 @@ -using Ocelot.DownstreamRouteFinder.Finder; -using Ocelot.Errors; -using Ocelot.LoadBalancer.Interfaces; -using Ocelot.Responses; -using Ocelot.Values; - -namespace Inventory.Gateway.LoadBalancer; - -public class RandomSelector(ILogger logger, List instances) : ILoadBalancer -{ - public string Type => "RandomSelector"; - - private static int ParseRequestId(HttpContext context) - { - var raw = context.Request.Query["id"].FirstOrDefault(); - - return int.TryParse(raw, out var value) ? value : -1; - } - - private Service SelectInstance() - { - var index = Random.Shared.Next(instances.Count); - - return instances[index]; - } - - public Task> LeaseAsync(HttpContext context) - { - if (instances.Count == 0) - { - return Task.FromResult>( - new ErrorResponse( - new UnableToFindDownstreamRouteError( - context.Request.Path, - context.Request.Method - ) - ) - ); - } - - var requestId = ParseRequestId(context); - - var selected = SelectInstance(); - - logger.LogInformation( - "Gateway selected instance {Host}:{Port} for request id {Id}", - selected.HostAndPort.DownstreamHost, - selected.HostAndPort.DownstreamPort, - requestId - ); - - return Task.FromResult>( - new OkResponse(selected.HostAndPort) - ); - } - - public void Release(ServiceHostAndPort hostAndPort){ } -} \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs new file mode 100644 index 00000000..6c90ea80 --- /dev/null +++ b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs @@ -0,0 +1,47 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Inventory.Gateway.LoadBalancer; + +public class WeightedRandom(ILogger logger, List services) : ILoadBalancer +{ + public string Type => "WeightedRandom"; + + private readonly Random _rng = new(); + + public Task> LeaseAsync(HttpContext httpContext) + { + var totalWeight = 0; + for (var i = 0; i < services.Count; i++) + totalWeight += (i + 1) * (i + 1); + + var ticket = _rng.Next(totalWeight); + + var cumulative = 0; + for (var i = 0; i < services.Count; i++) + { + var weight = (i + 1) * (i + 1); + + cumulative += weight; + + if (ticket <= cumulative) + { + var service = services[i]; + + logger.LogInformation("WeightedRandom selected port {port}", service.HostAndPort.DownstreamPort); + + return Task.FromResult>( + new OkResponse(service.HostAndPort)); + } + } + + + var fallback = services.Last(); + + return Task.FromResult>( + new OkResponse(fallback.HostAndPort)); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Program.cs b/InventoryManager/Inventory.Gateway/Program.cs index 94f8ac43..a3737f96 100644 --- a/InventoryManager/Inventory.Gateway/Program.cs +++ b/InventoryManager/Inventory.Gateway/Program.cs @@ -10,24 +10,18 @@ builder.Configuration .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); -builder.Services - .AddOcelot() - .AddCustomLoadBalancer((provider, route, discovery) => +builder.Services.AddOcelot() + .AddCustomLoadBalancer((sp, route, discovery) => { - var log = provider.GetRequiredService>(); + var logger = sp.GetRequiredService>(); + var services = discovery.GetAsync().GetAwaiter().GetResult().ToList(); - var downstream = discovery - .GetAsync() - .GetAwaiter() - .GetResult() - .ToList(); - - return new RandomSelector(log, downstream); + return new WeightedRandom(logger, services); }); builder.Services.AddCors(policy => { - policy.AddPolicy("ClientPolicy", cfg => + policy.AddPolicy("cors", cfg => { cfg.AllowAnyOrigin() .WithMethods("GET") @@ -37,7 +31,7 @@ var app = builder.Build(); -app.UseCors("ClientPolicy"); +app.UseCors("cors"); await app.UseOcelot(); diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json index 85cde092..36355b98 100644 --- a/InventoryManager/Inventory.Gateway/ocelot.json +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -23,7 +23,7 @@ ], "LoadBalancerOptions": { - "Type": "RandomSelector" + "Type": "WeightedRandom" } }, { @@ -50,7 +50,7 @@ ], "LoadBalancerOptions": { - "Type": "RandomSelector" + "Type": "WeightedRandom" } } ], From 7e6c67237f165a9c971424ee606c49bc96c74aca Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 8 Mar 2026 10:51:14 +0400 Subject: [PATCH 12/26] Small fix --- Client.Wasm/Components/StudentCard.razor | 2 +- Client.Wasm/wwwroot/appsettings.json | 2 +- InventoryManager/Inventory.AppHost/AppHost.cs | 31 +++++++++++++++---- .../Inventory.AppHost.csproj | 1 + InventoryManager/Inventory.Gateway/Program.cs | 2 +- .../Inventory.Gateway/ocelot.json | 7 +++-- 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index c524c990..76cd420f 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №1 Кеширование + Номер №2 Балансировка нагрузки Вариант №45 Товар на складе Выполнена Ле Хань Хоанг 6513 Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 5d86c4af..8ac71e93 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7266/api/inventory" + "BaseAddress": "https://localhost:7000/inventory" } diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 90e0b792..4f0a7b21 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,15 +1,34 @@ var builder = DistributedApplication.CreateBuilder(args); var cache = builder.AddRedis("cache") - .WithRedisCommander(); + .WithRedisCommander(); -var api = builder.AddProject("apiservice") - .WithReference(cache) - .WaitFor(cache); +var apis = new List>(); +var basePort = 7001; + +for (var i = 0; i < 3; i++) +{ + var port = basePort + i; + + var api = builder.AddProject($"apiservice-{i + 1}") + .WithReference(cache) + .WaitFor(cache) + .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); + + apis.Add(api); +} + +// Gateway +var gateway = builder.AddProject("apigateway") + .WithHttpsEndpoint(port: 7000, name: "gateway") + .WaitFor(apis[0]) + .WaitFor(apis[1]) + .WaitFor(apis[2]); + +// Client var client = builder.AddProject("client-wasm") .WithExternalHttpEndpoints() - .WithHttpHealthCheck("/health") - .WaitFor(api); + .WaitFor(gateway); builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj index 103b4405..726d1850 100644 --- a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -11,6 +11,7 @@ + diff --git a/InventoryManager/Inventory.Gateway/Program.cs b/InventoryManager/Inventory.Gateway/Program.cs index a3737f96..8e118abf 100644 --- a/InventoryManager/Inventory.Gateway/Program.cs +++ b/InventoryManager/Inventory.Gateway/Program.cs @@ -8,7 +8,7 @@ builder.AddServiceDefaults(); builder.Configuration - .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + .AddJsonFile("ocelot.json", false, true); builder.Services.AddOcelot() .AddCustomLoadBalancer((sp, route, discovery) => diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json index 36355b98..88c7db9a 100644 --- a/InventoryManager/Inventory.Gateway/ocelot.json +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -22,6 +22,8 @@ } ], + "DangerousAcceptAnyServerCertificateValidator": true, + "LoadBalancerOptions": { "Type": "WeightedRandom" } @@ -29,7 +31,6 @@ { "UpstreamPathTemplate": "/health", "UpstreamHttpMethod": [ "GET" ], - "Priority": 1, "DownstreamPathTemplate": "/health", "DownstreamScheme": "https", @@ -49,6 +50,8 @@ } ], + "DangerousAcceptAnyServerCertificateValidator": true, + "LoadBalancerOptions": { "Type": "WeightedRandom" } @@ -56,7 +59,7 @@ ], "GlobalConfiguration": { - "BaseUrl": "https://localhost:5000", + "BaseUrl": "https://localhost:7000", "RequestIdKey": "Gateway-Request-Id" } } \ No newline at end of file From 03bd1c6f19469557f4f765cd2341f0c4b4105b93 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 8 Mar 2026 11:08:07 +0400 Subject: [PATCH 13/26] Fix api --- Client.Wasm/wwwroot/appsettings.json | 2 +- InventoryManager/Inventory.Gateway/ocelot.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 8ac71e93..80688c21 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7000/inventory" + "BaseAddress": "https://localhost:7000/api/inventory" } diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json index 88c7db9a..cc433496 100644 --- a/InventoryManager/Inventory.Gateway/ocelot.json +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -1,10 +1,10 @@ { "Routes": [ { - "UpstreamPathTemplate": "/inventory", + "UpstreamPathTemplate": "/api/inventory", "UpstreamHttpMethod": [ "GET" ], - "DownstreamPathTemplate": "/inventory", + "DownstreamPathTemplate": "/api/inventory", "DownstreamScheme": "https", "DownstreamHostAndPorts": [ From 82f97234df7afd791d9086fd0d505e28c1bca666 Mon Sep 17 00:00:00 2001 From: Khanh Hoang <114916103+vieKH@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:22:16 +0400 Subject: [PATCH 14/26] Update README.md --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 90071f06..58478d0a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ ## Ле Хань Хоанг 6513 -## Вариант 45 - Товар на складке -## Лабораторная работа №1 - Кэширование -image +## Вариант 45 - Товар на складке, алгоритм балансировки - Weighted Random +## Лабораторная работа №2 - Балансировка нагрузки +image + + +image -image From 40ea46b3baee99bd99f54c6a6499e82a5177cfb0 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 8 Mar 2026 11:29:37 +0400 Subject: [PATCH 15/26] Add summary --- InventoryManager/Inventory.AppHost/AppHost.cs | 2 ++ .../LoadBalancer/WeightedRandom.cs | 30 ++++++++++++++++--- InventoryManager/Inventory.Gateway/Program.cs | 3 -- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 4f0a7b21..1cdcddc7 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,10 +1,12 @@ var builder = DistributedApplication.CreateBuilder(args); +// Redis var cache = builder.AddRedis("cache") .WithRedisCommander(); var apis = new List>(); + var basePort = 7001; for (var i = 0; i < 3; i++) diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs index 6c90ea80..b7ad34f1 100644 --- a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs +++ b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs @@ -4,24 +4,43 @@ namespace Inventory.Gateway.LoadBalancer; +/// +/// Пользовательский балансировщик нагрузки для Ocelot,реализующий алгоритм взвешенного случайного выбора (Weighted Random). +/// Каждый сервис получает вес, увеличивающийся в зависимости от его позиции в списке, +/// после чего один из сервисов выбирается случайным образом +/// пропорционально своему весу. +/// +/// Логгер для записи информации о выбранном сервисе +/// Список доступных сервисов для балансировки нагрузки public class WeightedRandom(ILogger logger, List services) : ILoadBalancer { + /// + /// Тип используемого балансировщика нагрузки. + /// public string Type => "WeightedRandom"; + /// + /// Генератор случайных чисел для выбора сервиса. + /// private readonly Random _rng = new(); + /// + /// Выбирает один из доступных сервисов на основе алгоритма взвешенного случайного выбора. + /// + /// Контекст HTTP-запроса. + /// Выбранный сервис (Host и Port). public Task> LeaseAsync(HttpContext httpContext) { var totalWeight = 0; for (var i = 0; i < services.Count; i++) - totalWeight += (i + 1) * (i + 1); + totalWeight += i + 1; - var ticket = _rng.Next(totalWeight); + var ticket = _rng.Next(totalWeight + 1); var cumulative = 0; for (var i = 0; i < services.Count; i++) { - var weight = (i + 1) * (i + 1); + var weight = i + 1; cumulative += weight; @@ -35,7 +54,6 @@ public Task> LeaseAsync(HttpContext httpContext) new OkResponse(service.HostAndPort)); } } - var fallback = services.Last(); @@ -43,5 +61,9 @@ public Task> LeaseAsync(HttpContext httpContext) new OkResponse(fallback.HostAndPort)); } + /// + /// Метод освобождения ресурса после использования. + /// + /// public void Release(ServiceHostAndPort hostAndPort) { } } \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/Program.cs b/InventoryManager/Inventory.Gateway/Program.cs index 8e118abf..683109d2 100644 --- a/InventoryManager/Inventory.Gateway/Program.cs +++ b/InventoryManager/Inventory.Gateway/Program.cs @@ -30,9 +30,6 @@ }); var app = builder.Build(); - app.UseCors("cors"); - await app.UseOcelot(); - app.Run(); \ No newline at end of file From d0e08cd91dc8916480d9364f22f923605745c294 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Tue, 10 Mar 2026 14:01:23 +0400 Subject: [PATCH 16/26] Fix --- InventoryManager/Inventory.ApiService/Program.cs | 13 ------------- InventoryManager/Inventory.AppHost/AppHost.cs | 14 ++++++++------ .../LoadBalancer/WeightedRandom.cs | 5 ++--- InventoryManager/Inventory.Gateway/ocelot.json | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index a8b185b8..6bd716dc 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -13,17 +13,6 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -// CORS -builder.Services.AddCors(options => -{ - options.AddPolicy("client", policy => - { - policy.AllowAnyOrigin() - .WithMethods("GET") - .WithHeaders("Content-Type"); - }); -}); - // DI builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -33,8 +22,6 @@ app.UseSwagger(); app.UseSwaggerUI(); -app.UseCors("client"); - app.MapControllers(); app.MapDefaultEndpoints(); diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 1cdcddc7..6b55e7c9 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -9,7 +9,7 @@ var basePort = 7001; -for (var i = 0; i < 3; i++) +for (var i = 0; i < 5; i++) { var port = basePort + i; @@ -23,13 +23,15 @@ // Gateway var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: 7000, name: "gateway") - .WaitFor(apis[0]) - .WaitFor(apis[1]) - .WaitFor(apis[2]); + .WithHttpsEndpoint(port: 7000, name: "gateway"); + +foreach (var api in apis) +{ + gateway = gateway.WaitFor(api); +} // Client -var client = builder.AddProject("client-wasm") +builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WaitFor(gateway); diff --git a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs index b7ad34f1..3878fe44 100644 --- a/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs +++ b/InventoryManager/Inventory.Gateway/LoadBalancer/WeightedRandom.cs @@ -7,8 +7,7 @@ namespace Inventory.Gateway.LoadBalancer; /// /// Пользовательский балансировщик нагрузки для Ocelot,реализующий алгоритм взвешенного случайного выбора (Weighted Random). /// Каждый сервис получает вес, увеличивающийся в зависимости от его позиции в списке, -/// после чего один из сервисов выбирается случайным образом -/// пропорционально своему весу. +/// после чего один из сервисов выбирается случайным образом пропорционально своему весу. /// /// Логгер для записи информации о выбранном сервисе /// Список доступных сервисов для балансировки нагрузки @@ -17,7 +16,7 @@ public class WeightedRandom(ILogger logger, List servic /// /// Тип используемого балансировщика нагрузки. /// - public string Type => "WeightedRandom"; + public string Type => nameof(WeightedRandom); /// /// Генератор случайных чисел для выбора сервиса. diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json index cc433496..0892c055 100644 --- a/InventoryManager/Inventory.Gateway/ocelot.json +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -19,6 +19,14 @@ { "Host": "localhost", "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 } ], @@ -47,6 +55,14 @@ { "Host": "localhost", "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 } ], From 95db18e6ca5e1de72c4ca113e6ec9f5e2c26ea01 Mon Sep 17 00:00:00 2001 From: Khanh Hoang <114916103+vieKH@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:01:42 +0400 Subject: [PATCH 17/26] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 58478d0a..e4f7dcfe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ## Ле Хань Хоанг 6513 ## Вариант 45 - Товар на складке, алгоритм балансировки - Weighted Random ## Лабораторная работа №2 - Балансировка нагрузки -image +image image From 7b1002860f78c30a5befb1e1b2bf01f00024c3d2 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Thu, 23 Apr 2026 01:18:48 +0400 Subject: [PATCH 18/26] Lab3 --- CloudDevelopment.sln | 72 +++++++++ .../Controllers/InventoryControler.cs | 17 +-- .../Inventory.ApiService.csproj | 3 + .../Messaging/IProducerService.cs | 8 + .../Messaging/SnsPublisherService.cs | 41 ++++++ .../Inventory.ApiService/Program.cs | 8 +- InventoryManager/Inventory.AppHost/AppHost.cs | 57 ++++++-- .../inventory-template-sns-s3.yaml | 41 ++++++ .../Inventory.AppHost.csproj | 7 + .../Properties/launchSettings.json | 2 +- .../Controllers/S3StorageController.cs | 51 +++++++ .../Controllers/SnsSubscriberController.cs | 60 ++++++++ .../Inventory.FileService.csproj | 22 +++ .../Messaging/ISubscriberService.cs | 12 ++ .../Messaging/SnsSubscriberService.cs | 47 ++++++ .../Inventory.FileService/Program.cs | 46 ++++++ .../Properties/launchSettings.json | 23 +++ .../Storage/IS3Service.cs | 33 +++++ .../Storage/S3AwsService.cs | 138 ++++++++++++++++++ .../appsettings.Development.json | 8 + .../Inventory.FileService/appsettings.json | 9 ++ .../Inventory.Tests/IntegrationTests.cs | 80 ++++++++++ .../Inventory.Tests/Inventory.Tests.csproj | 28 ++++ 23 files changed, 788 insertions(+), 25 deletions(-) create mode 100644 InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs create mode 100644 InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs create mode 100644 InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml create mode 100644 InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs create mode 100644 InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs create mode 100644 InventoryManager/Inventory.FileService/Inventory.FileService.csproj create mode 100644 InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs create mode 100644 InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs create mode 100644 InventoryManager/Inventory.FileService/Program.cs create mode 100644 InventoryManager/Inventory.FileService/Properties/launchSettings.json create mode 100644 InventoryManager/Inventory.FileService/Storage/IS3Service.cs create mode 100644 InventoryManager/Inventory.FileService/Storage/S3AwsService.cs create mode 100644 InventoryManager/Inventory.FileService/appsettings.Development.json create mode 100644 InventoryManager/Inventory.FileService/appsettings.json create mode 100644 InventoryManager/Inventory.Tests/IntegrationTests.cs create mode 100644 InventoryManager/Inventory.Tests/Inventory.Tests.csproj diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 660d6982..cd28b84a 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -13,32 +13,104 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.ServiceDefaults", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.Gateway", "InventoryManager\Inventory.Gateway\Inventory.Gateway.csproj", "{103A57B1-1180-645E-9B47-C67CDA2CD513}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.Tests", "InventoryManager\Inventory.Tests\Inventory.Tests.csproj", "{0F0A6F8E-863E-D920-2530-95CD2F7CD63D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Inventory.FileService", "InventoryManager\Inventory.FileService\Inventory.FileService.csproj", "{B30C51CA-2866-57DA-E0D4-D52385B5F3BE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x64.Build.0 = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|x86.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 + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.ActiveCfg = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x64.Build.0 = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.ActiveCfg = Release|Any CPU + {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|x86.Build.0 = Release|Any CPU {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x64.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x64.Build.0 = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x86.ActiveCfg = Debug|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Debug|x86.Build.0 = Debug|Any CPU {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.ActiveCfg = Release|Any CPU {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|Any CPU.Build.0 = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x64.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x64.Build.0 = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x86.ActiveCfg = Release|Any CPU + {E32F4311-CD79-A2FA-0DBA-55DAD48F6377}.Release|x86.Build.0 = Release|Any CPU {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x64.Build.0 = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Debug|x86.Build.0 = Debug|Any CPU {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x64.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x64.Build.0 = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x86.ActiveCfg = Release|Any CPU + {AEBB0C4B-1B1C-46E8-ED62-4289C5A76CEE}.Release|x86.Build.0 = Release|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x64.Build.0 = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Debug|x86.Build.0 = Debug|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|Any CPU.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x64.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x64.Build.0 = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x86.ActiveCfg = Release|Any CPU + {E302BFA1-84FC-63A7-EA3A-7872A83042B9}.Release|x86.Build.0 = Release|Any CPU {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|Any CPU.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x64.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x64.Build.0 = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x86.ActiveCfg = Debug|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Debug|x86.Build.0 = Debug|Any CPU {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.ActiveCfg = Release|Any CPU {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|Any CPU.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x64.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x64.Build.0 = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x86.ActiveCfg = Release|Any CPU + {103A57B1-1180-645E-9B47-C67CDA2CD513}.Release|x86.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x64.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x64.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x86.ActiveCfg = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Debug|x86.Build.0 = Debug|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|Any CPU.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x64.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x64.Build.0 = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x86.ActiveCfg = Release|Any CPU + {0F0A6F8E-863E-D920-2530-95CD2F7CD63D}.Release|x86.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x64.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x64.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Debug|x86.Build.0 = Debug|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|Any CPU.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x64.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x64.Build.0 = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x86.ActiveCfg = Release|Any CPU + {B30C51CA-2866-57DA-E0D4-D52385B5F3BE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs index f7c8db0a..66985d9d 100644 --- a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -1,23 +1,16 @@ using Microsoft.AspNetCore.Mvc; using Inventory.ApiService.Entity; using Inventory.ApiService.Cache; +using Inventory.ApiService.Messaging; namespace Inventory.ApiService.Controllers; -/// -/// Контроллер для обработки запросов, связанных с продуктами -/// -/// Сервис кэширования продуктов [ApiController] [Route("api/[controller]")] -public class InventoryController(IInventoryCache cache) : ControllerBase +public class InventoryController( + IInventoryCache cache, + IProducerService producerService) : ControllerBase { - /// - /// Обрабатывает GET-запрос на получение продукта по идентификатору - /// - /// Идентификатор продукта - /// Токен отмены операции - /// Объект продукта или ошибка 400 при некорректном идентификаторе [HttpGet] [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -27,6 +20,8 @@ public async Task> Get([FromQuery] int? id, CancellationTo return BadRequest("id is required and must be >= 0"); var product = await cache.GetAsync(id.Value, ct); + await producerService.SendMessage(product); + return Ok(product); } } \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj index 296b39dc..df6057b2 100644 --- a/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj +++ b/InventoryManager/Inventory.ApiService/Inventory.ApiService.csproj @@ -12,7 +12,10 @@ + + + diff --git a/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs new file mode 100644 index 00000000..27190708 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs @@ -0,0 +1,8 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Messaging; + +public interface IProducerService +{ + public Task SendMessage(Product product); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs new file mode 100644 index 00000000..f3ee55cc --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs @@ -0,0 +1,41 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Inventory.ApiService.Entity; +using System.Net; +using System.Text.Json; + +namespace Inventory.ApiService.Messaging; + +public class SnsPublisherService( + IAmazonSimpleNotificationService client, + IConfiguration configuration, + ILogger logger) : IProducerService +{ + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + public async Task SendMessage(Product product) + { + try + { + var json = JsonSerializer.Serialize(product); + + var request = new PublishRequest + { + Message = json, + TopicArn = _topicArn + }; + + var response = await client.PublishAsync(request); + + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Inventory {id} was sent to sink via SNS", product.Id); + else + throw new Exception($"SNS returned {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to send inventory through SNS topic"); + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index 6bd716dc..1f18cb05 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -1,5 +1,7 @@ +using Amazon.SimpleNotificationService; using Inventory.ApiService.Cache; using Inventory.ApiService.Generation; +using Inventory.ApiService.Messaging; using Inventory.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); @@ -9,6 +11,9 @@ // Cache builder.AddRedisDistributedCache("cache"); +// AWS SNS +builder.Services.AddAWSService(); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -16,6 +21,7 @@ // DI builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); @@ -25,4 +31,4 @@ app.MapControllers(); app.MapDefaultEndpoints(); -app.Run(); \ No newline at end of file +await app.RunAsync(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 6b55e7c9..b58c9ce7 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,33 +1,57 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; + var builder = DistributedApplication.CreateBuilder(args); // Redis var cache = builder.AddRedis("cache") .WithRedisCommander(); -var apis = new List>(); +// Gateway +var gateway = builder.AddProject("apigateway") + .WithHttpsEndpoint(port: 7000, name: "gateway"); +// AWS config +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); +// LocalStack +var localstack = builder.AddLocalStack("inventory-localstack", awsConfig: awsConfig, configureContainer: container => +{ + container.Lifetime = ContainerLifetime.Session; + container.DebugLevel = 1; + container.LogLevel = LocalStackLogLevel.Debug; + container.Port = 4566; + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); + container.AdditionalEnvironmentVariables.Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); +}); + +// SNS + S3 resources +var awsResources = builder.AddAWSCloudFormationTemplate( + "resources", + "CloudFormation/inventory-template-sns-s3.yaml", + "inventory") + .WithReference(awsConfig); + +// API replicas +var apis = new List>(); var basePort = 7001; for (var i = 0; i < 5; i++) { var port = basePort + i; - var api = builder.AddProject($"apiservice-{i + 1}") - .WithReference(cache) + var api = builder.AddProject($"apiservice-{i + 1}", launchProfileName: null) + .WithReference(cache, "RedisCache") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") .WaitFor(cache) + .WaitFor(awsResources) .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); apis.Add(api); -} - -// Gateway -var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: 7000, name: "gateway"); - -foreach (var api in apis) -{ - gateway = gateway.WaitFor(api); + gateway.WaitFor(api); } // Client @@ -35,4 +59,13 @@ .WithExternalHttpEndpoints() .WaitFor(gateway); +// FileService / Sink +builder.AddProject("inventory-files") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5280/api/sns") + .WaitFor(awsResources); + +builder.UseLocalStack(localstack); + builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml b/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml new file mode 100644 index 00000000..e250849d --- /dev/null +++ b/InventoryManager/Inventory.AppHost/CloudFormation/inventory-template-sns-s3.yaml @@ -0,0 +1,41 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Cloud formation template for inventory project' + +Parameters: + BucketName: + Type: String + Description: Name for the S3 bucket + Default: 'inventory-bucket' + + TopicName: + Type: String + Description: Name for the SNS topic + Default: 'inventory-topic' + +Resources: + InventoryBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + + InventoryTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Ref TopicName + +Outputs: + S3BucketName: + Description: Name of the S3 bucket + Value: !Ref InventoryBucket + + S3BucketArn: + Description: ARN of the S3 bucket + Value: !GetAtt InventoryBucket.Arn + + SNSTopicName: + Description: Name of the SNS topic + Value: !GetAtt InventoryTopic.TopicName + + SNSTopicArn: + Description: ARN of the SNS topic + Value: !Ref InventoryTopic \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj index 726d1850..2732769a 100644 --- a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -11,11 +11,18 @@ + + + + + + + diff --git a/InventoryManager/Inventory.AppHost/Properties/launchSettings.json b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json index 7532a920..a5532076 100644 --- a/InventoryManager/Inventory.AppHost/Properties/launchSettings.json +++ b/InventoryManager/Inventory.AppHost/Properties/launchSettings.json @@ -28,4 +28,4 @@ } } } -} +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs new file mode 100644 index 00000000..ab89aecf --- /dev/null +++ b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs @@ -0,0 +1,51 @@ +using Inventory.FileService.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Controllers; + +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + [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 occured during {method} of {controller}", nameof(ListFiles), nameof(S3StorageController)); + return BadRequest(ex); + } + } + + [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 occured during {method} of {controller}", nameof(GetFile), nameof(S3StorageController)); + return BadRequest(ex); + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs new file mode 100644 index 00000000..be2caebf --- /dev/null +++ b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs @@ -0,0 +1,60 @@ +using Amazon.SimpleNotificationService.Util; +using Inventory.FileService.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; + +namespace Inventory.FileService.Controllers; + +[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 snsMessage = Message.ParseMessage(jsonContent); + + if (snsMessage.Type == "SubscriptionConfirmation") + { + logger.LogInformation("SubscriptionConfirmation was received"); + + using var httpClient = new HttpClient(); + var builder = new UriBuilder(new Uri(snsMessage.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 (snsMessage.Type == "Notification") + { + await s3Service.UploadFile(snsMessage.MessageText); + logger.LogInformation("Notification was successfully processed"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred while processing SNS notifications"); + } + + return Ok(); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Inventory.FileService.csproj b/InventoryManager/Inventory.FileService/Inventory.FileService.csproj new file mode 100644 index 00000000..b5b84c6c --- /dev/null +++ b/InventoryManager/Inventory.FileService/Inventory.FileService.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs new file mode 100644 index 00000000..9d90b990 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Messaging/ISubscriberService.cs @@ -0,0 +1,12 @@ +namespace Inventory.FileService.Messaging; + +/// +/// Интерфейс службы подписки на сообщения +/// +public interface ISubscriberService +{ + /// + /// Выполняет инициализацию подписки при старте приложения + /// + public Task SubscribeEndpoint(); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs new file mode 100644 index 00000000..7df604a2 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs @@ -0,0 +1,47 @@ +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using System.Net; + +namespace Inventory.FileService.Messaging; + +/// +/// Служба подписки на SNS topic +/// +/// SNS клиент +/// Конфигурация +/// Логер +public class SnsSubscriberService( + IAmazonSimpleNotificationService snsClient, + IConfiguration configuration, + ILogger logger) : ISubscriberService +{ + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + + private readonly string _endpoint = configuration["AWS:Resources:SNSUrl"] + ?? throw new KeyNotFoundException("SNS callback endpoint was not found in configuration"); + + /// + public async Task SubscribeEndpoint() + { + logger.LogInformation("Sending subscribe request for {topic}", _topicArn); + + 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); + throw new InvalidOperationException($"Failed to subscribe to SNS topic {_topicArn}"); + } + + logger.LogInformation("Subscription request for {topic} is successful, waiting for confirmation", _topicArn); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs new file mode 100644 index 00000000..55d83edf --- /dev/null +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -0,0 +1,46 @@ +using Amazon.S3; +using Amazon.SimpleNotificationService; +using Inventory.FileService.Messaging; +using Inventory.FileService.Storage; +using Inventory.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddHttpClient(); + +builder.Services.AddAWSService(); +builder.Services.AddAWSService(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.MapControllers(); +app.MapDefaultEndpoints(); + +using (var scope = app.Services.CreateScope()) +{ + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.EnsureBucketExists(); + + var brokerType = app.Configuration["Settings:MessageBroker"] + ?? app.Configuration["Settings__MessageBroker"]; + + if (string.Equals(brokerType, "SNS", StringComparison.OrdinalIgnoreCase)) + { + var subscriberService = scope.ServiceProvider.GetRequiredService(); + await subscriberService.SubscribeEndpoint(); + } +} + +await app.RunAsync(); \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Properties/launchSettings.json b/InventoryManager/Inventory.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..43407bcb --- /dev/null +++ b/InventoryManager/Inventory.FileService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7110;http://localhost:5037", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/InventoryManager/Inventory.FileService/Storage/IS3Service.cs b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs new file mode 100644 index 00000000..854f1281 --- /dev/null +++ b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Storage; + +/// +/// Интерфейс службы для манипуляции файлами в объектном хранилище +/// +public interface IS3Service +{ + /// + /// Отправляет файл в хранилище + /// + /// Строковая репрезентация сохраняемого файла + public Task UploadFile(string fileData); + + /// + /// Получает список всех файлов из хранилища + /// + /// Список путей к файлам + public Task> GetFileList(); + + /// + /// Получает строковую репрезентацию файла из хранилища + /// + /// Путь к файлу в бакете + /// Строковая репрезентация прочтенного файла + public Task DownloadFile(string filePath); + + /// + /// Создает S3 бакет при необходимости + /// + public Task EnsureBucketExists(); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs new file mode 100644 index 00000000..1f903b7b --- /dev/null +++ b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs @@ -0,0 +1,138 @@ +using Amazon.S3; +using Amazon.S3.Model; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Inventory.FileService.Storage; + +/// +/// Cлужба для манипуляции файлами в объектном хранилище +/// +/// S3 клиент +/// Конфигурация +/// Логер +public class S3AwsService(IAmazonS3 client, IConfiguration configuration, ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"] + ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsV2Request + { + BucketName = _bucketName, + Prefix = "", + Delimiter = ",", + }; + var paginator = client.Paginators.ListObjectsV2(request); + + logger.LogInformation("Began listing files in {bucket}", _bucketName); + + await foreach (var response in paginator.Responses) + { + if (response != null && response.S3Objects != null) + { + foreach (var obj in response.S3Objects) + { + if (obj != null) + list.Add(obj.Key); + else + logger.LogWarning("Received null object from {bucket}", _bucketName); + } + } + else + { + logger.LogWarning("Received null response from {bucket}", _bucketName); + } + } + + 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() ?? throw new ArgumentException("Passed JSON has invalid structure"); + + using var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, rootNode); + stream.Seek(0, SeekOrigin.Begin); + + logger.LogInformation("Began uploading inventory {file} onto {bucket}", id, _bucketName); + + var request = new PutObjectRequest + { + BucketName = _bucketName, + Key = $"inventory_{id}.json", + InputStream = stream, + ContentType = "application/json" + }; + + var response = await client.PutObjectAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload inventory {file}: {code}", id, response.HttpStatusCode); + return false; + } + + logger.LogInformation("Finished uploading inventory {file} to {bucket}", id, _bucketName); + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Began downloading {file} from {bucket}", key, _bucketName); + + try + { + var request = new GetObjectRequest + { + BucketName = _bucketName, + Key = key + }; + + using var response = await client.GetObjectAsync(request); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to download {file}: {code}", key, response.HttpStatusCode); + throw new InvalidOperationException($"Error occurred downloading {key} - {response.HttpStatusCode}"); + } + + using var reader = new StreamReader(response.ResponseStream, 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 {file} downloading", key); + throw; + } + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {bucket} exists", _bucketName); + + try + { + await client.EnsureBucketExistsAsync(_bucketName); + logger.LogInformation("{bucket} existence ensured", _bucketName); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception occurred during {bucket} check", _bucketName); + throw; + } + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/appsettings.Development.json b/InventoryManager/Inventory.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/InventoryManager/Inventory.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/InventoryManager/Inventory.FileService/appsettings.json b/InventoryManager/Inventory.FileService/appsettings.json new file mode 100644 index 00000000..ec04bc12 --- /dev/null +++ b/InventoryManager/Inventory.FileService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs new file mode 100644 index 00000000..66016da3 --- /dev/null +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -0,0 +1,80 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Inventory.ApiService.Entity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Xunit.Abstractions; + +namespace Inventory.Tests; + +public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + + 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); + }); + } + + [Fact] + public async Task TestPipeline() + { + var cancellationToken = CancellationToken.None; + + _app = await _builder!.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var random = new Random(); + var id = random.Next(1, 100); + + using var gatewayClient = _app.CreateHttpClient("apigateway", "https"); + using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}", cancellationToken); + + var apiProduct = JsonSerializer.Deserialize( + await gatewayResponse.Content.ReadAsStringAsync(cancellationToken)); + + await Task.Delay(5000, cancellationToken); + + using var sinkClient = _app.CreateHttpClient("inventory-files", "http"); + + using var listResponse = await sinkClient.GetAsync("/api/s3", cancellationToken); + var inventoryList = JsonSerializer.Deserialize>( + await listResponse.Content.ReadAsStringAsync(cancellationToken)); + + using var s3Response = await sinkClient.GetAsync($"/api/s3/inventory_{id}.json", cancellationToken); + var s3Product = JsonSerializer.Deserialize( + await s3Response.Content.ReadAsStringAsync(cancellationToken)); + + Assert.NotNull(inventoryList); + Assert.Single(inventoryList); + Assert.NotNull(apiProduct); + Assert.NotNull(s3Product); + Assert.Equal(id, s3Product!.Id); + Assert.Equivalent(apiProduct, s3Product); + } + + public async Task DisposeAsync() + { + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + if (_builder != null) + await _builder.DisposeAsync(); + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.Tests/Inventory.Tests.csproj b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj new file mode 100644 index 00000000..5fcd8e3a --- /dev/null +++ b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 99f5aa0f2b01679fc1d931dc386b220288183fec Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Thu, 23 Apr 2026 10:34:59 +0400 Subject: [PATCH 19/26] fix --- InventoryManager/Inventory.AppHost/AppHost.cs | 29 ++++++++++++------- .../Inventory.AppHost.csproj | 8 +++-- .../Inventory.AppHost/appsettings.json | 10 ++++++- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index b58c9ce7..1c35c0d0 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,40 +1,47 @@ using Amazon; +using Aspire.Hosting.AWS; +using Aspire.Hosting.LocalStack; using Aspire.Hosting.LocalStack.Container; +using LocalStack.Client.Enums; var builder = DistributedApplication.CreateBuilder(args); -// Redis var cache = builder.AddRedis("cache") .WithRedisCommander(); -// Gateway var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: 7000, name: "gateway"); + .WithHttpsEndpoint(port: 7000, name: "gateway") + .WithExternalHttpEndpoints(); -// AWS config var awsConfig = builder.AddAWSSDKConfig() .WithProfile("default") .WithRegion(RegionEndpoint.EUCentral1); -// LocalStack var localstack = builder.AddLocalStack("inventory-localstack", awsConfig: awsConfig, configureContainer: container => { container.Lifetime = ContainerLifetime.Session; container.DebugLevel = 1; container.LogLevel = LocalStackLogLevel.Debug; container.Port = 4566; + + container.EagerLoadedServices = + [ + AwsService.CloudFormation, + AwsService.S3, + AwsService.Sns + ]; + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); container.AdditionalEnvironmentVariables.Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); -}); +}) ?? throw new InvalidOperationException("LocalStack resource could not be created."); -// SNS + S3 resources var awsResources = builder.AddAWSCloudFormationTemplate( "resources", "CloudFormation/inventory-template-sns-s3.yaml", "inventory") + .WithReference(localstack) .WithReference(awsConfig); -// API replicas var apis = new List>(); var basePort = 7001; @@ -44,9 +51,11 @@ var api = builder.AddProject($"apiservice-{i + 1}", launchProfileName: null) .WithReference(cache, "RedisCache") + .WithReference(localstack) .WithReference(awsResources) .WithEnvironment("Settings__MessageBroker", "SNS") .WaitFor(cache) + .WaitFor(localstack) .WaitFor(awsResources) .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); @@ -54,16 +63,16 @@ gateway.WaitFor(api); } -// Client builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WaitFor(gateway); -// FileService / Sink builder.AddProject("inventory-files") + .WithReference(localstack) .WithReference(awsResources) .WithEnvironment("Settings__MessageBroker", "SNS") .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5280/api/sns") + .WaitFor(localstack) .WaitFor(awsResources); builder.UseLocalStack(localstack); diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj index 2732769a..f7769f9f 100644 --- a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -17,12 +17,14 @@ - + - + + Always + diff --git a/InventoryManager/Inventory.AppHost/appsettings.json b/InventoryManager/Inventory.AppHost/appsettings.json index 31c092aa..10e423ea 100644 --- a/InventoryManager/Inventory.AppHost/appsettings.json +++ b/InventoryManager/Inventory.AppHost/appsettings.json @@ -5,5 +5,13 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "LocalStack": { + "UseLocalStack": true, + "Port": 4566, + "CloudFormationTemplate": "CloudFormation/inventory-template-sns-s3.yaml" + }, + "SNS": { + "EndpointURL": "http://host.docker.internal:5280/api/sns" } -} +} \ No newline at end of file From 31b3adf63ebaaede72abea1a66aac82a7d6847f4 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Thu, 23 Apr 2026 14:59:12 +0400 Subject: [PATCH 20/26] lab3 +- --- Client.Wasm/Components/StudentCard.razor | 2 +- .../Controllers/InventoryControler.cs | 16 ++--- .../Inventory.ApiService/Program.cs | 29 +++++++--- .../Services/IInventoryService.cs | 8 +++ .../Services/InventoryService.cs | 22 +++++++ InventoryManager/Inventory.AppHost/AppHost.cs | 53 +++++++++-------- .../Inventory.AppHost.csproj | 2 +- .../Inventory.AppHost/appsettings.json | 6 ++ .../Messaging/SnsSubscriberService.cs | 19 +++--- .../Inventory.FileService/Program.cs | 34 ++++++----- .../Inventory.Gateway/ocelot.json | 43 ++++++++++---- .../Inventory.Tests/IntegrationTests.cs | 58 +++++++++++++------ 12 files changed, 193 insertions(+), 99 deletions(-) create mode 100644 InventoryManager/Inventory.ApiService/Services/IInventoryService.cs create mode 100644 InventoryManager/Inventory.ApiService/Services/InventoryService.cs diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 76cd420f..9c1e70d9 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,7 +4,7 @@ - Номер №2 Балансировка нагрузки + Номер №3 Интеграционное тестирование Вариант №45 Товар на складе Выполнена Ле Хань Хоанг 6513 Ссылка на форк diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs index 66985d9d..89daf08a 100644 --- a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -1,15 +1,14 @@ -using Microsoft.AspNetCore.Mvc; -using Inventory.ApiService.Entity; -using Inventory.ApiService.Cache; -using Inventory.ApiService.Messaging; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Services; +using Microsoft.AspNetCore.Mvc; namespace Inventory.ApiService.Controllers; [ApiController] [Route("api/[controller]")] public class InventoryController( - IInventoryCache cache, - IProducerService producerService) : ControllerBase + ILogger logger, + IInventoryService inventoryService) : ControllerBase { [HttpGet] [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)] @@ -19,8 +18,9 @@ public async Task> Get([FromQuery] int? id, CancellationTo if (id is null || id < 0) return BadRequest("id is required and must be >= 0"); - var product = await cache.GetAsync(id.Value, ct); - await producerService.SendMessage(product); + logger.LogInformation("Processing request for inventory {ResourceId}", id); + + var product = await inventoryService.GetInventory(id.Value, ct); return Ok(product); } diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index 1f18cb05..e10e6b3e 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -2,33 +2,46 @@ using Inventory.ApiService.Cache; using Inventory.ApiService.Generation; using Inventory.ApiService.Messaging; +using Inventory.ApiService.Services; using Inventory.ServiceDefaults; +using LocalStack.Client.Extensions; var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); -// Cache +// Redis builder.AddRedisDistributedCache("cache"); +// OpenAPI / errors +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +// LocalStack +builder.Services.AddLocalStack(builder.Configuration); + // AWS SNS builder.Services.AddAWSService(); +// Controllers builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); // DI builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); -app.UseSwagger(); -app.UseSwaggerUI(); +app.UseExceptionHandler(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} app.MapControllers(); app.MapDefaultEndpoints(); -await app.RunAsync(); \ No newline at end of file +app.Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs new file mode 100644 index 00000000..4c1e1c79 --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs @@ -0,0 +1,8 @@ +using Inventory.ApiService.Entity; + +namespace Inventory.ApiService.Services; + +public interface IInventoryService +{ + public Task GetInventory(int id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs new file mode 100644 index 00000000..ddfc0b2c --- /dev/null +++ b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs @@ -0,0 +1,22 @@ +using Inventory.ApiService.Cache; +using Inventory.ApiService.Entity; +using Inventory.ApiService.Messaging; + +namespace Inventory.ApiService.Services; + +public class InventoryService( + ILogger logger, + IInventoryCache cache, + IProducerService producerService) : IInventoryService +{ + public async Task GetInventory(int id, CancellationToken cancellationToken = default) + { + var product = await cache.GetAsync(id, cancellationToken); + + await producerService.SendMessage(product); + + logger.LogInformation("Inventory {ResourceId} processed", id); + + return product; + } +} \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 1c35c0d0..4fb92919 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -3,14 +3,25 @@ using Aspire.Hosting.LocalStack; using Aspire.Hosting.LocalStack.Container; using LocalStack.Client.Enums; +using Microsoft.Extensions.Configuration; var builder = DistributedApplication.CreateBuilder(args); +var apiServiceConfig = builder.Configuration.GetSection("ApiService"); +var ports = apiServiceConfig.GetSection("Ports").Get() ?? []; + +var apiGatewayConfig = builder.Configuration.GetSection("ApiGateway"); +var gatewayPort = apiGatewayConfig.GetValue("Port"); + +var localStackPort = builder.Configuration.GetSection("LocalStack").GetValue("Port"); +var cloudFormationTemplate = builder.Configuration.GetSection("LocalStack").GetValue("CloudFormationTemplate") ?? ""; +var snsEndpointUrl = builder.Configuration.GetSection("SNS").GetValue("EndpointURL") ?? ""; + var cache = builder.AddRedis("cache") .WithRedisCommander(); var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: 7000, name: "gateway") + .WithHttpsEndpoint(port: gatewayPort, name: "gateway") .WithExternalHttpEndpoints(); var awsConfig = builder.AddAWSSDKConfig() @@ -22,59 +33,51 @@ container.Lifetime = ContainerLifetime.Session; container.DebugLevel = 1; container.LogLevel = LocalStackLogLevel.Debug; - container.Port = 4566; - + container.Port = localStackPort; container.EagerLoadedServices = [ AwsService.CloudFormation, AwsService.S3, AwsService.Sns ]; - container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); container.AdditionalEnvironmentVariables.Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); }) ?? throw new InvalidOperationException("LocalStack resource could not be created."); var awsResources = builder.AddAWSCloudFormationTemplate( "resources", - "CloudFormation/inventory-template-sns-s3.yaml", + cloudFormationTemplate, "inventory") - .WithReference(localstack) .WithReference(awsConfig); -var apis = new List>(); -var basePort = 7001; +var storage = builder.AddProject("inventory-files") + .WithReference(awsConfig) + .WithReference(awsResources) + .WithEnvironment("SNS__EndpointURL", snsEndpointUrl) + .WaitFor(localstack) + .WaitFor(awsResources); -for (var i = 0; i < 5; i++) +var serviceId = 1; +foreach (var port in ports) { - var port = basePort + i; - - var api = builder.AddProject($"apiservice-{i + 1}", launchProfileName: null) + var api = builder.AddProject($"apiservice-{serviceId++}", launchProfileName: null) .WithReference(cache, "RedisCache") - .WithReference(localstack) + .WithHttpsEndpoint(port: port, name: "api-endpoint") + .WithReference(awsConfig) .WithReference(awsResources) .WithEnvironment("Settings__MessageBroker", "SNS") - .WaitFor(cache) .WaitFor(localstack) - .WaitFor(awsResources) - .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); + .WaitFor(cache) + .WaitFor(storage); - apis.Add(api); gateway.WaitFor(api); } +// comment client nếu type project chưa đúng builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WaitFor(gateway); -builder.AddProject("inventory-files") - .WithReference(localstack) - .WithReference(awsResources) - .WithEnvironment("Settings__MessageBroker", "SNS") - .WithEnvironment("AWS__Resources__SNSUrl", "http://host.docker.internal:5280/api/sns") - .WaitFor(localstack) - .WaitFor(awsResources); - builder.UseLocalStack(localstack); builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj index f7769f9f..b239ee8f 100644 --- a/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj +++ b/InventoryManager/Inventory.AppHost/Inventory.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/InventoryManager/Inventory.AppHost/appsettings.json b/InventoryManager/Inventory.AppHost/appsettings.json index 10e423ea..6c126dba 100644 --- a/InventoryManager/Inventory.AppHost/appsettings.json +++ b/InventoryManager/Inventory.AppHost/appsettings.json @@ -6,6 +6,12 @@ "Aspire.Hosting.Dcp": "Warning" } }, + "ApiService": { + "Ports": [ 7001, 7002, 7003, 7004, 7005 ] + }, + "ApiGateway": { + "Port": 7000 + }, "LocalStack": { "UseLocalStack": true, "Port": 4566, diff --git a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs index 7df604a2..be7ba9ab 100644 --- a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs +++ b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs @@ -4,24 +4,19 @@ namespace Inventory.FileService.Messaging; -/// -/// Служба подписки на SNS topic -/// -/// SNS клиент -/// Конфигурация -/// Логер public class SnsSubscriberService( IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) : ISubscriberService { - private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] - ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + private readonly string _topicArn = + configuration["AWS:Resources:SNSTopicArn"] + ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration"); - private readonly string _endpoint = configuration["AWS:Resources:SNSUrl"] - ?? throw new KeyNotFoundException("SNS callback endpoint was not found in configuration"); + private readonly string _endpoint = + configuration["SNS:EndpointURL"] + ?? throw new KeyNotFoundException("SNS endpoint URL was not found in configuration"); - /// public async Task SubscribeEndpoint() { logger.LogInformation("Sending subscribe request for {topic}", _topicArn); @@ -42,6 +37,6 @@ public async Task SubscribeEndpoint() throw new InvalidOperationException($"Failed to subscribe to SNS topic {_topicArn}"); } - logger.LogInformation("Subscription request for {topic} is successful, waiting for confirmation", _topicArn); + logger.LogInformation("Subscription request for {topic} is successful", _topicArn); } } \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs index 55d83edf..555bee76 100644 --- a/InventoryManager/Inventory.FileService/Program.cs +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -3,6 +3,7 @@ using Inventory.FileService.Messaging; using Inventory.FileService.Storage; using Inventory.ServiceDefaults; +using LocalStack.Client.Extensions; var builder = WebApplication.CreateBuilder(args); @@ -12,35 +13,38 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddHttpClient(); +builder.Services.AddLocalStack(builder.Configuration); builder.Services.AddAWSService(); builder.Services.AddAWSService(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); -app.UseSwagger(); -app.UseSwaggerUI(); - -app.MapControllers(); -app.MapDefaultEndpoints(); +app.Logger.LogInformation("AWS ServiceURL: {ServiceUrl}", builder.Configuration["AWS:ServiceURL"]); +app.Logger.LogInformation("AWS Region: {Region}", builder.Configuration["AWS:Region"]); +app.Logger.LogInformation("S3 Bucket: {Bucket}", builder.Configuration["AWS:Resources:S3BucketName"]); +app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); +app.Logger.LogInformation("SNS EndpointURL: {Endpoint}", builder.Configuration["SNS:EndpointURL"]); using (var scope = app.Services.CreateScope()) { var s3Service = scope.ServiceProvider.GetRequiredService(); await s3Service.EnsureBucketExists(); - var brokerType = app.Configuration["Settings:MessageBroker"] - ?? app.Configuration["Settings__MessageBroker"]; + var subscriberService = scope.ServiceProvider.GetRequiredService(); + await subscriberService.SubscribeEndpoint(); +} - if (string.Equals(brokerType, "SNS", StringComparison.OrdinalIgnoreCase)) - { - var subscriberService = scope.ServiceProvider.GetRequiredService(); - await subscriberService.SubscribeEndpoint(); - } +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); } +app.MapControllers(); +app.MapDefaultEndpoints(); + await app.RunAsync(); \ No newline at end of file diff --git a/InventoryManager/Inventory.Gateway/ocelot.json b/InventoryManager/Inventory.Gateway/ocelot.json index 0892c055..d9308e15 100644 --- a/InventoryManager/Inventory.Gateway/ocelot.json +++ b/InventoryManager/Inventory.Gateway/ocelot.json @@ -2,11 +2,9 @@ "Routes": [ { "UpstreamPathTemplate": "/api/inventory", - "UpstreamHttpMethod": [ "GET" ], - + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ], "DownstreamPathTemplate": "/api/inventory", "DownstreamScheme": "https", - "DownstreamHostAndPorts": [ { "Host": "localhost", @@ -29,9 +27,39 @@ "Port": 7005 } ], - "DangerousAcceptAnyServerCertificateValidator": true, - + "LoadBalancerOptions": { + "Type": "WeightedRandom" + } + }, + { + "UpstreamPathTemplate": "/api/inventory/{everything}", + "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ], + "DownstreamPathTemplate": "/api/inventory/{everything}", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7001 + }, + { + "Host": "localhost", + "Port": 7002 + }, + { + "Host": "localhost", + "Port": 7003 + }, + { + "Host": "localhost", + "Port": 7004 + }, + { + "Host": "localhost", + "Port": 7005 + } + ], + "DangerousAcceptAnyServerCertificateValidator": true, "LoadBalancerOptions": { "Type": "WeightedRandom" } @@ -39,10 +67,8 @@ { "UpstreamPathTemplate": "/health", "UpstreamHttpMethod": [ "GET" ], - "DownstreamPathTemplate": "/health", "DownstreamScheme": "https", - "DownstreamHostAndPorts": [ { "Host": "localhost", @@ -65,15 +91,12 @@ "Port": 7005 } ], - "DangerousAcceptAnyServerCertificateValidator": true, - "LoadBalancerOptions": { "Type": "WeightedRandom" } } ], - "GlobalConfiguration": { "BaseUrl": "https://localhost:7000", "RequestIdKey": "Gateway-Request-Id" diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs index 66016da3..31b46531 100644 --- a/InventoryManager/Inventory.Tests/IntegrationTests.cs +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -32,37 +32,55 @@ public async Task InitializeAsync() [Fact] public async Task TestPipeline() { - var cancellationToken = CancellationToken.None; + Assert.NotNull(_builder); + + _app = await _builder.BuildAsync(); + await _app.StartAsync(); - _app = await _builder!.BuildAsync(cancellationToken); - await _app.StartAsync(cancellationToken); + await Task.Delay(10000); - var random = new Random(); - var id = random.Next(1, 100); + var id = Random.Shared.Next(1, 100); using var gatewayClient = _app.CreateHttpClient("apigateway", "https"); - using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}", cancellationToken); + using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}"); + + var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(); + Assert.True( + gatewayResponse.IsSuccessStatusCode, + $"Gateway failed: {gatewayResponse.StatusCode} - {gatewayContent}"); - var apiProduct = JsonSerializer.Deserialize( - await gatewayResponse.Content.ReadAsStringAsync(cancellationToken)); + var apiProduct = JsonSerializer.Deserialize(gatewayContent); + Assert.NotNull(apiProduct); + Assert.Equal(id, apiProduct.Id); - await Task.Delay(5000, cancellationToken); + await Task.Delay(5000); - using var sinkClient = _app.CreateHttpClient("inventory-files", "http"); + using var storageClient = _app.CreateHttpClient("inventory-files", "http"); - using var listResponse = await sinkClient.GetAsync("/api/s3", cancellationToken); - var inventoryList = JsonSerializer.Deserialize>( - await listResponse.Content.ReadAsStringAsync(cancellationToken)); + using var listResponse = await storageClient.GetAsync("/api/s3"); + var listContent = await listResponse.Content.ReadAsStringAsync(); - using var s3Response = await sinkClient.GetAsync($"/api/s3/inventory_{id}.json", cancellationToken); - var s3Product = JsonSerializer.Deserialize( - await s3Response.Content.ReadAsStringAsync(cancellationToken)); + Assert.True( + listResponse.IsSuccessStatusCode, + $"Storage list failed: {listResponse.StatusCode} - {listContent}"); + var inventoryList = JsonSerializer.Deserialize>(listContent); Assert.NotNull(inventoryList); - Assert.Single(inventoryList); - Assert.NotNull(apiProduct); + Assert.NotEmpty(inventoryList); + + var matchingFile = inventoryList.FirstOrDefault(f => f.Contains($"inventory_{id}")); + Assert.NotNull(matchingFile); + + using var s3Response = await storageClient.GetAsync($"/api/s3/{matchingFile}"); + var s3Content = await s3Response.Content.ReadAsStringAsync(); + + Assert.True( + s3Response.IsSuccessStatusCode, + $"Storage read failed: {s3Response.StatusCode} - {s3Content}"); + + var s3Product = JsonSerializer.Deserialize(s3Content); Assert.NotNull(s3Product); - Assert.Equal(id, s3Product!.Id); + Assert.Equal(id, s3Product.Id); Assert.Equivalent(apiProduct, s3Product); } @@ -75,6 +93,8 @@ public async Task DisposeAsync() } if (_builder != null) + { await _builder.DisposeAsync(); + } } } \ No newline at end of file From 937c7eef1543a09077f0d600ebd24ae5ac83a35d Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 26 Apr 2026 16:33:47 +0400 Subject: [PATCH 21/26] update lab3 --- InventoryManager/Inventory.AppHost/AppHost.cs | 51 ++++++++----------- .../Inventory.FileService/Program.cs | 42 +++++++++++++-- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index 4fb92919..d1611e0a 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,27 +1,14 @@ using Amazon; -using Aspire.Hosting.AWS; -using Aspire.Hosting.LocalStack; using Aspire.Hosting.LocalStack.Container; using LocalStack.Client.Enums; -using Microsoft.Extensions.Configuration; var builder = DistributedApplication.CreateBuilder(args); -var apiServiceConfig = builder.Configuration.GetSection("ApiService"); -var ports = apiServiceConfig.GetSection("Ports").Get() ?? []; - -var apiGatewayConfig = builder.Configuration.GetSection("ApiGateway"); -var gatewayPort = apiGatewayConfig.GetValue("Port"); - -var localStackPort = builder.Configuration.GetSection("LocalStack").GetValue("Port"); -var cloudFormationTemplate = builder.Configuration.GetSection("LocalStack").GetValue("CloudFormationTemplate") ?? ""; -var snsEndpointUrl = builder.Configuration.GetSection("SNS").GetValue("EndpointURL") ?? ""; - var cache = builder.AddRedis("cache") .WithRedisCommander(); var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: gatewayPort, name: "gateway") + .WithHttpsEndpoint(port: 7000, name: "gateway") .WithExternalHttpEndpoints(); var awsConfig = builder.AddAWSSDKConfig() @@ -33,7 +20,7 @@ container.Lifetime = ContainerLifetime.Session; container.DebugLevel = 1; container.LogLevel = LocalStackLogLevel.Debug; - container.Port = localStackPort; + container.Port = 4566; container.EagerLoadedServices = [ AwsService.CloudFormation, @@ -46,38 +33,42 @@ var awsResources = builder.AddAWSCloudFormationTemplate( "resources", - cloudFormationTemplate, + "CloudFormation/inventory-template-sns-s3.yaml", "inventory") .WithReference(awsConfig); -var storage = builder.AddProject("inventory-files") - .WithReference(awsConfig) - .WithReference(awsResources) - .WithEnvironment("SNS__EndpointURL", snsEndpointUrl) - .WaitFor(localstack) - .WaitFor(awsResources); +var apis = new List>(); +var basePort = 7001; -var serviceId = 1; -foreach (var port in ports) +for (var i = 0; i < 5; i++) { - var api = builder.AddProject($"apiservice-{serviceId++}", launchProfileName: null) + var port = basePort + i; + + var api = builder.AddProject($"apiservice-{i + 1}", launchProfileName: null) + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") .WithReference(cache, "RedisCache") - .WithHttpsEndpoint(port: port, name: "api-endpoint") - .WithReference(awsConfig) .WithReference(awsResources) .WithEnvironment("Settings__MessageBroker", "SNS") - .WaitFor(localstack) .WaitFor(cache) - .WaitFor(storage); + .WaitFor(awsResources) + .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); + apis.Add(api); gateway.WaitFor(api); } -// comment client nếu type project chưa đúng builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WaitFor(gateway); +builder.AddProject("inventory-files") + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") + .WithReference(awsResources) + .WithEnvironment("AWS__ServiceURL", "http://localhost:4566") + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("SNS__EndpointURL", "http://host.docker.internal:5280/api/sns") + .WaitFor(awsResources); + builder.UseLocalStack(localstack); builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs index 555bee76..fef3b25f 100644 --- a/InventoryManager/Inventory.FileService/Program.cs +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -1,4 +1,5 @@ using Amazon.S3; +using Amazon.Runtime; using Amazon.SimpleNotificationService; using Inventory.FileService.Messaging; using Inventory.FileService.Storage; @@ -13,17 +14,50 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddLocalStack(builder.Configuration); +//builder.Services.AddLocalStack(builder.Configuration); -builder.Services.AddAWSService(); -builder.Services.AddAWSService(); +//builder.Services.AddAWSService(); +//builder.Services.AddAWSService(); +var awsServiceUrl = + builder.Configuration["AWS:ServiceURL"] + ?? builder.Configuration["AWS__ServiceURL"] + ?? "http://localhost:4566"; + +builder.Services.AddSingleton(_ => +{ + var config = new AmazonS3Config + { + ServiceURL = awsServiceUrl, + ForcePathStyle = true, + AuthenticationRegion = "eu-central-1" + }; + + return new AmazonS3Client( + new BasicAWSCredentials("test", "test"), + config + ); +}); + +builder.Services.AddSingleton(_ => +{ + var config = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = awsServiceUrl, + AuthenticationRegion = "eu-central-1" + }; + + return new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + config + ); +}); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); -app.Logger.LogInformation("AWS ServiceURL: {ServiceUrl}", builder.Configuration["AWS:ServiceURL"]); +app.Logger.LogInformation("AWS ServiceURL: {ServiceUrl}", awsServiceUrl); app.Logger.LogInformation("AWS Region: {Region}", builder.Configuration["AWS:Region"]); app.Logger.LogInformation("S3 Bucket: {Bucket}", builder.Configuration["AWS:Resources:S3BucketName"]); app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); From 9b5d075171d073deb10fe2e8f25ab9fa0abe9c6d Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 26 Apr 2026 18:05:47 +0400 Subject: [PATCH 22/26] lab 3 final --- .../Inventory.ApiService/Program.cs | 33 ++++- .../Services/IInventoryService.cs | 9 ++ .../Services/InventoryService.cs | 13 ++ InventoryManager/Inventory.AppHost/AppHost.cs | 75 ++++++---- .../Inventory.AppHost/appsettings.json | 2 +- .../Controllers/S3StorageController.cs | 14 ++ .../Controllers/SnsSubscriberController.cs | 9 ++ .../Messaging/SnsSubscriberService.cs | 31 +++-- .../Inventory.FileService/Program.cs | 44 +----- .../Storage/S3AwsService.cs | 59 +++++++- .../Inventory.Tests/IntegrationTests.cs | 129 ++++++++++++++---- .../Inventory.Tests/Inventory.Tests.csproj | 2 +- 12 files changed, 314 insertions(+), 106 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index e10e6b3e..d6ffa913 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -1,3 +1,4 @@ +using Amazon.Runtime; using Amazon.SimpleNotificationService; using Inventory.ApiService.Cache; using Inventory.ApiService.Generation; @@ -17,11 +18,33 @@ builder.Services.AddProblemDetails(); builder.Services.AddOpenApi(); -// LocalStack +// LocalStack config builder.Services.AddLocalStack(builder.Configuration); -// AWS SNS -builder.Services.AddAWSService(); +// SNS client +builder.Services.AddSingleton(_ => +{ + var serviceUrl = + builder.Configuration["AWS:ServiceURL"] + ?? "http://localhost:4566"; + + var region = + builder.Configuration["AWS:Region"] + ?? builder.Configuration["AWS_REGION"] + ?? builder.Configuration["AWS_DEFAULT_REGION"] + ?? "eu-central-1"; + + var config = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = region + }; + + return new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + config + ); +}); // Controllers builder.Services.AddControllers(); @@ -34,6 +57,10 @@ var app = builder.Build(); +app.Logger.LogInformation("SNS ServiceURL: {ServiceURL}", builder.Configuration["AWS:ServiceURL"]); +app.Logger.LogInformation("SNS Region: {Region}", builder.Configuration["AWS:Region"]); +app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); + app.UseExceptionHandler(); if (app.Environment.IsDevelopment()) diff --git a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs index 4c1e1c79..a3df4e2d 100644 --- a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs +++ b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs @@ -2,7 +2,16 @@ namespace Inventory.ApiService.Services; +/// +/// Интерфейс сервиса для работы с инвентарём +/// public interface IInventoryService { + /// + /// Получает информацию о продукте по его идентификатору + /// + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта public Task GetInventory(int id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs index ddfc0b2c..9fbe7f6d 100644 --- a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs +++ b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs @@ -4,11 +4,24 @@ namespace Inventory.ApiService.Services; +/// +/// Сервис для обработки запросов, связанных с инвентарём +/// +/// Сервис логирования операций инвентаря +/// Сервис кэширования данных о продуктах +/// Сервис для отправки сообщений в брокер сообщений public class InventoryService( ILogger logger, IInventoryCache cache, IProducerService producerService) : IInventoryService { + /// + /// Получает информацию о продукте из кэша, отправляет сообщение о продукте в брокер сообщений + /// и записывает информацию об обработке в лог + /// + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта, полученный из кэша public async Task GetInventory(int id, CancellationToken cancellationToken = default) { var product = await cache.GetAsync(id, cancellationToken); diff --git a/InventoryManager/Inventory.AppHost/AppHost.cs b/InventoryManager/Inventory.AppHost/AppHost.cs index d1611e0a..f645f269 100644 --- a/InventoryManager/Inventory.AppHost/AppHost.cs +++ b/InventoryManager/Inventory.AppHost/AppHost.cs @@ -1,74 +1,103 @@ using Amazon; using Aspire.Hosting.LocalStack.Container; using LocalStack.Client.Enums; +using Microsoft.Extensions.Configuration; var builder = DistributedApplication.CreateBuilder(args); +// Read configuration +var apiServiceConfig = builder.Configuration.GetSection("ApiService"); +var ports = apiServiceConfig.GetSection("Ports").Get>() ?? [7001, 7002, 7003, 7004, 7005]; + +var apiGatewayConfig = builder.Configuration.GetSection("ApiGateway"); +var gatewayPort = apiGatewayConfig.GetValue("Port"); + +var localStackPort = builder.Configuration.GetSection("LocalStack").GetValue("Port"); +var cloudFormationTemplate = builder.Configuration.GetSection("LocalStack").GetValue("CloudFormationTemplate") + ?? "CloudFormation/inventory-template-sns-s3.yaml"; + +var snsEndpointUrl = builder.Configuration.GetSection("SNS").GetValue("EndpointURL") + ?? "http://host.docker.internal:5037/api/sns"; + +// Cache Redis var cache = builder.AddRedis("cache") .WithRedisCommander(); +// API Gateway var gateway = builder.AddProject("apigateway") - .WithHttpsEndpoint(port: 7000, name: "gateway") + .WithHttpsEndpoint(port: gatewayPort, name: "gateway") .WithExternalHttpEndpoints(); +// AWS config var awsConfig = builder.AddAWSSDKConfig() .WithProfile("default") .WithRegion(RegionEndpoint.EUCentral1); +// LocalStack var localstack = builder.AddLocalStack("inventory-localstack", awsConfig: awsConfig, configureContainer: container => { container.Lifetime = ContainerLifetime.Session; container.DebugLevel = 1; container.LogLevel = LocalStackLogLevel.Debug; - container.Port = 4566; + container.Port = localStackPort; + container.EagerLoadedServices = [ AwsService.CloudFormation, AwsService.S3, AwsService.Sns ]; + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); + container.AdditionalEnvironmentVariables.Add("AWS_REGION", "eu-central-1"); + container.AdditionalEnvironmentVariables.Add("AWS_DEFAULT_REGION", "eu-central-1"); container.AdditionalEnvironmentVariables.Add("SNS_CERT_URL_HOST", "sns.eu-central-1.amazonaws.com"); }) ?? throw new InvalidOperationException("LocalStack resource could not be created."); -var awsResources = builder.AddAWSCloudFormationTemplate( - "resources", - "CloudFormation/inventory-template-sns-s3.yaml", - "inventory") +// CloudFormation resources: S3 bucket + SNS topic +var awsResources = builder.AddAWSCloudFormationTemplate("resources", cloudFormationTemplate, "inventory") .WithReference(awsConfig); -var apis = new List>(); -var basePort = 7001; +// FileService +var fileService = builder.AddProject("inventory-files") + .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("SNS__EndpointURL", snsEndpointUrl) + .WithEnvironment("AWS_REGION", "eu-central-1") + .WithEnvironment("AWS_DEFAULT_REGION", "eu-central-1") + .WaitFor(awsResources); -for (var i = 0; i < 5; i++) -{ - var port = basePort + i; +// API services +var serviceId = 1; - var api = builder.AddProject($"apiservice-{i + 1}", launchProfileName: null) +foreach (var port in ports) +{ + var api = builder.AddProject($"apiservice-{serviceId}", launchProfileName: null) .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") - .WithReference(cache, "RedisCache") + .WithReference(cache) .WithReference(awsResources) .WithEnvironment("Settings__MessageBroker", "SNS") + .WithEnvironment("AWS__ServiceURL", "http://localhost:4566") + .WithEnvironment("AWS__Region", "eu-central-1") + .WithEnvironment("AWS_REGION", "eu-central-1") + .WithEnvironment("AWS_DEFAULT_REGION", "eu-central-1") + .WithEnvironment("AWS_ACCESS_KEY_ID", "test") + .WithEnvironment("AWS_SECRET_ACCESS_KEY", "test") .WaitFor(cache) .WaitFor(awsResources) - .WithHttpsEndpoint(port: port, name: $"api{i + 1}"); + .WaitFor(fileService) + .WithHttpsEndpoint(port: port, name: $"api{serviceId}"); - apis.Add(api); gateway.WaitFor(api); + serviceId++; } +// Client builder.AddProject("client-wasm") .WithExternalHttpEndpoints() .WaitFor(gateway); -builder.AddProject("inventory-files") - .WithEnvironment("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES", "") - .WithReference(awsResources) - .WithEnvironment("AWS__ServiceURL", "http://localhost:4566") - .WithEnvironment("Settings__MessageBroker", "SNS") - .WithEnvironment("SNS__EndpointURL", "http://host.docker.internal:5280/api/sns") - .WaitFor(awsResources); - builder.UseLocalStack(localstack); builder.Build().Run(); \ No newline at end of file diff --git a/InventoryManager/Inventory.AppHost/appsettings.json b/InventoryManager/Inventory.AppHost/appsettings.json index 6c126dba..cd3db2a6 100644 --- a/InventoryManager/Inventory.AppHost/appsettings.json +++ b/InventoryManager/Inventory.AppHost/appsettings.json @@ -18,6 +18,6 @@ "CloudFormationTemplate": "CloudFormation/inventory-template-sns-s3.yaml" }, "SNS": { - "EndpointURL": "http://host.docker.internal:5280/api/sns" + "EndpointURL": "http://host.docker.internal:5037/api/sns" } } \ No newline at end of file diff --git a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs index ab89aecf..13b731e0 100644 --- a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs +++ b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs @@ -5,10 +5,19 @@ namespace Inventory.FileService.Controllers; +/// +/// Контроллер для работы с файлами, хранящимися в S3-хранилище +/// +/// Сервис для выполнения операций с S3-хранилищем +/// Сервис логирования работы контроллера [ApiController] [Route("api/s3")] public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase { + /// + /// Получает список всех файлов из S3-хранилища + /// + /// Список ключей файлов, находящихся в S3-хранилище [HttpGet] [ProducesResponseType(200)] [ProducesResponseType(500)] @@ -29,6 +38,11 @@ public async Task>> ListFiles() } } + /// + /// Получает содержимое JSON-файла из S3-хранилища по его ключу + /// + /// Ключ файла в S3-хранилище + /// Содержимое файла в формате JSON [HttpGet("{key}")] [ProducesResponseType(200)] [ProducesResponseType(500)] diff --git a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs index be2caebf..2e45fc23 100644 --- a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs +++ b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs @@ -5,10 +5,19 @@ namespace Inventory.FileService.Controllers; +/// +/// Контроллер для получения и обработки сообщений из SNS +/// +/// Сервис для работы с S3-хранилищем +/// Сервис логирования работы контроллера [ApiController] [Route("api/sns")] public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase { + /// + /// Принимает входящее сообщение от SNS, подтверждает подписку или обрабатывает уведомление + /// + /// Результат обработки входящего SNS-сообщения [HttpPost] [ProducesResponseType(200)] public async Task ReceiveMessage() diff --git a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs index be7ba9ab..0b096367 100644 --- a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs +++ b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs @@ -4,19 +4,34 @@ namespace Inventory.FileService.Messaging; -public class SnsSubscriberService( - IAmazonSimpleNotificationService snsClient, - IConfiguration configuration, - ILogger logger) : ISubscriberService +/// +/// Сервис для подписки HTTP-endpoint на SNS-топик +/// +/// Клиент Amazon SNS для отправки запроса на подписку +/// Конфигурация приложения, содержащая ARN SNS-топика и URL endpoint +/// Сервис логирования процесса подписки +public class SnsSubscriberService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, + ILogger logger) : ISubscriberService { - private readonly string _topicArn = - configuration["AWS:Resources:SNSTopicArn"] + /// + /// ARN SNS-топика, на который должен быть подписан endpoint + /// + private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] ?? throw new KeyNotFoundException("SNS topic ARN was not found in configuration"); - private readonly string _endpoint = - configuration["SNS:EndpointURL"] + /// + /// URL HTTP-endpoint, который будет получать уведомления от SNS + /// + private readonly string _endpoint = configuration["SNS:EndpointURL"] ?? throw new KeyNotFoundException("SNS endpoint URL was not found in configuration"); + /// + /// Отправляет запрос на подписку HTTP-endpoint на указанный SNS-топик + /// + /// Асинхронная операция подписки endpoint на SNS-топик + /// + /// Возникает, если запрос на подписку завершился с неуспешным HTTP-статусом + /// public async Task SubscribeEndpoint() { logger.LogInformation("Sending subscribe request for {topic}", _topicArn); diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs index fef3b25f..837acaf1 100644 --- a/InventoryManager/Inventory.FileService/Program.cs +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -1,5 +1,4 @@ using Amazon.S3; -using Amazon.Runtime; using Amazon.SimpleNotificationService; using Inventory.FileService.Messaging; using Inventory.FileService.Storage; @@ -14,50 +13,19 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -//builder.Services.AddLocalStack(builder.Configuration); +// LocalStack +builder.Services.AddLocalStack(builder.Configuration); -//builder.Services.AddAWSService(); -//builder.Services.AddAWSService(); -var awsServiceUrl = - builder.Configuration["AWS:ServiceURL"] - ?? builder.Configuration["AWS__ServiceURL"] - ?? "http://localhost:4566"; - -builder.Services.AddSingleton(_ => -{ - var config = new AmazonS3Config - { - ServiceURL = awsServiceUrl, - ForcePathStyle = true, - AuthenticationRegion = "eu-central-1" - }; - - return new AmazonS3Client( - new BasicAWSCredentials("test", "test"), - config - ); -}); - -builder.Services.AddSingleton(_ => -{ - var config = new AmazonSimpleNotificationServiceConfig - { - ServiceURL = awsServiceUrl, - AuthenticationRegion = "eu-central-1" - }; - - return new AmazonSimpleNotificationServiceClient( - new BasicAWSCredentials("test", "test"), - config - ); -}); +// AWS services +builder.Services.AddAwsService(); +builder.Services.AddAwsService(); +// App services builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); -app.Logger.LogInformation("AWS ServiceURL: {ServiceUrl}", awsServiceUrl); app.Logger.LogInformation("AWS Region: {Region}", builder.Configuration["AWS:Region"]); app.Logger.LogInformation("S3 Bucket: {Bucket}", builder.Configuration["AWS:Resources:S3BucketName"]); app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); diff --git a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs index 1f903b7b..41374984 100644 --- a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs +++ b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs @@ -56,8 +56,18 @@ public async Task> GetFileList() /// 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() ?? throw new ArgumentException("Passed JSON has invalid structure"); + var rootNode = JsonNode.Parse(fileData) + ?? throw new ArgumentException("Passed string is not a valid JSON"); + + var idNode = rootNode["id"] ?? rootNode["Id"]; + + if (idNode is null) + { + logger.LogError("SNS message JSON has no id/Id field. Payload: {Payload}", fileData); + throw new ArgumentException("Passed JSON has invalid structure"); + } + + var id = idNode.GetValue(); using var stream = new MemoryStream(); JsonSerializer.Serialize(stream, rootNode); @@ -126,8 +136,49 @@ public async Task EnsureBucketExists() try { - await client.EnsureBucketExistsAsync(_bucketName); - logger.LogInformation("{bucket} existence ensured", _bucketName); + await client.GetBucketLocationAsync(new GetBucketLocationRequest + { + BucketName = _bucketName + }); + + logger.LogInformation("{bucket} already exists", _bucketName); + return; + } + catch (AmazonS3Exception ex) when ( + ex.StatusCode == HttpStatusCode.NotFound || + ex.ErrorCode == "NoSuchBucket" || + ex.ErrorCode == "NotFound") + { + logger.LogInformation("{bucket} does not exist, creating it", _bucketName); + } + + var region = + configuration["AWS:Region"] + ?? configuration["AWS_REGION"] + ?? configuration["AWS_DEFAULT_REGION"] + ?? "eu-central-1"; + + var request = new PutBucketRequest + { + BucketName = _bucketName + }; + + if (!string.Equals(region, "us-east-1", StringComparison.OrdinalIgnoreCase)) + { + request.BucketRegionName = region; + } + + try + { + await client.PutBucketAsync(request); + logger.LogInformation("{bucket} created in region {region}", _bucketName, region); + } + catch (AmazonS3Exception ex) when ( + ex.ErrorCode == "BucketAlreadyOwnedByYou" || + ex.ErrorCode == "BucketAlreadyExists" || + ex.StatusCode == HttpStatusCode.Conflict) + { + logger.LogInformation("{bucket} already exists", _bucketName); } catch (Exception ex) { diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs index 31b46531..73d22b4f 100644 --- a/InventoryManager/Inventory.Tests/IntegrationTests.cs +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -3,21 +3,39 @@ using Inventory.ApiService.Entity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Net; using System.Text.Json; using Xunit.Abstractions; namespace Inventory.Tests; +/// +/// Интеграционные тесты для проверки полного сценария генерации инвентаря, +/// публикации сообщения в SNS и сохранения результата в S3 +/// +/// Объект для вывода логов теста в xUnit public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime { + /// + /// Настройки десериализации JSON с нечувствительностью к регистру имён свойств + /// + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + private IDistributedApplicationTestingBuilder? _builder; private DistributedApplication? _app; + /// + /// Инициализирует тестовое распределённое приложение Aspire и настраивает логирование + /// + /// Асинхронная операция инициализации тестовой среды public async Task InitializeAsync() { - var cancellationToken = CancellationToken.None; + _builder = await DistributedApplicationTestingBuilder + .CreateAsync(); - _builder = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); _builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; _builder.Services.AddLogging(logging => @@ -29,70 +47,125 @@ public async Task InitializeAsync() }); } + /// + /// Проверяет, что запрос генерации инвентаря через API Gateway публикует сообщение в SNS + /// и сохраняет полученный продукт в S3-хранилище + /// + /// Асинхронная операция выполнения интеграционного теста [Fact] - public async Task TestPipeline() + public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS3() { Assert.NotNull(_builder); _app = await _builder.BuildAsync(); await _app.StartAsync(); - await Task.Delay(10000); - - var id = Random.Shared.Next(1, 100); + var id = Random.Shared.Next(1, 10_000); using var gatewayClient = _app.CreateHttpClient("apigateway", "https"); using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}"); var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(); + Assert.True( gatewayResponse.IsSuccessStatusCode, - $"Gateway failed: {gatewayResponse.StatusCode} - {gatewayContent}"); + $"Gateway failed: {(int)gatewayResponse.StatusCode} {gatewayResponse.StatusCode}. Body: {gatewayContent}"); + + var apiProduct = JsonSerializer.Deserialize(gatewayContent, _jsonOptions); - var apiProduct = JsonSerializer.Deserialize(gatewayContent); Assert.NotNull(apiProduct); Assert.Equal(id, apiProduct.Id); - await Task.Delay(5000); - - using var storageClient = _app.CreateHttpClient("inventory-files", "http"); - - using var listResponse = await storageClient.GetAsync("/api/s3"); - var listContent = await listResponse.Content.ReadAsStringAsync(); - - Assert.True( - listResponse.IsSuccessStatusCode, - $"Storage list failed: {listResponse.StatusCode} - {listContent}"); + using var fileServiceClient = _app.CreateHttpClient("inventory-files", "http"); - var inventoryList = JsonSerializer.Deserialize>(listContent); - Assert.NotNull(inventoryList); - Assert.NotEmpty(inventoryList); + var matchingFile = await WaitUntilInventoryFileAppearsAsync( + fileServiceClient, + id, + timeout: TimeSpan.FromSeconds(30)); - var matchingFile = inventoryList.FirstOrDefault(f => f.Contains($"inventory_{id}")); - Assert.NotNull(matchingFile); + Assert.False(string.IsNullOrWhiteSpace(matchingFile)); - using var s3Response = await storageClient.GetAsync($"/api/s3/{matchingFile}"); + using var s3Response = await fileServiceClient.GetAsync($"/api/s3/{matchingFile}"); var s3Content = await s3Response.Content.ReadAsStringAsync(); Assert.True( s3Response.IsSuccessStatusCode, - $"Storage read failed: {s3Response.StatusCode} - {s3Content}"); + $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); + + var s3Product = JsonSerializer.Deserialize(s3Content, _jsonOptions); - var s3Product = JsonSerializer.Deserialize(s3Content); Assert.NotNull(s3Product); Assert.Equal(id, s3Product.Id); Assert.Equivalent(apiProduct, s3Product); } + /// + /// Ожидает появления файла инвентаря в S3-хранилище в течение заданного времени + /// + /// HTTP-клиент сервиса файлов для обращения к S3 API + /// Идентификатор продукта, файл которого необходимо найти + /// Максимальное время ожидания появления файла + /// Имя найденного файла инвентаря + /// + /// Возникает, если файл с указанным идентификатором не появился в S3 за отведённое время + /// + private static async Task WaitUntilInventoryFileAppearsAsync(HttpClient fileServiceClient, int id, TimeSpan timeout) + { + var deadline = DateTimeOffset.UtcNow.Add(timeout); + var expectedPart = $"inventory_{id}"; + + Exception? lastException = null; + + while (DateTimeOffset.UtcNow < deadline) + { + try + { + using var listResponse = await fileServiceClient.GetAsync("/api/s3"); + var listContent = await listResponse.Content.ReadAsStringAsync(); + + if (listResponse.StatusCode == HttpStatusCode.NotFound) + { + await Task.Delay(500); + continue; + } + + listResponse.EnsureSuccessStatusCode(); + + var files = JsonSerializer.Deserialize>(listContent, _jsonOptions) ?? []; + + var matchingFile = files.FirstOrDefault(file => file.Contains(expectedPart)); + + if (!string.IsNullOrWhiteSpace(matchingFile)) + { + return matchingFile; + } + } + catch (Exception ex) + { + lastException = ex; + } + + await Task.Delay(500); + } + + throw new TimeoutException( + $"Inventory file '{expectedPart}' was not found in S3 within {timeout.TotalSeconds} seconds.", + lastException); + } + + /// + /// Останавливает и освобождает ресурсы тестового распределённого приложения Aspire + /// + /// Асинхронная операция освобождения ресурсов тестовой среды public async Task DisposeAsync() { - if (_app != null) + if (_app is not null) { await _app.StopAsync(); await _app.DisposeAsync(); } - if (_builder != null) + if (_builder is not null) { await _builder.DisposeAsync(); } diff --git a/InventoryManager/Inventory.Tests/Inventory.Tests.csproj b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj index 5fcd8e3a..f3c887f0 100644 --- a/InventoryManager/Inventory.Tests/Inventory.Tests.csproj +++ b/InventoryManager/Inventory.Tests/Inventory.Tests.csproj @@ -8,7 +8,7 @@ - + From 2ee2903d42b45d4b4f7900f50c91afc1f5def886 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Sun, 26 Apr 2026 18:15:18 +0400 Subject: [PATCH 23/26] last fix lab3 --- .../Controllers/InventoryControler.cs | 14 +++++++++++--- .../Messaging/IProducerService.cs | 9 +++++++++ .../Messaging/SnsPublisherService.cs | 18 ++++++++++++++---- .../Inventory.ApiService/Program.cs | 11 +++-------- .../Services/IInventoryService.cs | 6 +++--- .../Services/InventoryService.cs | 15 +++++++-------- .../Controllers/S3StorageController.cs | 10 +++++----- .../Controllers/SnsSubscriberController.cs | 6 +++--- .../Messaging/SnsSubscriberService.cs | 8 ++++---- .../Storage/IS3Service.cs | 4 ++-- .../Storage/S3AwsService.cs | 14 +++++--------- 11 files changed, 66 insertions(+), 49 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs index 89daf08a..8fbede53 100644 --- a/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs +++ b/InventoryManager/Inventory.ApiService/Controllers/InventoryControler.cs @@ -4,12 +4,20 @@ namespace Inventory.ApiService.Controllers; +/// +/// Контроллер для работы с инвентарём (товарами). +/// Предоставляет методы получения информации о продуктах по идентификатору. +/// [ApiController] [Route("api/[controller]")] -public class InventoryController( - ILogger logger, - IInventoryService inventoryService) : ControllerBase +public class InventoryController(ILogger logger, IInventoryService inventoryService) : ControllerBase { + /// + /// Получает информацию о продукте из инвентаря по указанному ID. + /// + /// Идентификатор продукта (целое неотрицательное число). + /// Токен отмены операции. + /// Объект продукта с кодом 200 OK или ошибку 400 Bad Request, если ID не указан или неверен. [HttpGet] [ProducesResponseType(typeof(Product), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs index 27190708..91bea1e9 100644 --- a/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs +++ b/InventoryManager/Inventory.ApiService/Messaging/IProducerService.cs @@ -2,7 +2,16 @@ namespace Inventory.ApiService.Messaging; +/// +/// Определяет контракт для сервиса-производителя сообщений. +/// Отвечает за отправку данных о продукте в систему обмена сообщениями. +/// public interface IProducerService { + /// + /// Асинхронно отправляет сообщение, содержащее информацию о продукте. + /// + /// Объект продукта, который необходимо отправить. + /// Задача, представляющая асинхронную операцию отправки. public Task SendMessage(Product product); } \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs index f3ee55cc..5c7d53b0 100644 --- a/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs +++ b/InventoryManager/Inventory.ApiService/Messaging/SnsPublisherService.cs @@ -6,14 +6,24 @@ namespace Inventory.ApiService.Messaging; -public class SnsPublisherService( - IAmazonSimpleNotificationService client, - IConfiguration configuration, - ILogger logger) : IProducerService +/// +/// Реализация для отправки сообщений в Amazon SNS (Simple Notification Service). +/// Сериализует продукт в JSON и публикует его в указанный SNS-топик. +/// +public class SnsPublisherService(IAmazonSimpleNotificationService client, IConfiguration configuration, ILogger logger) : IProducerService { + /// + /// ARN (Amazon Resource Name) SNS-топика, полученный из конфигурации приложения. + /// private readonly string _topicArn = configuration["AWS:Resources:SNSTopicArn"] ?? throw new KeyNotFoundException("SNS topic link was not found in configuration"); + /// + /// Асинхронно отправляет сериализованный в JSON продукт в SNS-топик.В случае успешной отправки (HTTP 200) логирует информацию. + /// При ошибке логирует исключение, но не выбрасывает его повторно. + /// + /// Продукт, который необходимо отправить. + /// Задача, представляющая асинхронную операцию. public async Task SendMessage(Product product) { try diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index d6ffa913..1c838162 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -24,12 +24,10 @@ // SNS client builder.Services.AddSingleton(_ => { - var serviceUrl = - builder.Configuration["AWS:ServiceURL"] + var serviceUrl = builder.Configuration["AWS:ServiceURL"] ?? "http://localhost:4566"; - var region = - builder.Configuration["AWS:Region"] + var region = builder.Configuration["AWS:Region"] ?? builder.Configuration["AWS_REGION"] ?? builder.Configuration["AWS_DEFAULT_REGION"] ?? "eu-central-1"; @@ -40,10 +38,7 @@ AuthenticationRegion = region }; - return new AmazonSimpleNotificationServiceClient( - new BasicAWSCredentials("test", "test"), - config - ); + return new AmazonSimpleNotificationServiceClient(new BasicAWSCredentials("test", "test"),config); }); // Controllers diff --git a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs index a3df4e2d..1708479c 100644 --- a/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs +++ b/InventoryManager/Inventory.ApiService/Services/IInventoryService.cs @@ -10,8 +10,8 @@ public interface IInventoryService /// /// Получает информацию о продукте по его идентификатору /// - /// Идентификатор продукта - /// Токен для отмены асинхронной операции - /// Объект продукта + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта public Task GetInventory(int id, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs index 9fbe7f6d..f0fca309 100644 --- a/InventoryManager/Inventory.ApiService/Services/InventoryService.cs +++ b/InventoryManager/Inventory.ApiService/Services/InventoryService.cs @@ -7,21 +7,20 @@ namespace Inventory.ApiService.Services; /// /// Сервис для обработки запросов, связанных с инвентарём /// -/// Сервис логирования операций инвентаря -/// Сервис кэширования данных о продуктах -/// Сервис для отправки сообщений в брокер сообщений +/// Сервис логирования операций инвентаря +/// Сервис кэширования данных о продуктах +/// Сервис для отправки сообщений в брокер сообщений public class InventoryService( ILogger logger, IInventoryCache cache, IProducerService producerService) : IInventoryService { /// - /// Получает информацию о продукте из кэша, отправляет сообщение о продукте в брокер сообщений - /// и записывает информацию об обработке в лог + /// Получает информацию о продукте из кэша, отправляет сообщение о продукте в брокер сообщений и записывает информацию об обработке в лог /// - /// Идентификатор продукта - /// Токен для отмены асинхронной операции - /// Объект продукта, полученный из кэша + /// Идентификатор продукта + /// Токен для отмены асинхронной операции + /// Объект продукта, полученный из кэша public async Task GetInventory(int id, CancellationToken cancellationToken = default) { var product = await cache.GetAsync(id, cancellationToken); diff --git a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs index 13b731e0..b0aba4f3 100644 --- a/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs +++ b/InventoryManager/Inventory.FileService/Controllers/S3StorageController.cs @@ -8,8 +8,8 @@ namespace Inventory.FileService.Controllers; /// /// Контроллер для работы с файлами, хранящимися в S3-хранилище /// -/// Сервис для выполнения операций с S3-хранилищем -/// Сервис логирования работы контроллера +/// Сервис для выполнения операций с S3-хранилищем +/// Сервис логирования работы контроллера [ApiController] [Route("api/s3")] public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase @@ -17,7 +17,7 @@ public class S3StorageController(IS3Service s3Service, ILogger /// Получает список всех файлов из S3-хранилища /// - /// Список ключей файлов, находящихся в S3-хранилище + /// Список ключей файлов, находящихся в S3-хранилище [HttpGet] [ProducesResponseType(200)] [ProducesResponseType(500)] @@ -41,8 +41,8 @@ public async Task>> ListFiles() /// /// Получает содержимое JSON-файла из S3-хранилища по его ключу /// - /// Ключ файла в S3-хранилище - /// Содержимое файла в формате JSON + /// Ключ файла в S3-хранилище + /// Содержимое файла в формате JSON [HttpGet("{key}")] [ProducesResponseType(200)] [ProducesResponseType(500)] diff --git a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs index 2e45fc23..b2f2f5f9 100644 --- a/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs +++ b/InventoryManager/Inventory.FileService/Controllers/SnsSubscriberController.cs @@ -8,8 +8,8 @@ namespace Inventory.FileService.Controllers; /// /// Контроллер для получения и обработки сообщений из SNS /// -/// Сервис для работы с S3-хранилищем -/// Сервис логирования работы контроллера +/// Сервис для работы с S3-хранилищем +/// Сервис логирования работы контроллера [ApiController] [Route("api/sns")] public class SnsSubscriberController(IS3Service s3Service, ILogger logger) : ControllerBase @@ -17,7 +17,7 @@ public class SnsSubscriberController(IS3Service s3Service, ILogger /// Принимает входящее сообщение от SNS, подтверждает подписку или обрабатывает уведомление /// - /// Результат обработки входящего SNS-сообщения + /// Результат обработки входящего SNS-сообщения [HttpPost] [ProducesResponseType(200)] public async Task ReceiveMessage() diff --git a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs index 0b096367..b4ad7349 100644 --- a/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs +++ b/InventoryManager/Inventory.FileService/Messaging/SnsSubscriberService.cs @@ -7,9 +7,9 @@ namespace Inventory.FileService.Messaging; /// /// Сервис для подписки HTTP-endpoint на SNS-топик /// -/// Клиент Amazon SNS для отправки запроса на подписку -/// Конфигурация приложения, содержащая ARN SNS-топика и URL endpoint -/// Сервис логирования процесса подписки +/// Клиент Amazon SNS для отправки запроса на подписку +/// Конфигурация приложения, содержащая ARN SNS-топика и URL endpoint +/// Сервис логирования процесса подписки public class SnsSubscriberService(IAmazonSimpleNotificationService snsClient, IConfiguration configuration, ILogger logger) : ISubscriberService { @@ -28,7 +28,7 @@ public class SnsSubscriberService(IAmazonSimpleNotificationService snsClient, IC /// /// Отправляет запрос на подписку HTTP-endpoint на указанный SNS-топик /// - /// Асинхронная операция подписки endpoint на SNS-топик + /// Асинхронная операция подписки endpoint на SNS-топик /// /// Возникает, если запрос на подписку завершился с неуспешным HTTP-статусом /// diff --git a/InventoryManager/Inventory.FileService/Storage/IS3Service.cs b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs index 854f1281..d51d6ef1 100644 --- a/InventoryManager/Inventory.FileService/Storage/IS3Service.cs +++ b/InventoryManager/Inventory.FileService/Storage/IS3Service.cs @@ -10,7 +10,7 @@ public interface IS3Service /// /// Отправляет файл в хранилище /// - /// Строковая репрезентация сохраняемого файла + /// Строковая репрезентация сохраняемого файла public Task UploadFile(string fileData); /// @@ -22,7 +22,7 @@ public interface IS3Service /// /// Получает строковую репрезентацию файла из хранилища /// - /// Путь к файлу в бакете + /// Путь к файлу в бакете /// Строковая репрезентация прочтенного файла public Task DownloadFile(string filePath); diff --git a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs index 41374984..db35d4ae 100644 --- a/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs +++ b/InventoryManager/Inventory.FileService/Storage/S3AwsService.cs @@ -10,9 +10,9 @@ namespace Inventory.FileService.Storage; /// /// Cлужба для манипуляции файлами в объектном хранилище /// -/// S3 клиент -/// Конфигурация -/// Логер +/// S3 клиент +/// Конфигурация +/// Логер public class S3AwsService(IAmazonS3 client, IConfiguration configuration, ILogger logger) : IS3Service { private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"] @@ -152,21 +152,17 @@ await client.GetBucketLocationAsync(new GetBucketLocationRequest logger.LogInformation("{bucket} does not exist, creating it", _bucketName); } - var region = - configuration["AWS:Region"] + var region = configuration["AWS:Region"] ?? configuration["AWS_REGION"] ?? configuration["AWS_DEFAULT_REGION"] ?? "eu-central-1"; - var request = new PutBucketRequest - { + var request = new PutBucketRequest{ BucketName = _bucketName }; if (!string.Equals(region, "us-east-1", StringComparison.OrdinalIgnoreCase)) - { request.BucketRegionName = region; - } try { From 2ad2b243f5b2cc97f01b27007dd276660de19370 Mon Sep 17 00:00:00 2001 From: Khanh Hoang <114916103+vieKH@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:22:40 +0400 Subject: [PATCH 24/26] Update README.md --- README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e4f7dcfe..715addd7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ ## Ле Хань Хоанг 6513 -## Вариант 45 - Товар на складке, алгоритм балансировки - Weighted Random -## Лабораторная работа №2 - Балансировка нагрузки -image +## Вариант 45 - Товар на складке, алгоритм балансировки - Weighted Random, Брокер - SNS, Хостинг S3 - LocalStack +## Лабораторная работа №3 - Интеграционное тестирование +image -image +image + + +image From 4202d325993bdec0a2bc553a37d18cd5a4058cf9 Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Mon, 27 Apr 2026 14:51:59 +0400 Subject: [PATCH 25/26] =?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=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=20=D0=B8=20=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Inventory.ApiService/Program.cs | 2 - .../Inventory.FileService/Program.cs | 2 - .../Inventory.Tests/IntegrationTests.cs | 48 +++++++------------ 3 files changed, 17 insertions(+), 35 deletions(-) diff --git a/InventoryManager/Inventory.ApiService/Program.cs b/InventoryManager/Inventory.ApiService/Program.cs index 1c838162..d06ed180 100644 --- a/InventoryManager/Inventory.ApiService/Program.cs +++ b/InventoryManager/Inventory.ApiService/Program.cs @@ -52,9 +52,7 @@ var app = builder.Build(); -app.Logger.LogInformation("SNS ServiceURL: {ServiceURL}", builder.Configuration["AWS:ServiceURL"]); app.Logger.LogInformation("SNS Region: {Region}", builder.Configuration["AWS:Region"]); -app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); app.UseExceptionHandler(); diff --git a/InventoryManager/Inventory.FileService/Program.cs b/InventoryManager/Inventory.FileService/Program.cs index 837acaf1..2bdda453 100644 --- a/InventoryManager/Inventory.FileService/Program.cs +++ b/InventoryManager/Inventory.FileService/Program.cs @@ -28,8 +28,6 @@ app.Logger.LogInformation("AWS Region: {Region}", builder.Configuration["AWS:Region"]); app.Logger.LogInformation("S3 Bucket: {Bucket}", builder.Configuration["AWS:Resources:S3BucketName"]); -app.Logger.LogInformation("SNS TopicArn: {TopicArn}", builder.Configuration["AWS:Resources:SNSTopicArn"]); -app.Logger.LogInformation("SNS EndpointURL: {Endpoint}", builder.Configuration["SNS:EndpointURL"]); using (var scope = app.Services.CreateScope()) { diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs index 73d22b4f..8c4074dd 100644 --- a/InventoryManager/Inventory.Tests/IntegrationTests.cs +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -10,8 +10,7 @@ namespace Inventory.Tests; /// -/// Интеграционные тесты для проверки полного сценария генерации инвентаря, -/// публикации сообщения в SNS и сохранения результата в S3 +/// Интеграционные тесты для проверки полного сценария генерации инвентаря, публикации сообщения в SNS и сохранения результата в S3 /// /// Объект для вывода логов теста в xUnit public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime @@ -24,41 +23,39 @@ public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime PropertyNameCaseInsensitive = true }; - private IDistributedApplicationTestingBuilder? _builder; private DistributedApplication? _app; /// - /// Инициализирует тестовое распределённое приложение Aspire и настраивает логирование + /// Инициализирует тестовое распределённое приложение Aspire, настраивает логирование + /// и запускает приложение для выполнения интеграционных тестов /// /// Асинхронная операция инициализации тестовой среды public async Task InitializeAsync() { - _builder = await DistributedApplicationTestingBuilder - .CreateAsync(); + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); - _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); logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); logging.AddFilter("Aspire.Hosting", LogLevel.Debug); }); + + _app = await builder.BuildAsync(); + await _app.StartAsync(); } /// - /// Проверяет, что запрос генерации инвентаря через API Gateway публикует сообщение в SNS - /// и сохраняет полученный продукт в S3-хранилище + /// Проверяет, что запрос генерации инвентаря через API Gateway публикует сообщение в SNS и сохраняет полученный продукт в S3-хранилище /// /// Асинхронная операция выполнения интеграционного теста [Fact] public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS3() { - Assert.NotNull(_builder); - - _app = await _builder.BuildAsync(); - await _app.StartAsync(); + Assert.NotNull(_app); var id = Random.Shared.Next(1, 10_000); @@ -78,19 +75,14 @@ public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS using var fileServiceClient = _app.CreateHttpClient("inventory-files", "http"); - var matchingFile = await WaitUntilInventoryFileAppearsAsync( - fileServiceClient, - id, - timeout: TimeSpan.FromSeconds(30)); + var matchingFile = await WaitUntilInventoryFileAppearsAsync(fileServiceClient, id, timeout: TimeSpan.FromSeconds(30)); Assert.False(string.IsNullOrWhiteSpace(matchingFile)); using var s3Response = await fileServiceClient.GetAsync($"/api/s3/{matchingFile}"); var s3Content = await s3Response.Content.ReadAsStringAsync(); - Assert.True( - s3Response.IsSuccessStatusCode, - $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); + Assert.True(s3Response.IsSuccessStatusCode, $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); var s3Product = JsonSerializer.Deserialize(s3Content, _jsonOptions); @@ -102,10 +94,10 @@ public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS /// /// Ожидает появления файла инвентаря в S3-хранилище в течение заданного времени /// - /// HTTP-клиент сервиса файлов для обращения к S3 API - /// Идентификатор продукта, файл которого необходимо найти - /// Максимальное время ожидания появления файла - /// Имя найденного файла инвентаря + /// HTTP-клиент сервиса файлов для обращения к S3 API + /// Идентификатор продукта, файл которого необходимо найти + /// Максимальное время ожидания появления файла + /// Имя найденного файла инвентаря /// /// Возникает, если файл с указанным идентификатором не появился в S3 за отведённое время /// @@ -161,13 +153,7 @@ 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 From 49bb325193eb4e14d403b517180db54ded466aac Mon Sep 17 00:00:00 2001 From: vieKH <10122001kh@mail.ru> Date: Mon, 27 Apr 2026 14:57:37 +0400 Subject: [PATCH 26/26] fix test --- .../Inventory.Tests/IntegrationTests.cs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/InventoryManager/Inventory.Tests/IntegrationTests.cs b/InventoryManager/Inventory.Tests/IntegrationTests.cs index 8c4074dd..7c7b54a7 100644 --- a/InventoryManager/Inventory.Tests/IntegrationTests.cs +++ b/InventoryManager/Inventory.Tests/IntegrationTests.cs @@ -26,8 +26,7 @@ public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime private DistributedApplication? _app; /// - /// Инициализирует тестовое распределённое приложение Aspire, настраивает логирование - /// и запускает приложение для выполнения интеграционных тестов + /// Инициализирует тестовое распределённое приложение Aspire, настраивает логирование и запускает приложение для выполнения интеграционных тестов /// /// Асинхронная операция инициализации тестовой среды public async Task InitializeAsync() @@ -55,17 +54,16 @@ public async Task InitializeAsync() [Fact] public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS3() { - Assert.NotNull(_app); + var app = _app ?? throw new InvalidOperationException("Test application was not initialized."); var id = Random.Shared.Next(1, 10_000); - using var gatewayClient = _app.CreateHttpClient("apigateway", "https"); + using var gatewayClient = app.CreateHttpClient("apigateway", "https"); using var gatewayResponse = await gatewayClient.GetAsync($"/api/inventory?id={id}"); var gatewayContent = await gatewayResponse.Content.ReadAsStringAsync(); - Assert.True( - gatewayResponse.IsSuccessStatusCode, + Assert.True(gatewayResponse.IsSuccessStatusCode, $"Gateway failed: {(int)gatewayResponse.StatusCode} {gatewayResponse.StatusCode}. Body: {gatewayContent}"); var apiProduct = JsonSerializer.Deserialize(gatewayContent, _jsonOptions); @@ -73,7 +71,7 @@ public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS Assert.NotNull(apiProduct); Assert.Equal(id, apiProduct.Id); - using var fileServiceClient = _app.CreateHttpClient("inventory-files", "http"); + using var fileServiceClient = app.CreateHttpClient("inventory-files", "http"); var matchingFile = await WaitUntilInventoryFileAppearsAsync(fileServiceClient, id, timeout: TimeSpan.FromSeconds(30)); @@ -82,7 +80,8 @@ public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS using var s3Response = await fileServiceClient.GetAsync($"/api/s3/{matchingFile}"); var s3Content = await s3Response.Content.ReadAsStringAsync(); - Assert.True(s3Response.IsSuccessStatusCode, $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); + Assert.True(s3Response.IsSuccessStatusCode, + $"S3 read failed: {(int)s3Response.StatusCode} {s3Response.StatusCode}. Body: {s3Content}"); var s3Product = JsonSerializer.Deserialize(s3Content, _jsonOptions); @@ -94,10 +93,10 @@ public async Task GenerateInventory_ThroughGateway_ShouldPublishToSns_AndSaveToS /// /// Ожидает появления файла инвентаря в S3-хранилище в течение заданного времени /// - /// HTTP-клиент сервиса файлов для обращения к S3 API - /// Идентификатор продукта, файл которого необходимо найти - /// Максимальное время ожидания появления файла - /// Имя найденного файла инвентаря + /// HTTP-клиент сервиса файлов для обращения к S3 API + /// Идентификатор продукта, файл которого необходимо найти + /// Максимальное время ожидания появления файла + /// Имя найденного файла инвентаря /// /// Возникает, если файл с указанным идентификатором не появился в S3 за отведённое время ///