diff --git a/Api.Gateway/Api.Gateway.csproj b/Api.Gateway/Api.Gateway.csproj new file mode 100644 index 00000000..21ca2f7f --- /dev/null +++ b/Api.Gateway/Api.Gateway.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/Api.Gateway/Program.cs b/Api.Gateway/Program.cs new file mode 100644 index 00000000..92a96436 --- /dev/null +++ b/Api.Gateway/Program.cs @@ -0,0 +1,35 @@ +using Api.Gateway; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Patient.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); +builder.Services.AddOcelot() + .AddCustomLoadBalancer((sp, _, provider) => + new WeightedRoundRobin(provider.GetAsync, sp.GetRequiredService())); + +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowLocalDev", policy => + { + policy + .WithOrigins(allowedOrigins) + .WithHeaders("Content-Type") + .WithMethods("GET"); + }); +}); + +builder.AddServiceDefaults(); +var app = builder.Build(); + +app.UseCors("AllowLocalDev"); + +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); diff --git a/Api.Gateway/Properties/launchSettings.json b/Api.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..563491d6 --- /dev/null +++ b/Api.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:15501", + "sslPort": 44374 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7212;http://localhost:5009", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Api.Gateway/WeightedRoundRobin.cs b/Api.Gateway/WeightedRoundRobin.cs new file mode 100644 index 00000000..7a2eae3e --- /dev/null +++ b/Api.Gateway/WeightedRoundRobin.cs @@ -0,0 +1,59 @@ +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace Api.Gateway; + +/// +/// Балансировщик Weighted Round Robin: распределяет запросы циклически +/// с учётом весов реплик из секции WeightedRoundRobin:Weights. +/// +/// Делегат, возвращающий список реплик. +/// Источник весов. +public class WeightedRoundRobin(Func>> serviceProviderFactory, IConfiguration configuration) : ILoadBalancer +{ + private readonly int[] _weights = configuration.GetSection("WeightedRoundRobin:Weights").Get() ?? []; + private long _counter = -1; + + public string Type => nameof(WeightedRoundRobin); + + public async Task> LeaseAsync(HttpContext context) + { + var services = await serviceProviderFactory(); + + if (services.Count == 0) + throw new InvalidOperationException("No available downstream services"); + + var serviceIndex = SelectIndex(services.Count); + + return new OkResponse(services[serviceIndex].HostAndPort); + } + + public void Release(ServiceHostAndPort hostAndPort) { } + + private int SelectIndex(int serviceCount) + { + var totalWeight = 0L; + for (var i = 0; i < serviceCount; i++) + totalWeight += GetWeight(i); + + var ticket = (Interlocked.Increment(ref _counter) & long.MaxValue) % totalWeight; + + var cumulative = 0L; + for (var i = 0; i < serviceCount; i++) + { + cumulative += GetWeight(i); + if (ticket < cumulative) + return i; + } + + return serviceCount - 1; + } + + private int GetWeight(int index) + { + if (index >= _weights.Length) return 1; + var weight = _weights[index]; + return weight > 0 ? weight : 1; + } +} diff --git a/Api.Gateway/appsettings.Development.json b/Api.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Api.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Api.Gateway/appsettings.json b/Api.Gateway/appsettings.json new file mode 100644 index 00000000..fd456a48 --- /dev/null +++ b/Api.Gateway/appsettings.json @@ -0,0 +1,18 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ + "http://localhost:5127", + "https://localhost:7282" + ] + }, + "WeightedRoundRobin": { + "Weights": [ 5, 4, 3, 2, 1 ] + } +} diff --git a/Api.Gateway/ocelot.json b/Api.Gateway/ocelot.json new file mode 100644 index 00000000..8115ff5d --- /dev/null +++ b/Api.Gateway/ocelot.json @@ -0,0 +1,20 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/api/patient", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/api/patient", + "DownstreamScheme": "https", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 5200 }, + { "Host": "localhost", "Port": 5201 }, + { "Host": "localhost", "Port": 5202 }, + { "Host": "localhost", "Port": 5203 }, + { "Host": "localhost", "Port": 5204 } + ], + "LoadBalancerOptions": { + "Type": "WeightedRoundRobin" + } + } + ] +} diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..bf6d60d2 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -1,10 +1,10 @@ -@inject IConfiguration Configuration +@inject Microsoft.Extensions.Configuration.IConfiguration Configuration @inject HttpClient Client - Характеристики текущего объекта + Характеристики пациента @@ -16,7 +16,7 @@ - @if(Value is null) + @if (Value is null) { 1 @@ -26,13 +26,13 @@ } else { - var array = Value.ToArray(); - foreach (var property in array) + var rows = BuildRows(); + foreach (var row in rows) { - @(Array.IndexOf(array, property)+1) - @property.Key - @property.Value?.ToString() + @row.Number + @row.Name + @row.Value } } @@ -43,18 +43,18 @@ - Запросить новый объект + Запросить нового пациента - Идентификатор нового объекта: + Идентификатор пациента: - + @@ -62,13 +62,76 @@ @code { + private static readonly string[] PropertyOrder = + [ + "id", + "fullName", + "address", + "birthDate", + "height", + "weight", + "bloodGroup", + "rhFactor", + "lastExaminationDate", + "isVaccinated" + ]; + + private static readonly Dictionary PropertyNames = new() + { + ["id"] = "Идентификатор в системе", + ["fullName"] = "ФИО пациента", + ["address"] = "Адрес проживания", + ["birthDate"] = "Дата рождения", + ["height"] = "Рост", + ["weight"] = "Вес", + ["bloodGroup"] = "Группа крови", + ["rhFactor"] = "Резус-фактор", + ["lastExaminationDate"] = "Дата последнего осмотра", + ["isVaccinated"] = "Отметка о вакцинации" + }; + private JsonObject? Value { get; set; } private int Id { get; set; } private async Task RequestNewData() { var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress"); - Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { }); + var requestUri = string.Concat(baseAddress, "?id=", Id); + Value = await Client.GetFromJsonAsync(requestUri, new JsonSerializerOptions()); StateHasChanged(); } + + private IReadOnlyList<(int Number, string Name, string Value)> BuildRows() + { + if (Value is null) + { + return []; + } + + return PropertyOrder + .Select((propertyKey, i) => ( + i + 1, + PropertyNames.GetValueOrDefault(propertyKey, propertyKey), + FormatValue(propertyKey, Value[propertyKey]))) + .ToList(); + } + + private static string FormatValue(string propertyKey, JsonNode? node) + { + if (node is null) + { + return "нет данных"; + } + + var rawValue = node.ToString(); + + return propertyKey switch + { + "height" => $"{rawValue} см", + "weight" => $"{rawValue} кг", + "rhFactor" => rawValue.Equals("true", StringComparison.OrdinalIgnoreCase) ? "положительный" : "отрицательный", + "isVaccinated" => rawValue.Equals("true", StringComparison.OrdinalIgnoreCase) ? "да" : "нет", + _ => rawValue + }; + } } diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..07b229b0 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №2 "Балансировка нагрузки" + Вариант №20 "Медицинский пациент" + Выполнила Горбунцова Александра 6512 + Ссылка на форк diff --git a/Client.Wasm/Pages/Home.razor b/Client.Wasm/Pages/Home.razor index b22b00ed..20b4fa7f 100644 --- a/Client.Wasm/Pages/Home.razor +++ b/Client.Wasm/Pages/Home.razor @@ -1,3 +1,3 @@ @page "/" - \ 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/Client.Wasm/_Imports.razor b/Client.Wasm/_Imports.razor index 31e16a84..074c4f97 100644 --- a/Client.Wasm/_Imports.razor +++ b/Client.Wasm/_Imports.razor @@ -1,14 +1,18 @@ -@using System.Net.Http +@using System +@using System.Collections.Generic +@using System.Net.Http @using System.Net.Http.Json +@using System.Text.Json +@using System.Text.Json.Nodes +@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.Extensions.Configuration @using Microsoft.JSInterop @using Client.Wasm.Layout @using Client.Wasm.Components -@using Blazorise +@using Blazorise @using Blazorise.Components -@using System.Text.Json -@using System.Text.Json.Nodes \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..ad30cc6b 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "https://localhost:7212/api/patient" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..9d1194e1 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,14 @@ 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}") = "Patient.ServiceDefaults", "Patient.ServiceDefaults\Patient.ServiceDefaults.csproj", "{97B30C3C-3125-4E99-BA67-240DD8126A25}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Patient.Generator", "Patient.Generator\Patient.Generator.csproj", "{A9B4BB9B-9F03-4C1F-AB67-9DAD2E4D66BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Patient.AppHost", "Patient.AppHost\Patient.AppHost.csproj", "{07AFB6CB-7359-432D-BF0B-14BA7C582AA5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.Gateway", "Api.Gateway\Api.Gateway.csproj", "{C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +23,22 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {97B30C3C-3125-4E99-BA67-240DD8126A25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97B30C3C-3125-4E99-BA67-240DD8126A25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97B30C3C-3125-4E99-BA67-240DD8126A25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97B30C3C-3125-4E99-BA67-240DD8126A25}.Release|Any CPU.Build.0 = Release|Any CPU + {A9B4BB9B-9F03-4C1F-AB67-9DAD2E4D66BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9B4BB9B-9F03-4C1F-AB67-9DAD2E4D66BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9B4BB9B-9F03-4C1F-AB67-9DAD2E4D66BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9B4BB9B-9F03-4C1F-AB67-9DAD2E4D66BA}.Release|Any CPU.Build.0 = Release|Any CPU + {07AFB6CB-7359-432D-BF0B-14BA7C582AA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07AFB6CB-7359-432D-BF0B-14BA7C582AA5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07AFB6CB-7359-432D-BF0B-14BA7C582AA5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07AFB6CB-7359-432D-BF0B-14BA7C582AA5}.Release|Any CPU.Build.0 = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C99E72F4-9BA7-7D56-C88E-FB28534EFCB6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Patient.AppHost/AppHost.cs b/Patient.AppHost/AppHost.cs new file mode 100644 index 00000000..321db5be --- /dev/null +++ b/Patient.AppHost/AppHost.cs @@ -0,0 +1,20 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("patient-cache") + .WithRedisInsight(containerName: "patient-insight"); + +var gateway = builder.AddProject("api-gateway"); + +for (var i = 0; i < 5; i++) +{ + var generator = builder.AddProject($"generator-{i}", launchProfileName: null) + .WithHttpsEndpoint(5200 + i) + .WithReference(cache, "patient-cache") + .WaitFor(cache); + gateway.WaitFor(generator); +} + +builder.AddProject("client") + .WaitFor(gateway); + +builder.Build().Run(); diff --git a/Patient.AppHost/Patient.AppHost.csproj b/Patient.AppHost/Patient.AppHost.csproj new file mode 100644 index 00000000..f8a3aea1 --- /dev/null +++ b/Patient.AppHost/Patient.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net8.0 + enable + enable + ed7e1e47-dc98-4419-8424-85412466aa9b + + + + + + + + + + + + + + diff --git a/Patient.AppHost/Properties/launchSettings.json b/Patient.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..330da1c2 --- /dev/null +++ b/Patient.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17129;http://localhost:15221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21101", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22255" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15221", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19083", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20274" + } + } + } +} \ No newline at end of file diff --git a/Patient.AppHost/appsettings.Development.json b/Patient.AppHost/appsettings.Development.json new file mode 100644 index 00000000..1b2d3baf --- /dev/null +++ b/Patient.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/Patient.AppHost/appsettings.json b/Patient.AppHost/appsettings.json new file mode 100644 index 00000000..888f884e --- /dev/null +++ b/Patient.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} \ No newline at end of file diff --git a/Patient.Generator/Controller/PatientController.cs b/Patient.Generator/Controller/PatientController.cs new file mode 100644 index 00000000..7e34bbdf --- /dev/null +++ b/Patient.Generator/Controller/PatientController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Patient.Generator.DTO; +using Patient.Generator.Service; + +namespace Patient.Generator.Controller; + +/// +/// API контроллер для работы с медицинскими пациентами. +/// +[ApiController] +[Route("api/patient")] +public sealed class PatientController(ILogger logger, IPatientService service) : ControllerBase +{ + /// + /// Получить пациента по идентификатору. + /// + /// Идентификатор пациента. + /// Токен отмены. + /// Данные пациента. + [HttpGet] + [ProducesResponseType(typeof(PatientDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> Get([FromQuery] int id, CancellationToken cancellationToken) + { + if (id < 0) + { + return BadRequest(new { message = "id cannot be negative" }); + } + + logger.LogInformation("Request patient id={id}.", id); + var dto = await service.GetAsync(id, cancellationToken); + logger.LogInformation("Response patient id={id}.", id); + + return Ok(dto); + } +} diff --git a/Patient.Generator/DTO/PatientDto.cs b/Patient.Generator/DTO/PatientDto.cs new file mode 100644 index 00000000..ddac9daf --- /dev/null +++ b/Patient.Generator/DTO/PatientDto.cs @@ -0,0 +1,57 @@ +namespace Patient.Generator.DTO; + +/// +/// DTO для передачи данных о медицинском пациенте. +/// +public sealed class PatientDto +{ + /// + /// Уникальный идентификатор пациента в системе. + /// + public int Id { get; set; } + + /// + /// Фамилия, имя и отчество пациента через пробел. + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Адрес проживания пациента. + /// + public string Address { get; set; } = string.Empty; + + /// + /// Дата рождения пациента. + /// + public DateOnly BirthDate { get; set; } + + /// + /// Рост пациента в сантиметрах. + /// + public double Height { get; set; } + + /// + /// Вес пациента в килограммах. + /// + public double Weight { get; set; } + + /// + /// Группа крови от 1 до 4. + /// + public int BloodGroup { get; set; } + + /// + /// Резус-фактор пациента. + /// + public bool RhFactor { get; set; } + + /// + /// Дата последнего осмотра. + /// + public DateOnly LastExaminationDate { get; set; } + + /// + /// Отметка о вакцинации. + /// + public bool IsVaccinated { get; set; } +} diff --git a/Patient.Generator/Generator/PatientGenerator.cs b/Patient.Generator/Generator/PatientGenerator.cs new file mode 100644 index 00000000..8b9d75c8 --- /dev/null +++ b/Patient.Generator/Generator/PatientGenerator.cs @@ -0,0 +1,105 @@ +using Bogus; +using Patient.Generator.DTO; + +namespace Patient.Generator.Generator; + +/// +/// Генератор тестовых данных для медицинских пациентов. +/// +public sealed class PatientGenerator(ILogger logger) +{ + /// + /// Максимальный возраст пациента в годах. + /// + private const int MaxAgeYears = 100; + /// + /// Минимальный рост пациента в сантиметрах. + /// + private const double MinHeight = 50.0; + /// + /// Максимальный рост пациента в сантиметрах. + /// + private const double MaxHeight = 220.0; + /// + /// Минимальный вес пациента в килограммах. + /// + private const double MinWeight = 3.0; + /// + /// Максимальный вес пациента в килограммах. + /// + private const double MaxWeight = 250.0; + + /// + /// Faker для генерации тестовых данных пациентов. + /// + private static readonly Faker _faker = new Faker("ru") + .RuleFor(x => x.FullName, f => + { + var gender = f.PickRandom(); + var firstName = f.Name.FirstName(gender); + var patronymicBase = f.Name.FirstName(Bogus.DataSets.Name.Gender.Male); + var patronymic = BuildPatronymic(patronymicBase, gender); + return $"{f.Name.LastName(gender)} {firstName} {patronymic}"; + }) + .RuleFor(x => x.Address, f => f.Address.FullAddress()) + .RuleFor(x => x.BirthDate, f => + { + var today = DateOnly.FromDateTime(DateTime.Today); + return f.Date.BetweenDateOnly(today.AddYears(-MaxAgeYears), today); + }) + .RuleFor(x => x.Height, + f => Math.Round(f.Random.Double(MinHeight, MaxHeight), 2, MidpointRounding.AwayFromZero)) + .RuleFor(x => x.Weight, + f => Math.Round(f.Random.Double(MinWeight, MaxWeight), 2, MidpointRounding.AwayFromZero)) + .RuleFor(x => x.BloodGroup, f => f.Random.Int(1, 4)) + .RuleFor(x => x.RhFactor, f => f.Random.Bool()) + .RuleFor(x => x.LastExaminationDate, (f, dto) => + { + var today = DateOnly.FromDateTime(DateTime.Today); + return f.Date.BetweenDateOnly(dto.BirthDate, today); + }) + .RuleFor(x => x.IsVaccinated, f => f.Random.Bool(0.8f)); + + /// + /// Генерирует случайные данные пациента с указанным идентификатором. + /// + /// Уникальный идентификатор пациента. + /// Объект PatientDto со случайно сгенерированными данными пациента. + public PatientDto Generate(int id) + { + logger.LogInformation("Generating patient for id={id}", id); + + var item = _faker.Generate(); + item.Id = id; + + logger.LogInformation("Patient generated: {@Patient}", new + { + item.Id, + item.FullName, + item.Address, + item.BirthDate, + item.Height, + item.Weight, + item.BloodGroup, + item.RhFactor, + item.LastExaminationDate, + item.IsVaccinated + }); + + return item; + } + + private static string BuildPatronymic(string baseName, Bogus.DataSets.Name.Gender gender) + { + var stem = baseName.TrimEnd('а', 'я', 'й', 'ь'); + + if (string.IsNullOrWhiteSpace(stem)) + { + stem = baseName; + } + + return gender == Bogus.DataSets.Name.Gender.Female + ? $"{stem}овна" + : $"{stem}ович"; + } +} diff --git a/Patient.Generator/Patient.Generator.csproj b/Patient.Generator/Patient.Generator.csproj new file mode 100644 index 00000000..4600dc28 --- /dev/null +++ b/Patient.Generator/Patient.Generator.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + Patient.Generator + + + + + + + + + + + + + diff --git a/Patient.Generator/Program.cs b/Patient.Generator/Program.cs new file mode 100644 index 00000000..16a31527 --- /dev/null +++ b/Patient.Generator/Program.cs @@ -0,0 +1,29 @@ +using Patient.Generator.Generator; +using Patient.Generator.Service; +using Patient.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("patient-cache"); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/Patient.Generator/Properties/LaunchSettings.json b/Patient.Generator/Properties/LaunchSettings.json new file mode 100644 index 00000000..cbeaf732 --- /dev/null +++ b/Patient.Generator/Properties/LaunchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:39434", + "sslPort": 44329 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5204", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7291;http://localhost:5204", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Patient.Generator/Service/IPatientCache.cs b/Patient.Generator/Service/IPatientCache.cs new file mode 100644 index 00000000..3a9b869b --- /dev/null +++ b/Patient.Generator/Service/IPatientCache.cs @@ -0,0 +1,25 @@ +using Patient.Generator.DTO; + +namespace Patient.Generator.Service; + +/// +/// Интерфейс для кэширования медицинских пациентов. +/// +public interface IPatientCache +{ + /// + /// Получить пациента из кэша по идентификатору. + /// + /// Идентификатор пациента. + /// Токен отмены. + /// DTO пациента или null, если не найден в кэше. + public Task GetAsync(int id, CancellationToken cancellationToken = default); + + /// + /// Сохранить пациента в кэш. + /// + /// Идентификатор пациента. + /// DTO пациента для сохранения. + /// Токен отмены. + public Task SetAsync(int id, PatientDto value, CancellationToken cancellationToken = default); +} diff --git a/Patient.Generator/Service/IPatientService.cs b/Patient.Generator/Service/IPatientService.cs new file mode 100644 index 00000000..7e2b6516 --- /dev/null +++ b/Patient.Generator/Service/IPatientService.cs @@ -0,0 +1,17 @@ +using Patient.Generator.DTO; + +namespace Patient.Generator.Service; + +/// +/// Интерфейс для сервиса работы с медицинскими пациентами. +/// +public interface IPatientService +{ + /// + /// Получить пациента по идентификатору. + /// + /// Идентификатор пациента. + /// Токен отмены. + /// DTO пациента. + public Task GetAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/Patient.Generator/Service/PatientCache.cs b/Patient.Generator/Service/PatientCache.cs new file mode 100644 index 00000000..149083db --- /dev/null +++ b/Patient.Generator/Service/PatientCache.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.Caching.Distributed; +using Patient.Generator.DTO; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Patient.Generator.Service; + +/// +/// Реализация кэширования медицинских пациентов с использованием распределенного кэша. +/// +public sealed class PatientCache( + ILogger logger, + IDistributedCache cache, + IConfiguration configuration) : IPatientCache +{ + private const string CacheKeyPrefix = "patient:"; + private const int CacheExpirationTimeMinutesDefault = 15; + + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never + }; + + private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes( + configuration.GetValue("CacheSettings:ExpirationTimeMinutes", CacheExpirationTimeMinutesDefault)); + + /// + /// Получить пациента из кэша по идентификатору. + /// + /// Идентификатор пациента. + /// Токен отмены. + /// DTO пациента или null, если не найден в кэше. + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + var cacheKey = $"{CacheKeyPrefix}{id}"; + + string? json; + try + { + json = await cache.GetStringAsync(cacheKey, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache read failed for key={cacheKey}.", cacheKey); + return null; + } + + if (string.IsNullOrWhiteSpace(json)) + { + logger.LogInformation("Cache miss for key={cacheKey}.", cacheKey); + return null; + } + + try + { + var obj = JsonSerializer.Deserialize(json, _jsonOptions); + if (obj is null) + { + logger.LogWarning("Cache value for key={cacheKey} deserialized as null.", cacheKey); + return null; + } + + logger.LogInformation("Cache hit for id={id}.", obj.Id); + return obj; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache JSON invalid for key={cacheKey}.", cacheKey); + return null; + } + } + + /// + /// Сохранить пациента в кэш. + /// + /// Идентификатор пациента. + /// DTO пациента для сохранения. + /// Токен отмены. + public async Task SetAsync(int id, PatientDto value, CancellationToken cancellationToken = default) + { + var cacheKey = $"{CacheKeyPrefix}{id}"; + + try + { + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheTtl + }; + + var json = JsonSerializer.Serialize(value, _jsonOptions); + await cache.SetStringAsync(cacheKey, json, options, cancellationToken); + logger.LogInformation("Cached id={id} for ttl={ttlMinutes}m.", value.Id, _cacheTtl.TotalMinutes); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache write failed for id={id}.", value.Id); + } + } +} diff --git a/Patient.Generator/Service/PatientService.cs b/Patient.Generator/Service/PatientService.cs new file mode 100644 index 00000000..d1a97ea2 --- /dev/null +++ b/Patient.Generator/Service/PatientService.cs @@ -0,0 +1,32 @@ +using Patient.Generator.DTO; +using Patient.Generator.Generator; + +namespace Patient.Generator.Service; + +/// +/// Реализация сервиса работы с медицинскими пациентами. +/// +public sealed class PatientService( + PatientGenerator generator, + IPatientCache cache) : IPatientService +{ + /// + /// Получить пациента по идентификатору. Если пациент не найден в кэше, генерирует нового и сохраняет в кэш. + /// + /// Идентификатор пациента. + /// Токен отмены. + /// DTO пациента. + public async Task GetAsync(int id, CancellationToken cancellationToken = default) + { + var cached = await cache.GetAsync(id, cancellationToken); + if (cached is not null) + { + return cached; + } + + var generated = generator.Generate(id); + await cache.SetAsync(id, generated, cancellationToken); + + return generated; + } +} diff --git a/Patient.Generator/appsettings.Development.json b/Patient.Generator/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/Patient.Generator/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Patient.Generator/appsettings.json b/Patient.Generator/appsettings.json new file mode 100644 index 00000000..84a788ed --- /dev/null +++ b/Patient.Generator/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "CacheSettings": { + "ExpirationTimeMinutes": 5 + }, + "AllowedHosts": "*" +} diff --git a/Patient.ServiceDefaults/Extensions.cs b/Patient.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..7615f58b --- /dev/null +++ b/Patient.ServiceDefaults/Extensions.cs @@ -0,0 +1,100 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Http; +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 Patient.ServiceDefaults; + +// Common cross-service wiring for Aspire: telemetry, health checks, service discovery, and resilient HttpClient. +public static class Extensions +{ + private const string ReadyPath = "/health"; + private const string AlivePath = "/alive"; + private const string LiveTag = "live"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.AddDefaultHealthChecks(); + builder.ConfigureOpenTelemetry(); + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(static http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(static logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(static metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(options => + { + options.Filter = (HttpContext context) => !IsHealthRequest(context.Request.Path); + }) + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), new[] { LiveTag }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(ReadyPath); + app.MapHealthChecks(AlivePath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains(LiveTag) + }); + } + + return app; + } + + private static bool IsHealthRequest(PathString path) + => path.StartsWithSegments(ReadyPath) || path.StartsWithSegments(AlivePath); +} diff --git a/Patient.ServiceDefaults/Patient.ServiceDefaults.csproj b/Patient.ServiceDefaults/Patient.ServiceDefaults.csproj new file mode 100644 index 00000000..230756f9 --- /dev/null +++ b/Patient.ServiceDefaults/Patient.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index dcaa5eb7..0b8301cc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,26 @@ [Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) [Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) +## Параметры этого варианта + +* Предметная область: `Медицинский пациент` +* Алгоритм балансировки: `Weighted Round Robin` +* Брокер сообщений: `SQS` +* Объектное хранилище: `Minio` + +Сервис генерации в текущем состоянии возвращает следующие характеристики пациента: + +1. Идентификатор в системе (`int`) +2. ФИО пациента (`string`) +3. Адрес проживания (`string`) +4. Дата рождения (`DateOnly`) +5. Рост (`double`) +6. Вес (`double`) +7. Группа крови (`int`) +8. Резус-фактор (`bool`) +9. Дата последнего осмотра (`DateOnly`) +10. Отметка о вакцинации (`bool`) + ## Схема сдачи На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). @@ -125,4 +145,3 @@ Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](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). -