From c013e52b0c1a5316212299628fbbba26461f8699 Mon Sep 17 00:00:00 2001 From: Alexey Date: Fri, 13 Mar 2026 00:23:42 +0400 Subject: [PATCH 01/14] =?UTF-8?q?=D1=81=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=BA=D0=B5?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5,=20?= =?UTF-8?q?=D0=BE=D1=80=D0=BA=D0=B5=D1=81=D1=82=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- Client.Wasm/Components/StudentCard.razor | 8 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 27 ++++- .../Controllers/ProjectController.cs | 27 +++++ ProjectApp.Api/Options/CacheSettings.cs | 6 + ProjectApp.Api/Program.cs | 64 +++++++++++ ProjectApp.Api/ProjectApp.Api.csproj | 23 ++++ ProjectApp.Api/Properties/launchSettings.json | 41 +++++++ .../Services/ProgramProjectGenerator.cs | 60 ++++++++++ .../ProgramProjectGeneratorService.cs | 91 +++++++++++++++ ProjectApp.Api/appsettings.Development.json | 12 ++ ProjectApp.Api/appsettings.json | 12 ++ ProjectApp.AppHost/Program.cs | 16 +++ ProjectApp.AppHost/ProjectApp.AppHost.csproj | 24 ++++ .../Properties/launchSettings.json | 29 +++++ .../appsettings.Development.json | 8 ++ ProjectApp.AppHost/appsettings.json | 9 ++ ProjectApp.Domain/Entities/ProgramProject.cs | 48 ++++++++ ProjectApp.Domain/ProjectApp.Domain.csproj | 11 ++ ProjectApp.ServiceDefaults/Extensions.cs | 104 ++++++++++++++++++ .../ProjectApp.ServiceDefaults.csproj | 20 ++++ README.md | 3 +- 23 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 ProjectApp.Api/Controllers/ProjectController.cs create mode 100644 ProjectApp.Api/Options/CacheSettings.cs create mode 100644 ProjectApp.Api/Program.cs create mode 100644 ProjectApp.Api/ProjectApp.Api.csproj create mode 100644 ProjectApp.Api/Properties/launchSettings.json create mode 100644 ProjectApp.Api/Services/ProgramProjectGenerator.cs create mode 100644 ProjectApp.Api/Services/ProgramProjectGeneratorService.cs create mode 100644 ProjectApp.Api/appsettings.Development.json create mode 100644 ProjectApp.Api/appsettings.json create mode 100644 ProjectApp.AppHost/Program.cs create mode 100644 ProjectApp.AppHost/ProjectApp.AppHost.csproj create mode 100644 ProjectApp.AppHost/Properties/launchSettings.json create mode 100644 ProjectApp.AppHost/appsettings.Development.json create mode 100644 ProjectApp.AppHost/appsettings.json create mode 100644 ProjectApp.Domain/Entities/ProgramProject.cs create mode 100644 ProjectApp.Domain/ProjectApp.Domain.csproj create mode 100644 ProjectApp.ServiceDefaults/Extensions.cs create mode 100644 ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj diff --git a/.gitignore b/.gitignore index ce892922..9b135ed1 100644 --- a/.gitignore +++ b/.gitignore @@ -415,4 +415,4 @@ FodyWeavers.xsd *.msi *.msix *.msm -*.msp +*.msp \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..830fb4fd 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №1 "«Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов" + Вариант №7 "Программный проект" + Выполнена Земель Алексеем 6511 + Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..258ff87f 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5179/api/project" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..7e69b888 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,17 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +VisualStudioVersion = 17.13.35931.197 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Api", "ProjectApp.Api\ProjectApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Domain", "ProjectApp.Domain\ProjectApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults", "ProjectApp.ServiceDefaults\ProjectApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "ProjectApp.AppHost\ProjectApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +22,22 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api/Controllers/ProjectController.cs b/ProjectApp.Api/Controllers/ProjectController.cs new file mode 100644 index 00000000..f98aacf4 --- /dev/null +++ b/ProjectApp.Api/Controllers/ProjectController.cs @@ -0,0 +1,27 @@ +using ProjectApp.Domain.Entities; +using Microsoft.AspNetCore.Mvc; +using ProjectApp.Api.Services; + +namespace ProjectApp.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class ProjectController(ProgramProjectGeneratorService generatorService, ILogger logger) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) + { + if (id < 0) + { + return BadRequest("id must be a positive integer."); + } + + logger.LogInformation("Received request to retrieve/generate project {Id}", id); + + var project = await generatorService.GetByIdAsync(id, cancellationToken); + + return Ok(project); + } +} diff --git a/ProjectApp.Api/Options/CacheSettings.cs b/ProjectApp.Api/Options/CacheSettings.cs new file mode 100644 index 00000000..f5c9cbcc --- /dev/null +++ b/ProjectApp.Api/Options/CacheSettings.cs @@ -0,0 +1,6 @@ +namespace ProjectApp.Api.Options; + +public class CacheSettings +{ + public int ExpirationMinutes { get; set; } = 10; +} diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs new file mode 100644 index 00000000..623048f8 --- /dev/null +++ b/ProjectApp.Api/Program.cs @@ -0,0 +1,64 @@ +using ProjectApp.Api.Services; +using ProjectApp.ServiceDefaults; +using ProjectApp.Api.Options; +using System.Text.Json; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddRedisDistributedCache("cache"); + +builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); +builder.Services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Project Generator API" + }); + + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } + + var domainXmlPath = Path.Combine(AppContext.BaseDirectory, "ProjectApp.Domain.xml"); + if (File.Exists(domainXmlPath)) + { + options.IncludeXmlComments(domainXmlPath); + } +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors(); +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj new file mode 100644 index 00000000..2aea6015 --- /dev/null +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + diff --git a/ProjectApp.Api/Properties/launchSettings.json b/ProjectApp.Api/Properties/launchSettings.json new file mode 100644 index 00000000..bbe9ee66 --- /dev/null +++ b/ProjectApp.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:46825", + "sslPort": 44333 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7170;http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.Api/Services/ProgramProjectGenerator.cs b/ProjectApp.Api/Services/ProgramProjectGenerator.cs new file mode 100644 index 00000000..6889db3a --- /dev/null +++ b/ProjectApp.Api/Services/ProgramProjectGenerator.cs @@ -0,0 +1,60 @@ +using Bogus; +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services; + +public class ProgramProjectGenerator +{ + private readonly Faker _faker; + + public ProgramProjectGenerator() + { + _faker = new Faker("ru") + .RuleFor(p => p.Id, f => f.IndexFaker + 1) + .RuleFor(p => p.ProjectName, f => + $"{f.Commerce.ProductName()} {f.Hacker.Noun()} {f.Finance.AccountName()} {f.Lorem.Word()}") + .RuleFor(p => p.Customer, f => + f.Company.CompanyName()) + .RuleFor(p => p.ProjectManager, f => + $"{f.Name.LastName()} {f.Name.FirstName()}") + .RuleFor(p => p.StartDate, + f => f.Date.PastDateOnly(3)) + .RuleFor(p => p.PlannedEndDate, + (f, p) => p.StartDate.AddDays(f.Random.Int(30, 730))) + .RuleFor(p => p.Budget, + f => Math.Round(f.Finance.Amount(500000, 50000000), 2)) + .RuleFor(p => p.ActualEndDate, (f, p) => + { + var completed = f.Random.Bool(0.4f); + + if (!completed) + return null; + + var start = p.StartDate.ToDateTime(TimeOnly.MinValue); + var end = f.Date.Between(start.AddDays(1), DateTime.Now); + + return DateOnly.FromDateTime(end); + }) + .RuleFor(p => p.CompletionPercentage, (f, p) => + { + if (p.ActualEndDate != null) + return 100; + + return f.Random.Int(0, 99); + }) + .RuleFor(p => p.ActualCost, (f, p) => + { + var minFactor = Math.Max(0.1m, p.CompletionPercentage / 100m * 0.8m); + var maxFactor = Math.Min(1.2m, p.CompletionPercentage / 100m * 1.2m); + + var factor = f.Random.Decimal(minFactor, maxFactor); + + return Math.Round(p.Budget * factor, 2); + }); + } + + public ProgramProject Generate() + { + return _faker.Generate(); + } +} \ No newline at end of file diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs new file mode 100644 index 00000000..8c7f7fa6 --- /dev/null +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -0,0 +1,91 @@ +using ProjectApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; +using Microsoft.Extensions.Options; +using ProjectApp.Api.Options; +using System.Diagnostics.Metrics; +using System.Diagnostics; + +namespace ProjectApp.Api.Services; + +public class ProgramProjectGeneratorService( + IDistributedCache cache, + ProgramProjectGenerator generator, + IOptions cacheSettings, + JsonSerializerOptions jsonSerializerOptions, + ILogger logger) +{ + private static readonly Meter _meter = new("ProjectApp.Api"); + private static readonly Counter _cacheErrorCounter = _meter.CreateCounter("cache.errors"); + private static readonly Histogram _projectGenerationDuration = _meter.CreateHistogram("project.generation.duration.ms"); + private readonly int _expirationMinutes = cacheSettings.Value.ExpirationMinutes; + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + logger.LogInformation("Attempting to retrieve software project {Id} from cache", id); + + var cacheKey = $"software-project-{id}"; + + ProgramProject? project = null; + try + { + var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (!string.IsNullOrEmpty(cachedData)) + { + project = JsonSerializer.Deserialize(cachedData, jsonSerializerOptions); + + if (project != null) + { + logger.LogInformation("Software project {Id} found in cache", id); + return project; + } + + logger.LogWarning("Project {Id} was found in cache but could not be deserialized. Generating a new one", id); + } + } + catch (Exception ex) + { + _cacheErrorCounter.Add(1, new KeyValuePair("operation", "get")); + logger.LogWarning(ex, "Failed to retrieve project {Id} from cache (error ignored)", id); + } + + logger.LogInformation("Project {Id} not found in cache or cache unavailable, generating a new one", id); + var stopwatch = Stopwatch.StartNew(); + project = generator.Generate(); + stopwatch.Stop(); + _projectGenerationDuration.Record(stopwatch.Elapsed.TotalMilliseconds); + project.Id = id; + + try + { + logger.LogInformation("Saving project {Id} to cache", id); + + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) + }; + + await cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(project, jsonSerializerOptions), + cacheOptions, + cancellationToken); + + logger.LogInformation( + "Software project generated and cached: Id={Id}, Name={ProjectName}, Customer={Customer}, Budget={Budget}, Completion={CompletionPercent}", + project.Id, + project.ProjectName, + project.Customer, + project.Budget, + project.CompletionPercentage); + } + catch (Exception ex) + { + _cacheErrorCounter.Add(1, new KeyValuePair("operation", "set")); + logger.LogWarning(ex, "Failed to save project {Id} to cache (error ignored)", id); + } + + return project; + } +} \ No newline at end of file diff --git a/ProjectApp.Api/appsettings.Development.json b/ProjectApp.Api/appsettings.Development.json new file mode 100644 index 00000000..b642d7aa --- /dev/null +++ b/ProjectApp.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 + } +} diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json new file mode 100644 index 00000000..b642d7aa --- /dev/null +++ b/ProjectApp.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 + } +} diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs new file mode 100644 index 00000000..e2a06011 --- /dev/null +++ b/ProjectApp.AppHost/Program.cs @@ -0,0 +1,16 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("cache") + .WithRedisCommander(); + +var api = builder.AddProject("projectapp-api") + .WithReference(redis) + .WaitFor(redis); + +builder.AddProject("client") + .WithReference(api) + .WaitFor(api); + +builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"; + +builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj new file mode 100644 index 00000000..7704751d --- /dev/null +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net8.0 + enable + enable + true + b8f3eae0-771a-4f3a-8df3-ef0a21b09b55 + + + + + + + + + + + + + diff --git a/ProjectApp.AppHost/Properties/launchSettings.json b/ProjectApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..fa890ea9 --- /dev/null +++ b/ProjectApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17214;http://localhost:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22273" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19190", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136" + } + } + } +} diff --git a/ProjectApp.AppHost/appsettings.Development.json b/ProjectApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/ProjectApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/ProjectApp.Domain/Entities/ProgramProject.cs b/ProjectApp.Domain/Entities/ProgramProject.cs new file mode 100644 index 00000000..07bca732 --- /dev/null +++ b/ProjectApp.Domain/Entities/ProgramProject.cs @@ -0,0 +1,48 @@ +namespace ProjectApp.Domain.Entities; + +/// +/// Программный проект +/// +public class ProgramProject +{ + /// + /// Идентификатор в системе + /// + public required int Id { get; set; } + /// + /// Название проекта + /// + public required string ProjectName { get; set; } + /// + /// Заказчик проекта + /// + public required string Customer { get; set; } + /// + /// Менеджер проекта + /// + public required string ProjectManager { get; set; } + /// + /// Дата начала + /// + public DateOnly StartDate { get; set; } + /// + /// Плановая дата завершения + /// + public DateOnly PlannedEndDate { get; set; } + /// + /// Фактическая дата завершения + /// + public DateOnly? ActualEndDate { get; set; } + /// + /// Бюджет + /// + public decimal Budget { get; set; } + /// + /// Фактические затраты + /// + public decimal ActualCost { get; set; } + /// + /// Процент выполнения + /// + public int CompletionPercentage { get; set; } +} diff --git a/ProjectApp.Domain/ProjectApp.Domain.csproj b/ProjectApp.Domain/ProjectApp.Domain.csproj new file mode 100644 index 00000000..fcfb2654 --- /dev/null +++ b/ProjectApp.Domain/ProjectApp.Domain.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..ac558a7d --- /dev/null +++ b/ProjectApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace ProjectApp.ServiceDefaults; + +/// +/// Расширения для настройки сервисов Aspire (структурное логирование, телеметрия, health checks) +/// +public static class Extensions +{ + /// + /// Добавляет стандартные настройки Aspire для сервисов + /// + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + /// + /// Настройка OpenTelemetry для структурного логирования и телеметрии + /// + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + /// + /// Добавляет стандартные health checks + /// + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Настройка стандартных endpoints (health checks) + /// + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj new file mode 100644 index 00000000..bf9f33a7 --- /dev/null +++ b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/README.md b/README.md index dcaa5eb7..079bbf3e 100644 --- a/README.md +++ b/README.md @@ -124,5 +124,4 @@ ## Вопросы и обратная связь по курсу Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](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). - +Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). \ No newline at end of file From 335b2d0d42ff117ee0a51b87c72b072ddf607422 Mon Sep 17 00:00:00 2001 From: Alexey Date: Fri, 13 Mar 2026 00:36:58 +0400 Subject: [PATCH 02/14] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=20=D1=80=D0=B8=D0=B4=D0=BC=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 221 +++++++++++++++++++++++------------------------------- 1 file changed, 95 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 079bbf3e..35456701 100644 --- a/README.md +++ b/README.md @@ -1,127 +1,96 @@ # Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](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). \ No newline at end of file + +**Вариант:** №7 — «Программный проект» +**Балансировка:** Weighted Random +**Брокер:** SQS +**Хостинг S3:** Minio + +## Что делает проект + +- Генерирует тестовые данные о программных проектах с помощью Bogus. +- Возвращает данные проекта по `id` через HTTP API. +- Кеширует результаты в Redis для повторных запросов. +- Предоставляет Swagger для просмотра и проверки API в режиме разработки. +- Публикует health-check эндпоинты и телеметрию на базе OpenTelemetry через Aspire service defaults. + +## Структура решения + +- `ProjectApp.Api` - ASP.NET Core Web API с логикой генерации, Redis-кешем, Swagger, CORS и контроллерами. +- `ProjectApp.AppHost` - проект оркестрации на .NET Aspire, который поднимает API, Redis, Redis Commander и клиент. +- `ProjectApp.Domain` - доменные сущности, используемые в решении. +- `ProjectApp.ServiceDefaults` - общая конфигурация Aspire: телеметрия, service discovery, resilience и health checks. +- `Client.Wasm` - клиент на Blazor WebAssembly для взаимодействия с API. + +## Технологии + +- .NET 8 +- ASP.NET Core Web API +- .NET Aspire +- Redis +- Bogus +- Swagger / OpenAPI +- OpenTelemetry +- Blazor WebAssembly + +## Основная сущность + +API работает с моделью `ProgramProject`: + +- `Id` +- `ProjectName` +- `Customer` +- `ProjectManager` +- `StartDate` +- `PlannedEndDate` +- `ActualEndDate` +- `Budget` +- `ActualCost` +- `CompletionPercentage` + +## Как это работает + +1. Клиент запрос в API с идентификатором проекта. +2. API пытается прочитать данные из Redis по ключу формата `software-project-{id}`. +3. Если значение найдено в кеше, возвращается сохраненный объект. +4. Если значения нет или Redis недоступен, создается новый `ProgramProject` с помощью Bogus. +5. Сгенерированный объект сохраняется в Redis на заданное время жизни и возвращается клиенту. + +По умолчанию время жизни кеша составляет 10 минут и задается в `ProjectApp.Api/appsettings.json`. + +## API + +Основной эндпоинт: + +```http +GET /api/project?id=1 +``` + +Пример запроса: + +```bash +curl "http://localhost:5179/api/project?id=1" +``` + +Пример структуры ответа: + +```json +{ + "id": 1, + "projectName": "...", + "customer": "...", + "projectManager": "...", + "startDate": "2024-04-01", + "plannedEndDate": "2025-01-10", + "actualEndDate": null, + "budget": 1200000.50, + "actualCost": 640000.25, + "completionPercentage": 54 +} +``` + +Если вызвать эндпоинт несколько раз с одним и тем же `id` в пределах времени жизни кеша, API должно вернуть один и тот же объект из Redis. + +## Особенности реализации + +- API использует распределенный кеш через `IDistributedCache` с хранилищем в Redis. +- Ошибки кеша не ломают обработку запроса: API логирует проблему и продолжает генерацию данных. From b19e17f77af71b3e4561265395d574652e8672f6 Mon Sep 17 00:00:00 2001 From: Alexey Date: Fri, 13 Mar 2026 01:13:03 +0400 Subject: [PATCH 03/14] =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=81=D0=B0=D0=BC=D0=BC=D0=B0=D1=80=D0=B8?= =?UTF-8?q?,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ProjectController.cs | 6 ++++++ ProjectApp.Api/Program.cs | 6 +++--- .../Services/ProgramProjectGenerator.cs | 20 +++++++++++++++++-- .../ProgramProjectGeneratorService.cs | 9 +++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/ProjectApp.Api/Controllers/ProjectController.cs b/ProjectApp.Api/Controllers/ProjectController.cs index f98aacf4..74a80b52 100644 --- a/ProjectApp.Api/Controllers/ProjectController.cs +++ b/ProjectApp.Api/Controllers/ProjectController.cs @@ -8,6 +8,12 @@ namespace ProjectApp.Api.Controllers; [ApiController] public class ProjectController(ProgramProjectGeneratorService generatorService, ILogger logger) : ControllerBase { + /// + /// , + /// + /// + /// + /// [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index 623048f8..b0b829d8 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -16,9 +16,9 @@ { options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + policy.SetIsOriginAllowed(origin => new Uri(origin).Host == "localhost") + .WithMethods("GET") + .WithHeaders("Content-Type"); }); }); diff --git a/ProjectApp.Api/Services/ProgramProjectGenerator.cs b/ProjectApp.Api/Services/ProgramProjectGenerator.cs index 6889db3a..97fafbab 100644 --- a/ProjectApp.Api/Services/ProgramProjectGenerator.cs +++ b/ProjectApp.Api/Services/ProgramProjectGenerator.cs @@ -3,20 +3,36 @@ namespace ProjectApp.Api.Services; +/// +/// +/// public class ProgramProjectGenerator { private readonly Faker _faker; + private static readonly string[] _patronymics = new[] + { + "", "", "", "", "", + "", "", "", "", "", + "", "", "", "", "", + "", "", "", "", "" + }; public ProgramProjectGenerator() { - _faker = new Faker("ru") + + _faker = new Faker("ru") .RuleFor(p => p.Id, f => f.IndexFaker + 1) .RuleFor(p => p.ProjectName, f => $"{f.Commerce.ProductName()} {f.Hacker.Noun()} {f.Finance.AccountName()} {f.Lorem.Word()}") .RuleFor(p => p.Customer, f => f.Company.CompanyName()) .RuleFor(p => p.ProjectManager, f => - $"{f.Name.LastName()} {f.Name.FirstName()}") + { + var lastName = f.Name.LastName(); + var firstName = f.Name.FirstName(); + var patronymic = f.PickRandom(_patronymics); + return $"{lastName} {firstName} {patronymic}"; + }) .RuleFor(p => p.StartDate, f => f.Date.PastDateOnly(3)) .RuleFor(p => p.PlannedEndDate, diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 8c7f7fa6..25870884 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -8,6 +8,9 @@ namespace ProjectApp.Api.Services; +/// +/// +/// public class ProgramProjectGeneratorService( IDistributedCache cache, ProgramProjectGenerator generator, @@ -20,6 +23,12 @@ public class ProgramProjectGeneratorService( private static readonly Histogram _projectGenerationDuration = _meter.CreateHistogram("project.generation.duration.ms"); private readonly int _expirationMinutes = cacheSettings.Value.ExpirationMinutes; + /// + /// + /// + /// + /// + /// public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { logger.LogInformation("Attempting to retrieve software project {Id} from cache", id); From 6b5317c2594856f713d108e527daeca41fa1e61b Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:30:03 +0400 Subject: [PATCH 04/14] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE:=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B3=D0=BE=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=B0,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Properties/launchSettings.json | 6 +++--- ProjectApp.Api/Services/ProgramProjectGeneratorService.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..60120ec3 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -12,7 +12,7 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "http://localhost:5127", "environmentVariables": { @@ -22,7 +22,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "applicationUrl": "https://localhost:7282;http://localhost:5127", "environmentVariables": { @@ -31,7 +31,7 @@ }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 25870884..69b924bb 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -9,7 +9,7 @@ namespace ProjectApp.Api.Services; /// -/// +/// /// public class ProgramProjectGeneratorService( IDistributedCache cache, From 91cc10eef70302bf3ab2a68d0d15dcb04a15e52e Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:32:12 +0400 Subject: [PATCH 05/14] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=202=20-=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 1 + ProjectApp.Api/Services/ProgramProjectGeneratorService.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 0f3bba5c..e877c891 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,5 @@ [*.cs] +charset = utf-8 csharp_indent_labels = one_less_than_current csharp_using_directive_placement = outside_namespace:silent csharp_prefer_simple_using_statement = true:suggestion diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 69b924bb..25870884 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -9,7 +9,7 @@ namespace ProjectApp.Api.Services; /// -/// +/// /// public class ProgramProjectGeneratorService( IDistributedCache cache, From 5e223aa29f55b8b91cb3832036cfc3d3ee20b30c Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:33:34 +0400 Subject: [PATCH 06/14] =?UTF-8?q?=D0=BF=D0=BE=D0=B6=D0=B0=D0=BB=D1=83?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectApp.Api/Services/ProgramProjectGeneratorService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 25870884..69b924bb 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -9,7 +9,7 @@ namespace ProjectApp.Api.Services; /// -/// +/// /// public class ProgramProjectGeneratorService( IDistributedCache cache, From a657c298623c7d33400fb7ea27de6c4b592e6000 Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:34:56 +0400 Subject: [PATCH 07/14] =?UTF-8?q?=D0=B0=D0=B0=D0=B0=D0=B0=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 1 - ProjectApp.Api/Services/ProgramProjectGeneratorService.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index e877c891..0f3bba5c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,4 @@ [*.cs] -charset = utf-8 csharp_indent_labels = one_less_than_current csharp_using_directive_placement = outside_namespace:silent csharp_prefer_simple_using_statement = true:suggestion diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 69b924bb..25870884 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -9,7 +9,7 @@ namespace ProjectApp.Api.Services; /// -/// +/// /// public class ProgramProjectGeneratorService( IDistributedCache cache, From 3ed95558b59a238a84f5ee32cbf318c612bc1a9f Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:35:55 +0400 Subject: [PATCH 08/14] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/ProgramProjectGeneratorService.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 25870884..36626759 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -1,4 +1,4 @@ -using ProjectApp.Domain.Entities; +using ProjectApp.Domain.Entities; using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; using Microsoft.Extensions.Options; @@ -9,7 +9,7 @@ namespace ProjectApp.Api.Services; /// -/// +/// Сервис получения программного проекта с использованием кэша и генерации при отсутствии данны /// public class ProgramProjectGeneratorService( IDistributedCache cache, @@ -24,11 +24,11 @@ public class ProgramProjectGeneratorService( private readonly int _expirationMinutes = cacheSettings.Value.ExpirationMinutes; /// - /// + /// Возвращает проект по идентификатору из кэша или генерирует новый и сохраняет его в кэш /// - /// - /// - /// + /// Идентификатор проекта + /// Токен отмены + /// Программный проект public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) { logger.LogInformation("Attempting to retrieve software project {Id} from cache", id); From 678006e84ffb5f6cb383ad637bc2b8b65a1d516d Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 22:38:49 +0400 Subject: [PATCH 09/14] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE:=20=D0=BA=D0=BE=D0=B4=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectApp.Api/Controllers/ProjectController.cs | 10 +++++----- ProjectApp.Api/Services/ProgramProjectGenerator.cs | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ProjectApp.Api/Controllers/ProjectController.cs b/ProjectApp.Api/Controllers/ProjectController.cs index 74a80b52..02b15a14 100644 --- a/ProjectApp.Api/Controllers/ProjectController.cs +++ b/ProjectApp.Api/Controllers/ProjectController.cs @@ -1,4 +1,4 @@ -using ProjectApp.Domain.Entities; +using ProjectApp.Domain.Entities; using Microsoft.AspNetCore.Mvc; using ProjectApp.Api.Services; @@ -9,11 +9,11 @@ namespace ProjectApp.Api.Controllers; public class ProjectController(ProgramProjectGeneratorService generatorService, ILogger logger) : ControllerBase { /// - /// , + /// Возвращает проект по идентификатору или генерирует новый, если он не найден в кэше /// - /// - /// - /// + /// Идентификатор проекта + /// Токен отмены + /// Программный проект [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] diff --git a/ProjectApp.Api/Services/ProgramProjectGenerator.cs b/ProjectApp.Api/Services/ProgramProjectGenerator.cs index 97fafbab..97b5d028 100644 --- a/ProjectApp.Api/Services/ProgramProjectGenerator.cs +++ b/ProjectApp.Api/Services/ProgramProjectGenerator.cs @@ -1,10 +1,10 @@ -using Bogus; +using Bogus; using ProjectApp.Domain.Entities; namespace ProjectApp.Api.Services; /// -/// +/// Генерирует случайный программный проект /// public class ProgramProjectGenerator { @@ -12,10 +12,10 @@ public class ProgramProjectGenerator private static readonly string[] _patronymics = new[] { - "", "", "", "", "", - "", "", "", "", "", - "", "", "", "", "", - "", "", "", "", "" + "Иванович", "Петрович", "Сидорович", "Александрович", "Дмитриевич", + "Андреевич", "Сергеевич", "Алексеевич", "Николаевич", "Владимирович", + "Ивановна", "Петровна", "Сидоровна", "Александровна", "Дмитриевна", + "Андреевна", "Сергеевна", "Алексеевна", "Николаевна", "Владимировна" }; public ProgramProjectGenerator() { From 3c7bfa8273f3b7c36fa154a920b1451d56a16bd5 Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 16 Mar 2026 23:36:24 +0400 Subject: [PATCH 10/14] =?UTF-8?q?=D0=B2=D1=81=D1=91=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/wwwroot/appsettings.json | 2 +- ProjectApp.Api/Program.cs | 9 +++--- .../Services/ProgramProjectGenerator.cs | 28 +++---------------- .../ProgramProjectGeneratorService.cs | 10 +++---- ProjectApp.Api/appsettings.json | 5 +++- ProjectApp.AppHost/Program.cs | 2 -- ProjectApp.ServiceDefaults/Extensions.cs | 4 ++- 7 files changed, 21 insertions(+), 39 deletions(-) diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 258ff87f..64050b54 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "http://localhost:5179/api/project" + "BaseAddress": "https://localhost:7170/api/project" } diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index b0b829d8..cb987628 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -4,6 +4,8 @@ using System.Text.Json; var builder = WebApplication.CreateBuilder(args); +var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get(); + builder.AddServiceDefaults(); @@ -16,13 +18,12 @@ { options.AddDefaultPolicy(policy => { - policy.SetIsOriginAllowed(origin => new Uri(origin).Host == "localhost") - .WithMethods("GET") - .WithHeaders("Content-Type"); + policy.WithOrigins(allowedOrigins) + .WithMethods("GET") + .WithHeaders("Content-Type"); }); }); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddControllers(); diff --git a/ProjectApp.Api/Services/ProgramProjectGenerator.cs b/ProjectApp.Api/Services/ProgramProjectGenerator.cs index 97b5d028..5d58790b 100644 --- a/ProjectApp.Api/Services/ProgramProjectGenerator.cs +++ b/ProjectApp.Api/Services/ProgramProjectGenerator.cs @@ -6,33 +6,14 @@ namespace ProjectApp.Api.Services; /// /// Генерирует случайный программный проект /// -public class ProgramProjectGenerator +public static class ProgramProjectGenerator { - private readonly Faker _faker; - - private static readonly string[] _patronymics = new[] - { - "Иванович", "Петрович", "Сидорович", "Александрович", "Дмитриевич", - "Андреевич", "Сергеевич", "Алексеевич", "Николаевич", "Владимирович", - "Ивановна", "Петровна", "Сидоровна", "Александровна", "Дмитриевна", - "Андреевна", "Сергеевна", "Алексеевна", "Николаевна", "Владимировна" - }; - public ProgramProjectGenerator() - { - - _faker = new Faker("ru") - .RuleFor(p => p.Id, f => f.IndexFaker + 1) + private static readonly Faker _faker = new Faker("ru") .RuleFor(p => p.ProjectName, f => $"{f.Commerce.ProductName()} {f.Hacker.Noun()} {f.Finance.AccountName()} {f.Lorem.Word()}") .RuleFor(p => p.Customer, f => f.Company.CompanyName()) - .RuleFor(p => p.ProjectManager, f => - { - var lastName = f.Name.LastName(); - var firstName = f.Name.FirstName(); - var patronymic = f.PickRandom(_patronymics); - return $"{lastName} {firstName} {patronymic}"; - }) + .RuleFor(p => p.ProjectManager, f => f.Name.FullName()) .RuleFor(p => p.StartDate, f => f.Date.PastDateOnly(3)) .RuleFor(p => p.PlannedEndDate, @@ -67,9 +48,8 @@ public ProgramProjectGenerator() return Math.Round(p.Budget * factor, 2); }); - } - public ProgramProject Generate() + public static ProgramProject Generate() { return _faker.Generate(); } diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 36626759..3d14250c 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -13,7 +13,6 @@ namespace ProjectApp.Api.Services; /// public class ProgramProjectGeneratorService( IDistributedCache cache, - ProgramProjectGenerator generator, IOptions cacheSettings, JsonSerializerOptions jsonSerializerOptions, ILogger logger) @@ -35,19 +34,18 @@ public async Task GetByIdAsync(int id, CancellationToken cancell var cacheKey = $"software-project-{id}"; - ProgramProject? project = null; try { var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); if (!string.IsNullOrEmpty(cachedData)) { - project = JsonSerializer.Deserialize(cachedData, jsonSerializerOptions); + var cashedProject = JsonSerializer.Deserialize(cachedData, jsonSerializerOptions); - if (project != null) + if (cashedProject != null) { logger.LogInformation("Software project {Id} found in cache", id); - return project; + return cashedProject; } logger.LogWarning("Project {Id} was found in cache but could not be deserialized. Generating a new one", id); @@ -61,7 +59,7 @@ public async Task GetByIdAsync(int id, CancellationToken cancell logger.LogInformation("Project {Id} not found in cache or cache unavailable, generating a new one", id); var stopwatch = Stopwatch.StartNew(); - project = generator.Generate(); + var project = ProgramProjectGenerator.Generate(); stopwatch.Stop(); _projectGenerationDuration.Record(stopwatch.Elapsed.TotalMilliseconds); project.Id = id; diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json index b642d7aa..6a7830b3 100644 --- a/ProjectApp.Api/appsettings.json +++ b/ProjectApp.Api/appsettings.json @@ -8,5 +8,8 @@ "AllowedHosts": "*", "CacheSettings": { "ExpirationMinutes": 10 - } + }, + "AllowedOrigins": [ + "https://localhost:7282" + ] } diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index e2a06011..9a749d01 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -11,6 +11,4 @@ .WithReference(api) .WaitFor(api); -builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true"; - builder.Build().Run(); diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs index ac558a7d..833f02eb 100644 --- a/ProjectApp.ServiceDefaults/Extensions.cs +++ b/ProjectApp.ServiceDefaults/Extensions.cs @@ -68,7 +68,9 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli if (useOtlpExporter) { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); + builder.Services.AddOpenTelemetry() + .WithMetrics(m => m.AddMeter("ProjectApp.Api")) + .UseOtlpExporter(); } return builder; From 8b1e76d2bb6b609fb1a07116d204d01b9e608bfe Mon Sep 17 00:00:00 2001 From: Alexey Date: Mon, 13 Apr 2026 15:45:59 +0400 Subject: [PATCH 11/14] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BB=D0=B8=D0=BA=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=B0=D0=BF=D0=B8=20=D0=B3?= =?UTF-8?q?=D0=B5=D0=B9=D1=82=D0=B2=D0=B5=D0=B9=20=D0=B8=20=D0=B0=D0=BB?= =?UTF-8?q?=D0=B3=D0=BE=D1=80=D0=B8=D1=82=D0=BC=20=D0=B1=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/DataCard.razor | 21 +++- Client.Wasm/Components/StudentCard.razor | 4 +- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 6 ++ .../Controllers/ProjectController.cs | 1 + ProjectApp.AppHost/Program.cs | 9 +- ProjectApp.AppHost/ProjectApp.AppHost.csproj | 1 + .../WeightedRandomLoadBalancerCreator.cs | 100 ++++++++++++++++++ ProjectApp.Gateway/Program.cs | 61 +++++++++++ ProjectApp.Gateway/ProjectApp.Gateway.csproj | 17 +++ .../Properties/launchSettings.json | 38 +++++++ .../appsettings.Development.json | 8 ++ ProjectApp.Gateway/appsettings.json | 17 +++ ProjectApp.Gateway/ocelot.json | 22 ++++ 14 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs create mode 100644 ProjectApp.Gateway/Program.cs create mode 100644 ProjectApp.Gateway/ProjectApp.Gateway.csproj create mode 100644 ProjectApp.Gateway/Properties/launchSettings.json create mode 100644 ProjectApp.Gateway/appsettings.Development.json create mode 100644 ProjectApp.Gateway/appsettings.json create mode 100644 ProjectApp.Gateway/ocelot.json diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..f666e131 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -67,8 +67,27 @@ private async Task RequestNewData() { - var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress"); + var baseAddress = ResolveBaseAddress(); Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { }); StateHasChanged(); } + + private string ResolveBaseAddress() + { + var direct = Configuration["BaseAddress"]; + if (!string.IsNullOrWhiteSpace(direct)) + { + return direct; + } + + var gateway = Configuration["services:projectapp-gateway:https:0"] + ?? Configuration["services:projectapp-gateway:http:0"]; + + if (string.IsNullOrWhiteSpace(gateway)) + { + throw new KeyNotFoundException("Не найден адрес API Gateway в конфигурации."); + } + + return gateway.TrimEnd('/') + "/api/project"; + } } diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 830fb4fd..5c64acbe 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,8 +4,8 @@ - Номер №1 "«Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов" - Вариант №7 "Программный проект" + Номер №2 "«Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы" + Вариант №7 "Weighted Random" Выполнена Земель Алексеем 6511 Ссылка на форк diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 64050b54..3882dba5 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "https://localhost:7170/api/project" + "BaseAddress": "http://localhost:5043/api/project" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 7e69b888..c0c45dc0 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "ProjectApp.AppHost\ProjectApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Gateway", "ProjectApp.Gateway\ProjectApp.Gateway.csproj", "{43B10B5B-9127-3B81-BB2C-E6AD6380245A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +40,10 @@ Global {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU + {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api/Controllers/ProjectController.cs b/ProjectApp.Api/Controllers/ProjectController.cs index 02b15a14..85a264a4 100644 --- a/ProjectApp.Api/Controllers/ProjectController.cs +++ b/ProjectApp.Api/Controllers/ProjectController.cs @@ -24,6 +24,7 @@ public async Task> GetById([FromQuery] int id, Canc return BadRequest("id must be a positive integer."); } + logger.LogInformation("Instance: {InstanceId}", Environment.MachineName); logger.LogInformation("Received request to retrieve/generate project {Id}", id); var project = await generatorService.GetByIdAsync(id, cancellationToken); diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 9a749d01..02c71204 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -5,10 +5,17 @@ var api = builder.AddProject("projectapp-api") .WithReference(redis) + .WithReplicas(5) .WaitFor(redis); -builder.AddProject("client") +var gateway = builder.AddProject("projectapp-gateway") + .WithExternalHttpEndpoints() .WithReference(api) .WaitFor(api); +builder.AddProject("client") + .WithReference(gateway) + .WithEnvironment("BaseAddress", gateway.GetEndpoint("http")) + .WaitFor(gateway); + builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj index 7704751d..e38d6aa8 100644 --- a/ProjectApp.AppHost/ProjectApp.AppHost.csproj +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -19,6 +19,7 @@ + diff --git a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs new file mode 100644 index 00000000..fcf065e3 --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Http; +using Ocelot.Configuration; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.ServiceDiscovery.Providers; +using Ocelot.Values; + +namespace ProjectApp.Gateway.LoadBalancing; + +/// +/// (Weighted Random) +/// +public sealed class WeightedRandomLoadBalancerCreator(IConfiguration configuration) : ILoadBalancerCreator +{ + public string Type => "WeightedRandom"; + + public Response Create(DownstreamRoute downstreamRoute, IServiceDiscoveryProvider serviceDiscoveryProvider) + { + var weights = configuration.GetSection("Gateway:WeightedRandom:Weights").Get() ?? []; + ILoadBalancer loadBalancer = new WeightedRandomLoadBalancer(downstreamRoute.DownstreamAddresses, weights); + return new OkResponse(loadBalancer); + } + + /// + /// + /// + private sealed class WeightedRandomLoadBalancer(IReadOnlyList downstreamAddresses, IReadOnlyList configuredWeights) : ILoadBalancer + { + private readonly Random _random = new(); + private readonly IReadOnlyList _replicas = downstreamAddresses + .Select(x => new ServiceHostAndPort(x.Host, x.Port)) + .ToArray(); + + private readonly IReadOnlyList _weights = NormalizeWeights(configuredWeights, downstreamAddresses.Count); + + public string Type => "WeightedRandom"; + + /// + /// + /// + public Task> LeaseAsync(HttpContext httpContext) + { + if (_replicas.Count == 0) + { + return Task.FromResult>(new OkResponse(new ServiceHostAndPort("localhost", 1))); + } + + var selected = _replicas[SelectIndexByWeight(_weights)]; + return Task.FromResult>(new OkResponse(selected)); + } + + public void Release(ServiceHostAndPort hostAndPort) + { + } + + /// + /// + /// + private int SelectIndexByWeight(IReadOnlyList weights) + { + var roll = _random.NextDouble(); + var cumulative = 0d; + + for (var i = 0; i < weights.Count; i++) + { + cumulative += weights[i]; + if (roll <= cumulative) + { + return i; + } + } + + return weights.Count - 1; + } + + /// + /// : 1, + /// + private static IReadOnlyList NormalizeWeights(IReadOnlyList configuredWeights, int replicasCount) + { + if (replicasCount == 0) + { + return []; + } + + if (configuredWeights.Count != replicasCount || configuredWeights.Any(x => x <= 0d)) + { + return Enumerable.Repeat(1d / replicasCount, replicasCount).ToArray(); + } + + var sum = configuredWeights.Sum(); + if (sum <= 0d) + { + return Enumerable.Repeat(1d / replicasCount, replicasCount).ToArray(); + } + + return configuredWeights.Select(x => x / sum).ToArray(); + } + } +} diff --git a/ProjectApp.Gateway/Program.cs b/ProjectApp.Gateway/Program.cs new file mode 100644 index 00000000..1e0bb958 --- /dev/null +++ b/ProjectApp.Gateway/Program.cs @@ -0,0 +1,61 @@ +using Ocelot.DependencyInjection; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Middleware; +using ProjectApp.Gateway.LoadBalancing; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration + .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + +var replicas = GetReplicas(builder.Configuration); +if (replicas.Count > 0) +{ + var overrides = new Dictionary + { + ["Routes:0:DownstreamScheme"] = replicas[0].Scheme + }; + + foreach (var (replica, index) in replicas.Select((value, i) => (value, i))) + { + overrides[$"Routes:0:DownstreamHostAndPorts:{index}:Host"] = replica.Host; + overrides[$"Routes:0:DownstreamHostAndPorts:{index}:Port"] = replica.Port.ToString(); + } + + builder.Configuration.AddInMemoryCollection(overrides); +} + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +builder.Services.AddSingleton(); +builder.Services.AddOcelot(builder.Configuration); + +var app = builder.Build(); + +app.UseCors(); +await app.UseOcelot(); + +app.Run(); + +static List<(string Scheme, string Host, int Port)> GetReplicas(IConfiguration configuration) +{ + var addresses = configuration.GetSection("Services:projectapp-api:https").Get() + ?? configuration.GetSection("Services:projectapp-api:http").Get() + ?? []; + + return addresses + .Select(x => Uri.TryCreate(x, UriKind.Absolute, out var uri) ? uri : null) + .Where(x => x is not null) + .Select(x => (x!.Scheme, x.Host, x.Port)) + .Distinct() + .ToList(); +} \ No newline at end of file diff --git a/ProjectApp.Gateway/ProjectApp.Gateway.csproj b/ProjectApp.Gateway/ProjectApp.Gateway.csproj new file mode 100644 index 00000000..b6e8f658 --- /dev/null +++ b/ProjectApp.Gateway/ProjectApp.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..115633a6 --- /dev/null +++ b/ProjectApp.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:15704", + "sslPort": 44336 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5043", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7153;http://localhost:5043", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.Gateway/appsettings.Development.json b/ProjectApp.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.Gateway/appsettings.json b/ProjectApp.Gateway/appsettings.json new file mode 100644 index 00000000..99cb0cb1 --- /dev/null +++ b/ProjectApp.Gateway/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Gateway": { + "WeightedRandom": { + "Weights": [ 0.4, 0.25, 0.15, 0.1, 0.1 ] + } + }, + "AllowedOrigins": [ + "https://localhost:7282" + ] +} diff --git a/ProjectApp.Gateway/ocelot.json b/ProjectApp.Gateway/ocelot.json new file mode 100644 index 00000000..d46b118e --- /dev/null +++ b/ProjectApp.Gateway/ocelot.json @@ -0,0 +1,22 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/api/project", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 1 + } + ], + "UpstreamPathTemplate": "/api/project", + "UpstreamHttpMethod": [ "Get" ], + "LoadBalancerOptions": { + "Type": "WeightedRandom" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5043" + } +} From 90f66d40928f5385ebe20d9127a0c20431c570f8 Mon Sep 17 00:00:00 2001 From: Alexey Date: Wed, 15 Apr 2026 14:09:31 +0400 Subject: [PATCH 12/14] =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WeightedRandomLoadBalancerCreator.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs index fcf065e3..56d75020 100644 --- a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs +++ b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Ocelot.Configuration; using Ocelot.LoadBalancer.Interfaces; using Ocelot.Responses; @@ -8,7 +8,7 @@ namespace ProjectApp.Gateway.LoadBalancing; /// -/// (Weighted Random) +/// Балансировщик нагрузки с алгоритмом взвешенного случайного выбора (Weighted Random) /// public sealed class WeightedRandomLoadBalancerCreator(IConfiguration configuration) : ILoadBalancerCreator { @@ -22,7 +22,7 @@ public Response Create(DownstreamRoute downstreamRoute, IServiceD } /// - /// + /// Реализация балансировщика нагрузки с взвешенным случайным распределением запросов /// private sealed class WeightedRandomLoadBalancer(IReadOnlyList downstreamAddresses, IReadOnlyList configuredWeights) : ILoadBalancer { @@ -36,7 +36,7 @@ private sealed class WeightedRandomLoadBalancer(IReadOnlyList "WeightedRandom"; /// - /// + /// Выбирает реплику для обработки запроса на основе взвешенного случайного алгоритма /// public Task> LeaseAsync(HttpContext httpContext) { @@ -54,7 +54,7 @@ public void Release(ServiceHostAndPort hostAndPort) } /// - /// + /// Выбирает индекс реплики на основе нормализованных весов методом рулетки /// private int SelectIndexByWeight(IReadOnlyList weights) { @@ -74,7 +74,7 @@ private int SelectIndexByWeight(IReadOnlyList weights) } /// - /// : 1, + /// Нормализует конфигурационные веса: приводит их к сумме 1, проверяет корректность /// private static IReadOnlyList NormalizeWeights(IReadOnlyList configuredWeights, int replicasCount) { From a42e91818f85604283ca479970075476a8f05b82 Mon Sep 17 00:00:00 2001 From: Alexey Date: Wed, 15 Apr 2026 15:37:39 +0400 Subject: [PATCH 13/14] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProjectApp.Api/Program.cs | 12 --- ProjectApp.Api/Properties/launchSettings.json | 4 +- ProjectApp.AppHost/Program.cs | 30 ++++-- .../WeightedRandomLoadBalancer.cs | 95 +++++++++++++++++++ .../WeightedRandomLoadBalancerCreator.cs | 94 +++--------------- ProjectApp.Gateway/Program.cs | 16 +++- ProjectApp.Gateway/appsettings.json | 3 +- README.md | 21 +++- 8 files changed, 163 insertions(+), 112 deletions(-) create mode 100644 ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index cb987628..1bd52296 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -4,7 +4,6 @@ using System.Text.Json; var builder = WebApplication.CreateBuilder(args); -var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get(); builder.AddServiceDefaults(); @@ -14,16 +13,6 @@ builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); builder.Services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.Web)); -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - { - policy.WithOrigins(allowedOrigins) - .WithMethods("GET") - .WithHeaders("Content-Type"); - }); -}); - builder.Services.AddScoped(); builder.Services.AddControllers(); @@ -58,7 +47,6 @@ } app.UseHttpsRedirection(); -app.UseCors(); app.MapControllers(); app.MapDefaultEndpoints(); diff --git a/ProjectApp.Api/Properties/launchSettings.json b/ProjectApp.Api/Properties/launchSettings.json index bbe9ee66..3341af89 100644 --- a/ProjectApp.Api/Properties/launchSettings.json +++ b/ProjectApp.Api/Properties/launchSettings.json @@ -14,7 +14,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "http://localhost:5179", + "applicationUrl": "http://localhost:0", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -24,7 +24,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:7170;http://localhost:5179", + "applicationUrl": "https://localhost:0;http://localhost:0", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 02c71204..57318832 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -3,19 +3,33 @@ var redis = builder.AddRedis("cache") .WithRedisCommander(); -var api = builder.AddProject("projectapp-api") - .WithReference(redis) - .WithReplicas(5) - .WaitFor(redis); +const int replicaCount = 5; +var apiReplicas = new List>(replicaCount); + +for (var i = 0; i < replicaCount; i++) +{ + var replica = builder.AddProject($"projectapp-api-{i + 1}") + .WithReference(redis) + .WaitFor(redis); + + apiReplicas.Add(replica); +} var gateway = builder.AddProject("projectapp-gateway") - .WithExternalHttpEndpoints() - .WithReference(api) - .WaitFor(api); + .WithExternalHttpEndpoints(); + +for (var i = 0; i < apiReplicas.Count; i++) +{ + var replica = apiReplicas[i]; + gateway = gateway + .WithReference(replica) + .WithEnvironment($"ApiReplicas__{i}", replica.GetEndpoint("https")) + .WaitFor(replica); +} builder.AddProject("client") .WithReference(gateway) - .WithEnvironment("BaseAddress", gateway.GetEndpoint("http")) + .WithEnvironment("BaseAddress", gateway.GetEndpoint("https")) .WaitFor(gateway); builder.Build().Run(); diff --git a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs new file mode 100644 index 00000000..d1a164b5 --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -0,0 +1,95 @@ +using Ocelot.Configuration; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ProjectApp.Gateway.LoadBalancing; + +/// +/// Балансировщик нагрузки с алгоритмом взвешенного случайного выбора (Weighted Random) +/// +public sealed class WeightedRandomLoadBalancer( + IReadOnlyList downstreamAddresses, + IReadOnlyList configuredWeights) + : ILoadBalancer +{ + private readonly Random _random = Random.Shared; + + private readonly IReadOnlyList _replicas = + downstreamAddresses + .Select(x => new ServiceHostAndPort(x.Host, x.Port)) + .ToArray(); + + private readonly double[] _weights = + NormalizeWeights(configuredWeights, downstreamAddresses.Count); + + public string Type => "WeightedRandom"; + + /// + /// Выбирает реплику для обработки запроса + /// + public Task> LeaseAsync(HttpContext httpContext) + { + if (_replicas.Count == 0) + { + return Task.FromResult>( + new OkResponse( + new ServiceHostAndPort("localhost", 1))); + } + + var selected = _replicas[SelectIndexByWeight(_weights)]; + + return Task.FromResult>( + new OkResponse(selected)); + } + + public void Release(ServiceHostAndPort hostAndPort) + { + } + + /// + /// Выбор индекса методом рулетки + /// + private int SelectIndexByWeight(IReadOnlyList weights) + { + var roll = _random.NextDouble(); + var cumulative = 0d; + + for (var i = 0; i < weights.Count; i++) + { + cumulative += weights[i]; + if (roll <= cumulative) + return i; + } + + return weights.Count - 1; + } + + /// + /// Нормализация весов + /// + private static double[] NormalizeWeights( + IReadOnlyList configuredWeights, + int replicasCount) + { + if (replicasCount == 0) + return []; + + if (configuredWeights.Count != replicasCount || + configuredWeights.Any(x => x <= 0d)) + { + return [.. Enumerable.Repeat(1d / replicasCount, replicasCount)]; + } + + var sum = configuredWeights.Sum(); + + if (sum <= 0d) + { + return [.. Enumerable.Repeat(1d / replicasCount, replicasCount)]; + } + + return configuredWeights + .Select(x => x / sum) + .ToArray(); + } +} \ No newline at end of file diff --git a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs index 56d75020..12dec8b6 100644 --- a/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs +++ b/ProjectApp.Gateway/LoadBalancing/WeightedRandomLoadBalancerCreator.cs @@ -1,9 +1,7 @@ -using Microsoft.AspNetCore.Http; -using Ocelot.Configuration; +using Ocelot.Configuration; using Ocelot.LoadBalancer.Interfaces; using Ocelot.Responses; using Ocelot.ServiceDiscovery.Providers; -using Ocelot.Values; namespace ProjectApp.Gateway.LoadBalancing; @@ -14,87 +12,17 @@ public sealed class WeightedRandomLoadBalancerCreator(IConfiguration configurati { public string Type => "WeightedRandom"; - public Response Create(DownstreamRoute downstreamRoute, IServiceDiscoveryProvider serviceDiscoveryProvider) + public Response Create( + DownstreamRoute downstreamRoute, + IServiceDiscoveryProvider serviceDiscoveryProvider) { - var weights = configuration.GetSection("Gateway:WeightedRandom:Weights").Get() ?? []; - ILoadBalancer loadBalancer = new WeightedRandomLoadBalancer(downstreamRoute.DownstreamAddresses, weights); - return new OkResponse(loadBalancer); - } - - /// - /// Реализация балансировщика нагрузки с взвешенным случайным распределением запросов - /// - private sealed class WeightedRandomLoadBalancer(IReadOnlyList downstreamAddresses, IReadOnlyList configuredWeights) : ILoadBalancer - { - private readonly Random _random = new(); - private readonly IReadOnlyList _replicas = downstreamAddresses - .Select(x => new ServiceHostAndPort(x.Host, x.Port)) - .ToArray(); - - private readonly IReadOnlyList _weights = NormalizeWeights(configuredWeights, downstreamAddresses.Count); - - public string Type => "WeightedRandom"; - - /// - /// Выбирает реплику для обработки запроса на основе взвешенного случайного алгоритма - /// - public Task> LeaseAsync(HttpContext httpContext) - { - if (_replicas.Count == 0) - { - return Task.FromResult>(new OkResponse(new ServiceHostAndPort("localhost", 1))); - } - - var selected = _replicas[SelectIndexByWeight(_weights)]; - return Task.FromResult>(new OkResponse(selected)); - } + var weights = configuration + .GetSection("Gateway:WeightedRandom:Weights") + .Get() ?? []; - public void Release(ServiceHostAndPort hostAndPort) - { - } + ILoadBalancer loadBalancer = + new WeightedRandomLoadBalancer(downstreamRoute.DownstreamAddresses, weights); - /// - /// Выбирает индекс реплики на основе нормализованных весов методом рулетки - /// - private int SelectIndexByWeight(IReadOnlyList weights) - { - var roll = _random.NextDouble(); - var cumulative = 0d; - - for (var i = 0; i < weights.Count; i++) - { - cumulative += weights[i]; - if (roll <= cumulative) - { - return i; - } - } - - return weights.Count - 1; - } - - /// - /// Нормализует конфигурационные веса: приводит их к сумме 1, проверяет корректность - /// - private static IReadOnlyList NormalizeWeights(IReadOnlyList configuredWeights, int replicasCount) - { - if (replicasCount == 0) - { - return []; - } - - if (configuredWeights.Count != replicasCount || configuredWeights.Any(x => x <= 0d)) - { - return Enumerable.Repeat(1d / replicasCount, replicasCount).ToArray(); - } - - var sum = configuredWeights.Sum(); - if (sum <= 0d) - { - return Enumerable.Repeat(1d / replicasCount, replicasCount).ToArray(); - } - - return configuredWeights.Select(x => x / sum).ToArray(); - } + return new OkResponse(loadBalancer); } -} +} \ No newline at end of file diff --git a/ProjectApp.Gateway/Program.cs b/ProjectApp.Gateway/Program.cs index 1e0bb958..31de351c 100644 --- a/ProjectApp.Gateway/Program.cs +++ b/ProjectApp.Gateway/Program.cs @@ -2,9 +2,12 @@ using Ocelot.LoadBalancer.Interfaces; using Ocelot.Middleware; using ProjectApp.Gateway.LoadBalancing; +using ProjectApp.ServiceDefaults; var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + builder.Configuration .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) .AddEnvironmentVariables(); @@ -26,13 +29,17 @@ builder.Configuration.AddInMemoryCollection(overrides); } +var allowedOrigins = builder.Configuration + .GetSection("AllowedOrigins") + .Get() ?? []; + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() - .AllowAnyHeader() - .AllowAnyMethod(); + policy.WithOrigins(allowedOrigins) + .WithMethods("GET") + .WithHeaders("Content-Type"); }); }); @@ -48,7 +55,8 @@ static List<(string Scheme, string Host, int Port)> GetReplicas(IConfiguration configuration) { - var addresses = configuration.GetSection("Services:projectapp-api:https").Get() + var addresses = configuration.GetSection("ApiReplicas").Get() + ?? configuration.GetSection("Services:projectapp-api:https").Get() ?? configuration.GetSection("Services:projectapp-api:http").Get() ?? []; diff --git a/ProjectApp.Gateway/appsettings.json b/ProjectApp.Gateway/appsettings.json index 99cb0cb1..115dbe5a 100644 --- a/ProjectApp.Gateway/appsettings.json +++ b/ProjectApp.Gateway/appsettings.json @@ -12,6 +12,7 @@ } }, "AllowedOrigins": [ - "https://localhost:7282" + "https://localhost:7282", + "http://localhost:5127" ] } diff --git a/README.md b/README.md index 35456701..65b4e918 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,16 @@ - Генерирует тестовые данные о программных проектах с помощью Bogus. - Возвращает данные проекта по `id` через HTTP API. - Кеширует результаты в Redis для повторных запросов. -- Предоставляет Swagger для просмотра и проверки API в режиме разработки. -- Публикует health-check эндпоинты и телеметрию на базе OpenTelemetry через Aspire service defaults. +- Использует API Gateway (Ocelot) для маршрутизации запросов. +- Балансирует нагрузку между несколькими экземплярами API с помощью алгоритма **Weighted Random**. +- Поднимает всю инфраструктуру через .NET Aspire (оркестрация сервисов). +- Предоставляет Swagger для тестирования API. +- Публикует health-check и телеметрию через OpenTelemetry. ## Структура решения - `ProjectApp.Api` - ASP.NET Core Web API с логикой генерации, Redis-кешем, Swagger, CORS и контроллерами. +- `ProjectApp.Gateway` - API Gateway на базе **Ocelot**, Балансировка нагрузки (Weighted Random) - `ProjectApp.AppHost` - проект оркестрации на .NET Aspire, который поднимает API, Redis, Redis Commander и клиент. - `ProjectApp.Domain` - доменные сущности, используемые в решении. - `ProjectApp.ServiceDefaults` - общая конфигурация Aspire: телеметрия, service discovery, resilience и health checks. @@ -28,6 +32,7 @@ - .NET Aspire - Redis - Bogus +- Ocelot (API Gateway) - Swagger / OpenAPI - OpenTelemetry - Blazor WebAssembly @@ -94,3 +99,15 @@ curl "http://localhost:5179/api/project?id=1" - API использует распределенный кеш через `IDistributedCache` с хранилищем в Redis. - Ошибки кеша не ломают обработку запроса: API логирует проблему и продолжает генерацию данных. +- Используется 5 реплик API + +## Балансировка нагрузки + +Реализован алгоритм: Weighted Random + +- Каждой реплике задаётся вес: +```json +"Weights": [0.4, 0.25, 0.15, 0.1, 0.1] +``` + +Запрос распределяется случайно, но с учётом весов \ No newline at end of file From fb76c388736166d05f3e9aecebd2894bf0b2e9aa Mon Sep 17 00:00:00 2001 From: Alexey Date: Sat, 16 May 2026 00:08:19 +0400 Subject: [PATCH 14/14] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B,=20=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B5=D1=80=20=D0=B8=20=D0=BE=D0=B1=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BD=D0=BE=D0=B5=20=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D1=89=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/Components/StudentCard.razor | 4 +- CloudDevelopment.sln | 12 ++ ProjectApp.Api/Messaging/IProducerService.cs | 16 +++ .../Messaging/SqsProducerService.cs | 41 ++++++ ProjectApp.Api/Program.cs | 12 +- ProjectApp.Api/ProjectApp.Api.csproj | 3 + .../ProgramProjectGeneratorService.cs | 25 +++- ProjectApp.Api/appsettings.json | 7 +- ProjectApp.AppHost.Tests/IntegrationTest1.cs | 103 +++++++++++++++ .../ProjectApp.AppHost.Tests.csproj | 32 +++++ .../CloudFormation/projectapp-template.yaml | 32 +++++ ProjectApp.AppHost/Program.cs | 46 +++++-- ProjectApp.AppHost/ProjectApp.AppHost.csproj | 9 ++ ProjectApp.AppHost/appsettings.json | 3 + .../Controllers/S3StorageController.cs | 60 +++++++++ .../Messaging/SqsConsumerService.cs | 76 +++++++++++ ProjectApp.FileService/Program.cs | 38 ++++++ .../ProjectApp.FileService.csproj | 26 ++++ .../Properties/launchSettings.json | 38 ++++++ ProjectApp.FileService/Storage/IS3Service.cs | 34 +++++ .../Storage/S3MinioService.cs | 122 ++++++++++++++++++ .../appsettings.Development.json | 8 ++ ProjectApp.FileService/appsettings.json | 12 ++ README.md | 39 ++++-- 24 files changed, 768 insertions(+), 30 deletions(-) create mode 100644 ProjectApp.Api/Messaging/IProducerService.cs create mode 100644 ProjectApp.Api/Messaging/SqsProducerService.cs create mode 100644 ProjectApp.AppHost.Tests/IntegrationTest1.cs create mode 100644 ProjectApp.AppHost.Tests/ProjectApp.AppHost.Tests.csproj create mode 100644 ProjectApp.AppHost/CloudFormation/projectapp-template.yaml create mode 100644 ProjectApp.FileService/Controllers/S3StorageController.cs create mode 100644 ProjectApp.FileService/Messaging/SqsConsumerService.cs create mode 100644 ProjectApp.FileService/Program.cs create mode 100644 ProjectApp.FileService/ProjectApp.FileService.csproj create mode 100644 ProjectApp.FileService/Properties/launchSettings.json create mode 100644 ProjectApp.FileService/Storage/IS3Service.cs create mode 100644 ProjectApp.FileService/Storage/S3MinioService.cs create mode 100644 ProjectApp.FileService/appsettings.Development.json create mode 100644 ProjectApp.FileService/appsettings.json diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 5c64acbe..99e89635 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,8 +4,8 @@ - Номер №2 "«Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы" - Вариант №7 "Weighted Random" + Номер №3 "«Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда" + Вариант №7 "SQS + Minio" Выполнена Земель Алексеем 6511 Ссылка на форк diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index c0c45dc0..1778e72a 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -14,6 +14,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "Proje EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Gateway", "ProjectApp.Gateway\ProjectApp.Gateway.csproj", "{43B10B5B-9127-3B81-BB2C-E6AD6380245A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService", "ProjectApp.FileService\ProjectApp.FileService.csproj", "{82EDC771-0698-C530-6A18-5E978A49F635}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost.Tests", "ProjectApp.AppHost.Tests\ProjectApp.AppHost.Tests.csproj", "{0583897E-A8AD-4184-8802-12D9CC172929}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +48,14 @@ Global {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Debug|Any CPU.Build.0 = Debug|Any CPU {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Release|Any CPU.ActiveCfg = Release|Any CPU {43B10B5B-9127-3B81-BB2C-E6AD6380245A}.Release|Any CPU.Build.0 = Release|Any CPU + {82EDC771-0698-C530-6A18-5E978A49F635}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {82EDC771-0698-C530-6A18-5E978A49F635}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82EDC771-0698-C530-6A18-5E978A49F635}.Release|Any CPU.ActiveCfg = Release|Any CPU + {82EDC771-0698-C530-6A18-5E978A49F635}.Release|Any CPU.Build.0 = Release|Any CPU + {0583897E-A8AD-4184-8802-12D9CC172929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0583897E-A8AD-4184-8802-12D9CC172929}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0583897E-A8AD-4184-8802-12D9CC172929}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0583897E-A8AD-4184-8802-12D9CC172929}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api/Messaging/IProducerService.cs b/ProjectApp.Api/Messaging/IProducerService.cs new file mode 100644 index 00000000..d4dcca2d --- /dev/null +++ b/ProjectApp.Api/Messaging/IProducerService.cs @@ -0,0 +1,16 @@ +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Messaging; + +/// +/// Интерфейс службы для отправки сгенерированного программного проекта в брокер сообщений +/// +public interface IProducerService +{ + /// + /// Отправляет сообщение с программным проектом в брокер + /// + /// Программный проект + /// Токен отмены + public Task SendMessage(ProgramProject project, CancellationToken cancellationToken = default); +} diff --git a/ProjectApp.Api/Messaging/SqsProducerService.cs b/ProjectApp.Api/Messaging/SqsProducerService.cs new file mode 100644 index 00000000..aab8acbd --- /dev/null +++ b/ProjectApp.Api/Messaging/SqsProducerService.cs @@ -0,0 +1,41 @@ +using Amazon.SQS; +using ProjectApp.Domain.Entities; +using System.Net; +using System.Text.Json; + +namespace ProjectApp.Api.Messaging; + +/// +/// Служба для отправки программных проектов в очередь SQS +/// +/// Клиент SQS +/// Конфигурация приложения +/// Опции сериализации JSON +/// Логгер +public class SqsProducerService( + IAmazonSQS client, + IConfiguration configuration, + JsonSerializerOptions jsonOptions, + ILogger logger) : IProducerService +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("SQS queue name was not found in configuration"); + + /// + public async Task SendMessage(ProgramProject project, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(project, jsonOptions); + var response = await client.SendMessageAsync(_queueName, json, cancellationToken); + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Program project {Id} was sent to file service via SQS", project.Id); + else + throw new Exception($"SQS returned {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to send program project {Id} through SQS queue", project.Id); + } + } +} diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs index 1bd52296..be4fc873 100644 --- a/ProjectApp.Api/Program.cs +++ b/ProjectApp.Api/Program.cs @@ -1,11 +1,13 @@ +using Amazon.SQS; +using LocalStack.Client.Extensions; +using ProjectApp.Api.Messaging; +using ProjectApp.Api.Options; using ProjectApp.Api.Services; using ProjectApp.ServiceDefaults; -using ProjectApp.Api.Options; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); - builder.AddServiceDefaults(); builder.AddRedisDistributedCache("cache"); @@ -13,6 +15,10 @@ builder.Services.Configure(builder.Configuration.GetSection("CacheSettings")); builder.Services.AddSingleton(new JsonSerializerOptions(JsonSerializerDefaults.Web)); +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); @@ -50,4 +56,4 @@ app.MapControllers(); app.MapDefaultEndpoints(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj index 2aea6015..4e2f56ff 100644 --- a/ProjectApp.Api/ProjectApp.Api.csproj +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -10,7 +10,10 @@ + + + diff --git a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs index 3d14250c..dd0da58d 100644 --- a/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs +++ b/ProjectApp.Api/Services/ProgramProjectGeneratorService.cs @@ -1,20 +1,28 @@ -using ProjectApp.Domain.Entities; +using ProjectApp.Domain.Entities; using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; using Microsoft.Extensions.Options; using ProjectApp.Api.Options; +using ProjectApp.Api.Messaging; using System.Diagnostics.Metrics; using System.Diagnostics; namespace ProjectApp.Api.Services; /// -/// Сервис получения программного проекта с использованием кэша и генерации при отсутствии данны +/// Сервис получения программного проекта с использованием кэша, генерации при отсутствии данных +/// и отправки сгенерированного проекта в очередь сообщений /// +/// Распределённый кэш +/// Настройки кэша +/// Опции сериализации JSON +/// Служба отправки сообщений в брокер +/// Логгер public class ProgramProjectGeneratorService( IDistributedCache cache, IOptions cacheSettings, JsonSerializerOptions jsonSerializerOptions, + IProducerService producer, ILogger logger) { private static readonly Meter _meter = new("ProjectApp.Api"); @@ -23,7 +31,8 @@ public class ProgramProjectGeneratorService( private readonly int _expirationMinutes = cacheSettings.Value.ExpirationMinutes; /// - /// Возвращает проект по идентификатору из кэша или генерирует новый и сохраняет его в кэш + /// Возвращает проект по идентификатору из кэша или генерирует новый, сохраняет его в кэш + /// и отправляет сообщение в очередь /// /// Идентификатор проекта /// Токен отмены @@ -40,12 +49,12 @@ public async Task GetByIdAsync(int id, CancellationToken cancell if (!string.IsNullOrEmpty(cachedData)) { - var cashedProject = JsonSerializer.Deserialize(cachedData, jsonSerializerOptions); + var cachedProject = JsonSerializer.Deserialize(cachedData, jsonSerializerOptions); - if (cashedProject != null) + if (cachedProject != null) { logger.LogInformation("Software project {Id} found in cache", id); - return cashedProject; + return cachedProject; } logger.LogWarning("Project {Id} was found in cache but could not be deserialized. Generating a new one", id); @@ -93,6 +102,8 @@ await cache.SetStringAsync( logger.LogWarning(ex, "Failed to save project {Id} to cache (error ignored)", id); } + await producer.SendMessage(project, cancellationToken); + return project; } -} \ No newline at end of file +} diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json index 6a7830b3..bf0fb18e 100644 --- a/ProjectApp.Api/appsettings.json +++ b/ProjectApp.Api/appsettings.json @@ -10,6 +10,9 @@ "ExpirationMinutes": 10 }, "AllowedOrigins": [ - "https://localhost:7282" - ] + "https://localhost:7282" + ], + "LocalStack": { + "UseLocalStack": true + } } diff --git a/ProjectApp.AppHost.Tests/IntegrationTest1.cs b/ProjectApp.AppHost.Tests/IntegrationTest1.cs new file mode 100644 index 00000000..c3d4b2cb --- /dev/null +++ b/ProjectApp.AppHost.Tests/IntegrationTest1.cs @@ -0,0 +1,103 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit.Abstractions; + +namespace ProjectApp.AppHost.Tests; + +/// +/// Интеграционные тесты +/// +/// Служба журналирования юнит-тестов +public class IntegrationTest1(ITestOutputHelper output) : IAsyncLifetime +{ + private DistributedApplication? _app; + + /// + public async Task InitializeAsync() + { + var cancellationToken = CancellationToken.None; + IDistributedApplicationTestingBuilder 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); + }); + _app = await builder.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + } + + /// + /// Проверяет, что вызов гейтвея: + /// + /// в ответ возвращает сгенерированный программный проект + /// сериализует его в объектное хранилище Minio под ключом project_{id}.json + /// содержимое в API-ответе и в S3 идентично + /// + /// + [Fact] + public async Task TestPipeline() + { + var cancellationToken = CancellationToken.None; + + var id = Random.Shared.Next(1, 100); + using var gatewayClient = _app!.CreateHttpClient("projectapp-gateway"); + using var gatewayResponse = await gatewayClient.GetAsync($"/api/project?id={id}", cancellationToken); + var apiProject = JsonNode.Parse(await gatewayResponse.Content.ReadAsStringAsync(cancellationToken)); + + await Task.Delay(5000, cancellationToken); + using var fileServiceClient = _app!.CreateHttpClient("projectapp-fileservice"); + using var listResponse = await fileServiceClient.GetAsync("/api/s3", cancellationToken); + var projectList = JsonSerializer.Deserialize>( + await listResponse.Content.ReadAsStringAsync(cancellationToken)); + using var s3Response = await fileServiceClient.GetAsync($"/api/s3/project_{id}.json", cancellationToken); + var s3Project = JsonNode.Parse(await s3Response.Content.ReadAsStringAsync(cancellationToken)); + + Assert.NotNull(projectList); + Assert.Contains($"project_{id}.json", projectList); + Assert.NotNull(apiProject); + Assert.NotNull(s3Project); + Assert.Equal(id, s3Project!["id"]!.GetValue()); + Assert.Equal(apiProject!.ToJsonString(), s3Project!.ToJsonString()); + } + + /// + /// Проверяет идемпотентность гейтвея по идентификатору: + /// два последовательных запроса с одним и тем же id должны вернуть + /// один и тот же объект (значение берётся из Redis-кэша) + /// + [Fact] + public async Task TestIdempotency() + { + var cancellationToken = CancellationToken.None; + + var id = Random.Shared.Next(100, 200); + using var gatewayClient = _app!.CreateHttpClient("projectapp-gateway"); + + using var firstResponse = await gatewayClient.GetAsync($"/api/project?id={id}", cancellationToken); + var firstProject = JsonNode.Parse(await firstResponse.Content.ReadAsStringAsync(cancellationToken)); + + using var secondResponse = await gatewayClient.GetAsync($"/api/project?id={id}", cancellationToken); + var secondProject = JsonNode.Parse(await secondResponse.Content.ReadAsStringAsync(cancellationToken)); + + Assert.NotNull(firstProject); + Assert.NotNull(secondProject); + Assert.Equal(id, firstProject!["id"]!.GetValue()); + Assert.Equal(id, secondProject!["id"]!.GetValue()); + Assert.Equal(firstProject!.ToJsonString(), secondProject!.ToJsonString()); + } + + /// + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } +} diff --git a/ProjectApp.AppHost.Tests/ProjectApp.AppHost.Tests.csproj b/ProjectApp.AppHost.Tests/ProjectApp.AppHost.Tests.csproj new file mode 100644 index 00000000..ba4fe61b --- /dev/null +++ b/ProjectApp.AppHost.Tests/ProjectApp.AppHost.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ProjectApp.AppHost/CloudFormation/projectapp-template.yaml b/ProjectApp.AppHost/CloudFormation/projectapp-template.yaml new file mode 100644 index 00000000..ad26ac5d --- /dev/null +++ b/ProjectApp.AppHost/CloudFormation/projectapp-template.yaml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'CloudFormation template for ProjectApp (SQS + Minio variant)' + +Parameters: + QueueName: + Type: String + Description: Name for the SQS queue + Default: 'projectapp-queue' + +Resources: + ProjectAppQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + VisibilityTimeout: 30 + MessageRetentionPeriod: 345600 + DelaySeconds: 0 + ReceiveMessageWaitTimeSeconds: 0 + Tags: + - Key: Name + Value: !Ref QueueName + - Key: Environment + Value: ProjectApp + +Outputs: + SQSQueueName: + Description: Name of the SQS queue + Value: !GetAtt ProjectAppQueue.QueueName + + SQSQueueArn: + Description: ARN of the SQS queue + Value: !GetAtt ProjectAppQueue.Arn diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs index 57318832..a133973e 100644 --- a/ProjectApp.AppHost/Program.cs +++ b/ProjectApp.AppHost/Program.cs @@ -1,26 +1,47 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; + var builder = DistributedApplication.CreateBuilder(args); var redis = builder.AddRedis("cache") .WithRedisCommander(); +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localstack = builder + .AddLocalStack("projectapp-localstack", awsConfig: awsConfig, configureContainer: container => + { + container.Lifetime = ContainerLifetime.Session; + container.DebugLevel = 1; + container.LogLevel = LocalStackLogLevel.Debug; + container.Port = 4566; + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); + }); + +var awsResources = builder + .AddAWSCloudFormationTemplate("resources", "CloudFormation/projectapp-template.yaml", "projectapp") + .WithReference(awsConfig); + +var minio = builder.AddMinioContainer("projectapp-minio"); + const int replicaCount = 5; var apiReplicas = new List>(replicaCount); +var gateway = builder.AddProject("projectapp-gateway") + .WithExternalHttpEndpoints(); + for (var i = 0; i < replicaCount; i++) { var replica = builder.AddProject($"projectapp-api-{i + 1}") .WithReference(redis) - .WaitFor(redis); + .WithReference(awsResources) + .WaitFor(redis) + .WaitFor(awsResources); apiReplicas.Add(replica); -} - -var gateway = builder.AddProject("projectapp-gateway") - .WithExternalHttpEndpoints(); -for (var i = 0; i < apiReplicas.Count; i++) -{ - var replica = apiReplicas[i]; gateway = gateway .WithReference(replica) .WithEnvironment($"ApiReplicas__{i}", replica.GetEndpoint("https")) @@ -32,4 +53,13 @@ .WithEnvironment("BaseAddress", gateway.GetEndpoint("https")) .WaitFor(gateway); +builder.AddProject("projectapp-fileservice") + .WithReference(awsResources) + .WithReference(minio) + .WithEnvironment("AWS__Resources__MinioBucketName", "projectapp-bucket") + .WaitFor(awsResources) + .WaitFor(minio); + +builder.UseLocalStack(localstack); + builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj index e38d6aa8..f2a3f774 100644 --- a/ProjectApp.AppHost/ProjectApp.AppHost.csproj +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -14,12 +14,21 @@ + + + + + + Always + + + diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json index 31c092aa..a6b256bb 100644 --- a/ProjectApp.AppHost/appsettings.json +++ b/ProjectApp.AppHost/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } + }, + "LocalStack": { + "UseLocalStack": true } } diff --git a/ProjectApp.FileService/Controllers/S3StorageController.cs b/ProjectApp.FileService/Controllers/S3StorageController.cs new file mode 100644 index 00000000..d5db7cff --- /dev/null +++ b/ProjectApp.FileService/Controllers/S3StorageController.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Mvc; +using ProjectApp.FileService.Storage; +using System.Text.Json.Nodes; + +namespace ProjectApp.FileService.Controllers; + +/// +/// Контроллер для взаимодействия с объектным хранилищем Minio +/// +/// Служба для работы с S3/Minio +/// Логгер +[ApiController] +[Route("api/s3")] +public class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Возвращает список ключей файлов, хранящихся в бакете + /// + /// Список ключей файлов + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task>> ListFiles() + { + logger.LogInformation("Listing files in bucket"); + try + { + var list = await s3Service.GetFileList(); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to list files"); + return BadRequest(ex.Message); + } + } + + /// + /// Возвращает содержимое файла из объектного хранилища по ключу + /// + /// Ключ файла в бакете + /// JSON содержимое файла + [HttpGet("{key}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetFile(string key) + { + logger.LogInformation("Downloading {Key}", key); + try + { + var node = await s3Service.DownloadFile(key); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to download {Key}", key); + return NotFound(ex.Message); + } + } +} diff --git a/ProjectApp.FileService/Messaging/SqsConsumerService.cs b/ProjectApp.FileService/Messaging/SqsConsumerService.cs new file mode 100644 index 00000000..d8583fe4 --- /dev/null +++ b/ProjectApp.FileService/Messaging/SqsConsumerService.cs @@ -0,0 +1,76 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using ProjectApp.FileService.Storage; +using System.Text.Json.Nodes; + +namespace ProjectApp.FileService.Messaging; + +/// +/// Фоновая служба для приёма сообщений из очереди SQS и загрузки тел сообщений в Minio +/// +/// Клиент SQS +/// Фабрика областей действия для разрешения scoped-зависимостей +/// Конфигурация приложения +/// Логгер +public class SqsConsumerService( + IAmazonSQS sqsClient, + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("SQS queue name was not found in configuration"); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("SQS consumer service started for queue {Queue}", _queueName); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var response = await sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _queueName, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 5 + }, stoppingToken); + + if (response?.Messages == null || response.Messages.Count == 0) + continue; + + logger.LogInformation("Received {Count} messages from {Queue}", response.Messages.Count, _queueName); + + foreach (var message in response.Messages) + { + try + { + logger.LogInformation("Processing message {MessageId}", message.MessageId); + + var node = JsonNode.Parse(message.Body) + ?? throw new InvalidOperationException("Message body is not a valid JSON"); + + using var scope = scopeFactory.CreateScope(); + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.UploadFile(node); + + await sqsClient.DeleteMessageAsync(_queueName, message.ReceiptHandle, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing message {MessageId}", message.MessageId); + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled error while polling SQS queue {Queue}", _queueName); + } + } + } +} diff --git a/ProjectApp.FileService/Program.cs b/ProjectApp.FileService/Program.cs new file mode 100644 index 00000000..d0d7b3bc --- /dev/null +++ b/ProjectApp.FileService/Program.cs @@ -0,0 +1,38 @@ +using Amazon.SQS; +using LocalStack.Client.Extensions; +using ProjectApp.FileService.Messaging; +using ProjectApp.FileService.Storage; +using ProjectApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddHostedService(); + +builder.AddMinioClient("projectapp-minio"); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +using var scope = app.Services.CreateScope(); + +var s3 = scope.ServiceProvider.GetRequiredService(); +await s3.EnsureBucketExists(); + + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapDefaultEndpoints(); +app.MapControllers(); + +app.Run(); diff --git a/ProjectApp.FileService/ProjectApp.FileService.csproj b/ProjectApp.FileService/ProjectApp.FileService.csproj new file mode 100644 index 00000000..0717ed67 --- /dev/null +++ b/ProjectApp.FileService/ProjectApp.FileService.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + diff --git a/ProjectApp.FileService/Properties/launchSettings.json b/ProjectApp.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..92a56878 --- /dev/null +++ b/ProjectApp.FileService/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:26512", + "sslPort": 44393 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7263;http://localhost:5054", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.FileService/Storage/IS3Service.cs b/ProjectApp.FileService/Storage/IS3Service.cs new file mode 100644 index 00000000..713dfe16 --- /dev/null +++ b/ProjectApp.FileService/Storage/IS3Service.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; + +namespace ProjectApp.FileService.Storage; + +/// +/// Интерфейс службы для манипуляции файлами в объектном хранилище Minio +/// +public interface IS3Service +{ + /// + /// Создаёт бакет, если он ещё не существует + /// + public Task EnsureBucketExists(); + + /// + /// Загружает сериализованный программный проект в объектное хранилище + /// + /// JSON-узел с описанием программного проекта + /// Признак успешной загрузки + public Task UploadFile(JsonNode fileData); + + /// + /// Возвращает список ключей всех файлов из бакета + /// + /// Список ключей файлов + public Task> GetFileList(); + + /// + /// Скачивает файл из объектного хранилища и возвращает его JSON-представление + /// + /// Ключ файла + /// JSON-узел с содержимым файла + public Task DownloadFile(string key); +} diff --git a/ProjectApp.FileService/Storage/S3MinioService.cs b/ProjectApp.FileService/Storage/S3MinioService.cs new file mode 100644 index 00000000..31fd4ca8 --- /dev/null +++ b/ProjectApp.FileService/Storage/S3MinioService.cs @@ -0,0 +1,122 @@ +using Minio; +using Minio.DataModel.Args; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace ProjectApp.FileService.Storage; + +/// +/// Служба для манипуляции файлами программных проектов в объектном хранилище Minio +/// +/// Клиент Minio +/// Конфигурация приложения +/// Логгер +public class S3MinioService( + IMinioClient client, + IConfiguration configuration, + ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"] + ?? throw new KeyNotFoundException("Minio bucket name was not found in configuration"); + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {Bucket} exists", _bucketName); + try + { + var existsArgs = new BucketExistsArgs().WithBucket(_bucketName); + var exists = await client.BucketExistsAsync(existsArgs); + if (!exists) + { + logger.LogInformation("Creating {Bucket}", _bucketName); + var createArgs = new MakeBucketArgs().WithBucket(_bucketName); + await client.MakeBucketAsync(createArgs); + return; + } + logger.LogInformation("{Bucket} already exists", _bucketName); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception occurred during {Bucket} existence check", _bucketName); + throw; + } + } + + /// + public async Task UploadFile(JsonNode fileData) + { + var id = fileData["id"]?.GetValue() + ?? throw new ArgumentException("Passed JSON has invalid structure (id missing)"); + + var bytes = Encoding.UTF8.GetBytes(fileData.ToJsonString()); + using var stream = new MemoryStream(bytes); + stream.Seek(0, SeekOrigin.Begin); + + var objectKey = $"project_{id}.json"; + logger.LogInformation("Began uploading program project {Id} as {Key} into {Bucket}", id, objectKey, _bucketName); + + var args = new PutObjectArgs() + .WithBucket(_bucketName) + .WithObject(objectKey) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithContentType("application/json"); + + var response = await client.PutObjectAsync(args); + + if (response.ResponseStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload {Key}: {Code}", objectKey, response.ResponseStatusCode); + return false; + } + logger.LogInformation("Finished uploading program project {Id} into {Bucket}", id, _bucketName); + return true; + } + + /// + public async Task> GetFileList() + { + var list = new List(); + var args = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithPrefix("") + .WithRecursive(true); + + logger.LogInformation("Began listing files in {Bucket}", _bucketName); + var items = client.ListObjectsEnumAsync(args); + await foreach (var item in items) + list.Add(item.Key); + return list; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Began downloading {Key} from {Bucket}", key, _bucketName); + try + { + var memoryStream = new MemoryStream(); + + var args = new GetObjectArgs() + .WithBucket(_bucketName) + .WithObject(key) + .WithCallbackStream(async (stream, ct) => + { + await stream.CopyToAsync(memoryStream, ct); + memoryStream.Seek(0, SeekOrigin.Begin); + }); + + var response = await client.GetObjectAsync(args) ?? throw new InvalidOperationException($"Error occurred downloading {key}: response is null"); + using var reader = new StreamReader(memoryStream, Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd()) + ?? throw new InvalidOperationException("Downloaded document is not a valid JSON"); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during downloading {Key}", key); + throw; + } + } +} diff --git a/ProjectApp.FileService/appsettings.Development.json b/ProjectApp.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.FileService/appsettings.json b/ProjectApp.FileService/appsettings.json new file mode 100644 index 00000000..a3c034a8 --- /dev/null +++ b/ProjectApp.FileService/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "LocalStack": { + "UseLocalStack": true + } +} diff --git a/README.md b/README.md index 65b4e918..0d36768e 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,22 @@ - Генерирует тестовые данные о программных проектах с помощью Bogus. - Возвращает данные проекта по `id` через HTTP API. - Кеширует результаты в Redis для повторных запросов. +- После генерации отправляет данные проекта в файловый сервис через SQS. +- Файловый сервис сериализует JSON в файл `project_{id}.json` и сохраняет его в Minio. - Использует API Gateway (Ocelot) для маршрутизации запросов. - Балансирует нагрузку между несколькими экземплярами API с помощью алгоритма **Weighted Random**. -- Поднимает всю инфраструктуру через .NET Aspire (оркестрация сервисов). +- Поднимает всю инфраструктуру через .NET Aspire. - Предоставляет Swagger для тестирования API. - Публикует health-check и телеметрию через OpenTelemetry. +- Имеет интеграционные тесты, которые проверяют всю цепочку работы backend-сервисов вместе. ## Структура решения -- `ProjectApp.Api` - ASP.NET Core Web API с логикой генерации, Redis-кешем, Swagger, CORS и контроллерами. -- `ProjectApp.Gateway` - API Gateway на базе **Ocelot**, Балансировка нагрузки (Weighted Random) -- `ProjectApp.AppHost` - проект оркестрации на .NET Aspire, который поднимает API, Redis, Redis Commander и клиент. +- `ProjectApp.Api` - ASP.NET Core Web API с генерацией данных, Redis-кешем и отправкой сообщений в SQS. +- `ProjectApp.Gateway` - API Gateway на базе **Ocelot** с балансировкой нагрузки Weighted Random. +- `ProjectApp.FileService` - сервис для приема сообщений из SQS, сериализации данных в JSON и работы с Minio. +- `ProjectApp.AppHost` - проект оркестрации на .NET Aspire, который поднимает API, Gateway, FileService, Redis, LocalStack и Minio. +- `ProjectApp.AppHost.Tests` - интеграционные тесты для проверки полного backend-пайплайна. - `ProjectApp.Domain` - доменные сущности, используемые в решении. - `ProjectApp.ServiceDefaults` - общая конфигурация Aspire: телеметрия, service discovery, resilience и health checks. - `Client.Wasm` - клиент на Blazor WebAssembly для взаимодействия с API. @@ -31,10 +36,14 @@ - ASP.NET Core Web API - .NET Aspire - Redis +- Amazon SQS +- Minio +- LocalStack - Bogus - Ocelot (API Gateway) - Swagger / OpenAPI - OpenTelemetry +- xUnit - Blazor WebAssembly ## Основная сущность @@ -54,11 +63,14 @@ API работает с моделью `ProgramProject`: ## Как это работает -1. Клиент запрос в API с идентификатором проекта. +1. Клиент отправляет запрос в API с идентификатором проекта. 2. API пытается прочитать данные из Redis по ключу формата `software-project-{id}`. 3. Если значение найдено в кеше, возвращается сохраненный объект. 4. Если значения нет или Redis недоступен, создается новый `ProgramProject` с помощью Bogus. -5. Сгенерированный объект сохраняется в Redis на заданное время жизни и возвращается клиенту. +5. Сгенерированный объект сохраняется в Redis на заданное время жизни. +6. После этого API сериализует проект и отправляет его в очередь SQS. +7. Файловый сервис получает сообщение из очереди, превращает JSON в файл и сохраняет его в Minio под ключом `project_{id}.json`. +8. Интеграционные тесты поднимают весь стек через AppHost и проверяют, что объект появился в Minio и совпадает с ответом API. По умолчанию время жизни кеша составляет 10 минут и задается в `ProjectApp.Api/appsettings.json`. @@ -95,11 +107,22 @@ curl "http://localhost:5179/api/project?id=1" Если вызвать эндпоинт несколько раз с одним и тем же `id` в пределах времени жизни кеша, API должно вернуть один и тот же объект из Redis. +## API файлового сервиса + +Основные эндпоинты: + +```http +GET /api/s3 +GET /api/s3/project_1.json +``` + +Первый эндпоинт возвращает список файлов в бакете, второй - содержимое выбранного JSON-файла из Minio. + ## Особенности реализации - API использует распределенный кеш через `IDistributedCache` с хранилищем в Redis. - Ошибки кеша не ломают обработку запроса: API логирует проблему и продолжает генерацию данных. -- Используется 5 реплик API +- Используется 5 реплик API. ## Балансировка нагрузки @@ -110,4 +133,4 @@ curl "http://localhost:5179/api/project?id=1" "Weights": [0.4, 0.25, 0.15, 0.1, 0.1] ``` -Запрос распределяется случайно, но с учётом весов \ No newline at end of file +Запрос распределяется случайно, но с учётом весов.