diff --git a/.gitignore b/.gitignore index ce892922..50ff4d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,5 @@ FodyWeavers.xsd *.msix *.msm *.msp + +.DS_Store diff --git a/AspireApp.ApiGateway/AspireApp.ApiGateway.csproj b/AspireApp.ApiGateway/AspireApp.ApiGateway.csproj new file mode 100644 index 00000000..f68856b5 --- /dev/null +++ b/AspireApp.ApiGateway/AspireApp.ApiGateway.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + \ No newline at end of file diff --git a/AspireApp.ApiGateway/LoadBalancing/ServicesAreEmptyError.cs b/AspireApp.ApiGateway/LoadBalancing/ServicesAreEmptyError.cs new file mode 100644 index 00000000..2e92855e --- /dev/null +++ b/AspireApp.ApiGateway/LoadBalancing/ServicesAreEmptyError.cs @@ -0,0 +1,5 @@ +using Ocelot.Errors; + +namespace AspireApp.ApiGateway.LoadBalancing; + +public class ServicesAreEmptyError(string message) : Error(message, OcelotErrorCode.ServicesAreEmptyError, 503); \ No newline at end of file diff --git a/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs b/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs new file mode 100644 index 00000000..115728fc --- /dev/null +++ b/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancer.cs @@ -0,0 +1,55 @@ +using Ocelot.Responses; +using Ocelot.Values; +using Ocelot.LoadBalancer.Interfaces; + +namespace AspireApp.ApiGateway.LoadBalancing; + +/// +/// Weighted Random балансировщик нагрузки. +/// Распределяет запросы между сервисами по заданным весам. +/// +public class WeightedRandomLoadBalancer : ILoadBalancer +{ + private readonly List _services; + private readonly List _weights; + private readonly int _totalWeight; + private static readonly Random _random = Random.Shared; + private readonly IConfiguration _configuration; + + public WeightedRandomLoadBalancer(List services, IConfiguration configuration) + { + _services = services; + _configuration = configuration; + _weights = services.Select(s => GetWeight(s)).ToList(); + _totalWeight = _weights.Sum(); + } + + private int GetWeight(Service service) + { + var port = service.HostAndPort.DownstreamPort.ToString(); + return _configuration.GetValue($"Weights:{port}", 1); + } + + public async Task> LeaseAsync(HttpContext httpContext) + { + if (_services.Count == 0) + return new ErrorResponse(new ServicesAreEmptyError("Нет доступных сервисов")); + + var randomValue = _random.Next(_totalWeight); + + var current = 0; + for (var i = 0; i < _services.Count; i++) + { + current += _weights[i]; + if (randomValue < current) + { + return new OkResponse(_services[i].HostAndPort); + } + } + + return new OkResponse(_services[0].HostAndPort); + } + + public void Release(ServiceHostAndPort hostAndPort) { } + public string Type => "WeightedRandom"; +} diff --git a/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancerFactory.cs b/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancerFactory.cs new file mode 100644 index 00000000..7a4cddd6 --- /dev/null +++ b/AspireApp.ApiGateway/LoadBalancing/WeightedRandomLoadBalancerFactory.cs @@ -0,0 +1,18 @@ +using Ocelot.Configuration; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace AspireApp.ApiGateway.LoadBalancing; + +public class WeightedRandomLoadBalancerFactory(IConfiguration configuration) : ILoadBalancerFactory +{ + public Response Get(DownstreamRoute route, ServiceProviderConfiguration serviceProviderConfiguration) + { + var services = route.DownstreamAddresses + .Select(x => new Service("service", new ServiceHostAndPort(x.Host, x.Port), "", "", new List())) + .ToList(); + + return new OkResponse(new WeightedRandomLoadBalancer(services, configuration)); + } +} diff --git a/AspireApp.ApiGateway/Program.cs b/AspireApp.ApiGateway/Program.cs new file mode 100644 index 00000000..c386dbf4 --- /dev/null +++ b/AspireApp.ApiGateway/Program.cs @@ -0,0 +1,29 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using Ocelot.LoadBalancer.Interfaces; +using AspireApp.ApiGateway.LoadBalancing; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowClient", policy => + { + policy.WithOrigins("http://localhost:5127") + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +builder.Services.AddOcelot(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseCors("AllowClient"); + +await app.UseOcelot(); + +app.Run(); \ No newline at end of file diff --git a/AspireApp.ApiGateway/Properties/launchSettings.json b/AspireApp.ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..5fb51344 --- /dev/null +++ b/AspireApp.ApiGateway/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5101", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/AspireApp.ApiGateway/appsettings.Development.json b/AspireApp.ApiGateway/appsettings.Development.json new file mode 100644 index 00000000..1b2d3baf --- /dev/null +++ b/AspireApp.ApiGateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/AspireApp.ApiGateway/appsettings.json b/AspireApp.ApiGateway/appsettings.json new file mode 100644 index 00000000..966b24ce --- /dev/null +++ b/AspireApp.ApiGateway/appsettings.json @@ -0,0 +1,7 @@ +{ + "Weights": { + "5001": 5, + "5002": 3, + "5003": 2 + } +} \ No newline at end of file diff --git a/AspireApp.ApiGateway/ocelot.json b/AspireApp.ApiGateway/ocelot.json new file mode 100644 index 00000000..f22b75e7 --- /dev/null +++ b/AspireApp.ApiGateway/ocelot.json @@ -0,0 +1,24 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/warehouse", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 5001 }, + { "Host": "localhost", "Port": 5002 }, + { "Host": "localhost", "Port": 5003 } + ], + "UpstreamPathTemplate": "/warehouse", + "UpstreamHttpMethod": [ "GET" ], + "LoadBalancerOptions": { + "Type": "WeightedRandom" + }, + "HttpHandlerOptions": { + "UseTracing": true + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5101" + } +} \ No newline at end of file diff --git a/AspireApp.ApiService/AspireApp.ApiService.csproj b/AspireApp.ApiService/AspireApp.ApiService.csproj new file mode 100644 index 00000000..cd3f83c3 --- /dev/null +++ b/AspireApp.ApiService/AspireApp.ApiService.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/AspireApp.ApiService/Entities/Warehouse.cs b/AspireApp.ApiService/Entities/Warehouse.cs new file mode 100644 index 00000000..37609d02 --- /dev/null +++ b/AspireApp.ApiService/Entities/Warehouse.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Serialization; + +namespace AspireApp.ApiService.Entities; + +/// +/// Товар на складе +/// +public class Warehouse +{ + /// + /// Идентификатор + /// + [JsonPropertyName("id")] + public int Id { get; set; } + + /// + /// Наименование товара + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Категория товара + /// + [JsonPropertyName("category")] + public string? Category { get; set; } + + /// + /// Количество на складе + /// + [JsonPropertyName("stockQuantity")] + public int StockQuantity { get; set; } + + /// + /// Цена за единицу товара + /// + [JsonPropertyName("price")] + public decimal Price { get; set; } + + /// + /// Вес единицы товара + /// + [JsonPropertyName("weight")] + public double Weight { get; set; } + + /// + /// Габариты единицы товара + /// + [JsonPropertyName("dimensions")] + public string? Dimensions { get; set; } + + /// + /// Хрупкий ли товар + /// + [JsonPropertyName("isFragile")] + public bool IsFragile { get; set; } + + /// + /// Дата последней поставки + /// + [JsonPropertyName("lastDeliveryDate")] + public DateOnly LastDeliveryDate { get; set; } + + /// + /// Дата следующей планируемой поставки + /// + [JsonPropertyName("nextDeliveryDate")] + public DateOnly NextDeliveryDate { get; set; } +} \ No newline at end of file diff --git a/AspireApp.ApiService/Generator/IWarehouseCache.cs b/AspireApp.ApiService/Generator/IWarehouseCache.cs new file mode 100644 index 00000000..bc9c977f --- /dev/null +++ b/AspireApp.ApiService/Generator/IWarehouseCache.cs @@ -0,0 +1,19 @@ +using AspireApp.ApiService.Entities; + +namespace AspireApp.ApiService.Generator; + +/// +/// Интерфейс для работы с кэшем товаров +/// +public interface IWarehouseCache +{ + /// + /// Получить товар из кэша по идентификатору + /// + Task GetAsync(int id); + + /// + /// Сохранить товар в кэш + /// + Task SetAsync(Warehouse warehouse); +} \ No newline at end of file diff --git a/AspireApp.ApiService/Generator/IWarehouseGeneratorService.cs b/AspireApp.ApiService/Generator/IWarehouseGeneratorService.cs new file mode 100644 index 00000000..193ab019 --- /dev/null +++ b/AspireApp.ApiService/Generator/IWarehouseGeneratorService.cs @@ -0,0 +1,11 @@ +using AspireApp.ApiService.Entities; + +namespace AspireApp.ApiService.Generator; + +/// +/// Сервис обработки товаров на складе +/// +public interface IWarehouseGeneratorService +{ + Task ProcessWarehouse(int id); +} \ No newline at end of file diff --git a/AspireApp.ApiService/Generator/WarehouseCache.cs b/AspireApp.ApiService/Generator/WarehouseCache.cs new file mode 100644 index 00000000..6e542f4a --- /dev/null +++ b/AspireApp.ApiService/Generator/WarehouseCache.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; +using AspireApp.ApiService.Entities; + +namespace AspireApp.ApiService.Generator; + +/// +/// Кэширование товаров +/// +public class WarehouseCache( + IDistributedCache cache, + ILogger logger, + IConfiguration configuration) : IWarehouseCache +{ + private readonly TimeSpan _defaultExpiration = int.TryParse(configuration["CacheExpiration"], out var seconds) + ? TimeSpan.FromSeconds(seconds) + : TimeSpan.FromSeconds(3600); + + public async Task GetAsync(int id) + { + var key = $"warehouse_{id}"; + var cached = await cache.GetStringAsync(key); + if (cached == null) + return null; + + try + { + return JsonSerializer.Deserialize(cached); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка десериализации товара {Id} из кэша", id); + return null; + } + } + + public async Task SetAsync(Warehouse warehouse) + { + var key = $"warehouse_{warehouse.Id}"; + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = _defaultExpiration + }; + var serialized = JsonSerializer.Serialize(warehouse); + await cache.SetStringAsync(key, serialized, options); + } +} \ No newline at end of file diff --git a/AspireApp.ApiService/Generator/WarehouseGenerator.cs b/AspireApp.ApiService/Generator/WarehouseGenerator.cs new file mode 100644 index 00000000..dbb68931 --- /dev/null +++ b/AspireApp.ApiService/Generator/WarehouseGenerator.cs @@ -0,0 +1,32 @@ +using Bogus; +using AspireApp.ApiService.Entities; + +namespace AspireApp.ApiService.Generator; + +/// +/// Генератор случайных товаров с использованием Bogus +/// +public class WarehouseGenerator +{ + private readonly Faker _faker; + + public WarehouseGenerator() + { + _faker = new Faker() + .RuleFor(w => w.Id, f => f.IndexFaker + 1) // будет перезаписан позже + .RuleFor(w => w.Name, f => f.Commerce.ProductName()) + .RuleFor(w => w.Category, f => f.Commerce.Categories(1)[0]) + .RuleFor(w => w.StockQuantity, f => f.Random.Int(0, 1000)) + .RuleFor(w => w.Price, f => decimal.Parse(f.Commerce.Price())) + .RuleFor(w => w.Weight, f => f.Random.Double(0.1, 50.0)) + .RuleFor(w => w.Dimensions, f => $"{f.Random.Int(10, 100)}x{f.Random.Int(10, 100)}x{f.Random.Int(10, 100)}") + .RuleFor(w => w.IsFragile, f => f.Random.Bool(0.3f)) + .RuleFor(w => w.LastDeliveryDate, f => DateOnly.FromDateTime(f.Date.Past(30))) + .RuleFor(w => w.NextDeliveryDate, f => DateOnly.FromDateTime(f.Date.Future(30))); + } + + /// + /// Генерирует один случайный товар (Id будет перезаписан вызывающим кодом) + /// + public Warehouse Generate() => _faker.Generate(); +} \ No newline at end of file diff --git a/AspireApp.ApiService/Generator/WarehouseGeneratorService.cs b/AspireApp.ApiService/Generator/WarehouseGeneratorService.cs new file mode 100644 index 00000000..10cdd142 --- /dev/null +++ b/AspireApp.ApiService/Generator/WarehouseGeneratorService.cs @@ -0,0 +1,61 @@ +using AspireApp.ApiService.Entities; +using AspireApp.ApiService.Messaging; + +namespace AspireApp.ApiService.Generator; + +/// +/// Служба для запуска юзкейса по обработке товаров на складе +/// +/// Служба кэширования +/// Служба-публикатор сообщений в SQS +/// Логгер +/// Генератор товаров +public class WarehouseGeneratorService( + IWarehouseCache warehouseCache, + SqsProducerService producer, + ILogger logger, + WarehouseGenerator generator) : IWarehouseGeneratorService +{ + /// + public async Task ProcessWarehouse(int id) + { + logger.LogInformation("Обработка товара с Id = {Id} начата", id); + + // Получаем товар из кэша + Warehouse? warehouse; + try + { + warehouse = await warehouseCache.GetAsync(id); + if (warehouse != null) + { + logger.LogInformation("Товар {Id} получен из кэша", id); + return warehouse; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось получить товар {Id} из кэша (ошибка игнорируется)", id); + } + + // Если в кэше нет или ошибка — генерируем новый товар + logger.LogInformation("Товар {Id} в кэше не найден или кэш недоступен, запуск генерации", id); + warehouse = generator.Generate(); + warehouse.Id = id; + + // Отправка в брокер только при генерации нового объекта + await producer.SendMessage(warehouse); + + // Попытка сохранить в кэш + try + { + logger.LogInformation("Сохранение товара {Id} в кэш", id); + await warehouseCache.SetAsync(warehouse); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Не удалось сохранить товар {Id} в кэш (ошибка игнорируется)", id); + } + + return warehouse; + } +} diff --git a/AspireApp.ApiService/Messaging/SqsProducerService.cs b/AspireApp.ApiService/Messaging/SqsProducerService.cs new file mode 100644 index 00000000..4bcb2d41 --- /dev/null +++ b/AspireApp.ApiService/Messaging/SqsProducerService.cs @@ -0,0 +1,42 @@ +using Amazon.SQS; +using AspireApp.ApiService.Entities; +using System.Net; +using System.Text.Json; + +namespace AspireApp.ApiService.Messaging; + +/// +/// Служба-публикатор сообщений в SQS. Сериализует объект Warehouse в JSON +/// и отправляет в очередь, имя которой берётся из конфигурации +/// +/// AWS SDK клиент SQS +/// Конфигурация приложения, содержит имя очереди +/// Логгер +public class SqsProducerService(IAmazonSQS client, IConfiguration configuration, ILogger logger) +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("Имя SQS очереди не найдено в конфигурации"); + + /// + /// Отправляет сериализованный Warehouse в SQS-очередь. + /// Исключения логируются и не пробрасываются — endpoint остаётся доступным + /// даже при сбое брокера + /// + /// Сгенерированный товар на складе + public async Task SendMessage(Warehouse warehouse) + { + try + { + var json = JsonSerializer.Serialize(warehouse); + var response = await client.SendMessageAsync(_queueName, json); + if (response.HttpStatusCode == HttpStatusCode.OK) + logger.LogInformation("Товар {Id} отправлен в SQS", warehouse.Id); + else + throw new Exception($"SQS вернул статус {response.HttpStatusCode}"); + } + catch (Exception ex) + { + logger.LogError(ex, "Не удалось отправить товар {Id} в SQS", warehouse.Id); + } + } +} diff --git a/AspireApp.ApiService/Program.cs b/AspireApp.ApiService/Program.cs new file mode 100644 index 00000000..7e985041 --- /dev/null +++ b/AspireApp.ApiService/Program.cs @@ -0,0 +1,28 @@ +using Amazon.SQS; +using AspireApp.ApiService.Generator; +using AspireApp.ApiService.Messaging; +using LocalStack.Client.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("RedisCache"); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.MapGet("/warehouse", async (IWarehouseGeneratorService service, int id) => +{ + var warehouse = await service.ProcessWarehouse(id); + return Results.Ok(warehouse); +}); + +app.Run(); diff --git a/AspireApp.ApiService/Properties/launchSettings.json b/AspireApp.ApiService/Properties/launchSettings.json new file mode 100644 index 00000000..b78d61eb --- /dev/null +++ b/AspireApp.ApiService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/AspireApp.ApiService/appsettings.Development.json b/AspireApp.ApiService/appsettings.Development.json new file mode 100644 index 00000000..0b120f35 --- /dev/null +++ b/AspireApp.ApiService/appsettings.Development.json @@ -0,0 +1,9 @@ +{"BaseAddress": "http://localhost:53677", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} + diff --git a/AspireApp.ApiService/appsettings.json b/AspireApp.ApiService/appsettings.json new file mode 100644 index 00000000..4d566948 --- /dev/null +++ b/AspireApp.ApiService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/AspireApp.AppHost.Tests/AspireApp.AppHost.Tests.csproj b/AspireApp.AppHost.Tests/AspireApp.AppHost.Tests.csproj new file mode 100644 index 00000000..8f517cc5 --- /dev/null +++ b/AspireApp.AppHost.Tests/AspireApp.AppHost.Tests.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + diff --git a/AspireApp.AppHost.Tests/IntegrationTest.cs b/AspireApp.AppHost.Tests/IntegrationTest.cs new file mode 100644 index 00000000..1372014a --- /dev/null +++ b/AspireApp.AppHost.Tests/IntegrationTest.cs @@ -0,0 +1,150 @@ +using Aspire.Hosting; +using AspireApp.ApiService.Entities; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using Xunit.Abstractions; + +namespace AspireApp.AppHost.Tests; + +/// +/// Интеграционные тесты для проверки микросервисного пайплайна +/// +/// Служба журналирования юнит-тестов +public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime +{ + private IDistributedApplicationTestingBuilder? _builder; + private DistributedApplication? _app; + + /// + public async Task InitializeAsync() + { + var cancellationToken = CancellationToken.None; + _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); + }); + } + + /// + /// Проверяет, что вызов гейтвея: + /// + /// В ответ отправляет сгенерированный товар + /// Сериализует товар в S3 хранилище через SQS + /// Данные из API и S3 идентичны + /// + /// + [Fact] + public async Task Pipeline_GatewayResponse_MatchesS3Object() + { + var cancellationToken = CancellationToken.None; + _app = await _builder!.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var random = new Random(); + var id = random.Next(1, 100); + using var gatewayClient = _app.CreateHttpClient("api-gateway", "gateway"); + using var gatewayResponse = await gatewayClient!.GetAsync($"/warehouse?id={id}"); + var apiWarehouse = JsonSerializer.Deserialize(await gatewayResponse.Content.ReadAsStringAsync()); + + await Task.Delay(5000); + using var fileClient = _app.CreateHttpClient("warehouse-fileservice", "http"); + using var listResponse = await fileClient!.GetAsync($"/api/s3"); + var fileList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync()); + using var s3Response = await fileClient!.GetAsync($"/api/s3/warehouse_{id}.json"); + var s3Warehouse = JsonSerializer.Deserialize(await s3Response.Content.ReadAsStringAsync()); + + Assert.NotNull(fileList); + Assert.Single(fileList); + Assert.NotNull(apiWarehouse); + Assert.NotNull(s3Warehouse); + Assert.Equal(id, s3Warehouse.Id); + Assert.Equivalent(apiWarehouse, s3Warehouse); + } + + /// + /// Проверяет идемпотентность пайплайна за счёт Redis-кэша: + /// + /// Повторный запрос с тем же id возвращает идентичные данные + /// Балансировщик отдаёт запросы разным репликам, но кэш един + /// Объект в S3 совпадает с ответом gateway + /// + /// + [Fact] + public async Task Cache_RepeatedRequests_ReturnSameWarehouse() + { + var cancellationToken = CancellationToken.None; + _app = await _builder!.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var random = new Random(); + var id = random.Next(1, 100); + + using var gatewayClient = _app.CreateHttpClient("api-gateway", "gateway"); + using var firstResponse = await gatewayClient!.GetAsync($"/warehouse?id={id}"); + var firstWarehouse = JsonSerializer.Deserialize(await firstResponse.Content.ReadAsStringAsync()); + + using var secondClient = _app.CreateHttpClient("api-gateway", "gateway"); + using var secondResponse = await secondClient!.GetAsync($"/warehouse?id={id}"); + var secondWarehouse = JsonSerializer.Deserialize(await secondResponse.Content.ReadAsStringAsync()); + + await Task.Delay(5000); + using var fileClient = _app.CreateHttpClient("warehouse-fileservice", "http"); + using var s3Response = await fileClient!.GetAsync($"/api/s3/warehouse_{id}.json"); + var s3Warehouse = JsonSerializer.Deserialize(await s3Response.Content.ReadAsStringAsync()); + + Assert.NotNull(firstWarehouse); + Assert.NotNull(secondWarehouse); + Assert.NotNull(s3Warehouse); + Assert.Equivalent(firstWarehouse, secondWarehouse); + Assert.Equivalent(firstWarehouse, s3Warehouse); + } + + /// + /// Проверяет надёжность связки балансировщик → SQS → потребитель: + /// + /// 10 запросов распределяются между 3 репликами ApiService + /// Все реплики публикуют в одну очередь SQS + /// Единственный потребитель FileService обрабатывает поток без потерь + /// В S3 оказываются все 10 файлов с ожидаемыми ключами + /// + /// + [Fact] + public async Task Pipeline_LoadBalancing_AllReplicasProduceToSameQueue() + { + var cancellationToken = CancellationToken.None; + _app = await _builder!.BuildAsync(cancellationToken); + await _app.StartAsync(cancellationToken); + + var ids = Enumerable.Range(100, 10).ToArray(); + + foreach (var id in ids) + { + using var client = _app.CreateHttpClient("api-gateway", "gateway"); + using var response = await client!.GetAsync($"/warehouse?id={id}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + await Task.Delay(10000); + + using var fileClient = _app.CreateHttpClient("warehouse-fileservice", "http"); + using var listResponse = await fileClient!.GetAsync($"/api/s3"); + var fileList = JsonSerializer.Deserialize>(await listResponse.Content.ReadAsStringAsync()); + + Assert.NotNull(fileList); + foreach (var id in ids) + Assert.Contains($"warehouse_{id}.json", fileList); + } + + /// + public async Task DisposeAsync() + { + await _app!.StopAsync(); + await _app.DisposeAsync(); + await _builder!.DisposeAsync(); + } +} diff --git a/AspireApp.AppHost/AppHost.cs b/AspireApp.AppHost/AppHost.cs new file mode 100644 index 00000000..f6306e4a --- /dev/null +++ b/AspireApp.AppHost/AppHost.cs @@ -0,0 +1,56 @@ +using Amazon; +using Aspire.Hosting.LocalStack.Container; + +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("RedisCache").WithRedisInsight(containerName: "insight"); + +var awsConfig = builder.AddAWSSDKConfig() + .WithProfile("default") + .WithRegion(RegionEndpoint.EUCentral1); + +var localstack = builder.AddLocalStack("warehouse-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("warehouse-resources", "CloudFormation/warehouse-template.yaml", "warehouse") + .WithReference(awsConfig); + +var ports = new[] { 5001, 5002, 5003 }; + +var gateway = builder.AddProject("api-gateway") + .WithHttpEndpoint(port: 5101, name: "gateway"); + +for (var i = 0; i < 3; i++) +{ + var api = builder.AddProject($"warehouse-api-{i}") + .WithReference(cache) + .WithReference(awsResources) + .WithEnvironment("REPLICA_ID", i.ToString()) + .WithEnvironment("Settings__MessageBroker", "SQS") + .WithHttpEndpoint(port: ports[i], name: $"api-{i}") + .WaitFor(cache) + .WaitFor(awsResources); + + gateway = gateway.WaitFor(api); +} + +builder.AddProject("warehouse-fileservice") + .WithReference(awsResources) + .WithEnvironment("Settings__MessageBroker", "SQS") + .WithEnvironment("Settings__S3Hosting", "Localstack") + .WaitFor(awsResources); + +builder.AddProject("client-wasm") + .WithReference(gateway) + .WithHttpEndpoint(port: 5127, name: "client") + .WaitFor(gateway); + +builder.UseLocalStack(localstack); + +builder.Build().Run(); diff --git a/AspireApp.AppHost/AspireApp.AppHost.csproj b/AspireApp.AppHost/AspireApp.AppHost.csproj new file mode 100644 index 00000000..2818d4f4 --- /dev/null +++ b/AspireApp.AppHost/AspireApp.AppHost.csproj @@ -0,0 +1,32 @@ + + + + + Exe + net10.0 + enable + enable + true + 59f7c075-68cd-4f31-89d0-9f5a69013 + + + + + + + + + + + + Always + + + + + + + + + + \ No newline at end of file diff --git a/AspireApp.AppHost/CloudFormation/warehouse-template.yaml b/AspireApp.AppHost/CloudFormation/warehouse-template.yaml new file mode 100644 index 00000000..5e39b12a --- /dev/null +++ b/AspireApp.AppHost/CloudFormation/warehouse-template.yaml @@ -0,0 +1,42 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'CloudFormation template for warehouse project: SQS queue + S3 bucket' + +Parameters: + BucketName: + Type: String + Default: 'warehouse-bucket' + QueueName: + Type: String + Default: 'warehouse-queue' + +Resources: + WarehouseBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + VersioningConfiguration: + Status: Suspended + PublicAccessBlockConfiguration: + BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + + WarehouseQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Ref QueueName + VisibilityTimeout: 30 + MessageRetentionPeriod: 345600 + DelaySeconds: 0 + ReceiveMessageWaitTimeSeconds: 0 + +Outputs: + S3BucketName: + Value: !Ref WarehouseBucket + S3BucketArn: + Value: !GetAtt WarehouseBucket.Arn + SQSQueueName: + Value: !GetAtt WarehouseQueue.QueueName + SQSQueueArn: + Value: !GetAtt WarehouseQueue.Arn diff --git a/AspireApp.AppHost/Properties/launchSettings.json b/AspireApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..cfbe69e4 --- /dev/null +++ b/AspireApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17244;http://localhost:15157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21396", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23238", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22247" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15157", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19285", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18208", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20362" + } + } + } +} \ No newline at end of file diff --git a/AspireApp.AppHost/appsettings.Development.json b/AspireApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/AspireApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AspireApp.AppHost/appsettings.json b/AspireApp.AppHost/appsettings.json new file mode 100644 index 00000000..158e0ac7 --- /dev/null +++ b/AspireApp.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/AspireApp.FileService/AspireApp.FileService.csproj b/AspireApp.FileService/AspireApp.FileService.csproj new file mode 100644 index 00000000..0bb34abc --- /dev/null +++ b/AspireApp.FileService/AspireApp.FileService.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/AspireApp.FileService/Controllers/StorageController.cs b/AspireApp.FileService/Controllers/StorageController.cs new file mode 100644 index 00000000..0c29c131 --- /dev/null +++ b/AspireApp.FileService/Controllers/StorageController.cs @@ -0,0 +1,61 @@ +using AspireApp.FileService.Storage; +using Microsoft.AspNetCore.Mvc; +using System.Text; +using System.Text.Json.Nodes; + +namespace AspireApp.FileService.Controllers; + +/// +/// REST API контроллер для работы с объектным хранилищем S3 +/// +/// Служба для работы с S3 +/// Логгер +[ApiController] +[Route("api/s3")] +public class StorageController(IS3Service s3Service, ILogger logger) : ControllerBase +{ + /// + /// Возвращает список ключей всех файлов в бакете + /// + /// 200 со списком ключей или 500 при ошибке + [HttpGet] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task>> ListFiles() + { + try + { + var list = await s3Service.GetFileList(); + logger.LogInformation("Получен список из {Count} файлов", list.Count); + return Ok(list); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при получении списка файлов"); + return StatusCode(500, ex.Message); + } + } + + /// + /// Возвращает содержимое файла по ключу + /// + /// Ключ объекта в бакете + /// 200 c JSON-содержимым файла или 500 при ошибке + [HttpGet("{key}")] + [ProducesResponseType(200)] + [ProducesResponseType(500)] + public async Task> GetFile(string key) + { + try + { + var node = await s3Service.DownloadFile(key); + logger.LogInformation("Файл {Key} получен, размер {Size} байт", key, Encoding.UTF8.GetByteCount(node.ToJsonString())); + return Ok(node); + } + catch (Exception ex) + { + logger.LogError(ex, "Ошибка при скачивании файла {Key}", key); + return StatusCode(500, ex.Message); + } + } +} diff --git a/AspireApp.FileService/Messaging/SqsConsumerService.cs b/AspireApp.FileService/Messaging/SqsConsumerService.cs new file mode 100644 index 00000000..81d68f34 --- /dev/null +++ b/AspireApp.FileService/Messaging/SqsConsumerService.cs @@ -0,0 +1,73 @@ +using Amazon.SQS; +using Amazon.SQS.Model; +using AspireApp.FileService.Storage; + +namespace AspireApp.FileService.Messaging; + +/// +/// Клиентская служба-потребитель сообщений из очереди SQS +/// +/// AWS SDK клиент SQS +/// Фабрика DI scope +/// Конфигурация приложения, содержит имя очереди +/// Логгер +public class SqsConsumerService( + IAmazonSQS sqsClient, + IServiceScopeFactory scopeFactory, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private readonly string _queueName = configuration["AWS:Resources:SQSQueueName"] + ?? throw new KeyNotFoundException("Имя SQS очереди не найдено в конфигурации"); + + /// + /// Основной цикл потребителя. Принимает батчи по 10 сообщений с long polling + /// + /// Токен остановки фонового сервиса + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Служба-потребитель SQS запущена"); + + while (!stoppingToken.IsCancellationRequested) + { + var response = await sqsClient.ReceiveMessageAsync( + new ReceiveMessageRequest + { + QueueUrl = _queueName, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 5 + }, stoppingToken); + + if (response == null) + { + logger.LogWarning("Получен пустой ответ из очереди {Queue}", _queueName); + continue; + } + + logger.LogInformation("Получено {Count} сообщений", response.Messages?.Count ?? 0); + + if (response.Messages != null) + { + foreach (var message in response.Messages) + { + try + { + logger.LogInformation("Обработка сообщения {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, "Ошибка обработки сообщения {MessageId}", message.MessageId); + continue; + } + } + logger.LogInformation("Батч из {Count} сообщений обработан", response.Messages.Count); + } + } + } +} diff --git a/AspireApp.FileService/Program.cs b/AspireApp.FileService/Program.cs new file mode 100644 index 00000000..f40b75fc --- /dev/null +++ b/AspireApp.FileService/Program.cs @@ -0,0 +1,28 @@ +using Amazon.S3; +using Amazon.SQS; +using AspireApp.FileService.Messaging; +using AspireApp.FileService.Storage; +using LocalStack.Client.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddControllers(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); +builder.Services.AddAwsService(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var s3 = scope.ServiceProvider.GetRequiredService(); + await s3.EnsureBucketExists(); +} + +app.MapDefaultEndpoints(); +app.MapControllers(); +app.Run(); diff --git a/AspireApp.FileService/Properties/launchSettings.json b/AspireApp.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..27d39795 --- /dev/null +++ b/AspireApp.FileService/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:27725", + "sslPort": 44391 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5263", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7253;http://localhost:5263", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/AspireApp.FileService/Storage/IS3Service.cs b/AspireApp.FileService/Storage/IS3Service.cs new file mode 100644 index 00000000..c6a253b6 --- /dev/null +++ b/AspireApp.FileService/Storage/IS3Service.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; + +namespace AspireApp.FileService.Storage; + +/// +/// Контракт службы для работы с объектным хранилищем S3 +/// +public interface IS3Service +{ + /// + /// Загружает файл в бакет. Ключ формируется из поля id в JSON + /// + /// Строковое представление JSON-документа + /// true если файл успешно загружен + public Task UploadFile(string fileData); + + /// + /// Возвращает список ключей всех файлов в бакете + /// + /// Список ключей объектов + public Task> GetFileList(); + + /// + /// Скачивает файл по ключу и парсит его как JSON + /// + /// Ключ объекта в бакете + /// Распарсенный JsonNode + public Task DownloadFile(string key); + + /// + /// Гарантирует существование бакета. Если бакета нет — создаёт его + /// + public Task EnsureBucketExists(); +} diff --git a/AspireApp.FileService/Storage/S3Service.cs b/AspireApp.FileService/Storage/S3Service.cs new file mode 100644 index 00000000..e214d94e --- /dev/null +++ b/AspireApp.FileService/Storage/S3Service.cs @@ -0,0 +1,112 @@ +using Amazon.S3; +using Amazon.S3.Model; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace AspireApp.FileService.Storage; + +/// +/// Служба для работы с объектным хранилищем S3 через AWS SDK +/// +/// AWS SDK клиент S3 +/// Конфигурация приложения, содержит имя бакета +/// Логгер +public class S3Service(IAmazonS3 client, IConfiguration configuration, ILogger logger) : IS3Service +{ + private readonly string _bucketName = configuration["AWS:Resources:S3BucketName"] + ?? throw new KeyNotFoundException("Имя S3 бакета не найдено в конфигурации"); + + /// + /// Возвращает список всех ключей в бакете через постраничный обход + /// + /// Список ключей объектов + public async Task> GetFileList() + { + var list = new List(); + var request = new ListObjectsV2Request + { + BucketName = _bucketName, + Prefix = "", + Delimiter = "," + }; + var paginator = client.Paginators.ListObjectsV2(request); + + logger.LogInformation("Получение списка файлов из бакета {Bucket}", _bucketName); + await foreach (var response in paginator.Responses) + if (response?.S3Objects != null) + foreach (var obj in response.S3Objects) + if (obj != null) + list.Add(obj.Key); + + return list; + } + + /// + /// Загружает JSON-документ в бакет. Ключ объекта формируется как warehouse_{id}.json + /// на основе поля id в исходном JSON + /// + /// Строковое представление JSON-документа + /// true если HTTP-статус загрузки = 200, иначе false + /// Если строка не валидный JSON или не содержит поля id + public async Task UploadFile(string fileData) + { + var rootNode = JsonNode.Parse(fileData) ?? throw new ArgumentException("Строка не является валидным JSON"); + var id = rootNode["id"]?.GetValue() ?? throw new ArgumentException("В JSON отсутствует поле 'id'"); + + using var stream = new MemoryStream(); + JsonSerializer.Serialize(stream, rootNode); + stream.Seek(0, SeekOrigin.Begin); + + logger.LogInformation("Начата загрузка товара {Id} в бакет {Bucket}", id, _bucketName); + var response = await client.PutObjectAsync(new PutObjectRequest + { + BucketName = _bucketName, + Key = $"warehouse_{id}.json", + InputStream = stream + }); + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + logger.LogError("Не удалось загрузить товар {Id}: {Code}", id, response.HttpStatusCode); + return false; + } + + logger.LogInformation("Товар {Id} загружен в бакет {Bucket}", id, _bucketName); + return true; + } + + /// + /// Скачивает файл из бакета и парсит его содержимое как JSON + /// + /// Ключ объекта в бакете + /// Распарсенный JsonNode + /// Если HTTP-статус не 200 или контент не валидный JSON + public async Task DownloadFile(string key) + { + logger.LogInformation("Начато скачивание файла {Key} из бакета {Bucket}", key, _bucketName); + var response = await client.GetObjectAsync(new GetObjectRequest + { + BucketName = _bucketName, + Key = key + }); + + if (response.HttpStatusCode != HttpStatusCode.OK) + throw new InvalidOperationException($"Не удалось скачать файл {key}: {response.HttpStatusCode}"); + + using var reader = new StreamReader(response.ResponseStream, Encoding.UTF8); + return JsonNode.Parse(reader.ReadToEnd()) ?? throw new InvalidOperationException($"Файл {key} не является валидным JSON"); + } + + /// + /// Идемпотентно проверяет существование бакета и создаёт его при необходимости. + /// Вызывается при старте приложения + /// + public async Task EnsureBucketExists() + { + logger.LogInformation("Проверка существования бакета {Bucket}", _bucketName); + await client.EnsureBucketExistsAsync(_bucketName); + logger.LogInformation("Бакет {Bucket} готов к работе", _bucketName); + } +} diff --git a/AspireApp.FileService/appsettings.Development.json b/AspireApp.FileService/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/AspireApp.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/AspireApp.FileService/appsettings.json b/AspireApp.FileService/appsettings.json new file mode 100644 index 00000000..4d566948 --- /dev/null +++ b/AspireApp.FileService/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj b/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj new file mode 100644 index 00000000..71cc9475 --- /dev/null +++ b/AspireApp.ServiceDefaults/AspireApp.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/AspireApp.ServiceDefaults/Extensions.cs b/AspireApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..e95afc31 --- /dev/null +++ b/AspireApp.ServiceDefaults/Extensions.cs @@ -0,0 +1,111 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/AspireApp.sln b/AspireApp.sln new file mode 100644 index 00000000..9f150de4 --- /dev/null +++ b/AspireApp.sln @@ -0,0 +1,61 @@ + +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}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost", "AspireApp.AppHost\AspireApp.AppHost.csproj", "{65F3885F-1D3D-497E-ADFA-2553D258B383}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ApiService", "AspireApp.ApiService\AspireApp.ApiService.csproj", "{14A10063-0D85-45A5-A1B8-413A9C98F82A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ServiceDefaults", "AspireApp.ServiceDefaults\AspireApp.ServiceDefaults.csproj", "{8384C807-5F88-4228-8803-3A7F440AAA40}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.ApiGateway", "AspireApp.ApiGateway\AspireApp.ApiGateway.csproj", "{825C848F-C707-4503-AC32-EEF4005538C3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.AppHost.Tests", "AspireApp.AppHost.Tests\AspireApp.AppHost.Tests.csproj", "{DD1307D9-381C-4D58-9326-059FB0D68B01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireApp.FileService", "AspireApp.FileService\AspireApp.FileService.csproj", "{9B85E5B8-F162-5982-86FE-CA23012C8D47}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {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 + {65F3885F-1D3D-497E-ADFA-2553D258B383}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65F3885F-1D3D-497E-ADFA-2553D258B383}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65F3885F-1D3D-497E-ADFA-2553D258B383}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65F3885F-1D3D-497E-ADFA-2553D258B383}.Release|Any CPU.Build.0 = Release|Any CPU + {14A10063-0D85-45A5-A1B8-413A9C98F82A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14A10063-0D85-45A5-A1B8-413A9C98F82A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14A10063-0D85-45A5-A1B8-413A9C98F82A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14A10063-0D85-45A5-A1B8-413A9C98F82A}.Release|Any CPU.Build.0 = Release|Any CPU + {8384C807-5F88-4228-8803-3A7F440AAA40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8384C807-5F88-4228-8803-3A7F440AAA40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8384C807-5F88-4228-8803-3A7F440AAA40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8384C807-5F88-4228-8803-3A7F440AAA40}.Release|Any CPU.Build.0 = Release|Any CPU + {825C848F-C707-4503-AC32-EEF4005538C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {825C848F-C707-4503-AC32-EEF4005538C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {825C848F-C707-4503-AC32-EEF4005538C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {825C848F-C707-4503-AC32-EEF4005538C3}.Release|Any CPU.Build.0 = Release|Any CPU + {DD1307D9-381C-4D58-9326-059FB0D68B01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD1307D9-381C-4D58-9326-059FB0D68B01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD1307D9-381C-4D58-9326-059FB0D68B01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD1307D9-381C-4D58-9326-059FB0D68B01}.Release|Any CPU.Build.0 = Release|Any CPU + {9B85E5B8-F162-5982-86FE-CA23012C8D47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B85E5B8-F162-5982-86FE-CA23012C8D47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B85E5B8-F162-5982-86FE-CA23012C8D47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B85E5B8-F162-5982-86FE-CA23012C8D47}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} + EndGlobalSection +EndGlobal diff --git a/Client.Wasm/Client.Wasm.csproj b/Client.Wasm/Client.Wasm.csproj index 0ba9f90c..78ebcefb 100644 --- a/Client.Wasm/Client.Wasm.csproj +++ b/Client.Wasm/Client.Wasm.csproj @@ -1,21 +1,18 @@  - net8.0 + net10.0 enable enable - - - - - + - - + + + diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..66dd19fd 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -1,4 +1,4 @@ -@inject IConfiguration Configuration +@inject IConfiguration Configuration @inject HttpClient Client @@ -7,16 +7,16 @@ Характеристики текущего объекта - +
- + # Характеристика Значение - + - @if(Value is null) + @if (Value is null) { 1 @@ -40,7 +40,7 @@
- + Запросить новый объект @@ -51,7 +51,7 @@ Идентификатор нового объекта: - + @@ -67,8 +67,7 @@ private async Task RequestNewData() { - var baseAddress = Configuration["BaseAddress"] ?? throw new KeyNotFoundException("Конфигурация клиента не содержит параметра BaseAddress"); - Value = await Client.GetFromJsonAsync($"{baseAddress}?id={Id}", new JsonSerializerOptions { }); + Value = await Client.GetFromJsonAsync($"warehouse?id={Id}"); StateHasChanged(); } -} +} \ No newline at end of file diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..266e6290 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -4,10 +4,10 @@ - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №3 "Интеграционное тестирование" + Вариант №22 "Товары на складе" + Выполнена Карпачевой Полиной 6512 + Ссылка на форк diff --git a/Client.Wasm/Program.cs b/Client.Wasm/Program.cs index a182a920..48c55c1b 100644 --- a/Client.Wasm/Program.cs +++ b/Client.Wasm/Program.cs @@ -1,17 +1,23 @@ using Blazorise; -using Blazorise.Bootstrap; +using Blazorise.Bootstrap5; using Blazorise.Icons.FontAwesome; using Client.Wasm; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); +builder.Configuration.AddJsonFile($"appsettings.{builder.HostEnvironment.Environment}.json", optional: true, reloadOnChange: false); + builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +var baseAddress = builder.Configuration["BaseAddress"] ?? "http://localhost:5101"; +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(baseAddress) }); + builder.Services.AddBlazorise(options => { options.Immediate = true; }) - .AddBootstrapProviders() + .AddBootstrap5Providers() .AddFontAwesomeIcons(); -await builder.Build().RunAsync(); +await builder.Build().RunAsync(); \ No newline at end of file diff --git a/Client.Wasm/Properties/launchSettings.json b/Client.Wasm/Properties/launchSettings.json index 0d824ea7..d7d3821f 100644 --- a/Client.Wasm/Properties/launchSettings.json +++ b/Client.Wasm/Properties/launchSettings.json @@ -11,31 +11,20 @@ "profiles": { "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": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7282;http://localhost:5127", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } -} +} \ No newline at end of file diff --git a/Client.Wasm/_Imports.razor b/Client.Wasm/_Imports.razor index 31e16a84..48f9eabe 100644 --- a/Client.Wasm/_Imports.razor +++ b/Client.Wasm/_Imports.razor @@ -11,4 +11,5 @@ @using Blazorise @using Blazorise.Components @using System.Text.Json -@using System.Text.Json.Nodes \ No newline at end of file +@using System.Text.Json.Nodes +@using Microsoft.Extensions.DependencyInjection \ No newline at end of file diff --git a/Client.Wasm/wwwroot/Client.styles.css b/Client.Wasm/wwwroot/Client.styles.css new file mode 100644 index 00000000..e69de29b diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..9ff8e64f 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -1,10 +1,9 @@ { + "BaseAddress": "http://localhost:5101", "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "AllowedHosts": "*", - "BaseAddress": "" -} + } +} \ No newline at end of file diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln deleted file mode 100644 index cb48241d..00000000 --- a/CloudDevelopment.sln +++ /dev/null @@ -1,25 +0,0 @@ - -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}") = "Client.Wasm", "Client.Wasm\Client.Wasm.csproj", "{AE7EEA74-2FE0-136F-D797-854FD87E022A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {90FE6B04-8381-437E-893A-FEBA1DA10AEE} - EndGlobalSection -EndGlobal