diff --git a/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.md b/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.md new file mode 100644 index 00000000..02038ea5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/#U0432#U043e#U043f#U0440#U043e#U0441-#U043f#U043e-#U043b#U0430#U0431#U043e#U0440#U0430#U0442#U043e#U0440#U043d#U043e#U0439.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 diff --git a/ApiGateway/ApiGateway.csproj b/ApiGateway/ApiGateway.csproj new file mode 100644 index 00000000..c8100831 --- /dev/null +++ b/ApiGateway/ApiGateway.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + ApiGateway + ApiGateway + + + + + + + + + + + + + Always + + + + diff --git a/ApiGateway/Configuration/WeightedRoundRobinOptions.cs b/ApiGateway/Configuration/WeightedRoundRobinOptions.cs new file mode 100644 index 00000000..d5547d71 --- /dev/null +++ b/ApiGateway/Configuration/WeightedRoundRobinOptions.cs @@ -0,0 +1,16 @@ +namespace ApiGateway.Configuration; + +public sealed class WeightedRoundRobinOptions +{ + public const string SectionName = "WeightedRoundRobin"; + + public List Nodes { get; init; } = new(); +} + +public sealed class ReplicaNodeOptions +{ + public string Host { get; init; } = string.Empty; + public int Port { get; init; } + public int Weight { get; init; } = 1; + public string ReplicaId { get; init; } = string.Empty; +} diff --git a/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs new file mode 100644 index 00000000..33425e09 --- /dev/null +++ b/ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -0,0 +1,72 @@ +using ApiGateway.Configuration; +using Microsoft.Extensions.Options; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ApiGateway.LoadBalancing; + +public sealed class WeightedRoundRobinBalancer : ILoadBalancer +{ + private static readonly object Sync = new(); + private readonly ILogger _logger; + private readonly List _rotation; + private int _currentIndex; + + public WeightedRoundRobinBalancer( + IOptions options, + ILogger logger) + { + _logger = logger; + _rotation = BuildRotation(options.Value.Nodes); + + if (_rotation.Count == 0) + { + throw new InvalidOperationException("Не настроены узлы для Weighted Round Robin балансировки."); + } + } + + public string Type => nameof(WeightedRoundRobinBalancer); + + public Task> LeaseAsync(HttpContext context) + { + lock (Sync) + { + if (_currentIndex >= _rotation.Count) + { + _currentIndex = 0; + } + + var next = _rotation[_currentIndex++]; + + _logger.LogInformation( + "Gateway routed request to {ReplicaAddress} by {BalancerType}", + next, + Type); + + return Task.FromResult>( + new OkResponse(next)); + } + } + + public void Release(ServiceHostAndPort hostAndPort) + { + } + + private static List BuildRotation(IEnumerable nodes) + { + var rotation = new List(); + + foreach (var node in nodes.Where(static n => !string.IsNullOrWhiteSpace(n.Host) && n.Port > 0)) + { + var normalizedWeight = Math.Max(1, node.Weight); + + for (var i = 0; i < normalizedWeight; i++) + { + rotation.Add(new ServiceHostAndPort(node.Host, node.Port)); + } + } + + return rotation; + } +} \ No newline at end of file diff --git a/ApiGateway/Program.cs b/ApiGateway/Program.cs new file mode 100644 index 00000000..dda7b95a --- /dev/null +++ b/ApiGateway/Program.cs @@ -0,0 +1,43 @@ +using ApiGateway.Configuration; +using ApiGateway.LoadBalancing; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddServiceDiscovery(); +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(options => +{ + options.IncludeScopes = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.Configure( + builder.Configuration.GetSection(WeightedRoundRobinOptions.SectionName)); + +builder.Services.AddOcelot(builder.Configuration) + .AddCustomLoadBalancer(sp => + new WeightedRoundRobinBalancer( + sp.GetRequiredService>(), + sp.GetRequiredService>())); + +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => +{ + policy.WithOrigins(["http://localhost:5127", "https://localhost:7282"]); + policy.WithMethods("GET"); + policy.WithHeaders("Content-Type"); + policy.WithExposedHeaders("X-Service-Replica", "X-Service-Weight"); +})); + +var app = builder.Build(); + +app.UseCors(); +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); diff --git a/ApiGateway/Properties/launchSettings.json b/ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..98385b13 --- /dev/null +++ b/ApiGateway/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:7200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ApiGateway/appsettings.Development.json b/ApiGateway/appsettings.Development.json new file mode 100644 index 00000000..79cac825 --- /dev/null +++ b/ApiGateway/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Information" + } + } +} diff --git a/ApiGateway/appsettings.json b/ApiGateway/appsettings.json new file mode 100644 index 00000000..cc426f6c --- /dev/null +++ b/ApiGateway/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/ApiGateway/ocelot.json b/ApiGateway/ocelot.json new file mode 100644 index 00000000..7c8dceea --- /dev/null +++ b/ApiGateway/ocelot.json @@ -0,0 +1,41 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/employee", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/employee", + "DownstreamScheme": "https", + "LoadBalancerOptions": { + "Type": "WeightedRoundRobinBalancer" + }, + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 15000 }, + { "Host": "localhost", "Port": 15001 }, + { "Host": "localhost", "Port": 15002 }, + { "Host": "localhost", "Port": 15003 }, + { "Host": "localhost", "Port": 15004 } + ] + }, + { + "UpstreamPathTemplate": "/files/{id}", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/files/{id}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 16000 } + ] + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:7200" + }, + "WeightedRoundRobin": { + "Nodes": [ + { "ReplicaId": "R1", "Host": "localhost", "Port": 15000, "Weight": 1 }, + { "ReplicaId": "R2", "Host": "localhost", "Port": 15001, "Weight": 2 }, + { "ReplicaId": "R3", "Host": "localhost", "Port": 15002, "Weight": 3 }, + { "ReplicaId": "R4", "Host": "localhost", "Port": 15003, "Weight": 2 }, + { "ReplicaId": "R5", "Host": "localhost", "Port": 15004, "Weight": 1 } + ] + } +} diff --git a/AspireApp/AspireApp.AppHost/AppHost.cs b/AspireApp/AspireApp.AppHost/AppHost.cs new file mode 100644 index 00000000..df267839 --- /dev/null +++ b/AspireApp/AspireApp.AppHost/AppHost.cs @@ -0,0 +1,50 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("employee-cache") + .WithRedisInsight(containerName: "employee-insight"); + +var localstack = builder.AddContainer("localstack", "localstack/localstack", "3.5") + .WithEnvironment("SERVICES", "s3,sns,sqs") + .WithEnvironment("AWS_DEFAULT_REGION", "us-east-1") + .WithEnvironment("DEBUG", "1") + .WithHttpEndpoint(port: 4566, targetPort: 4566, name: "http", isProxied: false); + +var gateway = builder.AddProject("api-gateway"); + +var replicaWeights = new[] { 1, 2, 3, 2, 1 }; + +var fileService = builder.AddProject("file-service", launchProfileName: null) + .WithHttpEndpoint(port: 16000) + .WithEnvironment("Aws__ServiceUrl", "http://localhost:4566") + .WithEnvironment("Aws__Region", "us-east-1") + .WithEnvironment("Aws__AccessKey", "test") + .WithEnvironment("Aws__SecretKey", "test") + .WithEnvironment("Aws__TopicName", "employee-generated-topic") + .WithEnvironment("Aws__QueueName", "employee-generated-queue") + .WithEnvironment("Aws__BucketName", "employee-files") + .WaitFor(localstack); + +for (var i = 0; i < 5; i++) +{ + var service = builder.AddProject($"service-api-{i}", launchProfileName: null) + .WithHttpsEndpoint(port: 15000 + i) + .WithReference(cache, "RedisCache") + .WithEnvironment("ReplicaId", "R" + (i + 1)) + .WithEnvironment("ReplicaWeight", replicaWeights[i].ToString()) + .WithEnvironment("Aws__ServiceUrl", "http://localhost:4566") + .WithEnvironment("Aws__Region", "us-east-1") + .WithEnvironment("Aws__AccessKey", "test") + .WithEnvironment("Aws__SecretKey", "test") + .WithEnvironment("Aws__TopicName", "employee-generated-topic") + .WaitFor(cache) + .WaitFor(localstack); + + gateway.WaitFor(service); +} + +gateway.WaitFor(fileService); + +builder.AddProject("employee") + .WaitFor(gateway); + +builder.Build().Run(); diff --git a/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj b/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj new file mode 100644 index 00000000..719a5b0c --- /dev/null +++ b/AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net8.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/AspireApp/AspireApp.AppHost/Properties/launchSettings.json b/AspireApp/AspireApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..df45cce3 --- /dev/null +++ b/AspireApp/AspireApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17096;http://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21139", + "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "https://localhost:21140", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22017" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19197", + "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL": "http://localhost:19198", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20116" + } + } + } +} diff --git a/AspireApp/AspireApp.AppHost/appsettings.Development.json b/AspireApp/AspireApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..167eb683 --- /dev/null +++ b/AspireApp/AspireApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Aspire.Hosting": "Information" + } + } +} diff --git a/AspireApp/AspireApp.AppHost/appsettings.json b/AspireApp/AspireApp.AppHost/appsettings.json new file mode 100644 index 00000000..167eb683 --- /dev/null +++ b/AspireApp/AspireApp.AppHost/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Aspire.Hosting": "Information" + } + } +} diff --git a/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj b/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj new file mode 100644 index 00000000..530518fa --- /dev/null +++ b/AspireApp/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + diff --git a/AspireApp/AspireApp.ServiceDefaults/Extensions.cs b/AspireApp/AspireApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..e8399ba7 --- /dev/null +++ b/AspireApp/AspireApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (!useOtlpExporter) + { + return builder; + } + + builder.Logging.AddOpenTelemetry(logging => logging.AddOtlpExporter()); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics.AddOtlpExporter()) + .WithTracing(tracing => tracing.AddOtlpExporter()); + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) + where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/Backend.IntegrationTests/Backend.IntegrationTests.csproj b/Backend.IntegrationTests/Backend.IntegrationTests.csproj new file mode 100644 index 00000000..aa1fbe9d --- /dev/null +++ b/Backend.IntegrationTests/Backend.IntegrationTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs b/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs new file mode 100644 index 00000000..0b1f4e51 --- /dev/null +++ b/Backend.IntegrationTests/EmployeeExportIntegrationTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Text.Json; +using Aspire.Hosting; +using Aspire.Hosting.Testing; + +namespace Backend.IntegrationTests; + +public sealed class EmployeeExportIntegrationTests : IAsyncLifetime +{ + private static readonly TimeSpan StartupTimeout = TimeSpan.FromSeconds(120); + private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120); + + private DistributedApplication? _app; + + public async Task InitializeAsync() + { + var builder = await DistributedApplicationTestingBuilder.CreateAsync(); + _app = await builder.BuildAsync().WaitAsync(StartupTimeout); + await _app.StartAsync().WaitAsync(StartupTimeout); + + var fileClient = _app.CreateHttpClient("file-service"); + var apiClient = _app.CreateHttpClient("service-api-0"); + + using var cts = new CancellationTokenSource(StartupTimeout); + + await WaitForFileServiceReadyAsync(fileClient, cts.Token); + await WaitForApiAsync(apiClient, cts.Token); + } + + public async Task DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + } + + [Fact] + public async Task GeneratedEmployeeIsEventuallyExportedToObjectStorage() + { + Assert.NotNull(_app); + + const int employeeId = 501; + + var apiClient = _app!.CreateHttpClient("service-api-0"); + var fileClient = _app.CreateHttpClient("file-service"); + + using var response = await apiClient.GetAsync($"/employee?id={employeeId}"); + response.EnsureSuccessStatusCode(); + + var generatedJson = await response.Content.ReadAsStringAsync(); + var exportedJson = await WaitForExportAsync(fileClient, employeeId); + + Assert.Equal(Normalize(generatedJson), Normalize(exportedJson)); + } + + [Fact] + public async Task SameEmployeeIdReturnsCachedPayloadAndExportRemainsAvailable() + { + Assert.NotNull(_app); + + const int employeeId = 777; + + var apiClient = _app!.CreateHttpClient("service-api-0"); + var fileClient = _app.CreateHttpClient("file-service"); + + var first = await apiClient.GetStringAsync($"/employee?id={employeeId}"); + var second = await apiClient.GetStringAsync($"/employee?id={employeeId}"); + + Assert.Equal(Normalize(first), Normalize(second)); + + var exportedJson = await WaitForExportAsync(fileClient, employeeId); + Assert.Equal(Normalize(first), Normalize(exportedJson)); + } + + private static async Task WaitForFileServiceReadyAsync(HttpClient client, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var response = await client.GetAsync("/ready", cancellationToken); + + if (response.IsSuccessStatusCode) + { + return; + } + + if (response.StatusCode != HttpStatusCode.ServiceUnavailable) + { + response.EnsureSuccessStatusCode(); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch + { + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + throw new TimeoutException("File Service не стал ready за отведённое время."); + } + + private static async Task WaitForApiAsync(HttpClient client, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + using var response = await client.GetAsync("/", cancellationToken); + if (response.IsSuccessStatusCode) + { + return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch + { + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + throw new TimeoutException("Service API не стал доступен за отведённое время."); + } + + private static async Task WaitForExportAsync(HttpClient fileClient, int employeeId) + { + using var cts = new CancellationTokenSource(ExportTimeout); + + while (!cts.IsCancellationRequested) + { + try + { + using var response = await fileClient.GetAsync($"/files/{employeeId}", cts.Token); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(cts.Token); + } + + if (response.StatusCode != HttpStatusCode.NotFound) + { + response.EnsureSuccessStatusCode(); + } + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), cts.Token); + } + + throw new TimeoutException($"Файл сотрудника {employeeId} не был выгружен в объектное хранилище за отведённое время."); + } + + private static string Normalize(string json) + { + using var document = JsonDocument.Parse(json); + return JsonSerializer.Serialize(document.RootElement); + } +} \ No newline at end of file diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..68c080d9 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -1,22 +1,34 @@ -@inject IConfiguration Configuration +@using System.Net.Http.Json +@using System.Text.Json.Nodes +@inject IConfiguration Configuration @inject HttpClient Client - Характеристики текущего объекта + Характеристики текущего объекта - + @if (!string.IsNullOrWhiteSpace(ErrorMessage)) + { + @ErrorMessage + } + + @if (!string.IsNullOrWhiteSpace(ReplicaInfo)) + { + @ReplicaInfo + } + +
- + # Характеристика Значение - + - @if(Value is null) + @if (Value is null) { 1 @@ -30,7 +42,7 @@ foreach (var property in array) { - @(Array.IndexOf(array, property)+1) + @(Array.IndexOf(array, property) + 1) @property.Key @property.Value?.ToString() @@ -40,10 +52,10 @@
- + - Запросить новый объект + Запросить новый объект @@ -51,10 +63,10 @@ Идентификатор нового объекта: - + - + @@ -63,12 +75,51 @@ @code { private JsonObject? Value { get; set; } - private int Id { get; set; } + private string? ErrorMessage { get; set; } + private string? ReplicaInfo { get; set; } + private int Id { get; set; } = 1; private async Task RequestNewData() { - var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress"); - Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { }); - StateHasChanged(); + ErrorMessage = null; + ReplicaInfo = null; + + if (Id <= 0) + { + ErrorMessage = "Идентификатор должен быть больше нуля."; + return; + } + + var baseAddress = Configuration["BaseAddress"]; + if (string.IsNullOrWhiteSpace(baseAddress)) + { + ErrorMessage = "Конфигурация клиента не содержит параметра BaseAddress."; + return; + } + + try + { + using var response = await Client.GetAsync($"{baseAddress}?id={Id}"); + response.EnsureSuccessStatusCode(); + + Value = await response.Content.ReadFromJsonAsync(); + + var replica = response.Headers.TryGetValues("X-Service-Replica", out var replicaValues) + ? replicaValues.FirstOrDefault() + : null; + var weight = response.Headers.TryGetValues("X-Service-Weight", out var weightValues) + ? weightValues.FirstOrDefault() + : null; + + if (!string.IsNullOrWhiteSpace(replica)) + { + ReplicaInfo = $"Ответ пришёл от реплики {replica} (вес {weight ?? "1"}). Алгоритм: Weighted Round Robin."; + } + } + catch (Exception ex) + { + ErrorMessage = $"Не удалось получить данные: {ex.Message}"; + Value = null; + } } } diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..2b64c5ce 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,17 @@  - Лабораторная работа + + Лабораторная работа + - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 "Интеграционное тестирование" + Вариант №28 "Сотрудник компании" + Выполнена Миронюк Матвеем 6512 + + Ссылка на форк + diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..d4f650b0 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:7200/employee" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..2d87f8d4 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -3,18 +3,54 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36811.4 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{A12BE865-36BB-4326-A104-2CD8CE6771E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost", "AspireApp\AspireApp.AppHost\AspireApp.AppHost.csproj", "{B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ServiceDefaults", "AspireApp\AspireApp.ServiceDefaults\AspireApp.ServiceDefaults.csproj", "{37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}" +EndProject 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}") = "File.Service", "FileService\File.Service.csproj", "{F2141FAE-7E53-44A4-8FE9-A06B37787660}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Service.Api", "ServiceApi\Service.Api.csproj", "{7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Backend.IntegrationTests", "Backend.IntegrationTests\Backend.IntegrationTests.csproj", "{975A8B0A-4D45-41D9-A26D-76DB152AA1F1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A12BE865-36BB-4326-A104-2CD8CE6771E7}.Release|Any CPU.Build.0 = Release|Any CPU + {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7822A24-50CB-4BF6-AEB0-7A54DA4CEB89}.Release|Any CPU.Build.0 = Release|Any CPU + {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {37C683F6-6A55-4B79-8C4C-E9F632B4A3A2}.Release|Any CPU.Build.0 = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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 + {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2141FAE-7E53-44A4-8FE9-A06B37787660}.Release|Any CPU.Build.0 = Release|Any CPU + {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A67B2AB-14E5-467B-BE6A-EAE7C9C538C4}.Release|Any CPU.Build.0 = Release|Any CPU + {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {975A8B0A-4D45-41D9-A26D-76DB152AA1F1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FileService/Background/FileExportHealthCheck.cs b/FileService/Background/FileExportHealthCheck.cs new file mode 100644 index 00000000..68cd4674 --- /dev/null +++ b/FileService/Background/FileExportHealthCheck.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace File.Service.Background; + +/// +/// Проверка готовности файлового сервиса к обработке сообщений. +/// +public sealed class FileExportHealthCheck(FileExportInfrastructureState state) : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult( + state.IsInitialized + ? HealthCheckResult.Healthy("Инфраструктура LocalStack инициализирована.") + : HealthCheckResult.Unhealthy("Файловый сервис ещё не инициализировал SNS/SQS/S3.")); + } +} diff --git a/FileService/Background/FileExportInfrastructureState.cs b/FileService/Background/FileExportInfrastructureState.cs new file mode 100644 index 00000000..ab3c73af --- /dev/null +++ b/FileService/Background/FileExportInfrastructureState.cs @@ -0,0 +1,12 @@ +namespace File.Service.Background; + +/// +/// Состояние инициализации инфраструктуры файлового сервиса. +/// +public sealed class FileExportInfrastructureState +{ + /// + /// Признак готовности инфраструктуры SNS/SQS/S3. + /// + public bool IsInitialized { get; set; } +} diff --git a/FileService/Background/SnsSqsFileExportWorker.cs b/FileService/Background/SnsSqsFileExportWorker.cs new file mode 100644 index 00000000..ec47296f --- /dev/null +++ b/FileService/Background/SnsSqsFileExportWorker.cs @@ -0,0 +1,283 @@ +using System.Text.Json; +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using File.Service.Configuration; +using File.Service.Storage; +using Microsoft.Extensions.Options; + +namespace File.Service.Background; + +/// +/// Фоновый обработчик, читающий события из SNS через подписанную SQS-очередь, +/// сериализующий их в файлы и сохраняющий в S3. +/// +public sealed class SnsSqsFileExportWorker : BackgroundService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly AwsStorageOptions _options; + private readonly IEmployeeFileStorage _fileStorage; + private readonly ILogger _logger; + private readonly IAmazonSimpleNotificationService _snsClient; + private readonly IAmazonSQS _sqsClient; + private readonly FileExportInfrastructureState _state; + + private string? _queueUrl; + private string? _topicArn; + + public SnsSqsFileExportWorker( + IOptions options, + IEmployeeFileStorage fileStorage, + ILogger logger, + FileExportInfrastructureState state) + { + _options = options.Value; + _fileStorage = fileStorage; + _logger = logger; + _state = state; + + var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey); + + var snsConfig = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = _options.ServiceUrl, + AuthenticationRegion = _options.Region, + UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + }; + + var sqsConfig = new AmazonSQSConfig + { + ServiceURL = _options.ServiceUrl, + AuthenticationRegion = _options.Region, + UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + }; + + _snsClient = new AmazonSimpleNotificationServiceClient(credentials, snsConfig); + _sqsClient = new AmazonSQSClient(credentials, sqsConfig); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _state.IsInitialized = false; + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EnsureInfrastructureAsync(stoppingToken); + _state.IsInitialized = true; + + _logger.LogInformation( + "File export infrastructure initialized. Topic={TopicArn}, Queue={QueueUrl}", + _topicArn, + _queueUrl); + + break; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _state.IsInitialized = false; + _logger.LogWarning(ex, "LocalStack infrastructure is not ready yet. Retrying initialization..."); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var response = await _sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _queueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 10, + MessageAttributeNames = ["All"], + MessageSystemAttributeNames = ["All"] + }, stoppingToken); + + if (response.Messages.Count == 0) + { + continue; + } + + foreach (var message in response.Messages) + { + await ProcessMessageAsync(message, stoppingToken); + await _sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while processing messages from queue {QueueName}", _options.QueueName); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } + + private async Task EnsureInfrastructureAsync(CancellationToken cancellationToken) + { + _topicArn = (await _snsClient.CreateTopicAsync(new CreateTopicRequest + { + Name = _options.TopicName + }, cancellationToken)).TopicArn; + + _queueUrl = (await _sqsClient.CreateQueueAsync(new CreateQueueRequest + { + QueueName = _options.QueueName + }, cancellationToken)).QueueUrl; + + var attributes = await _sqsClient.GetQueueAttributesAsync(new GetQueueAttributesRequest + { + QueueUrl = _queueUrl, + AttributeNames = ["QueueArn"] + }, cancellationToken); + + var queueArn = attributes.Attributes["QueueArn"]; + + var policy = $$""" + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowSnsPublish", + "Effect": "Allow", + "Principal": "*", + "Action": "sqs:SendMessage", + "Resource": "{{queueArn}}", + "Condition": { + "ArnEquals": { + "aws:SourceArn": "{{_topicArn}}" + } + } + } + ] + } + """; + + await _sqsClient.SetQueueAttributesAsync(new SetQueueAttributesRequest + { + QueueUrl = _queueUrl, + Attributes = new Dictionary + { + ["Policy"] = policy + } + }, cancellationToken); + + var subscriptions = await _snsClient.ListSubscriptionsByTopicAsync(new ListSubscriptionsByTopicRequest + { + TopicArn = _topicArn + }, cancellationToken); + + var existingSubscription = subscriptions.Subscriptions + .FirstOrDefault(s => string.Equals(s.Endpoint, queueArn, StringComparison.OrdinalIgnoreCase)); + + if (existingSubscription is null) + { + await _snsClient.SubscribeAsync(new SubscribeRequest + { + TopicArn = _topicArn, + Protocol = "sqs", + Endpoint = queueArn, + Attributes = new Dictionary + { + ["RawMessageDelivery"] = "true" + } + }, cancellationToken); + } + else if (!string.IsNullOrWhiteSpace(existingSubscription.SubscriptionArn) && + !string.Equals(existingSubscription.SubscriptionArn, "PendingConfirmation", StringComparison.OrdinalIgnoreCase)) + { + await _snsClient.SetSubscriptionAttributesAsync(new SetSubscriptionAttributesRequest + { + SubscriptionArn = existingSubscription.SubscriptionArn, + AttributeName = "RawMessageDelivery", + AttributeValue = "true" + }, cancellationToken); + } + } + + private async Task ProcessMessageAsync(Message message, CancellationToken cancellationToken) + { + var payloadJson = ExtractPayloadJson(message.Body); + + if (string.IsNullOrWhiteSpace(payloadJson)) + { + _logger.LogWarning("Received empty employee export message"); + return; + } + + var envelope = JsonSerializer.Deserialize(payloadJson, JsonOptions); + if (envelope is null || envelope.Payload.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + _logger.LogWarning("Received invalid employee export message: {Body}", message.Body); + return; + } + + var employeeJson = envelope.Payload.GetRawText(); + + await _fileStorage.SaveEmployeeJsonAsync(envelope.EmployeeId, employeeJson, cancellationToken); + + _logger.LogInformation( + "Employee {EmployeeId} exported to object storage from replica {ReplicaId}", + envelope.EmployeeId, + envelope.ReplicaId); + } + + private static string? ExtractPayloadJson(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + + if (root.TryGetProperty("Message", out var messageProperty) && + messageProperty.ValueKind == JsonValueKind.String) + { + return messageProperty.GetString(); + } + } + catch + { + // Если body не envelope SNS, считаем что это raw JSON. + } + + return body; + } + + public override void Dispose() + { + _snsClient.Dispose(); + _sqsClient.Dispose(); + base.Dispose(); + } + + private sealed class EmployeeGeneratedEnvelope + { + public int EmployeeId { get; init; } + + public DateTime PublishedAtUtc { get; init; } + + public string ReplicaId { get; init; } = string.Empty; + + public JsonElement Payload { get; init; } + } +} \ No newline at end of file diff --git a/FileService/Configuration/AwsStorageOptions.cs b/FileService/Configuration/AwsStorageOptions.cs new file mode 100644 index 00000000..0f277515 --- /dev/null +++ b/FileService/Configuration/AwsStorageOptions.cs @@ -0,0 +1,44 @@ +namespace File.Service.Configuration; + +/// +/// Настройки интеграции с LocalStack для хранения файлов и чтения сообщений. +/// +public sealed class AwsStorageOptions +{ + public const string SectionName = "Aws"; + + /// + /// Базовый URL LocalStack. + /// + public string ServiceUrl { get; set; } = "http://localhost:4566"; + + /// + /// Регион AWS. + /// + public string Region { get; set; } = "us-east-1"; + + /// + /// Access key для LocalStack. + /// + public string AccessKey { get; set; } = "test"; + + /// + /// Secret key для LocalStack. + /// + public string SecretKey { get; set; } = "test"; + + /// + /// Имя SNS-топика с событиями генерации сотрудников. + /// + public string TopicName { get; set; } = "employee-generated-topic"; + + /// + /// Имя SQS-очереди, подписанной на SNS-топик. + /// + public string QueueName { get; set; } = "employee-generated-queue"; + + /// + /// Имя S3-бакета для файлов сотрудников. + /// + public string BucketName { get; set; } = "employee-files"; +} diff --git a/FileService/File.Service.csproj b/FileService/File.Service.csproj new file mode 100644 index 00000000..ea9ad640 --- /dev/null +++ b/FileService/File.Service.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + File.Service + File.Service + + + + + + + + + + + + + diff --git a/FileService/Infrastructure/FileExportReadinessProbe.cs b/FileService/Infrastructure/FileExportReadinessProbe.cs new file mode 100644 index 00000000..fcb78df4 --- /dev/null +++ b/FileService/Infrastructure/FileExportReadinessProbe.cs @@ -0,0 +1,197 @@ +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; +using File.Service.Configuration; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace File.Service.Infrastructure; + +public sealed class FileExportReadinessProbe +{ + private readonly AwsStorageOptions _options; + private readonly ILogger _logger; + + public FileExportReadinessProbe( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task IsReadyAsync(CancellationToken cancellationToken) + { + var serviceUrl = GetStringProperty(_options, "ServiceUrl", "AwsServiceUrl"); + var region = GetStringProperty(_options, "Region", "RegionName") ?? "us-east-1"; + var accessKey = GetStringProperty(_options, "AccessKey", "AccessKeyId") ?? "test"; + var secretKey = GetStringProperty(_options, "SecretKey", "SecretAccessKey") ?? "test"; + var topicName = GetStringProperty(_options, "TopicName", "SnsTopicName"); + var queueName = GetStringProperty(_options, "QueueName", "SqsQueueName"); + + if (string.IsNullOrWhiteSpace(serviceUrl) || + string.IsNullOrWhiteSpace(topicName) || + string.IsNullOrWhiteSpace(queueName)) + { + _logger.LogDebug( + "File export readiness is false because required AWS settings are missing. ServiceUrl={ServiceUrl}, TopicName={TopicName}, QueueName={QueueName}", + serviceUrl, + topicName, + queueName); + + return false; + } + + try + { + var credentials = new BasicAWSCredentials(accessKey, secretKey); + + using var sns = new AmazonSimpleNotificationServiceClient( + credentials, + new AmazonSimpleNotificationServiceConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = region + }); + + using var sqs = new AmazonSQSClient( + credentials, + new AmazonSQSConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = region + }); + + var topicArn = await FindTopicArnAsync(sns, topicName, cancellationToken); + if (string.IsNullOrWhiteSpace(topicArn)) + { + return false; + } + + var queueUrlResponse = await sqs.GetQueueUrlAsync( + new GetQueueUrlRequest + { + QueueName = queueName + }, + cancellationToken); + + if (string.IsNullOrWhiteSpace(queueUrlResponse.QueueUrl)) + { + return false; + } + + var subscriptionExists = await HasQueueSubscriptionAsync( + sns, + topicArn, + queueName, + cancellationToken); + + return subscriptionExists; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "File export readiness probe failed."); + return false; + } + } + + private static async Task FindTopicArnAsync( + IAmazonSimpleNotificationService sns, + string topicName, + CancellationToken cancellationToken) + { + string? nextToken = null; + + do + { + var response = await sns.ListTopicsAsync( + new ListTopicsRequest + { + NextToken = nextToken + }, + cancellationToken); + + var topic = response.Topics.FirstOrDefault(t => + !string.IsNullOrWhiteSpace(t.TopicArn) && + t.TopicArn.EndsWith($":{topicName}", StringComparison.OrdinalIgnoreCase)); + + if (topic is not null) + { + return topic.TopicArn; + } + + nextToken = response.NextToken; + } + while (!string.IsNullOrWhiteSpace(nextToken)); + + return null; + } + + private static async Task HasQueueSubscriptionAsync( + IAmazonSimpleNotificationService sns, + string topicArn, + string queueName, + CancellationToken cancellationToken) + { + string? nextToken = null; + + do + { + var response = await sns.ListSubscriptionsByTopicAsync( + new ListSubscriptionsByTopicRequest + { + TopicArn = topicArn, + NextToken = nextToken + }, + cancellationToken); + + var exists = response.Subscriptions.Any(subscription => + !string.IsNullOrWhiteSpace(subscription.Endpoint) && + EndpointMatchesQueue(subscription.Endpoint, queueName)); + + if (exists) + { + return true; + } + + nextToken = response.NextToken; + } + while (!string.IsNullOrWhiteSpace(nextToken)); + + return false; + } + + private static bool EndpointMatchesQueue(string endpoint, string queueName) + { + return endpoint.EndsWith($"/{queueName}", StringComparison.OrdinalIgnoreCase) || + endpoint.Contains($":{queueName}", StringComparison.OrdinalIgnoreCase) || + endpoint.Contains(queueName, StringComparison.OrdinalIgnoreCase); + } + + private static string? GetStringProperty(object source, params string[] names) + { + var type = source.GetType(); + + foreach (var name in names) + { + var property = type.GetProperty( + name, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + + if (property?.PropertyType != typeof(string)) + { + continue; + } + + var value = property.GetValue(source) as string; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } +} \ No newline at end of file diff --git a/FileService/Program.cs b/FileService/Program.cs new file mode 100644 index 00000000..a0798758 --- /dev/null +++ b/FileService/Program.cs @@ -0,0 +1,57 @@ +using File.Service.Background; +using File.Service.Configuration; +using File.Service.Storage; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Logging.AddJsonConsole(options => +{ + options.IncludeScopes = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.Configure(builder.Configuration.GetSection(AwsStorageOptions.SectionName)); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "File.Service", + description = "Файловый сервис, сохраняющий сведения о сотрудниках в объектное хранилище", + endpoints = new[] { "/ready", "/files/{id}" } +})); + +app.MapGet("/ready", (FileExportInfrastructureState state) => +{ + return state.IsInitialized + ? Results.Ok(new { status = "ready" }) + : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); +}); + +app.MapGet("/files/{id:int}", async (int id, IEmployeeFileStorage storage, CancellationToken cancellationToken) => +{ + if (id <= 0) + { + return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." }); + } + + var content = await storage.TryReadEmployeeJsonAsync(id, cancellationToken); + if (string.IsNullOrWhiteSpace(content)) + { + return Results.NotFound(new { message = $"Файл для сотрудника {id} не найден." }); + } + + return Results.Text(content, "application/json"); +}); + +app.Run(); + +public partial class Program; \ No newline at end of file diff --git a/FileService/Properties/launchSettings.json b/FileService/Properties/launchSettings.json new file mode 100644 index 00000000..111d9598 --- /dev/null +++ b/FileService/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:16000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FileService/Storage/IEmployeeFileStorage.cs b/FileService/Storage/IEmployeeFileStorage.cs new file mode 100644 index 00000000..8580a9c2 --- /dev/null +++ b/FileService/Storage/IEmployeeFileStorage.cs @@ -0,0 +1,17 @@ +namespace File.Service.Storage; + +/// +/// Обеспечивает сохранение и чтение файлов сотрудников из объектного хранилища. +/// +public interface IEmployeeFileStorage +{ + /// + /// Сохраняет JSON-файл сотрудника в объектное хранилище. + /// + Task SaveEmployeeJsonAsync(int employeeId, string json, CancellationToken cancellationToken = default); + + /// + /// Пытается прочитать JSON-файл сотрудника из объектного хранилища. + /// + Task TryReadEmployeeJsonAsync(int employeeId, CancellationToken cancellationToken = default); +} diff --git a/FileService/Storage/S3EmployeeFileStorage.cs b/FileService/Storage/S3EmployeeFileStorage.cs new file mode 100644 index 00000000..63e7eb50 --- /dev/null +++ b/FileService/Storage/S3EmployeeFileStorage.cs @@ -0,0 +1,91 @@ +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using Amazon.S3.Util; +using Microsoft.Extensions.Options; +using File.Service.Configuration; + +namespace File.Service.Storage; + +/// +/// Хранилище файлов сотрудников в S3/LocalStack. +/// +public sealed class S3EmployeeFileStorage : IEmployeeFileStorage, IAsyncDisposable +{ + private readonly AwsStorageOptions _options; + private readonly ILogger _logger; + private readonly IAmazonS3 _s3Client; + + public S3EmployeeFileStorage(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + + var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey); + var config = new AmazonS3Config + { + ServiceURL = _options.ServiceUrl, + AuthenticationRegion = _options.Region, + ForcePathStyle = true, + UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + }; + + _s3Client = new AmazonS3Client(credentials, config); + } + + public async Task SaveEmployeeJsonAsync(int employeeId, string json, CancellationToken cancellationToken = default) + { + await EnsureBucketExistsAsync(cancellationToken); + + var key = BuildObjectKey(employeeId); + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + await _s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = _options.BucketName, + Key = key, + InputStream = stream, + ContentType = "application/json" + }, cancellationToken); + + _logger.LogInformation("Employee file stored in bucket {Bucket} with key {Key}", _options.BucketName, key); + } + + public async Task TryReadEmployeeJsonAsync(int employeeId, CancellationToken cancellationToken = default) + { + await EnsureBucketExistsAsync(cancellationToken); + + try + { + var response = await _s3Client.GetObjectAsync(_options.BucketName, BuildObjectKey(employeeId), cancellationToken); + using var reader = new StreamReader(response.ResponseStream); + return await reader.ReadToEndAsync(); + } + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound || ex.ErrorCode == "NoSuchKey") + { + return null; + } + } + + private async Task EnsureBucketExistsAsync(CancellationToken cancellationToken) + { + var exists = await AmazonS3Util.DoesS3BucketExistV2Async(_s3Client, _options.BucketName); + if (exists) + { + return; + } + + await _s3Client.PutBucketAsync(new PutBucketRequest + { + BucketName = _options.BucketName + }, cancellationToken); + } + + private static string BuildObjectKey(int employeeId) => $"employees/employee-{employeeId}.json"; + + public async ValueTask DisposeAsync() + { + _s3Client.Dispose(); + await Task.CompletedTask; + } +} diff --git a/FileService/appsettings.Development.json b/FileService/appsettings.Development.json new file mode 100644 index 00000000..149d8558 --- /dev/null +++ b/FileService/appsettings.Development.json @@ -0,0 +1,17 @@ +{ + "Aws": { + "ServiceUrl": "http://localhost:4566", + "Region": "us-east-1", + "AccessKey": "test", + "SecretKey": "test", + "TopicName": "employee-generated-topic", + "QueueName": "employee-generated-queue", + "BucketName": "employee-files" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/FileService/appsettings.json b/FileService/appsettings.json new file mode 100644 index 00000000..da66dcd8 --- /dev/null +++ b/FileService/appsettings.json @@ -0,0 +1,18 @@ +{ + "Aws": { + "ServiceUrl": "http://localhost:4566", + "Region": "us-east-1", + "AccessKey": "test", + "SecretKey": "test", + "TopicName": "employee-generated-topic", + "QueueName": "employee-generated-queue", + "BucketName": "employee-files" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/README.md b/README.md index dcaa5eb7..8ea40cc3 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,57 @@ # Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
- -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +## Лабораторная работа 3 +### Интеграционное тестирование — файловый сервис, SNS и объектное хранилище + +> Вариант 28: брокер **SNS** и хранилище **S3 LocalStack**. Для надёжной доставки в файловый сервис SNS-топик подписан на SQS-очередь, которую опрашивает `File.Service`. + + +## Запуск + +### Старт приложения + +```bash +dotnet restore +dotnet run --project ./AspireApp/AspireApp.AppHost/AspireApp.AppHost.csproj --launch-profile http +``` + +## Ручная проверка + +### 1. Генерация сотрудника + +```bash +curl -k "https://localhost:15000/employee?id=101" +``` +### 2. Проверка gateway + +```bash +curl -k -i "https://localhost:7200/employee?id=101" +``` + +### 3. Проверка сохранённого файла + +```bash +curl "http://localhost:16000/files/101" +``` +![alt text](image2.png) + +## Интеграционные тесты + +Тестовый проект `Backend.IntegrationTests` поднимает весь backend через `Aspire.Hosting.Testing` и проверяет: + +- что `Service.Api` возвращает сотрудника; +- что сотрудник после публикации события появляется в объектном хранилище; +- что повторный запрос по одному и тому же `id` возвращает тот же JSON и файл остаётся доступен. + +Запуск: + +```bash +dotnet test ./Backend.IntegrationTests/Backend.IntegrationTests.csproj +``` +![alt text](image1.png) + +## Ключевые endpoints + +- `GET https://localhost:15000/employee?id=1` — прямая генерация через одну реплику сервиса; +- `GET http://localhost:7200/employee?id=1` — запрос через Ocelot gateway; +- `GET http://localhost:16000/files/1` — чтение сохранённого файла сотрудника. \ No newline at end of file diff --git a/ServiceApi/Configuration/AwsMessagingOptions.cs b/ServiceApi/Configuration/AwsMessagingOptions.cs new file mode 100644 index 00000000..32feee93 --- /dev/null +++ b/ServiceApi/Configuration/AwsMessagingOptions.cs @@ -0,0 +1,34 @@ +namespace Service.Api.Configuration; + +/// +/// Параметры интеграции с AWS-совместимыми сервисами LocalStack. +/// +public sealed class AwsMessagingOptions +{ + public const string SectionName = "Aws"; + + /// + /// Базовый URL LocalStack. + /// + public string ServiceUrl { get; set; } = "http://localhost:4566"; + + /// + /// Регион AWS. + /// + public string Region { get; set; } = "us-east-1"; + + /// + /// Access key для LocalStack. + /// + public string AccessKey { get; set; } = "test"; + + /// + /// Secret key для LocalStack. + /// + public string SecretKey { get; set; } = "test"; + + /// + /// Имя SNS-топика с событиями генерации сотрудников. + /// + public string TopicName { get; set; } = "employee-generated-topic"; +} diff --git a/ServiceApi/Entities/Employee.cs b/ServiceApi/Entities/Employee.cs new file mode 100644 index 00000000..fec349fb --- /dev/null +++ b/ServiceApi/Entities/Employee.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Service.Api.Entities; + +/// +/// Сотрудник компании. +/// +public sealed class Employee +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("fullName")] + public string FullName { get; set; } = string.Empty; + + [JsonPropertyName("position")] + public string Position { get; set; } = string.Empty; + + [JsonPropertyName("department")] + public string Department { get; set; } = string.Empty; + + [JsonPropertyName("hireDate")] + public DateOnly HireDate { get; set; } + + [JsonPropertyName("salary")] + public decimal Salary { get; set; } + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + [JsonPropertyName("phone")] + public string Phone { get; set; } = string.Empty; + + [JsonPropertyName("isFired")] + public bool IsFired { get; set; } + + [JsonPropertyName("fireDate")] + public DateOnly? FireDate { get; set; } +} diff --git a/ServiceApi/Generator/EmployeeGenerator.cs b/ServiceApi/Generator/EmployeeGenerator.cs new file mode 100644 index 00000000..b3e7645e --- /dev/null +++ b/ServiceApi/Generator/EmployeeGenerator.cs @@ -0,0 +1,92 @@ +using Bogus; +using Bogus.DataSets; +using Service.Api.Entities; + +namespace Service.Api.Generator; + +/// +/// Генератор случайных сотрудников компании. +/// +public static class EmployeeGenerator +{ + private static readonly string[] ProfessionCatalog = + { + "Developer", + "Manager", + "Analyst", + "Tester", + "Administrator", + "Designer" + }; + + private static readonly string[] PositionLevels = + { + "Junior", + "Middle", + "Senior" + }; + + public static Employee Generate(int id) + { + var faker = new Faker("ru"); + + var gender = faker.PickRandom(); + var firstName = faker.Name.FirstName(gender); + var lastName = faker.Name.LastName(gender); + var patronymic = BuildPatronymic(faker.Name.FirstName(Name.Gender.Male), gender); + + var level = faker.PickRandom(PositionLevels); + var profession = faker.PickRandom(ProfessionCatalog); + var hireDate = faker.Date.Past(10, DateTime.Today); + var isFired = faker.Random.Bool(0.18f); + + DateOnly? fireDate = null; + if (isFired) + { + fireDate = DateOnly.FromDateTime(faker.Date.Between(hireDate, DateTime.Today)); + } + + return new Employee + { + Id = id, + FullName = $"{lastName} {firstName} {patronymic}", + Position = $"{level} {profession}", + Department = faker.Commerce.Department(), + HireDate = DateOnly.FromDateTime(hireDate), + Salary = CalculateSalary(level, faker), + Email = faker.Internet.Email(firstName, lastName), + Phone = faker.Phone.PhoneNumber("+7(###)###-##-##"), + IsFired = isFired, + FireDate = fireDate + }; + } + + private static string BuildPatronymic(string sourceName, Name.Gender gender) + { + if (string.IsNullOrWhiteSpace(sourceName)) + { + return gender == Name.Gender.Male ? "Иванович" : "Ивановна"; + } + + return gender switch + { + Name.Gender.Male when sourceName.EndsWith('й') => $"{sourceName[..^1]}евич", + Name.Gender.Female when sourceName.EndsWith('й') => $"{sourceName[..^1]}евна", + Name.Gender.Male => $"{sourceName}ович", + _ => $"{sourceName}овна" + }; + } + + private static decimal CalculateSalary(string level, Faker faker) + { + var value = level switch + { + "Junior" => faker.Random.Decimal(60_000m, 95_000m), + "Middle" => faker.Random.Decimal(100_000m, 170_000m), + "Senior" => faker.Random.Decimal(180_000m, 280_000m), + _ => faker.Random.Decimal(80_000m, 120_000m) + }; + + return Math.Round(value, 2, MidpointRounding.AwayFromZero); + } +} diff --git a/ServiceApi/Generator/EmployeeGeneratorService.cs b/ServiceApi/Generator/EmployeeGeneratorService.cs new file mode 100644 index 00000000..15b43aba --- /dev/null +++ b/ServiceApi/Generator/EmployeeGeneratorService.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Service.Api.Entities; +using Service.Api.Messaging; + +namespace Service.Api.Generator; + +public sealed class EmployeeGeneratorService( + IDistributedCache cache, + ILogger logger, + IConfiguration configuration, + IEmployeeEventPublisher eventPublisher) : IEmployeeGeneratorService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly TimeSpan _cacheExpiration = + TimeSpan.FromMinutes(configuration.GetValue("CacheExpirationMinutes") ?? 30); + + public async Task ProcessEmployee(int id, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id); + + var cacheKey = $"employee:{id}"; + var fromCache = false; + + using var _ = logger.BeginScope(new Dictionary + { + ["EmployeeId"] = id, + ["CacheKey"] = cacheKey + }); + + logger.LogInformation("Employee request received"); + + try + { + var employee = await RetrieveFromCache(cacheKey, cancellationToken); + if (employee is not null) + { + fromCache = true; + logger.LogInformation("Cache hit. Returning employee from Redis"); + await eventPublisher.PublishAsync(employee, cancellationToken); + return employee; + } + + logger.LogInformation("Cache miss. Generating new employee"); + employee = EmployeeGenerator.Generate(id); + await PopulateCache(cacheKey, employee, cancellationToken); + await eventPublisher.PublishAsync(employee, cancellationToken); + + logger.LogInformation( + "Employee {EmployeeId} stored in cache for {CacheLifetimeMinutes} minutes and published to SNS", + id, + _cacheExpiration.TotalMinutes); + + return employee; + } + catch (Exception exception) + { + logger.LogError(exception, "Error while processing employee {EmployeeId}. FromCache={FromCache}", id, fromCache); + throw; + } + } + + private async Task RetrieveFromCache(string cacheKey, CancellationToken cancellationToken) + { + var json = await cache.GetStringAsync(cacheKey, cancellationToken); + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private async Task PopulateCache(string cacheKey, Employee employee, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(employee, JsonOptions); + + await cache.SetStringAsync( + cacheKey, + json, + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _cacheExpiration + }, + cancellationToken); + } +} diff --git a/ServiceApi/Generator/IEmployeeGeneratorService.cs b/ServiceApi/Generator/IEmployeeGeneratorService.cs new file mode 100644 index 00000000..c8de0288 --- /dev/null +++ b/ServiceApi/Generator/IEmployeeGeneratorService.cs @@ -0,0 +1,11 @@ +using Service.Api.Entities; + +namespace Service.Api.Generator; + +/// +/// Интерфейс обработки запросов на получение сотрудника. +/// +public interface IEmployeeGeneratorService +{ + Task ProcessEmployee(int id, CancellationToken cancellationToken = default); +} diff --git a/ServiceApi/Messaging/EmployeeGeneratedMessage.cs b/ServiceApi/Messaging/EmployeeGeneratedMessage.cs new file mode 100644 index 00000000..e0d06531 --- /dev/null +++ b/ServiceApi/Messaging/EmployeeGeneratedMessage.cs @@ -0,0 +1,29 @@ +using Service.Api.Entities; + +namespace Service.Api.Messaging; + +/// +/// Сообщение о сформированных данных сотрудника. +/// +public sealed class EmployeeGeneratedMessage +{ + /// + /// Идентификатор сотрудника. + /// + public int EmployeeId { get; init; } + + /// + /// Время публикации события в UTC. + /// + public DateTime PublishedAtUtc { get; init; } + + /// + /// Идентификатор реплики сервиса, опубликовавшей событие. + /// + public string ReplicaId { get; init; } = string.Empty; + + /// + /// Сформированные данные сотрудника. + /// + public Employee Payload { get; init; } = new(); +} diff --git a/ServiceApi/Messaging/IEmployeeEventPublisher.cs b/ServiceApi/Messaging/IEmployeeEventPublisher.cs new file mode 100644 index 00000000..7a3f844c --- /dev/null +++ b/ServiceApi/Messaging/IEmployeeEventPublisher.cs @@ -0,0 +1,14 @@ +using Service.Api.Entities; + +namespace Service.Api.Messaging; + +/// +/// Публикует сведения о сотруднике в брокер сообщений. +/// +public interface IEmployeeEventPublisher +{ + /// + /// Публикует событие генерации сотрудника. + /// + Task PublishAsync(Employee employee, CancellationToken cancellationToken = default); +} diff --git a/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs b/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs new file mode 100644 index 00000000..9d6efa11 --- /dev/null +++ b/ServiceApi/Messaging/SnsEmployeeEventPublisher.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using Amazon.Runtime; +using Amazon.SimpleNotificationService; +using Amazon.SimpleNotificationService.Model; +using Microsoft.Extensions.Options; +using Service.Api.Configuration; +using Service.Api.Entities; + +namespace Service.Api.Messaging; + +/// +/// Публикует события генерации сотрудников в SNS. +/// +public sealed class SnsEmployeeEventPublisher : IEmployeeEventPublisher, IAsyncDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false + }; + + private readonly AwsMessagingOptions _options; + private readonly ILogger _logger; + private readonly IAmazonSimpleNotificationService _snsClient; + private readonly string _replicaId; + private string? _topicArn; + + public SnsEmployeeEventPublisher( + IOptions options, + IConfiguration configuration, + ILogger logger) + { + _options = options.Value; + _logger = logger; + _replicaId = configuration["ReplicaId"] ?? Environment.MachineName; + + var credentials = new BasicAWSCredentials(_options.AccessKey, _options.SecretKey); + var config = new AmazonSimpleNotificationServiceConfig + { + ServiceURL = _options.ServiceUrl, + AuthenticationRegion = _options.Region, + UseHttp = _options.ServiceUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + }; + + _snsClient = new AmazonSimpleNotificationServiceClient(credentials, config); + } + + public async Task PublishAsync(Employee employee, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(employee); + + Exception? lastException = null; + + for (var attempt = 1; attempt <= 15; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var topicArn = await EnsureTopicAsync(cancellationToken); + + var message = new EmployeeGeneratedMessage + { + EmployeeId = employee.Id, + PublishedAtUtc = DateTime.UtcNow, + ReplicaId = _replicaId, + Payload = employee + }; + + var payload = JsonSerializer.Serialize(message, JsonOptions); + + _logger.LogInformation( + "Publishing employee {EmployeeId} to SNS topic {TopicArn}. Attempt {Attempt}", + employee.Id, + topicArn, + attempt); + + await _snsClient.PublishAsync(new PublishRequest + { + TopicArn = topicArn, + Subject = $"employee-{employee.Id}", + Message = payload, + MessageAttributes = new Dictionary + { + ["employeeId"] = new() + { + DataType = "Number", + StringValue = employee.Id.ToString() + }, + ["replicaId"] = new() + { + DataType = "String", + StringValue = _replicaId + } + } + }, cancellationToken); + + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + lastException = ex; + _topicArn = null; + + _logger.LogWarning( + ex, + "Failed to publish employee {EmployeeId} to SNS on attempt {Attempt}. Retrying...", + employee.Id, + attempt); + + if (attempt == 15) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); + } + } + + throw new InvalidOperationException( + $"Не удалось опубликовать сотрудника {employee.Id} в SNS после повторных попыток.", + lastException); + } + + private async Task EnsureTopicAsync(CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(_topicArn)) + { + return _topicArn; + } + + var response = await _snsClient.CreateTopicAsync(new CreateTopicRequest + { + Name = _options.TopicName + }, cancellationToken); + + _topicArn = response.TopicArn; + return _topicArn; + } + + public async ValueTask DisposeAsync() + { + _snsClient.Dispose(); + await Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ServiceApi/Program.cs b/ServiceApi/Program.cs new file mode 100644 index 00000000..26560057 --- /dev/null +++ b/ServiceApi/Program.cs @@ -0,0 +1,83 @@ +using Service.Api.Configuration; +using Service.Api.Generator; +using Service.Api.Messaging; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("RedisCache"); + +builder.Logging.AddJsonConsole(options => +{ + options.IncludeScopes = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.Configure(builder.Configuration.GetSection(AwsMessagingOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => +{ + policy.SetIsOriginAllowed(origin => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) + && uri.IsLoopback + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + .AllowAnyHeader() + .WithMethods("GET") + .WithExposedHeaders("X-Service-Replica", "X-Service-Weight"); +})); + +var app = builder.Build(); + +var replicaId = app.Configuration["ReplicaId"] ?? Environment.MachineName; +var replicaWeight = app.Configuration.GetValue("ReplicaWeight") ?? 1; + +app.UseCors(); +app.Use(async (context, next) => +{ + context.Response.Headers["X-Service-Replica"] = replicaId; + context.Response.Headers["X-Service-Weight"] = replicaWeight.ToString(); + await next(); +}); + +app.MapDefaultEndpoints(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "Service.Api", + replica = replicaId, + weight = replicaWeight, + description = "Сервис генерации сотрудников компании", + endpoints = new[] { "/employee?id=1", "/employee/1" } +})); + +app.MapGet("/employee", async (IEmployeeGeneratorService service, ILoggerFactory loggerFactory, int id, CancellationToken cancellationToken) => +{ + var logger = loggerFactory.CreateLogger("ServiceApiEndpoints"); + logger.LogInformation("Replica {ReplicaId} received request for employee {EmployeeId}", replicaId, id); + + if (id <= 0) + { + return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." }); + } + + return Results.Ok(await service.ProcessEmployee(id, cancellationToken)); +}); + +app.MapGet("/employee/{id:int}", async (IEmployeeGeneratorService service, ILoggerFactory loggerFactory, int id, CancellationToken cancellationToken) => +{ + var logger = loggerFactory.CreateLogger("ServiceApiEndpoints"); + logger.LogInformation("Replica {ReplicaId} received request for employee {EmployeeId}", replicaId, id); + + if (id <= 0) + { + return Results.BadRequest(new { message = "Идентификатор сотрудника должен быть больше нуля." }); + } + + return Results.Ok(await service.ProcessEmployee(id, cancellationToken)); +}); + +app.Run(); + +public partial class Program; diff --git a/ServiceApi/Properties/launchSettings.json b/ServiceApi/Properties/launchSettings.json new file mode 100644 index 00000000..0641ca27 --- /dev/null +++ b/ServiceApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:7099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ServiceApi/Service.Api.csproj b/ServiceApi/Service.Api.csproj new file mode 100644 index 00000000..f6c45b81 --- /dev/null +++ b/ServiceApi/Service.Api.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + Service.Api + Service.Api + + + + + + + + + + + + + diff --git a/ServiceApi/appsettings.Development.json b/ServiceApi/appsettings.Development.json new file mode 100644 index 00000000..30303786 --- /dev/null +++ b/ServiceApi/appsettings.Development.json @@ -0,0 +1,19 @@ +{ + "CacheExpirationMinutes": 30, + "ConnectionStrings": { + "RedisCache": "localhost:6379" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Aws": { + "ServiceUrl": "http://localhost:4566", + "Region": "us-east-1", + "AccessKey": "test", + "SecretKey": "test", + "TopicName": "employee-generated-topic" + } +} diff --git a/ServiceApi/appsettings.json b/ServiceApi/appsettings.json new file mode 100644 index 00000000..a743d9e1 --- /dev/null +++ b/ServiceApi/appsettings.json @@ -0,0 +1,17 @@ +{ + "CacheExpirationMinutes": 30, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Aws": { + "ServiceUrl": "http://localhost:4566", + "Region": "us-east-1", + "AccessKey": "test", + "SecretKey": "test", + "TopicName": "employee-generated-topic" + } +} diff --git a/image1.png b/image1.png new file mode 100644 index 00000000..17c8e2f8 Binary files /dev/null and b/image1.png differ diff --git a/image2.png b/image2.png new file mode 100644 index 00000000..cd6d8782 Binary files /dev/null and b/image2.png differ