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..0f26a797 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 "Интеграционное тестирование" + Вариант №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..a19c5569 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -5,6 +5,18 @@ 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 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "File.Service", "File.Service\File.Service.csproj", "{DF944664-05C9-E01F-B262-C8B381C1EFE1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Patient.AppHost.Tests", "Patient.AppHost.Tests\Patient.AppHost.Tests.csproj", "{68C6E6DD-B99E-481D-A274-DFB028B203D4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +27,30 @@ 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 + {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DF944664-05C9-E01F-B262-C8B381C1EFE1}.Release|Any CPU.Build.0 = Release|Any CPU + {68C6E6DD-B99E-481D-A274-DFB028B203D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68C6E6DD-B99E-481D-A274-DFB028B203D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68C6E6DD-B99E-481D-A274-DFB028B203D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68C6E6DD-B99E-481D-A274-DFB028B203D4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/File.Service/Controllers/S3StorageController.cs b/File.Service/Controllers/S3StorageController.cs new file mode 100644 index 00000000..debcfc0c --- /dev/null +++ b/File.Service/Controllers/S3StorageController.cs @@ -0,0 +1,63 @@ +using File.Service.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace File.Service.Controllers; + +/// +/// Контроллер для взаимодействия с объектным хранилищем S3 +/// +/// Служба для работы с S3 +/// Логгер +[ApiController] +[Route("api/s3")] +public sealed class S3StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Получает список хранящихся в S3 файлов + /// + /// Список ключей файлов + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task>> ListFiles() + { + logger.LogInformation("Method {method} of {controller} was called", nameof(ListFiles), nameof(S3StorageController)); + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Got a list of {count} files from bucket", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(ListFiles), nameof(S3StorageController)); + return BadRequest(ex); + } + } + + /// + /// Получает строковое представление хранящегося в S3 документа. + /// + /// Ключ файла. + /// JSON-представление файла. + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(400)] + public async Task> GetFile(string key) + { + logger.LogInformation("Method {method} of {controller} was called", nameof(GetFile), nameof(S3StorageController)); + try + { + var node = await s3Service.DownloadFile(key); + logger.LogInformation("Received json of {size} bytes", Encoding.UTF8.GetByteCount(node.ToJsonString())); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occured during {method} of {controller}", nameof(GetFile), nameof(S3StorageController)); + return BadRequest(ex); + } + } +} diff --git a/File.Service/File.Service.csproj b/File.Service/File.Service.csproj new file mode 100644 index 00000000..b3fc8e60 --- /dev/null +++ b/File.Service/File.Service.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + File.Service + true + + + + + + + + + + + + + + + diff --git a/File.Service/Messaging/SqsConsumerService.cs b/File.Service/Messaging/SqsConsumerService.cs new file mode 100644 index 00000000..6b85a2a7 --- /dev/null +++ b/File.Service/Messaging/SqsConsumerService.cs @@ -0,0 +1,70 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using File.Service.Storage; + +namespace File.Service.Messaging; + +/// +/// Клиентская служба для приёма сообщений с пациентами из очереди SQS +/// +/// Клиент SQS +/// Фабрика областей служб +/// Конфигурация +/// Логгер +public sealed class SqsConsumerService( + IAmazonSQS sqsClient, + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("SQS queue name was not found in configuration"); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("SQS consumer service started."); + + while (!stoppingToken.IsCancellationRequested) + { + var response = await sqsClient.ReceiveMessageAsync( + new ReceiveMessageRequest + { + QueueUrl = _queueName, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 5 + }, stoppingToken); + + if (response == null) + { + logger.LogWarning("Received null from {queue}", _queueName); + continue; + } + + if (response.Messages is { Count: > 0 }) + { + logger.LogInformation("Received {count} messages", response.Messages.Count); + + foreach (var message in response.Messages) + { + try + { + logger.LogInformation("Processing message: {messageId}", message.MessageId); + + using var scope = scopeFactory.CreateScope(); + var s3Service = scope.ServiceProvider.GetRequiredService(); + await s3Service.UploadFile(message.Body); + + await sqsClient.DeleteMessageAsync(_queueName, message.ReceiptHandle, stoppingToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing message: {messageId}", message.MessageId); + continue; + } + } + logger.LogInformation("Batch of {count} messages processed", response.Messages.Count); + } + } + } +} diff --git a/File.Service/Program.cs b/File.Service/Program.cs new file mode 100644 index 00000000..3346f1e3 --- /dev/null +++ b/File.Service/Program.cs @@ -0,0 +1,44 @@ +using Amazon.SQS; +using File.Service.Messaging; +using File.Service.Storage; +using LocalStack.Client.Extensions; +using Patient.ServiceDefaults; +using System.Reflection; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + var assembly = Assembly.GetExecutingAssembly(); + var xmlPath = Path.Combine(AppContext.BaseDirectory, $"{assembly.GetName().Name}.xml"); + if (System.IO.File.Exists(xmlPath)) + options.IncludeXmlComments(xmlPath); +}); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddHostedService(); + +builder.AddMinioClient("patient-minio"); +builder.Services.AddScoped(); + +var app = builder.Build(); + +using var scope = app.Services.CreateScope(); + +var s3Service = scope.ServiceProvider.GetRequiredService(); +await s3Service.EnsureBucketExists(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/File.Service/Properties/launchSettings.json b/File.Service/Properties/launchSettings.json new file mode 100644 index 00000000..9f725650 --- /dev/null +++ b/File.Service/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:18243", + "sslPort": 44342 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5208", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7215;http://localhost:5208", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/File.Service/Storage/IS3Service.cs b/File.Service/Storage/IS3Service.cs new file mode 100644 index 00000000..fae41db7 --- /dev/null +++ b/File.Service/Storage/IS3Service.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; + +namespace File.Service.Storage; + +/// +/// Интерфейс службы для манипуляции файлами в объектном хранилище +/// +public interface IS3Service +{ + /// + /// Отправляет файл в хранилище + /// + /// Строковая репрезентация сохраняемого файла + /// Признак успешной загрузки + public Task UploadFile(string fileData); + + /// + /// Получает список всех файлов из хранилища + /// + /// Список ключей файлов + public Task> GetFileList(); + + /// + /// Получает строковую репрезентацию файла из хранилища + /// + /// Ключ файла в бакете + /// JSON-представление прочитанного файл + public Task DownloadFile(string filePath); + + /// + /// Создаёт S3-бакет при необходимости + /// + public Task EnsureBucketExists(); +} diff --git a/File.Service/Storage/S3MinioService.cs b/File.Service/Storage/S3MinioService.cs new file mode 100644 index 00000000..2ba62bd8 --- /dev/null +++ b/File.Service/Storage/S3MinioService.cs @@ -0,0 +1,131 @@ +using Minio; +using Minio.DataModel.Args; +using System.Net; +using System.Text; +using System.Text.Json.Nodes; + +namespace File.Service.Storage; + +/// +/// Служба для манипуляции файлами с пациентами в объектном хранилище Minio. +/// +/// S3-клиент Minio. +/// Конфигурация. +/// Логгер. +public sealed class S3MinioService( + IMinioClient client, + IConfiguration configuration, + ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:MinioBucketName"] + ?? throw new KeyNotFoundException("S3 bucket name was not found in configuration"); + + /// + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsArgs() + .WithBucket(_bucketName) + .WithPrefix("") + .WithRecursive(true); + logger.LogInformation("Began listing files in {bucket}", _bucketName); + var responseList = client.ListObjectsEnumAsync(request); + + if (responseList == null) + logger.LogWarning("Received null response from {bucket}", _bucketName); + + await foreach (var response in responseList!) + list.Add(response.Key); + return list; + } + + /// + public async Task UploadFile(string fileData) + { + var rootNode = JsonNode.Parse(fileData) ?? throw new ArgumentException("Passed string is not a valid JSON"); + var id = rootNode["id"]?.GetValue() ?? throw new ArgumentException("Passed JSON has invalid structure"); + + var bytes = Encoding.UTF8.GetBytes(fileData); + using var stream = new MemoryStream(bytes); + stream.Seek(0, SeekOrigin.Begin); + + logger.LogInformation("Began uploading patient {file} onto {bucket}", id, _bucketName); + var request = new PutObjectArgs() + .WithBucket(_bucketName) + .WithStreamData(stream) + .WithObjectSize(bytes.Length) + .WithObject($"patient_{id}.json"); + + var response = await client.PutObjectAsync(request); + + if (response.ResponseStatusCode != HttpStatusCode.OK) + { + logger.LogError("Failed to upload patient {file}: {code}", id, response.ResponseStatusCode); + return false; + } + logger.LogInformation("Finished uploading patient {file} to {bucket}", id, _bucketName); + return true; + } + + /// + public async Task DownloadFile(string key) + { + logger.LogInformation("Began downloading {file} from {bucket}", key, _bucketName); + + try + { + var memoryStream = new MemoryStream(); + + var request = new GetObjectArgs() + .WithBucket(_bucketName) + .WithObject(key) + .WithCallbackStream(async (stream, cancellationToken) => + { + await stream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Seek(0, SeekOrigin.Begin); + }); + + var response = await client.GetObjectAsync(request); + + if (response == null) + { + logger.LogError("Failed to download {file}", key); + throw new InvalidOperationException($"Error occurred downloading {key} - object is null"); + } + using var reader = new StreamReader(memoryStream, Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd()) ?? throw new InvalidOperationException("Downloaded document is not a valid JSON"); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception occurred during {file} downloading", key); + throw; + } + } + + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Checking whether {bucket} exists", _bucketName); + try + { + var request = new BucketExistsArgs() + .WithBucket(_bucketName); + + var exists = await client.BucketExistsAsync(request); + if (!exists) + { + logger.LogInformation("Creating {bucket}", _bucketName); + var createRequest = new MakeBucketArgs() + .WithBucket(_bucketName); + await client.MakeBucketAsync(createRequest); + return; + } + logger.LogInformation("{bucket} exists", _bucketName); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception occurred during {bucket} check", _bucketName); + throw; + } + } +} diff --git a/File.Service/appsettings.Development.json b/File.Service/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/File.Service/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/File.Service/appsettings.json b/File.Service/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/File.Service/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Patient.AppHost.Tests/IntegrationTest.cs b/Patient.AppHost.Tests/IntegrationTest.cs new file mode 100644 index 00000000..1748a61f --- /dev/null +++ b/Patient.AppHost.Tests/IntegrationTest.cs @@ -0,0 +1,79 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Logging; +using Patient.Generator.DTO; +using System.Text.Json; +using Xunit.Abstractions; + +namespace Patient.AppHost.Tests; + +/// +/// Интеграционные тесты микросервисного пайплайна (SQS + Minio) +/// +/// Служба журналирования юнит-тестов. +public sealed class IntegrationTest(ITestOutputHelper output) : IAsyncLifetime +{ + private DistributedApplication? _app; + + /// + public async Task InitializeAsync() + { + var cancellationToken = CancellationToken.None; + var builder = await DistributedApplicationTestingBuilder.CreateAsync(cancellationToken); + builder.Configuration["DcpPublisher:RandomizePorts"] = "false"; + builder.Services.AddLogging(logging => + { + logging.AddXUnit(output); + logging.SetMinimumLevel(LogLevel.Debug); + logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug); + logging.AddFilter("Aspire.Hosting", LogLevel.Debug); + }); + _app = await builder.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + } + + /// + /// Проверяет весь пайплайн: запрос к гейтвею генерирует пациента, тот через SQS + /// попадает в Minio, и сохранённый файл совпадает с ответом API + /// + [Fact] + public async Task TestPipeline() + { + var cancellationToken = CancellationToken.None; + + var random = new Random(); + var id = random.Next(1, 1000); + + using var gatewayClient = _app!.CreateHttpClient("api-gateway", "http"); + using var gatewayResponse = await gatewayClient.GetAsync($"/api/patient?id={id}", cancellationToken); + var apiPatient = JsonSerializer.Deserialize( + await gatewayResponse.Content.ReadAsStringAsync(cancellationToken)); + + await Task.Delay(5000, cancellationToken); + + using var fileClient = _app!.CreateHttpClient("file-service", "http"); + using var listResponse = await fileClient.GetAsync("/api/s3", cancellationToken); + var fileList = JsonSerializer.Deserialize>( + await listResponse.Content.ReadAsStringAsync(cancellationToken)); + + using var s3Response = await fileClient.GetAsync($"/api/s3/patient_{id}.json", cancellationToken); + var s3Patient = JsonSerializer.Deserialize( + await s3Response.Content.ReadAsStringAsync(cancellationToken)); + + Assert.NotNull(fileList); + Assert.Contains($"patient_{id}.json", fileList); + Assert.NotNull(apiPatient); + Assert.NotNull(s3Patient); + Assert.Equal(id, s3Patient.Id); + Assert.Equivalent(apiPatient, s3Patient); + } + + /// + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } +} diff --git a/Patient.AppHost.Tests/Patient.AppHost.Tests.csproj b/Patient.AppHost.Tests/Patient.AppHost.Tests.csproj new file mode 100644 index 00000000..4c548a0b --- /dev/null +++ b/Patient.AppHost.Tests/Patient.AppHost.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Patient.AppHost/AppHost.cs b/Patient.AppHost/AppHost.cs new file mode 100644 index 00000000..acc26ec4 --- /dev/null +++ b/Patient.AppHost/AppHost.cs @@ -0,0 +1,53 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; + +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("patient-cache") + .WithRedisInsight(containerName: "patient-insight"); + +var gateway = builder.AddProject("api-gateway"); + +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localstack = builder + .AddLocalStack("patient-localstack", awsConfig: awsConfig, configureContainer: container => + { + container.Lifetime = ContainerLifetime.Session; + container.DebugLevel = 1; + container.LogLevel = LocalStackLogLevel.Debug; + container.Port = 4566; + container.AdditionalEnvironmentVariables.Add("DEBUG", "1"); + }); + +var awsResources = builder.AddAWSCloudFormationTemplate("resources", "CloudFormation/patient-template-sqs.yaml", "patient") + .WithReference(awsConfig); + +for (var i = 0; i < 5; i++) +{ + var generator = builder.AddProject($"generator-{i}", launchProfileName: null) + .WithHttpsEndpoint(5200 + i) + .WithReference(cache, "patient-cache") + .WithReference(awsResources) + .WaitFor(cache) + .WaitFor(awsResources); + gateway.WaitFor(generator); +} + +builder.AddProject("client") + .WaitFor(gateway); + +var minio = builder.AddMinioContainer("patient-minio"); + +builder.AddProject("file-service") + .WithReference(awsResources) + .WithReference(minio) + .WithEnvironment("AWS__Resources__MinioBucketName", "patient-bucket") + .WaitFor(awsResources) + .WaitFor(minio); + +builder.UseLocalStack(localstack); + +builder.Build().Run(); diff --git a/Patient.AppHost/CloudFormation/patient-template-sqs.yaml b/Patient.AppHost/CloudFormation/patient-template-sqs.yaml new file mode 100644 index 00000000..9e8d51fb --- /dev/null +++ b/Patient.AppHost/CloudFormation/patient-template-sqs.yaml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Cloud formation template for patient project (SQS + Minio)' + +Parameters: + QueueName: + Type: String + Description: Name for the SQS queue + Default: 'patient-queue' + +Resources: + PatientQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + VisibilityTimeout: 30 + MessageRetentionPeriod: 345600 + DelaySeconds: 0 + ReceiveMessageWaitTimeSeconds: 0 + Tags: + - Key: Name + Value: !Ref QueueName + - Key: Environment + Value: Sample + +Outputs: + SQSQueueName: + Description: Name of the SQS queue + Value: !GetAtt PatientQueue.QueueName + + SQSQueueArn: + Description: ARN of the SQS queue + Value: !GetAtt PatientQueue.Arn diff --git a/Patient.AppHost/Patient.AppHost.csproj b/Patient.AppHost/Patient.AppHost.csproj new file mode 100644 index 00000000..6b9dd7be --- /dev/null +++ b/Patient.AppHost/Patient.AppHost.csproj @@ -0,0 +1,33 @@ + + + + + + Exe + net8.0 + enable + enable + ed7e1e47-dc98-4419-8424-85412466aa9b + + + + + + + + + + + + + + + + + + + Always + + + + 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..3cce4c0d --- /dev/null +++ b/Patient.AppHost/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "LocalStack": { + "UseLocalStack": true + } +} \ 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..f1831c74 --- /dev/null +++ b/Patient.Generator/DTO/PatientDto.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace Patient.Generator.DTO; + +/// +/// DTO для передачи данных о медицинском пациенте. +/// +public sealed class PatientDto +{ + /// + /// Уникальный идентификатор пациента в системе. + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Фамилия, имя и отчество пациента через пробел. + /// + [JsonPropertyName("fullName")] + public string FullName { get; set; } = string.Empty; + + /// + /// Адрес проживания пациента. + /// + [JsonPropertyName("address")] + public string Address { get; set; } = string.Empty; + + /// + /// Дата рождения пациента. + /// + [JsonPropertyName("birthDate")] + public DateOnly BirthDate { get; set; } + + /// + /// Рост пациента в сантиметрах. + /// + [JsonPropertyName("height")] + public double Height { get; set; } + + /// + /// Вес пациента в килограммах. + /// + [JsonPropertyName("weight")] + public double Weight { get; set; } + + /// + /// Группа крови от 1 до 4. + /// + [JsonPropertyName("bloodGroup")] + public int BloodGroup { get; set; } + + /// + /// Резус-фактор пациента. + /// + [JsonPropertyName("rhFactor")] + public bool RhFactor { get; set; } + + /// + /// Дата последнего осмотра. + /// + [JsonPropertyName("lastExaminationDate")] + public DateOnly LastExaminationDate { get; set; } + + /// + /// Отметка о вакцинации. + /// + [JsonPropertyName("isVaccinated")] + 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/Messaging/IProducerService.cs b/Patient.Generator/Messaging/IProducerService.cs new file mode 100644 index 00000000..c606e033 --- /dev/null +++ b/Patient.Generator/Messaging/IProducerService.cs @@ -0,0 +1,15 @@ +using Patient.Generator.DTO; + +namespace Patient.Generator.Messaging; + +/// +/// Интерфейс службы для отправки сгенерированных пациентов в брокер сообщений +/// +public interface IProducerService +{ + /// + /// Отправляет сообщение с пациентом в брокер + /// + /// Пациент + public Task SendMessage(PatientDto patient); +} diff --git a/Patient.Generator/Messaging/SqsProducerService.cs b/Patient.Generator/Messaging/SqsProducerService.cs new file mode 100644 index 00000000..dc64bde7 --- /dev/null +++ b/Patient.Generator/Messaging/SqsProducerService.cs @@ -0,0 +1,39 @@ +using Amazon.SQS; +using Patient.Generator.DTO; +using System.Net; +using System.Text.Json; + +namespace Patient.Generator.Messaging; + +/// +/// Служба для отправки сообщений с пациентами в SQS +/// +/// Клиент SQS +/// Конфигурация +/// Логгер +public sealed class SqsProducerService( + IAmazonSQS client, + IConfiguration configuration, + ILogger logger) : IProducerService +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("SQS queue name was not found in configuration"); + + /// + public async Task SendMessage(PatientDto patient) + { + try + { + var json = JsonSerializer.Serialize(patient); + var response = await client.SendMessageAsync(_queueName, json); + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Patient {id} was sent to file service via SQS", patient.Id); + else + throw new Exception($"SQS returned {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Unable to send patient through SQS queue"); + } + } +} diff --git a/Patient.Generator/Patient.Generator.csproj b/Patient.Generator/Patient.Generator.csproj new file mode 100644 index 00000000..b067e448 --- /dev/null +++ b/Patient.Generator/Patient.Generator.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + Patient.Generator + + + + + + + + + + + + + + + + diff --git a/Patient.Generator/Program.cs b/Patient.Generator/Program.cs new file mode 100644 index 00000000..9abab222 --- /dev/null +++ b/Patient.Generator/Program.cs @@ -0,0 +1,36 @@ +using Amazon.SQS; +using LocalStack.Client.Extensions; +using Patient.Generator.Generator; +using Patient.Generator.Messaging; +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.AddScoped(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddScoped(); + +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..b307dba2 --- /dev/null +++ b/Patient.Generator/Service/PatientService.cs @@ -0,0 +1,39 @@ +using Patient.Generator.DTO; +using Patient.Generator.Generator; +using Patient.Generator.Messaging; + +namespace Patient.Generator.Service; + +/// +/// Реализация сервиса работы с медицинскими пациентами. +/// +/// Генератор пациентов. +/// Кэш пациентов. +/// Служба отправки пациентов в брокер сообщений. +public sealed class PatientService( + PatientGenerator generator, + IPatientCache cache, + IProducerService producer) : 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); + await producer.SendMessage(generated); + + 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). -