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
17 changes: 17 additions & 0 deletions ApiGateway/ApiGateway.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

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

<ItemGroup>
<PackageReference Include="Ocelot" Version="24.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AppHost\AppHost.ServiceDefaults\AppHost.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
80 changes: 80 additions & 0 deletions ApiGateway/LoadBalancer/WeightedRandomBalancer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using Ocelot.LoadBalancer.Errors;
using Ocelot.LoadBalancer.Interfaces;
using Ocelot.Responses;
using Ocelot.Values;

namespace ApiGateway.LoadBalancer;

/// <summary>
/// Балансировщик с взвешенным случайным выбором реплики сервиса.
/// </summary>
/// <param name="services">Все доступные экземпляры сервиса из service discovery.</param>
/// <param name="weights">Набор весов для эндпоинтов в формате name:"service-name-{i}" value:"weight".
/// </param>
public class WeightedRandomBalancer(Func<Task<List<Service>>> services, Dictionary<string, double> weights) : ILoadBalancer
{
public string Type => nameof(WeightedRandomBalancer);

/// <summary>
/// Выбирает реплику сервиса по алгоритму weighted random.
/// </summary>
/// <param name="services">Список доступных сервисов.</param>
/// <param name="weights">Словарь весов для всех сервисов.</param>
/// <returns>Выбранная реплика сервиса.</returns>
private static Service GetServiceByWeight(List<Service> services, Dictionary<string, double> weights)
{
var cumulativeWeights = new double[services.Count];
var sum = 0.0;
for (var i = 0; i < services.Count; ++i)
{
var service = services[i];
var key = $"{service.HostAndPort.DownstreamHost}_{service.HostAndPort.DownstreamPort}";
var weight = weights.TryGetValue(key, out var w) ? w : 1.0;

sum += weight;
cumulativeWeights[i] = sum;
}

if (sum <= 0)
{
return services[Random.Shared.Next(services.Count)];
}

var randomValue = Random.Shared.NextDouble() * sum;

var index = Array.BinarySearch(cumulativeWeights, randomValue);
if (index < 0)
{
index = ~index;
}
index = Math.Min(index, services.Count - 1);

return services[index];
}

/// <summary>
/// Выдает эндпоинт сервиса для текущего запроса.
/// </summary>
/// <param name="httpContext">Контекст входящего запроса.</param>
/// <returns>Выбранный адрес сервиса или ошибка, если сервисы недоступны.</returns>
public async Task<Response<ServiceHostAndPort>> LeaseAsync(HttpContext httpContext)
{
var allServices = await services.Invoke();
if (allServices == null || allServices.Count == 0)
{
return new ErrorResponse<ServiceHostAndPort>(
new ServicesAreNullError("No services available")
);
}

var selectedService = GetServiceByWeight(allServices, weights);

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

/// <summary>
/// Освобождает ранее выданный эндпоинт.
/// </summary>
/// <param name="serviceHostAndPort">Адрес освобождаемого сервиса.</param>
public void Release(ServiceHostAndPort serviceHostAndPort) { }
}
94 changes: 94 additions & 0 deletions ApiGateway/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using ApiGateway.LoadBalancer;
using AppHost.ServiceDefaults;
using Ocelot.DependencyInjection;
using Ocelot.Middleware;
using System.Globalization;

static (Dictionary<string, string?> RouteOverrides, Dictionary<string, double> weights)
BuildEnv(IConfiguration configuration)
{
var namedWeights = configuration
.GetSection("LoadBalancerWeights")
.Get<Dictionary<string, double>>() ?? [];

var routeOverrides = new Dictionary<string, string?>();
var weights = new Dictionary<string, double>();

Uri? firstEndpoint = null;
for (var i = 0; i < 5; ++i)
{
var envKey = $"services__generation-service-{i}__http__0";
var raw = Environment.GetEnvironmentVariable(envKey);
Comment on lines +20 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Можно из конфигурации забирать:

var envKey = $"services:generation-service-{i}:http:0";
var raw = configuration.GetValue<string?>(envKey, null);

if (string.IsNullOrWhiteSpace(raw))
continue;

if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
throw new InvalidOperationException($"Invalid endpoint in {envKey}: {raw}");

firstEndpoint ??= uri;

routeOverrides[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] = uri.Host;
routeOverrides[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] =
uri.Port.ToString(CultureInfo.InvariantCulture);

var key = $"{uri.Host}_{uri.Port}";
var serviceName = $"generation-service-{i}";
weights[key] = namedWeights.TryGetValue(serviceName, out var w) ? w : 1.0;
}

routeOverrides["Routes:0:DownstreamScheme"] = firstEndpoint!.Scheme;

return (routeOverrides, weights);
}

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.Services.AddServiceDiscovery();
builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);

