diff --git a/.idea/.idea.CloudDevelopment/.idea/.gitignore b/.idea/.idea.CloudDevelopment/.idea/.gitignore new file mode 100644 index 0000000..ec9f8f6 --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.CloudDevelopment.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/.idea.CloudDevelopment/.idea/.name b/.idea/.idea.CloudDevelopment/.idea/.name new file mode 100644 index 0000000..8eae80e --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/.name @@ -0,0 +1 @@ +CloudDevelopment \ No newline at end of file diff --git a/.idea/.idea.CloudDevelopment/.idea/encodings.xml b/.idea/.idea.CloudDevelopment/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.CloudDevelopment/.idea/indexLayout.xml b/.idea/.idea.CloudDevelopment/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.CloudDevelopment/.idea/inspectionProfiles/Project_Default.xml b/.idea/.idea.CloudDevelopment/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..5b90977 --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,427 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.CloudDevelopment/.idea/vcs.xml b/.idea/.idea.CloudDevelopment/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/.idea.CloudDevelopment/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a83..1ca27c8 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,81 @@ @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 []; + } + + var rows = new List<(int Number, string Name, string Value)>(); + + for (var i = 0; i < PropertyOrder.Length; i++) + { + var propertyKey = PropertyOrder[i]; + var displayName = PropertyNames.GetValueOrDefault(propertyKey, propertyKey); + var displayValue = FormatValue(propertyKey, Value[propertyKey]); + rows.Add((i + 1, displayName, displayValue)); + } + + return rows; + } + + 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 661f118..4c89aab 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,13 @@  - Лабораторная работа + Вариант проекта - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Предметная область «Медицинский пациент» + Балансировка Weighted Round Robin + Брокер сообщений SQS + Объектное хранилище Minio diff --git a/Client.Wasm/Pages/Home.razor b/Client.Wasm/Pages/Home.razor index b22b00e..20b4fa7 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/Program.cs b/Client.Wasm/Program.cs index a182a92..cbad30c 100644 --- a/Client.Wasm/Program.cs +++ b/Client.Wasm/Program.cs @@ -1,9 +1,12 @@ +using System; +using System.Net.Http; using Blazorise; using Blazorise.Bootstrap; using Blazorise.Icons.FontAwesome; using Client.Wasm; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); diff --git a/Client.Wasm/_Imports.razor b/Client.Wasm/_Imports.razor index 31e16a8..074c4f9 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 d1fe7ab..9d27bea 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5204/api/patient" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241..a3e3707 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,12 @@ VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +21,18 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Patient.AppHost/AppHost.cs b/Patient.AppHost/AppHost.cs new file mode 100644 index 0000000..c08253a --- /dev/null +++ b/Patient.AppHost/AppHost.cs @@ -0,0 +1,15 @@ +using Aspire.Hosting; + +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("patient-cache") + .WithRedisInsight(containerName: "patient-insight"); + +var generator = builder.AddProject("generator", "../Patient.Generator/Patient.Generator.csproj") + .WithReference(cache, "patient-cache") + .WaitFor(cache); + +builder.AddProject("client", "../Client.Wasm/Client.Wasm.csproj") + .WaitFor(generator); + +builder.Build().Run(); diff --git a/Patient.AppHost/Patient.AppHost.csproj b/Patient.AppHost/Patient.AppHost.csproj new file mode 100644 index 0000000..411c49f --- /dev/null +++ b/Patient.AppHost/Patient.AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + 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 0000000..330da1c --- /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 0000000..1b2d3ba --- /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 0000000..888f884 --- /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 0000000..17c34a0 --- /dev/null +++ b/Patient.Generator/Controller/PatientController.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +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 0000000..834914b --- /dev/null +++ b/Patient.Generator/DTO/PatientDto.cs @@ -0,0 +1,59 @@ +using System; + +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 0000000..21ed6dd --- /dev/null +++ b/Patient.Generator/Generator/PatientGenerator.cs @@ -0,0 +1,115 @@ +using System; +using Bogus; +using Microsoft.Extensions.Logging; +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(gender); + 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); + var earliestBirthDate = today.AddYears(-MaxAgeYears); + var totalDays = today.DayNumber - earliestBirthDate.DayNumber; + var offset = f.Random.Int(0, totalDays); + var birthDate = earliestBirthDate.AddDays(offset); + + return birthDate > today ? today : birthDate; + }) + .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); + var totalDays = today.DayNumber - dto.BirthDate.DayNumber; + var offset = f.Random.Int(0, totalDays); + var examinationDate = dto.BirthDate.AddDays(offset); + return examinationDate < dto.BirthDate ? dto.BirthDate : examinationDate; + }) + .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 0000000..4600dc2 --- /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 0000000..d21e6fc --- /dev/null +++ b/Patient.Generator/Program.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Patient.Generator.Generator; +using Patient.Generator.Service; +using Patient.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("patient-cache"); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowLocalDev", policy => + { + policy + .AllowAnyOrigin() + .WithHeaders("Content-Type") + .WithMethods("GET"); + }); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +app.UseCors("AllowLocalDev"); + +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 0000000..cbeaf73 --- /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 0000000..640b59d --- /dev/null +++ b/Patient.Generator/Service/IPatientCache.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; +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 0000000..0f40a10 --- /dev/null +++ b/Patient.Generator/Service/IPatientService.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +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 0000000..4a41317 --- /dev/null +++ b/Patient.Generator/Service/PatientCache.cs @@ -0,0 +1,104 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Patient.Generator.DTO; + +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 0000000..f959d2a --- /dev/null +++ b/Patient.Generator/Service/PatientService.cs @@ -0,0 +1,34 @@ +using System.Threading; +using System.Threading.Tasks; +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 0000000..0c208ae --- /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 0000000..84a788e --- /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 0000000..7615f58 --- /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 0000000..230756f --- /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 dcaa5eb..0b8301c 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). - diff --git "a/\320\262\320\276\320\277\321\200\320\276\321\201.md" "b/\320\262\320\276\320\277\321\200\320\276\321\201.md" new file mode 100644 index 0000000..02038ea --- /dev/null +++ "b/\320\262\320\276\320\277\321\200\320\276\321\201.md" @@ -0,0 +1,33 @@ +--- +name: Вопрос по лабораторной +about: Этот шаблон предназначен для того, чтобы студенты могли задать вопрос по лабораторной +title: Вопрос по лабораторной +labels: '' +assignees: Gwymlas, alxmcs, danlla + +--- + +**Меня зовут:** +Укажите свои ФИО + +**Я из группы:** +Укажите номер группы + +**У меня вопрос по лабе:** +Укажите номер и название лабораторной, по которой появился вопрос. + +**Мой вопрос:** +Максимально подробно опишите, что вы хотите узнать/что у вас не получается/что у вас не работает. При необходимости, добавьте примеры кода. Примеры кода должны быть оформлены с использованием md разметки, чтобы их можно было удобно воспринимать: + +```cs +public class Program +{ + public static void Main(string[] args) + { + System.Console.WriteLine("Hello, World!"); + } +} +``` + +**Дополнительная информация** +Опишите тут все, что не попадает под перечисленные ранее категории (если в том есть необходимость). \ No newline at end of file