Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Api.GateWay/Api.GateWay.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ocelot" Version="23.4.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CloudDevelopment.ServiceDefaults\CloudDevelopment.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
45 changes: 45 additions & 0 deletions Api.GateWay/LoadBalancers/WeightedRoundRobin.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Ocelot.LoadBalancer.LoadBalancers;
using Ocelot.Responses;
using Ocelot.Values;

namespace Api.GateWay.LoadBalancers;

/// <summary>
/// Балансировщик нагрузки на основе параметров запроса
/// </summary>
/// <param name="services">Функция получения списка доступных сервисов</param>
public class WeightedRoundRobin(Func<Task<List<Service>>> services) : ILoadBalancer
{
private readonly Func<Task<List<Service>>> _services = services;
private readonly int[] _weights = [1, 2, 3, 2, 1];
private int _currentIndex = -1;
private int _remainingCalls = 0;

public string Type => nameof(WeightedRoundRobin);

private static readonly object _lock = new();

public async Task<Response<ServiceHostAndPort>> LeaseAsync(HttpContext httpContext)
{
var services = await _services.Invoke();
if (services == null || services.Count == 0)
return new ErrorResponse<ServiceHostAndPort>(
new ServicesAreEmptyError("No services available"));

lock (_lock)
{
if (_currentIndex == -1 || _remainingCalls == 0)
{
_currentIndex = (_currentIndex + 1) % services.Count;
_remainingCalls = _weights[_currentIndex];
}

var selectedService = services[_currentIndex];
_remainingCalls--;

return new OkResponse<ServiceHostAndPort>(selectedService.HostAndPort);
}
}

public void Release(ServiceHostAndPort hostAndPort) { }
}
26 changes: 26 additions & 0 deletions Api.GateWay/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Api.GateWay.LoadBalancers;
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.Services.AddOcelot()
.AddCustomLoadBalancer<WeightedRoundRobin>((_, _, provider) => new(provider.GetAsync));

builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(["http://localhost:5127", "https://localhost:7282"]);
policy.WithMethods("GET");
policy.WithHeaders("Content-Type");
}));

var app = builder.Build();

app.UseCors();

await app.UseOcelot();

app.Run();
38 changes: 38 additions & 0 deletions Api.GateWay/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:9811",
"sslPort": 44389
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7147;http://localhost:5142",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
8 changes: 8 additions & 0 deletions Api.GateWay/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions Api.GateWay/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
35 changes: 35 additions & 0 deletions Api.GateWay/ocelot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"Routes": [
{
"UpstreamPathTemplate": "/employee",
"UpstreamHttpMethod": [ "GET" ],
"DownstreamPathTemplate": "/employee",
"DownstreamScheme": "https",
"LoadBalancerOptions": {
"Type": "WeightedRoundRobin"
},
"DownstreamHostAndPorts": [
{
"Host": "localhost",
"Port": 15000
},
{
"Host": "localhost",
"Port": 15001
},
{
"Host": "localhost",
"Port": 15002
},
{
"Host": "localhost",
"Port": 15003
},
{
"Host": "localhost",
"Port": 15004
}
]
}
]
}
27 changes: 27 additions & 0 deletions Aspire.AppHost.Tests/Aspire.AppHost.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="9.5.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.7.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CloudDevelopment.AppHost\CloudDevelopment.AppHost.csproj" />
<ProjectReference Include="..\Service.Api\Service.Api.csproj" />
<Using Include="Xunit" />
</ItemGroup>

</Project>
161 changes: 161 additions & 0 deletions Aspire.AppHost.Tests/IntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using Aspire.Hosting;
using Aspire.Hosting.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Service.Api.Entities;
using System.Text.Json;
using Xunit.Abstractions;
using System.Collections.Concurrent;

namespace Aspire.AppHost.Tests;

/// <summary>
/// Интеграционные тесты для проверки микросервисного пайплайна:
/// API -> SQS -> Event.Sink -> MinIO
/// </summary>
/// <param name="output">Служба журналирования юнит-тестов</param>
public class IntegrationTests(ITestOutputHelper output) : IAsyncLifetime
{
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };

private DistributedApplication? _app;

/// <inheritdoc/>
public async Task InitializeAsync()
{
var cancellationToken = CancellationToken.None;
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.CloudDevelopment_AppHost>(cancellationToken);

builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
builder.Services.AddLogging(logging =>
{
logging.AddXUnit(output);
logging.SetMinimumLevel(LogLevel.Debug);
logging.AddFilter("Aspire.Hosting.Dcp", LogLevel.Debug);
logging.AddFilter("Aspire.Hosting", LogLevel.Debug);
});

_app = await builder.BuildAsync(cancellationToken);
await _app.StartAsync(cancellationToken);
}

