From 3e47d822a6ea2a9c550e253764d1fca2f6addcd8 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Sat, 14 Mar 2026 12:31:53 +0300 Subject: [PATCH 01/12] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=A0?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D1=81=D0=B0=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=20inf?= =?UTF-8?q?rastructure=20=D1=81=D0=BB=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Client.Wasm/wwwroot/appsettings.json | 9 +-- CloudDevelopment.sln | 16 ++++- Dockerfile | 13 ++++ Dockerfile.client | 15 +++++ .../GeneratorService.Tests.csproj | 23 +++++++ .../MedicalPatientGeneratorTests.cs | 63 +++++++++++++++++++ GeneratorService/GeneratorService.csproj | 19 ++++++ .../Generators/MedicalPatientGenerator.cs | 36 +++++++++++ GeneratorService/Models/MedicalPatient.cs | 15 +++++ GeneratorService/Program.cs | 59 +++++++++++++++++ .../Properties/launchSettings.json | 12 ++++ GeneratorService/Services/PatientService.cs | 46 ++++++++++++++ GeneratorService/appsettings.json | 15 +++++ docker-compose.yml | 32 ++++++++++ 14 files changed, 363 insertions(+), 10 deletions(-) create mode 100644 Dockerfile create mode 100644 Dockerfile.client create mode 100644 GeneratorService.Tests/GeneratorService.Tests.csproj create mode 100644 GeneratorService.Tests/MedicalPatientGeneratorTests.cs create mode 100644 GeneratorService/GeneratorService.csproj create mode 100644 GeneratorService/Generators/MedicalPatientGenerator.cs create mode 100644 GeneratorService/Models/MedicalPatient.cs create mode 100644 GeneratorService/Program.cs create mode 100644 GeneratorService/Properties/launchSettings.json create mode 100644 GeneratorService/Services/PatientService.cs create mode 100644 GeneratorService/appsettings.json create mode 100644 docker-compose.yml diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..bd0f76cb 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -1,10 +1,3 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:8080/patient" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..cd3a85f8 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11519.219 insiders 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}") = "GeneratorService", "GeneratorService\GeneratorService.csproj", "{139BD442-54A6-9109-CF9A-53DA218D46F2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService.Tests", "GeneratorService.Tests\GeneratorService.Tests.csproj", "{EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +19,14 @@ 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 + {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {139BD442-54A6-9109-CF9A-53DA218D46F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {139BD442-54A6-9109-CF9A-53DA218D46F2}.Release|Any CPU.Build.0 = Release|Any CPU + {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..97ad367d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY GeneratorService/GeneratorService.csproj GeneratorService/ +RUN dotnet restore GeneratorService/GeneratorService.csproj + +COPY GeneratorService/ GeneratorService/ +RUN dotnet publish GeneratorService/GeneratorService.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "GeneratorService.dll"] diff --git a/Dockerfile.client b/Dockerfile.client new file mode 100644 index 00000000..1a370ba7 --- /dev/null +++ b/Dockerfile.client @@ -0,0 +1,15 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY Client.Wasm/Client.Wasm.csproj Client.Wasm/ +RUN dotnet restore Client.Wasm/Client.Wasm.csproj + +COPY Client.Wasm/ Client.Wasm/ +RUN dotnet publish Client.Wasm/Client.Wasm.csproj -c Release -o /app/publish + +FROM node:20-alpine +RUN npm install -g http-server +COPY --from=build /app/publish/wwwroot /app +WORKDIR /app +EXPOSE 80 +CMD ["http-server", ".", "-p", "80", "--cors"] \ No newline at end of file diff --git a/GeneratorService.Tests/GeneratorService.Tests.csproj b/GeneratorService.Tests/GeneratorService.Tests.csproj new file mode 100644 index 00000000..c9bc33ca --- /dev/null +++ b/GeneratorService.Tests/GeneratorService.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/GeneratorService.Tests/MedicalPatientGeneratorTests.cs b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs new file mode 100644 index 00000000..74d07981 --- /dev/null +++ b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs @@ -0,0 +1,63 @@ +using GeneratorService.Generators; +using Xunit; + +namespace GeneratorService.Tests; + +public sealed class MedicalPatientGeneratorTests +{ + public static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today); + + // Генерируем 300 штук, чтобы проверить граничные условия статистически + public static IEnumerable Patients() => + Enumerable.Range(1, 300).Select(i => new object[] { MedicalPatientGenerator.Generate(i) }); + + [Theory] + [MemberData(nameof(Patients))] + public void Id_MatchesRequested(GeneratorService.Models.MedicalPatient p) + => Assert.True(p.Id > 0); + + [Theory] + [MemberData(nameof(Patients))] + public void FullName_HasThreeParts(GeneratorService.Models.MedicalPatient p) + => Assert.Equal(3, p.FullName.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length); + + [Theory] + [MemberData(nameof(Patients))] + public void BirthDate_NotInFuture(GeneratorService.Models.MedicalPatient p) + => Assert.True(p.BirthDate <= Today); + + [Theory] + [MemberData(nameof(Patients))] + public void Height_InReasonableBounds(GeneratorService.Models.MedicalPatient p) + => Assert.InRange(p.Height, 50.0, 220.0); + + [Theory] + [MemberData(nameof(Patients))] + public void Height_RoundedToTwoDecimals(GeneratorService.Models.MedicalPatient p) + => Assert.Equal(p.Height, Math.Round(p.Height, 2)); + + [Theory] + [MemberData(nameof(Patients))] + public void Weight_InReasonableBounds(GeneratorService.Models.MedicalPatient p) + => Assert.InRange(p.Weight, 2.5, 200.0); + + [Theory] + [MemberData(nameof(Patients))] + public void Weight_RoundedToTwoDecimals(GeneratorService.Models.MedicalPatient p) + => Assert.Equal(p.Weight, Math.Round(p.Weight, 2)); + + [Theory] + [MemberData(nameof(Patients))] + public void BloodGroup_Between1And4(GeneratorService.Models.MedicalPatient p) + => Assert.InRange(p.BloodGroup, 1, 4); + + [Theory] + [MemberData(nameof(Patients))] + public void LastExamination_NotBeforeBirthDate(GeneratorService.Models.MedicalPatient p) + => Assert.True(p.LastExaminationDate >= p.BirthDate); + + [Theory] + [MemberData(nameof(Patients))] + public void LastExamination_NotInFuture(GeneratorService.Models.MedicalPatient p) + => Assert.True(p.LastExaminationDate <= Today); +} diff --git a/GeneratorService/GeneratorService.csproj b/GeneratorService/GeneratorService.csproj new file mode 100644 index 00000000..d772c9cd --- /dev/null +++ b/GeneratorService/GeneratorService.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/GeneratorService/Generators/MedicalPatientGenerator.cs b/GeneratorService/Generators/MedicalPatientGenerator.cs new file mode 100644 index 00000000..c301a014 --- /dev/null +++ b/GeneratorService/Generators/MedicalPatientGenerator.cs @@ -0,0 +1,36 @@ +using Bogus; +using GeneratorService.Models; + +namespace GeneratorService.Generators; + +public static class MedicalPatientGenerator +{ + public static MedicalPatient Generate(int id) + { + var today = DateOnly.FromDateTime(DateTime.Today); + + var faker = new Faker("ru") + .RuleFor(p => p.Id, _ => id) + .RuleFor(p => p.FullName, f => + $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.FirstName()}") + .RuleFor(p => p.Address, f => f.Address.FullAddress()) + .RuleFor(p => p.BirthDate, f => + { + var minDate = today.AddYears(-100); + var totalDays = (int)(today.ToDateTime(TimeOnly.MinValue) - minDate.ToDateTime(TimeOnly.MinValue)).TotalDays; + return minDate.AddDays(f.Random.Int(0, totalDays)); + }) + .RuleFor(p => p.Height, f => Math.Round(f.Random.Double(50.0, 220.0), 2)) + .RuleFor(p => p.Weight, f => Math.Round(f.Random.Double(2.5, 200.0), 2)) + .RuleFor(p => p.BloodGroup, f => f.Random.Int(1, 4)) + .RuleFor(p => p.RhFactor, f => f.Random.Bool()) + .RuleFor(p => p.LastExaminationDate, (f, p) => + { + var totalDays = (int)(today.ToDateTime(TimeOnly.MinValue) - p.BirthDate.ToDateTime(TimeOnly.MinValue)).TotalDays; + return totalDays <= 0 ? today : p.BirthDate.AddDays(f.Random.Int(0, totalDays)); + }) + .RuleFor(p => p.IsVaccinated, f => f.Random.Bool()); + + return faker.Generate(); + } +} diff --git a/GeneratorService/Models/MedicalPatient.cs b/GeneratorService/Models/MedicalPatient.cs new file mode 100644 index 00000000..244a8f41 --- /dev/null +++ b/GeneratorService/Models/MedicalPatient.cs @@ -0,0 +1,15 @@ +namespace GeneratorService.Models; + +public sealed class MedicalPatient +{ + public int Id { get; init; } + public string FullName { get; init; } = string.Empty; + public string Address { get; init; } = string.Empty; + public DateOnly BirthDate { get; init; } + public double Height { get; init; } + public double Weight { get; init; } + public int BloodGroup { get; init; } + public bool RhFactor { get; init; } + public DateOnly LastExaminationDate { get; init; } + public bool IsVaccinated { get; init; } +} diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs new file mode 100644 index 00000000..e3b744e9 --- /dev/null +++ b/GeneratorService/Program.cs @@ -0,0 +1,59 @@ +using GeneratorService.Models; +using GeneratorService.Services; +using Serilog; +using Serilog.Events; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithEnvironmentName() + .Enrich.WithThreadId() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] tid={ThreadId} | {Message:lj}{NewLine}{Exception}") + .CreateBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Host.UseSerilog((ctx, _, cfg) => cfg + .ReadFrom.Configuration(ctx.Configuration) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .Enrich.WithEnvironmentName() + .Enrich.WithThreadId() + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] tid={ThreadId} | {Message:lj}{NewLine}{Exception}")); + + var redisConnection = builder.Configuration.GetConnectionString("redis") + ?? throw new InvalidOperationException("Не задана строка подключения 'redis'"); + + builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConnection); + builder.Services.AddScoped(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(o => + o.SwaggerDoc("v1", new() { Title = "GeneratorService — Medical Patient", Version = "v1" })); + + var app = builder.Build(); + + app.UseSerilogRequestLogging(); + app.UseSwagger(); + app.UseSwaggerUI(); + + // GET /patient?id=42 + app.MapGet("/patient", async (int id, PatientService svc, CancellationToken ct) => + id <= 0 + ? Results.BadRequest("id must be > 0") + : Results.Ok(await svc.GetAsync(id, ct))) + .WithName("GetPatient") + .Produces() + .ProducesProblem(400); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/GeneratorService/Properties/launchSettings.json b/GeneratorService/Properties/launchSettings.json new file mode 100644 index 00000000..f084666c --- /dev/null +++ b/GeneratorService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "GeneratorService": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:3342;http://localhost:3343" + } + } +} \ No newline at end of file diff --git a/GeneratorService/Services/PatientService.cs b/GeneratorService/Services/PatientService.cs new file mode 100644 index 00000000..e26acd5b --- /dev/null +++ b/GeneratorService/Services/PatientService.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using GeneratorService.Generators; +using GeneratorService.Models; +using Microsoft.Extensions.Caching.Distributed; + +namespace GeneratorService.Services; + +public sealed class PatientService +{ + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + + private static readonly DistributedCacheEntryOptions CacheOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) + }; + + public PatientService(IDistributedCache cache, ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task GetAsync(int id, CancellationToken ct = default) + { + var key = $"patient:{id}"; + + var cached = await _cache.GetStringAsync(key, ct); + if (cached is not null) + { + _logger.LogInformation("Cache HIT | id={Id}", id); + return JsonSerializer.Deserialize(cached)!; + } + + _logger.LogInformation("Cache MISS | id={Id} — generating", id); + + var patient = MedicalPatientGenerator.Generate(id); + await _cache.SetStringAsync(key, JsonSerializer.Serialize(patient), CacheOptions, ct); + + _logger.LogInformation( + "Generated | id={Id} Name={FullName} BirthDate={BirthDate} BloodGroup={BloodGroup} RhFactor={RhFactor}", + patient.Id, patient.FullName, patient.BirthDate, patient.BloodGroup, patient.RhFactor); + + return patient; + } +} diff --git a/GeneratorService/appsettings.json b/GeneratorService/appsettings.json new file mode 100644 index 00000000..eb39122e --- /dev/null +++ b/GeneratorService/appsettings.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "redis": "redis:6379" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + } + }, + "AllowedHosts": "*" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..ba245f9b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + generator-service: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - ASPNETCORE_URLS=http://+:8080 + - ConnectionStrings__redis=redis:6379 + depends_on: + redis: + condition: service_healthy + + client: + build: + context: . + dockerfile: Dockerfile.client + ports: + - "3500:80" + depends_on: + - generator-service From e3392b640e470de52c2c49e3cc79e062d0269040 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Sat, 14 Mar 2026 12:39:48 +0300 Subject: [PATCH 02/12] =?UTF-8?q?hotfix:=20-=20=D1=80=D0=B0=D0=B7=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B8=D0=BB=20id=20=3D=3D=200=20-=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=D0=BA=D0=B8=20cors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GeneratorService/Program.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index e3b744e9..23d794e9 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -31,6 +31,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new() { Title = "GeneratorService — Medical Patient", Version = "v1" })); + builder.Services.AddCors(); var app = builder.Build(); @@ -40,13 +41,15 @@ // GET /patient?id=42 app.MapGet("/patient", async (int id, PatientService svc, CancellationToken ct) => - id <= 0 - ? Results.BadRequest("id must be > 0") + id < 0 + ? Results.BadRequest("id must be positive") : Results.Ok(await svc.GetAsync(id, ct))) .WithName("GetPatient") .Produces() .ProducesProblem(400); + app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + app.Run(); } catch (Exception ex) From 756ff79dc2664df1c9e004b1b883fc06ba6d07da Mon Sep 17 00:00:00 2001 From: ichumakov Date: Sat, 14 Mar 2026 12:40:45 +0300 Subject: [PATCH 03/12] upda readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index dcaa5eb7..91e15b0c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ +# Запуск проекта + +```bash +docker compose up +``` + # Современные технологии разработки программного обеспечения [Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) From 97d99500106f60518a25d0572acd5a5e3bc521e1 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Sat, 14 Mar 2026 13:31:35 +0300 Subject: [PATCH 04/12] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B5=D1=80?= =?UTF-8?q?=D0=B7=D0=B4=20=D0=BD=D0=B0=20aspire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AppHost/AppHost.csproj | 21 ++++++++++++ AppHost/Program.cs | 10 ++++++ AppHost/Properties/launchSettings.json | 16 ++++++++++ Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 12 +++++-- Dockerfile | 13 -------- Dockerfile.client | 15 --------- .../MedicalPatientGeneratorTests.cs | 1 - GeneratorService/GeneratorService.csproj | 3 +- GeneratorService/Program.cs | 22 +++++++------ .../Properties/launchSettings.json | 2 +- GeneratorService/appsettings.json | 3 -- docker-compose.yml | 32 ------------------- global.json | 6 ++++ 14 files changed, 79 insertions(+), 79 deletions(-) create mode 100644 AppHost/AppHost.csproj create mode 100644 AppHost/Program.cs create mode 100644 AppHost/Properties/launchSettings.json delete mode 100644 Dockerfile delete mode 100644 Dockerfile.client delete mode 100644 docker-compose.yml create mode 100644 global.json diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj new file mode 100644 index 00000000..32d7febc --- /dev/null +++ b/AppHost/AppHost.csproj @@ -0,0 +1,21 @@ + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/AppHost/Program.cs b/AppHost/Program.cs new file mode 100644 index 00000000..5b68a63f --- /dev/null +++ b/AppHost/Program.cs @@ -0,0 +1,10 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("redis"); + +builder.AddProject("generator-service") + .WithReference(redis); + +builder.AddProject("client"); + +builder.Build().Run(); diff --git a/AppHost/Properties/launchSettings.json b/AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..6d91260d --- /dev/null +++ b/AppHost/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "AppHost": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17193;http://localhost:15237", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21193", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" + } + } + } +} \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index bd0f76cb..1e5114fa 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -1,3 +1,3 @@ { - "BaseAddress": "http://localhost:8080/patient" + "BaseAddress": "http://localhost:3343/patient" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cd3a85f8..ba4012e3 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,12 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.4.11519.219 insiders +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "AppHost\AppHost.csproj", "{B1C2D3E4-F5A6-7890-ABCD-EF1234567890}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService", "GeneratorService\GeneratorService.csproj", "{139BD442-54A6-9109-CF9A-53DA218D46F2}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService.Tests", "GeneratorService.Tests\GeneratorService.Tests.csproj", "{EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}" @@ -19,6 +21,10 @@ 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 + {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1C2D3E4-F5A6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {139BD442-54A6-9109-CF9A-53DA218D46F2}.Debug|Any CPU.Build.0 = Debug|Any CPU {139BD442-54A6-9109-CF9A-53DA218D46F2}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 97ad367d..00000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src - -COPY GeneratorService/GeneratorService.csproj GeneratorService/ -RUN dotnet restore GeneratorService/GeneratorService.csproj - -COPY GeneratorService/ GeneratorService/ -RUN dotnet publish GeneratorService/GeneratorService.csproj -c Release -o /app/publish - -FROM mcr.microsoft.com/dotnet/aspnet:8.0 -WORKDIR /app -COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "GeneratorService.dll"] diff --git a/Dockerfile.client b/Dockerfile.client deleted file mode 100644 index 1a370ba7..00000000 --- a/Dockerfile.client +++ /dev/null @@ -1,15 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src - -COPY Client.Wasm/Client.Wasm.csproj Client.Wasm/ -RUN dotnet restore Client.Wasm/Client.Wasm.csproj - -COPY Client.Wasm/ Client.Wasm/ -RUN dotnet publish Client.Wasm/Client.Wasm.csproj -c Release -o /app/publish - -FROM node:20-alpine -RUN npm install -g http-server -COPY --from=build /app/publish/wwwroot /app -WORKDIR /app -EXPOSE 80 -CMD ["http-server", ".", "-p", "80", "--cors"] \ No newline at end of file diff --git a/GeneratorService.Tests/MedicalPatientGeneratorTests.cs b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs index 74d07981..5daec200 100644 --- a/GeneratorService.Tests/MedicalPatientGeneratorTests.cs +++ b/GeneratorService.Tests/MedicalPatientGeneratorTests.cs @@ -7,7 +7,6 @@ public sealed class MedicalPatientGeneratorTests { public static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today); - // Генерируем 300 штук, чтобы проверить граничные условия статистически public static IEnumerable Patients() => Enumerable.Range(1, 300).Select(i => new object[] { MedicalPatientGenerator.Generate(i) }); diff --git a/GeneratorService/GeneratorService.csproj b/GeneratorService/GeneratorService.csproj index d772c9cd..ce194f80 100644 --- a/GeneratorService/GeneratorService.csproj +++ b/GeneratorService/GeneratorService.csproj @@ -7,13 +7,14 @@ + - + diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index 23d794e9..c17acec9 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -21,12 +21,18 @@ .Enrich.FromLogContext() .Enrich.WithEnvironmentName() .Enrich.WithThreadId() - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] tid={ThreadId} | {Message:lj}{NewLine}{Exception}")); + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] tid={ThreadId} | {Message:lj}{NewLine}{Exception}") + .WriteTo.OpenTelemetry(options => + { + options.Endpoint = ctx.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] ?? "http://localhost:4317"; + options.ResourceAttributes = new Dictionary + { + ["service.name"] = "generator-service" + }; + })); - var redisConnection = builder.Configuration.GetConnectionString("redis") - ?? throw new InvalidOperationException("Не задана строка подключения 'redis'"); + builder.AddRedisDistributedCache("redis"); - builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConnection); builder.Services.AddScoped(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => @@ -38,18 +44,16 @@ app.UseSerilogRequestLogging(); app.UseSwagger(); app.UseSwaggerUI(); + app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); - // GET /patient?id=42 app.MapGet("/patient", async (int id, PatientService svc, CancellationToken ct) => - id < 0 - ? Results.BadRequest("id must be positive") + id <= 0 + ? Results.BadRequest("id must be > 0") : Results.Ok(await svc.GetAsync(id, ct))) .WithName("GetPatient") .Produces() .ProducesProblem(400); - app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); - app.Run(); } catch (Exception ex) diff --git a/GeneratorService/Properties/launchSettings.json b/GeneratorService/Properties/launchSettings.json index f084666c..53d7af01 100644 --- a/GeneratorService/Properties/launchSettings.json +++ b/GeneratorService/Properties/launchSettings.json @@ -9,4 +9,4 @@ "applicationUrl": "https://localhost:3342;http://localhost:3343" } } -} \ No newline at end of file +} diff --git a/GeneratorService/appsettings.json b/GeneratorService/appsettings.json index eb39122e..69e50051 100644 --- a/GeneratorService/appsettings.json +++ b/GeneratorService/appsettings.json @@ -1,7 +1,4 @@ { - "ConnectionStrings": { - "redis": "redis:6379" - }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index ba245f9b..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -services: - redis: - image: redis:7-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - generator-service: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - - ASPNETCORE_URLS=http://+:8080 - - ConnectionStrings__redis=redis:6379 - depends_on: - redis: - condition: service_healthy - - client: - build: - context: . - dockerfile: Dockerfile.client - ports: - - "3500:80" - depends_on: - - generator-service diff --git a/global.json b/global.json new file mode 100644 index 00000000..75a80e90 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor" + } +} \ No newline at end of file From f440860691089de45eceedbfc7f2b1188fab563e Mon Sep 17 00:00:00 2001 From: ichumakov Date: Sat, 14 Mar 2026 13:34:20 +0300 Subject: [PATCH 05/12] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91e15b0c..7a6ed7ad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Запуск проекта ```bash -docker compose up +dotnet run --project AppHost ``` # Современные технологии разработки программного обеспечения From 37f5fac441872712f25a1b48075b47a4e78ac7f2 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Wed, 18 Mar 2026 00:08:15 +0300 Subject: [PATCH 06/12] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AppHost/AppHost.csproj | 33 ++-- AppHost/Program.cs | 14 +- Client.Wasm/Components/StudentCard.razor | 8 +- GeneratorService/GeneratorService.csproj | 3 +- .../Generators/MedicalPatientGenerator.cs | 55 +++--- GeneratorService/Models/MedicalPatient.cs | 28 ++- GeneratorService/Program.cs | 22 ++- GeneratorService/Services/PatientService.cs | 41 ++--- GeneratorService/appsettings.json | 5 +- README.md | 169 ++++-------------- 10 files changed, 163 insertions(+), 215 deletions(-) diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj index 32d7febc..4b870754 100644 --- a/AppHost/AppHost.csproj +++ b/AppHost/AppHost.csproj @@ -1,21 +1,22 @@ + - - Exe - net8.0 - enable - enable - true - + + Exe + net8.0 + enable + enable + true + - - - - + + + + - - - - + + + + - + \ No newline at end of file diff --git a/AppHost/Program.cs b/AppHost/Program.cs index 5b68a63f..ab52e867 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -1,10 +1,14 @@ var builder = DistributedApplication.CreateBuilder(args); -var redis = builder.AddRedis("redis"); +var redis = builder.AddRedis("redis") + .WithRedisInsight(); -builder.AddProject("generator-service") - .WithReference(redis); +var client = builder.AddProject("client"); -builder.AddProject("client"); +builder.AddProject("generator-service") + .WithReference(redis) + .WaitFor(redis) + .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")) + .WaitFor(client); -builder.Build().Run(); +builder.Build().Run(); \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..a96e557a 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер № 1 + Вариант № 18 + Выполнена Чумаковым Иваном 6511 + Ссылка на форк diff --git a/GeneratorService/GeneratorService.csproj b/GeneratorService/GeneratorService.csproj index ce194f80..785b4965 100644 --- a/GeneratorService/GeneratorService.csproj +++ b/GeneratorService/GeneratorService.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + true @@ -17,4 +18,4 @@ - + \ No newline at end of file diff --git a/GeneratorService/Generators/MedicalPatientGenerator.cs b/GeneratorService/Generators/MedicalPatientGenerator.cs index c301a014..e5dbc7a6 100644 --- a/GeneratorService/Generators/MedicalPatientGenerator.cs +++ b/GeneratorService/Generators/MedicalPatientGenerator.cs @@ -5,32 +5,35 @@ namespace GeneratorService.Generators; public static class MedicalPatientGenerator { + private static readonly Faker _faker = new Faker("ru") + .RuleFor(p => p.Id, _ => 0) + .RuleFor(p => p.FullName, f => + $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.LastName()}ович") + .RuleFor(p => p.Address, f => f.Address.FullAddress()) + .RuleFor(p => p.BirthDate, f => f.Date.PastDateOnly(100)) + .RuleFor(p => p.Height, f => Math.Round(f.Random.Double(50.0, 220.0), 2)) + .RuleFor(p => p.Weight, f => Math.Round(f.Random.Double(2.5, 200.0), 2)) + .RuleFor(p => p.BloodGroup, f => f.Random.Int(1, 4)) + .RuleFor(p => p.RhFactor, f => f.Random.Bool()) + .RuleFor(p => p.LastExaminationDate, (f, p) => + f.Date.BetweenDateOnly(p.BirthDate, DateOnly.FromDateTime(DateTime.Today))) + .RuleFor(p => p.IsVaccinated, f => f.Random.Bool()); + public static MedicalPatient Generate(int id) { - var today = DateOnly.FromDateTime(DateTime.Today); - - var faker = new Faker("ru") - .RuleFor(p => p.Id, _ => id) - .RuleFor(p => p.FullName, f => - $"{f.Name.LastName()} {f.Name.FirstName()} {f.Name.FirstName()}") - .RuleFor(p => p.Address, f => f.Address.FullAddress()) - .RuleFor(p => p.BirthDate, f => - { - var minDate = today.AddYears(-100); - var totalDays = (int)(today.ToDateTime(TimeOnly.MinValue) - minDate.ToDateTime(TimeOnly.MinValue)).TotalDays; - return minDate.AddDays(f.Random.Int(0, totalDays)); - }) - .RuleFor(p => p.Height, f => Math.Round(f.Random.Double(50.0, 220.0), 2)) - .RuleFor(p => p.Weight, f => Math.Round(f.Random.Double(2.5, 200.0), 2)) - .RuleFor(p => p.BloodGroup, f => f.Random.Int(1, 4)) - .RuleFor(p => p.RhFactor, f => f.Random.Bool()) - .RuleFor(p => p.LastExaminationDate, (f, p) => - { - var totalDays = (int)(today.ToDateTime(TimeOnly.MinValue) - p.BirthDate.ToDateTime(TimeOnly.MinValue)).TotalDays; - return totalDays <= 0 ? today : p.BirthDate.AddDays(f.Random.Int(0, totalDays)); - }) - .RuleFor(p => p.IsVaccinated, f => f.Random.Bool()); - - return faker.Generate(); + var generated = _faker.Generate(); + return new MedicalPatient + { + Id = id, + FullName = generated.FullName, + Address = generated.Address, + BirthDate = generated.BirthDate, + Height = generated.Height, + Weight = generated.Weight, + BloodGroup = generated.BloodGroup, + RhFactor = generated.RhFactor, + LastExaminationDate = generated.LastExaminationDate, + IsVaccinated = generated.IsVaccinated + }; } -} +} \ No newline at end of file diff --git a/GeneratorService/Models/MedicalPatient.cs b/GeneratorService/Models/MedicalPatient.cs index 244a8f41..156e56fd 100644 --- a/GeneratorService/Models/MedicalPatient.cs +++ b/GeneratorService/Models/MedicalPatient.cs @@ -1,15 +1,37 @@ namespace GeneratorService.Models; +/// +/// Медицинская карта пациента. +/// public sealed class MedicalPatient { + /// Уникальный идентификатор пациента. public int Id { get; init; } - public string FullName { get; init; } = string.Empty; - public string Address { get; init; } = string.Empty; + + /// Полное имя пациента (фамилия, имя, отчество). + public required string FullName { get; init; } + + /// Адрес проживания пациента. + public required string Address { get; init; } + + /// Дата рождения пациента. public DateOnly BirthDate { get; init; } + + /// Рост пациента в сантиметрах. public double Height { get; init; } + + /// Вес пациента в килограммах. public double Weight { get; init; } + + /// Группа крови (1–4). public int BloodGroup { get; init; } + + /// Резус-фактор: true — положительный, false — отрицательный. public bool RhFactor { get; init; } + + /// Дата последнего медицинского осмотра. public DateOnly LastExaminationDate { get; init; } + + /// Наличие прививок. public bool IsVaccinated { get; init; } -} +} \ No newline at end of file diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index c17acec9..c736f927 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -36,15 +36,28 @@ builder.Services.AddScoped(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => - o.SwaggerDoc("v1", new() { Title = "GeneratorService — Medical Patient", Version = "v1" })); + { + o.SwaggerDoc("v1", new() { Title = "GeneratorService — Medical Patient", Version = "v1" }); + + var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + o.IncludeXmlComments(xmlPath); + }); builder.Services.AddCors(); var app = builder.Build(); + var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] + ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured"); + app.UseSerilogRequestLogging(); app.UseSwagger(); app.UseSwaggerUI(); - app.UseCors(policy => policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); + app.UseCors(policy => policy + .WithOrigins(allowedOrigin) + .AllowAnyMethod() + .AllowAnyHeader()); app.MapGet("/patient", async (int id, PatientService svc, CancellationToken ct) => id <= 0 @@ -54,6 +67,9 @@ .Produces() .ProducesProblem(400); + app.Logger.LogInformation("CORS AllowedOrigin = {Origin}", + builder.Configuration["Cors:AllowedOrigin"] ?? "NOT SET"); + app.Run(); } catch (Exception ex) @@ -63,4 +79,4 @@ finally { Log.CloseAndFlush(); -} +} \ No newline at end of file diff --git a/GeneratorService/Services/PatientService.cs b/GeneratorService/Services/PatientService.cs index e26acd5b..7a789654 100644 --- a/GeneratorService/Services/PatientService.cs +++ b/GeneratorService/Services/PatientService.cs @@ -5,42 +5,39 @@ namespace GeneratorService.Services; -public sealed class PatientService +public sealed class PatientService(IDistributedCache cache, ILogger logger, IConfiguration configuration) { - private readonly IDistributedCache _cache; - private readonly ILogger _logger; - - private static readonly DistributedCacheEntryOptions CacheOptions = new() - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) - }; - - public PatientService(IDistributedCache cache, ILogger logger) - { - _cache = cache; - _logger = logger; - } - public async Task GetAsync(int id, CancellationToken ct = default) { var key = $"patient:{id}"; - var cached = await _cache.GetStringAsync(key, ct); + var cached = await cache.GetStringAsync(key, ct); if (cached is not null) { - _logger.LogInformation("Cache HIT | id={Id}", id); - return JsonSerializer.Deserialize(cached)!; + var cachedPatient = JsonSerializer.Deserialize(cached); + if (cachedPatient is not null) + { + logger.LogInformation("Cache HIT | id={Id}", id); + return cachedPatient; + } } - _logger.LogInformation("Cache MISS | id={Id} — generating", id); + logger.LogInformation("Cache MISS | id={Id} — generating", id); var patient = MedicalPatientGenerator.Generate(id); - await _cache.SetStringAsync(key, JsonSerializer.Serialize(patient), CacheOptions, ct); - _logger.LogInformation( + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes( + configuration.GetValue("CacheSettings:AbsoluteExpirationMinutes")) + }; + + await cache.SetStringAsync(key, JsonSerializer.Serialize(patient), cacheOptions, ct); + + logger.LogInformation( "Generated | id={Id} Name={FullName} BirthDate={BirthDate} BloodGroup={BloodGroup} RhFactor={RhFactor}", patient.Id, patient.FullName, patient.BirthDate, patient.BloodGroup, patient.RhFactor); return patient; } -} +} \ No newline at end of file diff --git a/GeneratorService/appsettings.json b/GeneratorService/appsettings.json index 69e50051..619a0143 100644 --- a/GeneratorService/appsettings.json +++ b/GeneratorService/appsettings.json @@ -8,5 +8,8 @@ } } }, + "CacheSettings": { + "AbsoluteExpirationMinutes": 5 + }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/README.md b/README.md index 7a6ed7ad..7a3645b7 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,35 @@ -# Запуск проекта - -```bash -dotnet run --project AppHost -``` - -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
- -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). - +# Лабораторная работа №1 «Кэширование» +## Вариант №18 — «Медицинский пациент» +## Описание +Реализован сервис генерации данных о медицинских пациентах с кэшированием ответов в Redis и оркестрацией через .NET Aspire + +## Студент +**Чумаков Иван Игоревич**, группа 6511 + +## Что реализовано +### Генерация данных (Bogus) +- Класс `MedicalPatientGenerator` с `RuleFor` для каждого поля +- Генерация ФИО с отчеством на основе фамилии с суффиксом «ович» +- Локаль `ru` для русскоязычных имён + +### Кэширование (Redis + IDistributedCache) +- Сервис `PatientService` с кэшированием через `IDistributedCache` +- TTL вынесен в `appsettings.json` (`CacheSettings:AbsoluteExpirationMinutes`) + +### Структурное логирование +- Логирование через `ILogger` с Serilog +- Структурные параметры `{Id}`, `{FullName}`, `{BirthDate}` и др. +- `Information` для Cache HIT/MISS и успешной генерации + +### CORS +- Разрешён только `localhost`-origin в Development-окружении +- Доверенный origin клиента передаётся через Aspire (`Cors:AllowedOrigin`) + +### Оркестрация (.NET Aspire) +- Redis с RedisInsight +- API сервис ждёт Redis (`WaitFor(redis)`) +- Клиент WASM ждёт API сервис (`WaitFor(generatorService)`) + +### API +- Единственный эндпоинт: `GET /patient?id={id}` +- Minimal API с XML-документацией для Swagger \ No newline at end of file From 96ae604ea27276ed5526c4b4c1b9a76256ed0f9d Mon Sep 17 00:00:00 2001 From: ichumakov Date: Thu, 19 Mar 2026 15:42:52 +0300 Subject: [PATCH 07/12] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AppHost/AppHost.csproj | 1 + AppHost/Program.cs | 4 +- Client.Wasm/Properties/launchSettings.json | 6 +- CloudDevelopment.sln | 8 ++- GeneratorService/GeneratorService.csproj | 3 + GeneratorService/Program.cs | 5 ++ ServiceDefaults/Extensions.cs | 80 ++++++++++++++++++++++ ServiceDefaults/ServiceDefaults.csproj | 21 ++++++ 8 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 ServiceDefaults/Extensions.cs create mode 100644 ServiceDefaults/ServiceDefaults.csproj diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj index 4b870754..22b84afa 100644 --- a/AppHost/AppHost.csproj +++ b/AppHost/AppHost.csproj @@ -17,6 +17,7 @@ + \ No newline at end of file diff --git a/AppHost/Program.cs b/AppHost/Program.cs index ab52e867..08a8fe6b 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -9,6 +9,8 @@ .WithReference(redis) .WaitFor(redis) .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")) - .WaitFor(client); + .WaitFor(client) + .WithUrlForEndpoint("http", url => url.Url += "/swagger") + .WithUrlForEndpoint("https", url => url.Url += "/swagger"); builder.Build().Run(); \ No newline at end of file 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/CloudDevelopment.sln b/CloudDevelopment.sln index ba4012e3..e7b068d0 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36811.4 @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService", "Generat EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService.Tests", "GeneratorService.Tests\GeneratorService.Tests.csproj", "{EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "ServiceDefaults\ServiceDefaults.csproj", "{9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBCA1829-1CB1-773B-8241-CE00EBFBCF9C}.Release|Any CPU.Build.0 = Release|Any CPU + {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/GeneratorService/GeneratorService.csproj b/GeneratorService/GeneratorService.csproj index 785b4965..676109b5 100644 --- a/GeneratorService/GeneratorService.csproj +++ b/GeneratorService/GeneratorService.csproj @@ -18,4 +18,7 @@ + + + \ No newline at end of file diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index c736f927..9940f24c 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -46,8 +46,12 @@ }); builder.Services.AddCors(); + builder.AddServiceDefaults(); + var app = builder.Build(); + app.MapDefaultEndpoints(); + var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured"); @@ -64,6 +68,7 @@ ? Results.BadRequest("id must be > 0") : Results.Ok(await svc.GetAsync(id, ct))) .WithName("GetPatient") + .WithSummary("Возвращает медицинскую карту пациента по идентификатору") .Produces() .ProducesProblem(400); diff --git a/ServiceDefaults/Extensions.cs b/ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..c497d055 --- /dev/null +++ b/ServiceDefaults/Extensions.cs @@ -0,0 +1,80 @@ +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 Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + return builder; + } + + 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; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + return builder; + } + + 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; + } +} \ No newline at end of file diff --git a/ServiceDefaults/ServiceDefaults.csproj b/ServiceDefaults/ServiceDefaults.csproj new file mode 100644 index 00000000..6cb5f230 --- /dev/null +++ b/ServiceDefaults/ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + \ No newline at end of file From 1a36dfc3ebe7e74539123c19ea3d14e74ea97f7a Mon Sep 17 00:00:00 2001 From: Ivan Chumakov Date: Wed, 15 Apr 2026 10:39:57 +0300 Subject: [PATCH 08/12] =?UTF-8?q?=D0=BB=D0=B0=D0=B1=D0=B0=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ApiGateway/ApiGateway.csproj | 17 +++++++ .../LoadBalancing/ServicesAreEmptyError.cs | 6 +++ .../WeightedRoundRobinBalancer.cs | 48 +++++++++++++++++++ ApiGateway/Program.cs | 25 ++++++++++ ApiGateway/Properties/launchSettings.json | 12 +++++ ApiGateway/appsettings.json | 9 ++++ ApiGateway/ocelot.json | 18 +++++++ AppHost/AppHost.csproj | 1 + AppHost/Program.cs | 20 ++++---- Client.Wasm/wwwroot/appsettings.json | 2 +- CloudDevelopment.sln | 8 +++- 11 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 ApiGateway/ApiGateway.csproj create mode 100644 ApiGateway/LoadBalancing/ServicesAreEmptyError.cs create mode 100644 ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs create mode 100644 ApiGateway/Program.cs create mode 100644 ApiGateway/Properties/launchSettings.json create mode 100644 ApiGateway/appsettings.json create mode 100644 ApiGateway/ocelot.json diff --git a/ApiGateway/ApiGateway.csproj b/ApiGateway/ApiGateway.csproj new file mode 100644 index 00000000..d3911d2e --- /dev/null +++ b/ApiGateway/ApiGateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs b/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs new file mode 100644 index 00000000..2ce3efb6 --- /dev/null +++ b/ApiGateway/LoadBalancing/ServicesAreEmptyError.cs @@ -0,0 +1,6 @@ +using Ocelot.Errors; + +namespace ApiGateway.LoadBalancing; + +public sealed class ServicesAreEmptyError(string message) + : Error(message, OcelotErrorCode.UnableToFindDownstreamRouteError, 503); diff --git a/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs new file mode 100644 index 00000000..b7b97740 --- /dev/null +++ b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -0,0 +1,48 @@ +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ApiGateway.LoadBalancing; + +/// +/// Взвешенная карусель (Weighted Round Robin). +/// Каждой реплике присваивается вес — она обслуживает ровно weight запросов подряд, +/// после чего очередь переходит к следующей реплике. +/// Веса: R1=3, R2=2, R3=1 → R1,R1,R1,R2,R2,R3,R1,... +/// +public sealed class WeightedRoundRobinBalancer(Func>> services) : ILoadBalancer +{ + private readonly Func>> _services = services; + private readonly int[] _weights = [3, 2, 1]; + private readonly object _lock = new(); + + private int _currentIndex = -1; + private int _remainingCalls = 0; + + public string Type => nameof(WeightedRoundRobinBalancer); + + public async Task> LeaseAsync(HttpContext httpContext) + { + var available = await _services.Invoke(); + + if (available is null || available.Count == 0) + return new ErrorResponse( + new ServicesAreEmptyError("No downstream services available")); + + lock (_lock) + { + if (_currentIndex == -1 || _remainingCalls == 0) + { + _currentIndex = (_currentIndex + 1) % available.Count; + _remainingCalls = _weights[_currentIndex % _weights.Length]; + } + + var service = available[_currentIndex]; + _remainingCalls--; + + return new OkResponse(service.HostAndPort); + } + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs new file mode 100644 index 00000000..bdfd958b --- /dev/null +++ b/ApiGateway/Program.cs @@ -0,0 +1,25 @@ +using ApiGateway.LoadBalancing; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); +builder.Services.AddOcelot() + .AddCustomLoadBalancer((_, _, provider) => new(provider.GetAsync)); + +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => +{ + policy.AllowAnyOrigin(); + policy.WithMethods("GET"); + policy.WithHeaders("Content-Type"); +})); + +var app = builder.Build(); + +app.UseCors(); + +await app.UseOcelot(); + +app.Run(); diff --git a/ApiGateway/Properties/launchSettings.json b/ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..647a0d20 --- /dev/null +++ b/ApiGateway/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "ApiGateway": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ApiGateway/appsettings.json b/ApiGateway/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/ApiGateway/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ApiGateway/ocelot.json b/ApiGateway/ocelot.json new file mode 100644 index 00000000..ff4e1ddd --- /dev/null +++ b/ApiGateway/ocelot.json @@ -0,0 +1,18 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/patient", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/patient", + "DownstreamScheme": "http", + "LoadBalancerOptions": { + "Type": "WeightedRoundRobinBalancer" + }, + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 15000 }, + { "Host": "localhost", "Port": 15001 }, + { "Host": "localhost", "Port": 15002 } + ] + } + ] +} diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj index 22b84afa..12fc787a 100644 --- a/AppHost/AppHost.csproj +++ b/AppHost/AppHost.csproj @@ -16,6 +16,7 @@ + diff --git a/AppHost/Program.cs b/AppHost/Program.cs index 08a8fe6b..8fc20b91 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -5,12 +5,16 @@ var client = builder.AddProject("client"); -builder.AddProject("generator-service") - .WithReference(redis) - .WaitFor(redis) - .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")) - .WaitFor(client) - .WithUrlForEndpoint("http", url => url.Url += "/swagger") - .WithUrlForEndpoint("https", url => url.Url += "/swagger"); +var gateway = builder.AddProject("api-gateway"); -builder.Build().Run(); \ No newline at end of file +for (var i = 0; i < 3; i++) +{ + var replica = builder.AddProject($"generator-service-{i}", launchProfileName: null) + .WithHttpEndpoint(port: 15000 + i) + .WithReference(redis) + .WaitFor(redis) + .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")); + gateway.WaitFor(replica); +} + +builder.Build().Run(); diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index 1e5114fa..0ed61cc5 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -1,3 +1,3 @@ { - "BaseAddress": "http://localhost:3343/patient" + "BaseAddress": "http://localhost:5200/patient" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index e7b068d0..1adefa99 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36811.4 @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GeneratorService.Tests", "G EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "ServiceDefaults\ServiceDefaults.csproj", "{9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{C2D3E4F5-A6B7-8901-CDEF-234567890ABC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Debug|Any CPU.Build.0 = Debug|Any CPU {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F0B1437-6E39-4706-8A9D-89BCE4B31AA0}.Release|Any CPU.Build.0 = Release|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From d55918962756d22924f5a9178a7c84dc31113d9e Mon Sep 17 00:00:00 2001 From: ichumakov Date: Mon, 20 Apr 2026 13:43:39 +0300 Subject: [PATCH 09/12] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ApiGateway/Program.cs | 15 ++++++++++++++- AppHost/Program.cs | 8 ++++---- GeneratorService/Program.cs | 12 ------------ README.md | 18 ++++++++++-------- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs index bdfd958b..4ad80f5d 100644 --- a/ApiGateway/Program.cs +++ b/ApiGateway/Program.cs @@ -6,12 +6,25 @@ builder.AddServiceDefaults(); builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +for (var i = 0; i < 3; i++) +{ + var url = builder.Configuration[$"services__generator-service-{i}__http__0"]; + if (url is null) break; + var uri = new Uri(url); + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host; + builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] = uri.Port.ToString(); +} + builder.Services.AddOcelot() .AddCustomLoadBalancer((_, _, provider) => new(provider.GetAsync)); +var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] + ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured"); + builder.Services.AddCors(options => options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin(); + policy.WithOrigins(allowedOrigin); policy.WithMethods("GET"); policy.WithHeaders("Content-Type"); })); diff --git a/AppHost/Program.cs b/AppHost/Program.cs index 8fc20b91..11c56981 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -5,16 +5,16 @@ var client = builder.AddProject("client"); -var gateway = builder.AddProject("api-gateway"); +var gateway = builder.AddProject("api-gateway") + .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")); for (var i = 0; i < 3; i++) { var replica = builder.AddProject($"generator-service-{i}", launchProfileName: null) .WithHttpEndpoint(port: 15000 + i) .WithReference(redis) - .WaitFor(redis) - .WithEnvironment("Cors__AllowedOrigin", client.GetEndpoint("http")); - gateway.WaitFor(replica); + .WaitFor(redis); + gateway.WithReference(replica).WaitFor(replica); } builder.Build().Run(); diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index 9940f24c..9a28d387 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -44,24 +44,15 @@ if (File.Exists(xmlPath)) o.IncludeXmlComments(xmlPath); }); - builder.Services.AddCors(); - builder.AddServiceDefaults(); var app = builder.Build(); app.MapDefaultEndpoints(); - var allowedOrigin = builder.Configuration["Cors:AllowedOrigin"] - ?? throw new InvalidOperationException("Cors:AllowedOrigin is not configured"); - app.UseSerilogRequestLogging(); app.UseSwagger(); app.UseSwaggerUI(); - app.UseCors(policy => policy - .WithOrigins(allowedOrigin) - .AllowAnyMethod() - .AllowAnyHeader()); app.MapGet("/patient", async (int id, PatientService svc, CancellationToken ct) => id <= 0 @@ -72,9 +63,6 @@ .Produces() .ProducesProblem(400); - app.Logger.LogInformation("CORS AllowedOrigin = {Origin}", - builder.Configuration["Cors:AllowedOrigin"] ?? "NOT SET"); - app.Run(); } catch (Exception ex) diff --git a/README.md b/README.md index 7a3645b7..42da3401 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Лабораторная работа №1 «Кэширование» +# Лабораторная работа №2 «Балансировка нагрузки» ## Вариант №18 — «Медицинский пациент» ## Описание -Реализован сервис генерации данных о медицинских пациентах с кэшированием ответов в Redis и оркестрацией через .NET Aspire +Реализован API Gateway с балансировкой нагрузки между тремя репликами сервиса генерации данных о медицинских пациентах с кэшированием в Redis и оркестрацией через .NET Aspire ## Студент **Чумаков Иван Игоревич**, группа 6511 @@ -21,15 +21,17 @@ - Структурные параметры `{Id}`, `{FullName}`, `{BirthDate}` и др. - `Information` для Cache HIT/MISS и успешной генерации -### CORS -- Разрешён только `localhost`-origin в Development-окружении -- Доверенный origin клиента передаётся через Aspire (`Cors:AllowedOrigin`) +### API Gateway (Ocelot) +- Маршрутизация запросов через `ApiGateway` к репликам `GeneratorService` +- Кастомный балансировщик нагрузки `WeightedRoundRobinBalancer` +- CORS настроен на уровне Gateway — разрешён только origin клиента, переданный через Aspire ### Оркестрация (.NET Aspire) - Redis с RedisInsight -- API сервис ждёт Redis (`WaitFor(redis)`) -- Клиент WASM ждёт API сервис (`WaitFor(generatorService)`) +- Три реплики `GeneratorService` с фиксированными портами (15000–15002) +- `ApiGateway` ссылается на все реплики через `WithReference` и динамически переопределяет адреса Ocelot из переменных окружения Aspire +- Клиент WASM (`Client.Wasm`) взаимодействует только с Gateway ### API - Единственный эндпоинт: `GET /patient?id={id}` -- Minimal API с XML-документацией для Swagger \ No newline at end of file +- Minimal API с XML-документацией для Swagger From 3df03aa640baf179b500979ac19c1e5b8d6f1f03 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Mon, 20 Apr 2026 18:49:07 +0300 Subject: [PATCH 10/12] hotfix --- ApiGateway/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs index 4ad80f5d..3ab3f5f7 100644 --- a/ApiGateway/Program.cs +++ b/ApiGateway/Program.cs @@ -9,7 +9,7 @@ for (var i = 0; i < 3; i++) { - var url = builder.Configuration[$"services__generator-service-{i}__http__0"]; + var url = builder.Configuration[$"services:generator-service-{i}:http:0"]; if (url is null) break; var uri = new Uri(url); builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host; From 6f4fe411ab7b2b8564b02d860716124e8e326403 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Tue, 21 Apr 2026 00:35:16 +0300 Subject: [PATCH 11/12] lab3 --- AppHost/AppHost.csproj | 1 + AppHost/Program.cs | 19 ++- CloudDevelopment.sln | 14 +- FileService/FileService.csproj | 19 +++ FileService/Program.cs | 17 +++ FileService/Services/MinioStorageService.cs | 69 ++++++++++ FileService/Services/SqsPollingService.cs | 127 ++++++++++++++++++ FileService/appsettings.json | 25 ++++ GeneratorService/GeneratorService.csproj | 1 + GeneratorService/Program.cs | 1 + GeneratorService/Services/PatientService.cs | 18 ++- .../Services/SnsPublisherService.cs | 54 ++++++++ GeneratorService/appsettings.json | 7 + Integration.Tests/BackendIntegrationTests.cs | 116 ++++++++++++++++ Integration.Tests/Integration.Tests.csproj | 25 ++++ README.md | 39 ++++-- 16 files changed, 538 insertions(+), 14 deletions(-) create mode 100644 FileService/FileService.csproj create mode 100644 FileService/Program.cs create mode 100644 FileService/Services/MinioStorageService.cs create mode 100644 FileService/Services/SqsPollingService.cs create mode 100644 FileService/appsettings.json create mode 100644 GeneratorService/Services/SnsPublisherService.cs create mode 100644 Integration.Tests/BackendIntegrationTests.cs create mode 100644 Integration.Tests/Integration.Tests.csproj diff --git a/AppHost/AppHost.csproj b/AppHost/AppHost.csproj index 12fc787a..8954367d 100644 --- a/AppHost/AppHost.csproj +++ b/AppHost/AppHost.csproj @@ -18,6 +18,7 @@ + diff --git a/AppHost/Program.cs b/AppHost/Program.cs index 11c56981..99e7fb90 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -3,6 +3,17 @@ var redis = builder.AddRedis("redis") .WithRedisInsight(); +var localstack = builder.AddContainer("localstack", "localstack/localstack", "3.8.1") + .WithEnvironment("SERVICES", "sns,sqs") + .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "http"); + +var minio = builder.AddContainer("minio", "minio/minio") + .WithArgs("server", "/data", "--console-address", ":9001") + .WithEnvironment("MINIO_ROOT_USER", "minioadmin") + .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin") + .WithHttpEndpoint(port: 9000, targetPort: 9000, name: "api") + .WithHttpEndpoint(port: 9001, targetPort: 9001, name: "console"); + var client = builder.AddProject("client"); var gateway = builder.AddProject("api-gateway") @@ -13,8 +24,14 @@ var replica = builder.AddProject($"generator-service-{i}", launchProfileName: null) .WithHttpEndpoint(port: 15000 + i) .WithReference(redis) - .WaitFor(redis); + .WaitFor(redis) + .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http")); gateway.WithReference(replica).WaitFor(replica); } +builder.AddProject("file-service") + .WithHttpEndpoint(port: 5300, name: "http") + .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http")) + .WithEnvironment("Minio__ServiceUrl", minio.GetEndpoint("api")); + builder.Build().Run(); diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index 1adefa99..40bcaaca 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36811.4 @@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceDefaults", "ServiceD EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{C2D3E4F5-A6B7-8901-CDEF-234567890ABC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileService", "FileService\FileService.csproj", "{3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Integration.Tests", "Integration.Tests\Integration.Tests.csproj", "{6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -45,6 +49,14 @@ Global {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU {C2D3E4F5-A6B7-8901-CDEF-234567890ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A9D7F1C-9A6F-425E-A2F5-8BDFFA064707}.Release|Any CPU.Build.0 = Release|Any CPU + {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6BC4650D-FEA0-4BA7-8DA7-1A8B097DAF3C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FileService/FileService.csproj b/FileService/FileService.csproj new file mode 100644 index 00000000..87de66f9 --- /dev/null +++ b/FileService/FileService.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/FileService/Program.cs b/FileService/Program.cs new file mode 100644 index 00000000..b62ff8cb --- /dev/null +++ b/FileService/Program.cs @@ -0,0 +1,17 @@ +using FileService.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/files", async (MinioStorageService storage, CancellationToken ct) => + Results.Ok(await storage.ListFilesAsync(ct))); + +app.Run(); diff --git a/FileService/Services/MinioStorageService.cs b/FileService/Services/MinioStorageService.cs new file mode 100644 index 00000000..9efebf57 --- /dev/null +++ b/FileService/Services/MinioStorageService.cs @@ -0,0 +1,69 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace FileService.Services; + +public sealed class MinioStorageService : IDisposable +{ + private readonly IAmazonS3 _s3; + private readonly string _bucket; + + public MinioStorageService(IConfiguration configuration) + { + var serviceUrl = configuration["Minio:ServiceUrl"] ?? "http://localhost:9000"; + var accessKey = configuration["Minio:AccessKey"] ?? "minioadmin"; + var secretKey = configuration["Minio:SecretKey"] ?? "minioadmin"; + _bucket = configuration["Minio:BucketName"] ?? "patients"; + + _s3 = new AmazonS3Client( + new BasicAWSCredentials(accessKey, secretKey), + new AmazonS3Config + { + ServiceURL = serviceUrl, + ForcePathStyle = true, + AuthenticationRegion = "us-east-1" + }); + } + + public async Task EnsureBucketExistsAsync(CancellationToken ct = default) + { + try + { + await _s3.PutBucketAsync(_bucket, ct); + } + catch (AmazonS3Exception e) when (e.ErrorCode is "BucketAlreadyOwnedByYou" or "BucketAlreadyExists") + { + } + } + + public async Task SavePatientAsync(string patientJson, int patientId, CancellationToken ct = default) + { + var key = $"patient-{patientId}-{DateTime.UtcNow:yyyyMMddHHmmss}.json"; + await _s3.PutObjectAsync(new PutObjectRequest + { + BucketName = _bucket, + Key = key, + ContentBody = patientJson, + ContentType = "application/json" + }, ct); + } + + public async Task> ListFilesAsync(CancellationToken ct = default) + { + try + { + var response = await _s3.ListObjectsV2Async(new ListObjectsV2Request + { + BucketName = _bucket + }, ct); + return response.S3Objects.Select(o => o.Key).ToList(); + } + catch + { + return []; + } + } + + public void Dispose() => _s3.Dispose(); +} diff --git a/FileService/Services/SqsPollingService.cs b/FileService/Services/SqsPollingService.cs new file mode 100644 index 00000000..d723afa3 --- /dev/null +++ b/FileService/Services/SqsPollingService.cs @@ -0,0 +1,127 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using System.Text.Json; + +namespace FileService.Services; + +public sealed class SqsPollingService( + ILogger logger, + MinioStorageService storage, + IConfiguration configuration) : BackgroundService +{ + private readonly string _topicName = configuration["Sns:TopicName"] ?? "medical-patients"; + private readonly string _queueName = configuration["Sqs:QueueName"] ?? "medical-patients-queue"; + private readonly string _awsServiceUrl = configuration["AWS:ServiceURL"] ?? "http://localhost:4566"; + + private string? _queueUrl; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var credentials = new BasicAWSCredentials("test", "test"); + using var sqs = new AmazonSQSClient(credentials, new AmazonSQSConfig { ServiceURL = _awsServiceUrl }); + using var sns = new AmazonSimpleNotificationServiceClient(credentials, + new AmazonSimpleNotificationServiceConfig { ServiceURL = _awsServiceUrl }); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await storage.EnsureBucketExistsAsync(stoppingToken); + await SetupAsync(sns, sqs, stoppingToken); + break; + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogWarning(ex, "Infrastructure setup failed, retrying in 5s"); + await Task.Delay(5000, stoppingToken); + } + } + + logger.LogInformation("SQS polling started. Queue={QueueUrl}", _queueUrl); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PollAsync(sqs, stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + logger.LogError(ex, "Polling error"); + await Task.Delay(2000, stoppingToken); + } + } + } + + private async Task SetupAsync( + IAmazonSimpleNotificationService sns, + IAmazonSQS sqs, + CancellationToken ct) + { + var topicArn = (await sns.CreateTopicAsync(_topicName, ct)).TopicArn; + + _queueUrl = (await sqs.CreateQueueAsync(_queueName, ct)).QueueUrl; + + var attrs = await sqs.GetQueueAttributesAsync(_queueUrl, ["QueueArn"], ct); + var queueArn = attrs.QueueARN; + + var subscriptions = await sns.ListSubscriptionsByTopicAsync(topicArn, ct); + if (!subscriptions.Subscriptions.Any(s => s.Endpoint == queueArn)) + { + await sns.SubscribeAsync(topicArn, "sqs", queueArn, ct); + await sqs.SetQueueAttributesAsync(_queueUrl, new Dictionary + { + ["Policy"] = $$$""" + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": "sqs:SendMessage", + "Resource": "{{{queueArn}}}", + "Condition": {"ArnEquals": {"aws:SourceArn": "{{{topicArn}}}"}} + }] + } + """ + }, ct); + } + } + + private async Task PollAsync(IAmazonSQS sqs, CancellationToken ct) + { + if (_queueUrl is null) return; + + var response = await sqs.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _queueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 5 + }, ct); + + foreach (var message in response.Messages) + { + try + { + using var envelope = JsonDocument.Parse(message.Body); + var patientJson = envelope.RootElement.TryGetProperty("Message", out var msg) + ? msg.GetString() ?? message.Body + : message.Body; + + using var patientDoc = JsonDocument.Parse(patientJson); + var patientId = patientDoc.RootElement.GetProperty("Id").GetInt32(); + + await storage.SavePatientAsync(patientJson, patientId, ct); + await sqs.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); + + logger.LogInformation("Saved patient {PatientId} to MinIO", patientId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process SQS message"); + } + } + } +} diff --git a/FileService/appsettings.json b/FileService/appsettings.json new file mode 100644 index 00000000..a01f3970 --- /dev/null +++ b/FileService/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AWS": { + "ServiceURL": "http://localhost:4566", + "Region": "us-east-1" + }, + "Sns": { + "TopicName": "medical-patients" + }, + "Sqs": { + "QueueName": "medical-patients-queue" + }, + "Minio": { + "ServiceUrl": "http://localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "BucketName": "patients" + } +} diff --git a/GeneratorService/GeneratorService.csproj b/GeneratorService/GeneratorService.csproj index 676109b5..f9d39d4a 100644 --- a/GeneratorService/GeneratorService.csproj +++ b/GeneratorService/GeneratorService.csproj @@ -16,6 +16,7 @@ + diff --git a/GeneratorService/Program.cs b/GeneratorService/Program.cs index 9a28d387..1ab3ab99 100644 --- a/GeneratorService/Program.cs +++ b/GeneratorService/Program.cs @@ -33,6 +33,7 @@ builder.AddRedisDistributedCache("redis"); + builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => diff --git a/GeneratorService/Services/PatientService.cs b/GeneratorService/Services/PatientService.cs index 7a789654..22056438 100644 --- a/GeneratorService/Services/PatientService.cs +++ b/GeneratorService/Services/PatientService.cs @@ -5,7 +5,11 @@ namespace GeneratorService.Services; -public sealed class PatientService(IDistributedCache cache, ILogger logger, IConfiguration configuration) +public sealed class PatientService( + IDistributedCache cache, + ILogger logger, + IConfiguration configuration, + SnsPublisherService snsPublisher) { public async Task GetAsync(int id, CancellationToken ct = default) { @@ -25,6 +29,7 @@ public async Task GetAsync(int id, CancellationToken ct = defaul logger.LogInformation("Cache MISS | id={Id} — generating", id); var patient = MedicalPatientGenerator.Generate(id); + var patientJson = JsonSerializer.Serialize(patient); var cacheOptions = new DistributedCacheEntryOptions { @@ -32,12 +37,21 @@ public async Task GetAsync(int id, CancellationToken ct = defaul configuration.GetValue("CacheSettings:AbsoluteExpirationMinutes")) }; - await cache.SetStringAsync(key, JsonSerializer.Serialize(patient), cacheOptions, ct); + await cache.SetStringAsync(key, patientJson, cacheOptions, ct); logger.LogInformation( "Generated | id={Id} Name={FullName} BirthDate={BirthDate} BloodGroup={BloodGroup} RhFactor={RhFactor}", patient.Id, patient.FullName, patient.BirthDate, patient.BloodGroup, patient.RhFactor); + try + { + await snsPublisher.PublishAsync(patientJson, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to publish patient {Id} to SNS", id); + } + return patient; } } \ No newline at end of file diff --git a/GeneratorService/Services/SnsPublisherService.cs b/GeneratorService/Services/SnsPublisherService.cs new file mode 100644 index 00000000..d0d97b4f --- /dev/null +++ b/GeneratorService/Services/SnsPublisherService.cs @@ -0,0 +1,54 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; + +namespace GeneratorService.Services; + +public sealed class SnsPublisherService : IDisposable +{ + private readonly IAmazonSimpleNotificationService _sns; + private readonly string _topicName; + private readonly ILogger _logger; + private string? _topicArn; + private readonly SemaphoreSlim _lock = new(1, 1); + + public SnsPublisherService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _topicName = configuration["Sns:TopicName"] ?? "medical-patients"; + var serviceUrl = configuration["AWS:ServiceURL"] ?? "http://localhost:4566"; + _sns = new AmazonSimpleNotificationServiceClient( + new BasicAWSCredentials("test", "test"), + new AmazonSimpleNotificationServiceConfig { ServiceURL = serviceUrl }); + } + + public async Task PublishAsync(string message, CancellationToken ct = default) + { + if (_topicArn is null) + { + await _lock.WaitAsync(ct); + try + { + _topicArn ??= (await _sns.CreateTopicAsync(_topicName, ct)).TopicArn; + } + finally + { + _lock.Release(); + } + } + + await _sns.PublishAsync(new PublishRequest + { + TopicArn = _topicArn, + Message = message + }, ct); + + _logger.LogInformation("Published to SNS topic {TopicArn}", _topicArn); + } + + public void Dispose() + { + _sns.Dispose(); + _lock.Dispose(); + } +} diff --git a/GeneratorService/appsettings.json b/GeneratorService/appsettings.json index 619a0143..36d2bca6 100644 --- a/GeneratorService/appsettings.json +++ b/GeneratorService/appsettings.json @@ -1,4 +1,11 @@ { + "AWS": { + "ServiceURL": "http://localhost:4566", + "Region": "us-east-1" + }, + "Sns": { + "TopicName": "medical-patients" + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/Integration.Tests/BackendIntegrationTests.cs b/Integration.Tests/BackendIntegrationTests.cs new file mode 100644 index 00000000..88cb747e --- /dev/null +++ b/Integration.Tests/BackendIntegrationTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Xunit; + +namespace Integration.Tests; + +public sealed class BackendIntegrationTests : IAsyncLifetime +{ + private DistributedApplication? _app; + private HttpClient? _gatewayClient; + private HttpClient? _fileServiceClient; + + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + _app = await builder.BuildAsync(); + await _app.StartAsync(); + + _gatewayClient = _app.CreateHttpClient("api-gateway"); + _fileServiceClient = _app.CreateHttpClient("file-service"); + } + + public async Task DisposeAsync() + { + _gatewayClient?.Dispose(); + _fileServiceClient?.Dispose(); + if (_app is not null) + await _app.DisposeAsync(); + } + + [Fact] + public async Task GetPatient_ValidId_ReturnsPatient() + { + var response = await _gatewayClient!.GetAsync("/patient?id=1"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal(1, root.GetProperty("id").GetInt32()); + Assert.False(string.IsNullOrEmpty(root.GetProperty("fullName").GetString())); + Assert.InRange(root.GetProperty("bloodGroup").GetInt32(), 1, 4); + } + + [Fact] + public async Task GetPatient_InvalidId_ReturnsBadRequest() + { + var response = await _gatewayClient!.GetAsync("/patient?id=0"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetPatient_SameId_ReturnsCachedPatient() + { + var r1 = await _gatewayClient!.GetAsync("/patient?id=42"); + var r2 = await _gatewayClient!.GetAsync("/patient?id=42"); + + r1.EnsureSuccessStatusCode(); + r2.EnsureSuccessStatusCode(); + + using var doc1 = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()); + using var doc2 = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()); + + Assert.Equal( + doc1.RootElement.GetProperty("fullName").GetString(), + doc2.RootElement.GetProperty("fullName").GetString()); + } + + [Fact] + public async Task GetPatient_FileAppearsInMinio() + { + const int testId = 777; + + var genResponse = await _gatewayClient!.GetAsync($"/patient?id={testId}"); + genResponse.EnsureSuccessStatusCode(); + + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + var filesResponse = await _fileServiceClient!.GetAsync("/files"); + if (filesResponse.IsSuccessStatusCode) + { + var files = JsonSerializer.Deserialize>( + await filesResponse.Content.ReadAsStringAsync()) ?? []; + + if (files.Any(f => f.Contains($"patient-{testId}-"))) + return; + } + + await Task.Delay(1000); + } + + Assert.Fail($"File for patient-{testId} did not appear in MinIO within 30 seconds"); + } + + [Fact] + public async Task GetPatient_DifferentIds_ReturnDifferentPatients() + { + var r1 = await _gatewayClient!.GetAsync("/patient?id=10"); + var r2 = await _gatewayClient!.GetAsync("/patient?id=20"); + + r1.EnsureSuccessStatusCode(); + r2.EnsureSuccessStatusCode(); + + using var doc1 = JsonDocument.Parse(await r1.Content.ReadAsStringAsync()); + using var doc2 = JsonDocument.Parse(await r2.Content.ReadAsStringAsync()); + + Assert.Equal(10, doc1.RootElement.GetProperty("id").GetInt32()); + Assert.Equal(20, doc2.RootElement.GetProperty("id").GetInt32()); + } +} diff --git a/Integration.Tests/Integration.Tests.csproj b/Integration.Tests/Integration.Tests.csproj new file mode 100644 index 00000000..89cee36b --- /dev/null +++ b/Integration.Tests/Integration.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/README.md b/README.md index 42da3401..e5cf9a64 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Лабораторная работа №2 «Балансировка нагрузки» +# Лабораторная работа №3 «Интеграционное тестирование» ## Вариант №18 — «Медицинский пациент» ## Описание -Реализован API Gateway с балансировкой нагрузки между тремя репликами сервиса генерации данных о медицинских пациентах с кэшированием в Redis и оркестрацией через .NET Aspire +Реализован файловый сервис с объектным хранилищем MinIO и брокером сообщений SNS/SQS (LocalStack). Генерируемые данные о пациентах публикуются в SNS и сохраняются в файлы через FileService. Добавлены интеграционные тесты на базе Aspire Testing. ## Студент **Чумаков Иван Игоревич**, группа 6511 @@ -22,16 +22,35 @@ - `Information` для Cache HIT/MISS и успешной генерации ### API Gateway (Ocelot) -- Маршрутизация запросов через `ApiGateway` к репликам `GeneratorService` -- Кастомный балансировщик нагрузки `WeightedRoundRobinBalancer` -- CORS настроен на уровне Gateway — разрешён только origin клиента, переданный через Aspire +- Маршрутизация запросов к репликам `GeneratorService` +- Кастомный балансировщик нагрузки `WeightedRoundRobinBalancer` (R1:3, R2:2, R3:1) +- CORS настроен на уровне Gateway с динамическим origin от Aspire + +### Брокер сообщений (SNS + SQS через LocalStack) +- `GeneratorService` публикует сгенерированного пациента в SNS-топик при Cache MISS +- `FileService` создаёт SQS-очередь, подписывает её на SNS-топик и опрашивает очередь +- Публикация не блокирует ответ API при недоступности брокера + +### Файловый сервис + объектное хранилище (MinIO) +- `FileService` принимает сообщения из SQS и сохраняет JSON-файлы в MinIO +- Имя файла: `patient-{id}-{yyyyMMddHHmmss}.json` +- Бакет создаётся автоматически при старте сервиса +- Endpoint `GET /files` возвращает список сохранённых файлов + +### Интеграционные тесты (xUnit + Aspire.Hosting.Testing) +- `GetPatient_ValidId_ReturnsPatient` — проверка корректного ответа через Gateway +- `GetPatient_InvalidId_ReturnsBadRequest` — проверка валидации входных данных +- `GetPatient_SameId_ReturnsCachedPatient` — проверка работы кэша Redis +- `GetPatient_DifferentIds_ReturnDifferentPatients` — проверка независимости записей +- `GetPatient_FileAppearsInMinio` — E2E тест: запрос → SNS → SQS → MinIO ### Оркестрация (.NET Aspire) - Redis с RedisInsight -- Три реплики `GeneratorService` с фиксированными портами (15000–15002) -- `ApiGateway` ссылается на все реплики через `WithReference` и динамически переопределяет адреса Ocelot из переменных окружения Aspire -- Клиент WASM (`Client.Wasm`) взаимодействует только с Gateway +- LocalStack (SNS + SQS) +- MinIO с консолью управления на порту 9001 +- Три реплики `GeneratorService` (порты 15000–15002) с балансировкой через Gateway +- `FileService` получает адреса LocalStack и MinIO через переменные окружения Aspire ### API -- Единственный эндпоинт: `GET /patient?id={id}` -- Minimal API с XML-документацией для Swagger +- Единственный публичный эндпоинт: `GET /patient?id={id}` (через Gateway) +- `GET /files` (FileService) — список файлов в MinIO From d8b62bc136f9366f735ad3919b1db787916a1ed8 Mon Sep 17 00:00:00 2001 From: ichumakov Date: Tue, 21 Apr 2026 16:39:47 +0300 Subject: [PATCH 12/12] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AppHost/Program.cs | 5 +- FileService/Services/MinioStorageService.cs | 3 + FileService/Services/SqsPollingService.cs | 1 - Integration.Tests/AppHostFixture.cs | 47 +++++++++++++++ Integration.Tests/BackendIntegrationTests.cs | 60 +++++++------------- Integration.Tests/Integration.Tests.csproj | 4 ++ Integration.Tests/xunit.runner.json | 4 ++ 7 files changed, 82 insertions(+), 42 deletions(-) create mode 100644 Integration.Tests/AppHostFixture.cs create mode 100644 Integration.Tests/xunit.runner.json diff --git a/AppHost/Program.cs b/AppHost/Program.cs index 99e7fb90..2bfc9725 100644 --- a/AppHost/Program.cs +++ b/AppHost/Program.cs @@ -25,6 +25,7 @@ .WithHttpEndpoint(port: 15000 + i) .WithReference(redis) .WaitFor(redis) + .WaitFor(localstack) .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http")); gateway.WithReference(replica).WaitFor(replica); } @@ -32,6 +33,8 @@ builder.AddProject("file-service") .WithHttpEndpoint(port: 5300, name: "http") .WithEnvironment("AWS__ServiceURL", localstack.GetEndpoint("http")) - .WithEnvironment("Minio__ServiceUrl", minio.GetEndpoint("api")); + .WithEnvironment("Minio__ServiceUrl", minio.GetEndpoint("api")) + .WaitFor(localstack) + .WaitFor(minio); builder.Build().Run(); diff --git a/FileService/Services/MinioStorageService.cs b/FileService/Services/MinioStorageService.cs index 9efebf57..a5decf15 100644 --- a/FileService/Services/MinioStorageService.cs +++ b/FileService/Services/MinioStorageService.cs @@ -8,6 +8,7 @@ public sealed class MinioStorageService : IDisposable { private readonly IAmazonS3 _s3; private readonly string _bucket; + private int _bucketEnsured; public MinioStorageService(IConfiguration configuration) { @@ -39,6 +40,8 @@ public async Task EnsureBucketExistsAsync(CancellationToken ct = default) public async Task SavePatientAsync(string patientJson, int patientId, CancellationToken ct = default) { + if (Interlocked.CompareExchange(ref _bucketEnsured, 1, 0) == 0) + await EnsureBucketExistsAsync(ct); var key = $"patient-{patientId}-{DateTime.UtcNow:yyyyMMddHHmmss}.json"; await _s3.PutObjectAsync(new PutObjectRequest { diff --git a/FileService/Services/SqsPollingService.cs b/FileService/Services/SqsPollingService.cs index d723afa3..8b28dd33 100644 --- a/FileService/Services/SqsPollingService.cs +++ b/FileService/Services/SqsPollingService.cs @@ -29,7 +29,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - await storage.EnsureBucketExistsAsync(stoppingToken); await SetupAsync(sns, sqs, stoppingToken); break; } diff --git a/Integration.Tests/AppHostFixture.cs b/Integration.Tests/AppHostFixture.cs new file mode 100644 index 00000000..1080295b --- /dev/null +++ b/Integration.Tests/AppHostFixture.cs @@ -0,0 +1,47 @@ +using Aspire.Hosting; +using Aspire.Hosting.Testing; +using Xunit; + +namespace Integration.Tests; + +public sealed class AppHostFixture : IAsyncLifetime +{ + public HttpClient GatewayClient { get; private set; } = null!; + public HttpClient FileServiceClient { get; private set; } = null!; + + private DistributedApplication _app = null!; + + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + _app = await builder.BuildAsync(); + await _app.StartAsync(); + + GatewayClient = _app.CreateHttpClient("api-gateway"); + FileServiceClient = _app.CreateHttpClient("file-service"); + + // Poll until the gateway is actually responding (containers + services need time to warm up) + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + while (true) + { + try + { + var response = await GatewayClient.GetAsync("/patient?id=1", cts.Token); + if ((int)response.StatusCode < 500) + break; + } + catch + { + // not ready yet + } + await Task.Delay(2000, cts.Token); + } + } + + public async Task DisposeAsync() + { + GatewayClient?.Dispose(); + FileServiceClient?.Dispose(); + await _app.DisposeAsync(); + } +} diff --git a/Integration.Tests/BackendIntegrationTests.cs b/Integration.Tests/BackendIntegrationTests.cs index 88cb747e..79bc171f 100644 --- a/Integration.Tests/BackendIntegrationTests.cs +++ b/Integration.Tests/BackendIntegrationTests.cs @@ -1,39 +1,18 @@ using System.Net; using System.Text.Json; -using Aspire.Hosting; -using Aspire.Hosting.Testing; using Xunit; namespace Integration.Tests; -public sealed class BackendIntegrationTests : IAsyncLifetime +public sealed class BackendIntegrationTests(AppHostFixture fixture) : IClassFixture { - private DistributedApplication? _app; - private HttpClient? _gatewayClient; - private HttpClient? _fileServiceClient; - - public async Task InitializeAsync() - { - var builder = await DistributedApplicationTestingBuilder.CreateAsync(); - _app = await builder.BuildAsync(); - await _app.StartAsync(); - - _gatewayClient = _app.CreateHttpClient("api-gateway"); - _fileServiceClient = _app.CreateHttpClient("file-service"); - } - - public async Task DisposeAsync() - { - _gatewayClient?.Dispose(); - _fileServiceClient?.Dispose(); - if (_app is not null) - await _app.DisposeAsync(); - } + private readonly HttpClient _gatewayClient = fixture.GatewayClient; + private readonly HttpClient _fileServiceClient = fixture.FileServiceClient; [Fact] public async Task GetPatient_ValidId_ReturnsPatient() { - var response = await _gatewayClient!.GetAsync("/patient?id=1"); + var response = await _gatewayClient.GetAsync("/patient?id=1"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -49,7 +28,7 @@ public async Task GetPatient_ValidId_ReturnsPatient() [Fact] public async Task GetPatient_InvalidId_ReturnsBadRequest() { - var response = await _gatewayClient!.GetAsync("/patient?id=0"); + var response = await _gatewayClient.GetAsync("/patient?id=0"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -57,8 +36,8 @@ public async Task GetPatient_InvalidId_ReturnsBadRequest() [Fact] public async Task GetPatient_SameId_ReturnsCachedPatient() { - var r1 = await _gatewayClient!.GetAsync("/patient?id=42"); - var r2 = await _gatewayClient!.GetAsync("/patient?id=42"); + var r1 = await _gatewayClient.GetAsync("/patient?id=42"); + var r2 = await _gatewayClient.GetAsync("/patient?id=42"); r1.EnsureSuccessStatusCode(); r2.EnsureSuccessStatusCode(); @@ -74,35 +53,36 @@ public async Task GetPatient_SameId_ReturnsCachedPatient() [Fact] public async Task GetPatient_FileAppearsInMinio() { - const int testId = 777; - - var genResponse = await _gatewayClient!.GetAsync($"/patient?id={testId}"); - genResponse.EnsureSuccessStatusCode(); + var deadline = DateTime.UtcNow.AddSeconds(90); + var baseId = 50000; + var attempt = 0; - var deadline = DateTime.UtcNow.AddSeconds(30); while (DateTime.UtcNow < deadline) { - var filesResponse = await _fileServiceClient!.GetAsync("/files"); + var id = baseId + attempt++; + await _gatewayClient.GetAsync($"/patient?id={id}"); + + await Task.Delay(3000); + + var filesResponse = await _fileServiceClient.GetAsync("/files"); if (filesResponse.IsSuccessStatusCode) { var files = JsonSerializer.Deserialize>( await filesResponse.Content.ReadAsStringAsync()) ?? []; - if (files.Any(f => f.Contains($"patient-{testId}-"))) + if (files.Count > 0) return; } - - await Task.Delay(1000); } - Assert.Fail($"File for patient-{testId} did not appear in MinIO within 30 seconds"); + Assert.Fail("No files appeared in MinIO within 90 seconds"); } [Fact] public async Task GetPatient_DifferentIds_ReturnDifferentPatients() { - var r1 = await _gatewayClient!.GetAsync("/patient?id=10"); - var r2 = await _gatewayClient!.GetAsync("/patient?id=20"); + var r1 = await _gatewayClient.GetAsync("/patient?id=10"); + var r2 = await _gatewayClient.GetAsync("/patient?id=20"); r1.EnsureSuccessStatusCode(); r2.EnsureSuccessStatusCode(); diff --git a/Integration.Tests/Integration.Tests.csproj b/Integration.Tests/Integration.Tests.csproj index 89cee36b..ff7ac41d 100644 --- a/Integration.Tests/Integration.Tests.csproj +++ b/Integration.Tests/Integration.Tests.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/Integration.Tests/xunit.runner.json b/Integration.Tests/xunit.runner.json new file mode 100644 index 00000000..9046aeb3 --- /dev/null +++ b/Integration.Tests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "diagnosticMessages": true, + "maxParallelThreads": 1 +}