var (routeOverrides, weights) = BuildEnv(builder.Configuration);
builder.Configuration.AddInMemoryCollection(routeOverrides);

builder.Services
.AddOcelot()
.AddCustomLoadBalancer<WeightedRandomBalancer>((_, _, discoveryProvider) => new(discoveryProvider.GetAsync, weights));

var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>();
var allowedMethods = builder.Configuration.GetSection("Cors:AllowedMethods").Get<string[]>();
var allowedHeaders = builder.Configuration.GetSection("Cors:AllowedHeaders").Get<string[]>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
if (allowedOrigins != null)
{
_ = allowedOrigins.Contains("*")
? policy.AllowAnyOrigin()
: policy.WithOrigins(allowedOrigins);
}

if (allowedMethods != null)
{
_ = allowedMethods.Contains("*")
? policy.AllowAnyMethod()
: policy.WithMethods(allowedMethods);
}

if (allowedHeaders != null)
{
_ = allowedHeaders.Contains("*")
? policy.AllowAnyHeader()
: policy.WithHeaders(allowedHeaders);
}
});
});

var app = builder.Build();

app.MapDefaultEndpoints();
app.UseCors();

await app.UseOcelot();

app.Run();
23 changes: 23 additions & 0 deletions ApiGateway/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7268;http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
8 changes: 8 additions & 0 deletions ApiGateway/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
17 changes: 17 additions & 0 deletions ApiGateway/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"RedisCache": "https://localhost:2843"
},
"Cors": {
"AllowedOrigins": [ "https://localhost:7282" ],
"AllowedMethods": [ "GET" ],
"AllowedHeaders": [ "*" ]
}
}
21 changes: 21 additions & 0 deletions ApiGateway/ocelot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Routes": [
{
"DownstreamPathTemplate": "/patient",
"DownstreamScheme": "http",
"DownstreamHostAndPorts": [],
"UpstreamPathTemplate": "/patient",
"UpstreamHttpMethod": [ "Get" ],
"LoadBalancerOptions": {
"Type": "WeightedRandomBalancer"
}
}
],
"LoadBalancerWeights": {
"generation-service-0": 0.4,
"generation-service-1": 0.25,
"generation-service-2": 0.15,
"generation-service-3": 0.1,
"generation-service-4": 0.1
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Проблема такого подхода в том, что если поменять порты в aspire, то все перестанет работать, а так быть не должно, нужно передавать из aspire нужные адреса для подключения через переменные окружения

}
22 changes: 22 additions & 0 deletions AppHost/AppHost.AppHost/AppHost.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.2">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>96fbf34e-00b4-4158-9be4-e6f641d5c362</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.2" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.5.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\ApiGateway\ApiGateway.csproj" />
<ProjectReference Include="..\..\Client.Wasm\Client.Wasm.csproj" />
<ProjectReference Include="..\..\GenerationService\GenerationService.csproj" />
</ItemGroup>

</Project>
23 changes: 23 additions & 0 deletions AppHost/AppHost.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache")
.WithRedisInsight(containerName: "cache-insight");

var gateway = builder.AddProject<Projects.ApiGateway>("apigateway");

for (var i = 0; i < 5; ++i)
{
var generationService = builder.AddProject<Projects.GenerationService>($"generation-service-{i}", launchProfileName: null)
.WithHttpEndpoint(8000 + i)
.WithReference(cache, "RedisCache")
.WaitFor(cache)
.WithHttpHealthCheck("/health");
gateway
.WithReference(generationService)
.WaitFor(generationService);
}

builder.AddProject<Projects.Client_Wasm>("client")
.WaitFor(gateway);

builder.Build().Run();
31 changes: 31 additions & 0 deletions AppHost/AppHost.AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17103;http://localhost:15134",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21139",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23225",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22067"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15134",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19075",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18212",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20001"
}
}
}
}
8 changes: 8 additions & 0 deletions AppHost/AppHost.AppHost/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
12 changes: 12 additions & 0 deletions AppHost/AppHost.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
},
"Cache": {
"CacheTime": 60
}
}
22 changes: 22 additions & 0 deletions AppHost/AppHost.ServiceDefaults/AppHost.ServiceDefaults.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">

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

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
</ItemGroup>

</Project>
Loading