/// <summary>
/// Основной тест: запрос сотрудника через gateway -> файл появляется в Minio,
/// данные в ответе API и в объектном хранилище совпадают.
/// </summary>
[Fact]
public async Task EmployeePipeline_ApiToStorage_Success()
{
var id = new Random().Next(1, 100);

using var gatewayClient = _app!.CreateHttpClient("api-gateway", "http");
using var gatewayResponse = await gatewayClient.GetAsync($"/employee?id={id}");
gatewayResponse.EnsureSuccessStatusCode();
var apiEmployee = JsonSerializer.Deserialize<Employee>(
await gatewayResponse.Content.ReadAsStringAsync(), _jsonOptions);

await Task.Delay(TimeSpan.FromSeconds(5));

using var storageClient = _app!.CreateHttpClient("employee-sink", "http");
using var listResponse = await storageClient.GetAsync("/api/files");
listResponse.EnsureSuccessStatusCode();
var fileList = JsonSerializer.Deserialize<List<string>>(
await listResponse.Content.ReadAsStringAsync());

using var fileResponse = await storageClient.GetAsync($"/api/files/employee_{id}.json");
fileResponse.EnsureSuccessStatusCode();
var s3Employee = JsonSerializer.Deserialize<Employee>(
await fileResponse.Content.ReadAsStringAsync(), _jsonOptions);

Assert.NotNull(fileList);
Assert.Single(fileList);
Assert.Equal($"employee_{id}.json", fileList![0]);

Assert.NotNull(apiEmployee);
Assert.NotNull(s3Employee);
Assert.Equal(id, s3Employee!.Id);
Assert.Equivalent(apiEmployee, s3Employee);
}

/// <summary>
/// Проверка устойчивости: некорректный запрос (без id) не попадает в очередь
/// и не создаёт мусорных файлов.
/// </summary>
[Fact]
public async Task InvalidRequest_DoesNotCreateFile()
{
using var gatewayClient = _app!.CreateHttpClient("api-gateway", "http");

using var badResponse = await gatewayClient.GetAsync("/employee");
Assert.False(badResponse.IsSuccessStatusCode);

await Task.Delay(TimeSpan.FromSeconds(3));

using var storageClient = _app!.CreateHttpClient("employee-sink", "http");
using var listResponse = await storageClient.GetAsync("/api/files");
listResponse.EnsureSuccessStatusCode();
var files = JsonSerializer.Deserialize<List<string>>(await listResponse.Content.ReadAsStringAsync());

Assert.NotNull(files);
Assert.DoesNotContain(files!, f => f.StartsWith("employee_0") || f.Equals("employee_.json"));
}

/// <summary>
/// Параллельная отправка нескольких сотрудников: все файлы должны быть созданы,
/// содержимое совпадает с ответами API.
/// </summary>
[Fact]
public async Task ConcurrentRequests_AllEmployeesStored()
{
const int concurrentCount = 5;
var ids = Enumerable.Range(1, concurrentCount).Select(_ => new Random().Next(200, 300)).Distinct().ToArray();
var results = new ConcurrentDictionary<int, Employee?>();

using var gatewayClient = _app!.CreateHttpClient("api-gateway", "http");

var tasks = ids.Select(async id =>
{
using var response = await gatewayClient.GetAsync($"/employee?id={id}");
response.EnsureSuccessStatusCode();
var emp = JsonSerializer.Deserialize<Employee>(
await response.Content.ReadAsStringAsync(), _jsonOptions);
results[id] = emp;
});
await Task.WhenAll(tasks);

await Task.Delay(TimeSpan.FromSeconds(8));

using var storageClient = _app!.CreateHttpClient("employee-sink", "http");
using var listResponse = await storageClient.GetAsync("/api/files");
listResponse.EnsureSuccessStatusCode();
var fileList = JsonSerializer.Deserialize<List<string>>(await listResponse.Content.ReadAsStringAsync());

Assert.NotNull(fileList);
foreach (var id in ids)
{
var expectedFileName = $"employee_{id}.json";
Assert.Contains(expectedFileName, fileList!);

using var fileResponse = await storageClient.GetAsync($"/api/files/{expectedFileName}");
fileResponse.EnsureSuccessStatusCode();
var storedEmployee = JsonSerializer.Deserialize<Employee>(
await fileResponse.Content.ReadAsStringAsync(), _jsonOptions);

Assert.NotNull(storedEmployee);
Assert.Equal(id, storedEmployee!.Id);
Assert.True(results.TryGetValue(id, out var apiEmployee));
Assert.Equivalent(apiEmployee, storedEmployee);
}
}

/// <inheritdoc/>
public async Task DisposeAsync()
{
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
}
2 changes: 1 addition & 1 deletion Client.Wasm/Client.Wasm.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Blazorise" Version="1.8.8" />
<PackageReference Include="Blazorise.Bootstrap" Version="1.8.8" />
<PackageReference Include="Blazorise.Icons.FontAwesome" Version="1.8.8" />
<PackageReference Include="Blazorise.Icons.FontAwesome" Version="1.8.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.22" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.22" PrivateAssets="all" />
</ItemGroup>
Expand Down
8 changes: 4 additions & 4 deletions Client.Wasm/Components/StudentCard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
</CardHeader>
<CardBody>
<UnorderedList Unstyled>
<UnorderedListItem>Номер <Strong>№X "Название лабораторной"</Strong></UnorderedListItem>
<UnorderedListItem>Вариант <Strong>№Х "Название варианта"</Strong></UnorderedListItem>
<UnorderedListItem>Выполнена <Strong>Фамилией Именем 65ХХ</Strong> </UnorderedListItem>
<UnorderedListItem><Link To="https://puginarug.com/">Ссылка на форк</Link></UnorderedListItem>
<UnorderedListItem>Номер <Strong>№3 Интеграционное тестирование</Strong></UnorderedListItem>
<UnorderedListItem>Вариант <Strong>№40 "Сотрудник Компании"</Strong></UnorderedListItem>
<UnorderedListItem>Выполнена <Strong>Золотилов Никита 6513</Strong> </UnorderedListItem>
<UnorderedListItem><Link To="https://github.com/Chuck-man/cloud-development">Ссылка на форк</Link></UnorderedListItem>
</UnorderedList>
</CardBody>
</Card>
Loading