diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..076d33d4 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,17 @@  - Лабораторная работа + + Лабораторная работа + - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №2 "Балансировка нагрузки" + Вариант №19 "Объект жилого строительства" + Выполнена Абаниным Иваном 6512 + + Ссылка на форк + - + \ No newline at end of file diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..4374f150 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,6 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + + "BaseAddress": "https://localhost:7297/api/ResidentialProperty" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..05694280 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,20 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11605.240 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}") = "ResidentialProperty.AppHost", "ResidentialProperty.AppHost\ResidentialProperty.AppHost.csproj", "{C15C9641-651E-4929-A9AC-F91BA292ED4F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResidentialProperty.Domain", "ResidentialProperty.Domain\ResidentialProperty.Domain.csproj", "{BCCEEEA7-39DD-7F63-CC8A-99C00D0CDD05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResidentialProperty.ServiceDefaults", "ResidentialProperty.ServiceDefaults\ResidentialProperty.ServiceDefaults.csproj", "{603B5FBD-0AA1-02F5-BFAB-CB290AEFE7C0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResidentialProperty.Api", "ResidentialProperty.Api\ResidentialProperty.Api.csproj", "{02A8658C-5DF4-D5DF-1B14-34C6D1A25FFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResidentialProperty.ApiGateway", "ResidentialProperty.ApiGateway\ResidentialProperty.ApiGateway.csproj", "{75DBDD1A-EB23-50C6-74EC-22E1C27E1809}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +25,26 @@ Global {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE7EEA74-2FE0-136F-D797-854FD87E022A}.Release|Any CPU.Build.0 = Release|Any CPU + {C15C9641-651E-4929-A9AC-F91BA292ED4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C15C9641-651E-4929-A9AC-F91BA292ED4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C15C9641-651E-4929-A9AC-F91BA292ED4F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C15C9641-651E-4929-A9AC-F91BA292ED4F}.Release|Any CPU.Build.0 = Release|Any CPU + {BCCEEEA7-39DD-7F63-CC8A-99C00D0CDD05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCCEEEA7-39DD-7F63-CC8A-99C00D0CDD05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCCEEEA7-39DD-7F63-CC8A-99C00D0CDD05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCCEEEA7-39DD-7F63-CC8A-99C00D0CDD05}.Release|Any CPU.Build.0 = Release|Any CPU + {603B5FBD-0AA1-02F5-BFAB-CB290AEFE7C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {603B5FBD-0AA1-02F5-BFAB-CB290AEFE7C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {603B5FBD-0AA1-02F5-BFAB-CB290AEFE7C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {603B5FBD-0AA1-02F5-BFAB-CB290AEFE7C0}.Release|Any CPU.Build.0 = Release|Any CPU + {02A8658C-5DF4-D5DF-1B14-34C6D1A25FFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02A8658C-5DF4-D5DF-1B14-34C6D1A25FFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02A8658C-5DF4-D5DF-1B14-34C6D1A25FFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02A8658C-5DF4-D5DF-1B14-34C6D1A25FFE}.Release|Any CPU.Build.0 = Release|Any CPU + {75DBDD1A-EB23-50C6-74EC-22E1C27E1809}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {75DBDD1A-EB23-50C6-74EC-22E1C27E1809}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75DBDD1A-EB23-50C6-74EC-22E1C27E1809}.Release|Any CPU.ActiveCfg = Release|Any CPU + {75DBDD1A-EB23-50C6-74EC-22E1C27E1809}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ResidentialProperty.Api/Controllers/ResidentialPropertyController.cs b/ResidentialProperty.Api/Controllers/ResidentialPropertyController.cs new file mode 100644 index 00000000..0bfba503 --- /dev/null +++ b/ResidentialProperty.Api/Controllers/ResidentialPropertyController.cs @@ -0,0 +1,32 @@ +using ResidentialProperty.Api.Services.ResidentialPropertyGeneratorService; +using ResidentialProperty.Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace ResidentialProperty.Api.Controllers; + +[Route("api/[controller]")] +[ApiController] +public class ResidentialPropertyController( + IResidentialPropertyGeneratorService generatorService, + ILogger logger) : ControllerBase +{ + /// + /// Получить объект жилого строительства по ID, если не найден в кэше — сгенерировать новый + /// + /// ID объекта + /// Токен отмены операции + /// Объект жилого строительства + [HttpGet] + [ProducesResponseType(typeof(ResidentialPropertyEntity), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById([FromQuery] int id, CancellationToken cancellationToken) + { + logger.LogInformation("Received request to retrieve/generate property {Id}", id); + + var property = await generatorService.GetByIdAsync(id, cancellationToken); + + return Ok(property); + } +} \ No newline at end of file diff --git a/ResidentialProperty.Api/Program.cs b/ResidentialProperty.Api/Program.cs new file mode 100644 index 00000000..67b3157e --- /dev/null +++ b/ResidentialProperty.Api/Program.cs @@ -0,0 +1,63 @@ +using ResidentialProperty.Api.Services.ResidentialPropertyGeneratorService; +using ResidentialProperty.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddRedisDistributedCache("redis"); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.SetIsOriginAllowed(origin => + new Uri(origin).Host == "localhost") + .WithMethods("GET") + .WithHeaders("Content-Type") + .AllowCredentials(); + }); +}); + +// Регистрация сервисов +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Residential Property Generator API", + Description = "API для генерации объектов жилого строительства", + Version = "v1" + }); + + var xmlFilename = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } + + var domainXmlPath = Path.Combine(AppContext.BaseDirectory, "ResidentialProperty.Domain.xml"); + if (File.Exists(domainXmlPath)) + { + options.IncludeXmlComments(domainXmlPath); + } +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseCors(); +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/ResidentialProperty.Api/Properties/launchSettings.json b/ResidentialProperty.Api/Properties/launchSettings.json new file mode 100644 index 00000000..524f37a2 --- /dev/null +++ b/ResidentialProperty.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:32342", + "sslPort": 44362 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5187", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ResidentialProperty.Api/ResidentialProperty.Api.csproj b/ResidentialProperty.Api/ResidentialProperty.Api.csproj new file mode 100644 index 00000000..65e726df --- /dev/null +++ b/ResidentialProperty.Api/ResidentialProperty.Api.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + diff --git a/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/IResidentialPropertyGeneratorService.cs b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/IResidentialPropertyGeneratorService.cs new file mode 100644 index 00000000..ee19e922 --- /dev/null +++ b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/IResidentialPropertyGeneratorService.cs @@ -0,0 +1,11 @@ +using ResidentialProperty.Domain.Entities; + +namespace ResidentialProperty.Api.Services.ResidentialPropertyGeneratorService; + +/// +/// Сервис получения объекта жилого строительства +/// +public interface IResidentialPropertyGeneratorService +{ + public Task GetByIdAsync(int id, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGenerator.cs b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGenerator.cs new file mode 100644 index 00000000..08c3fb65 --- /dev/null +++ b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGenerator.cs @@ -0,0 +1,36 @@ +using Bogus; +using ResidentialProperty.Domain.Entities; + +namespace ResidentialProperty.Api.Services.ResidentialPropertyGeneratorService; + +/// +/// Генератор случайных объектов жилого строительства с использованием Bogus +/// +public class ResidentialPropertyGenerator +{ + private static readonly string[] _propertyTypes = new[] { "Квартира", "ИЖС", "Апартаменты", "Офис", "Коммерческая" }; + private readonly Faker _faker; + private int _idCounter = 1; + + public ResidentialPropertyGenerator() + { + _faker = new Faker("ru") + .RuleFor(p => p.Id, f => _idCounter++) + .RuleFor(p => p.Address, f => $"{f.Address.City()}, ул. {f.Address.StreetName()}, д. {f.Random.Number(1, 100)}") + .RuleFor(p => p.PropertyType, f => f.PickRandom(_propertyTypes)) + .RuleFor(p => p.YearBuilt, f => f.Random.Number(1950, DateTime.Now.Year)) + .RuleFor(p => p.TotalArea, f => Math.Round(f.Random.Double(30, 200), 2)) + .RuleFor(p => p.LivingArea, (f, p) => Math.Round(p.TotalArea * f.Random.Double(0.5, 0.9), 2)) + .RuleFor(p => p.TotalFloors, f => f.Random.Number(1, 25)) + .RuleFor(p => p.Floor, (f, p) => + p.PropertyType == "ИЖС" ? null : f.Random.Number(1, p.TotalFloors)) + .RuleFor(p => p.CadastralNumber, f => f.Random.ReplaceNumbers("##:##:#######:####")) + .RuleFor(p => p.CadastralValue, (f, p) => + Math.Round((decimal)(p.TotalArea * f.Random.Double(50000, 150000)), 2)); + } + + /// + /// Генерирует один случайный объект жилого строительства + /// + public ResidentialPropertyEntity Generate() => _faker.Generate(); +} \ No newline at end of file diff --git a/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGeneratorService.cs b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGeneratorService.cs new file mode 100644 index 00000000..7f6a7c19 --- /dev/null +++ b/ResidentialProperty.Api/Services/ResidentialPropertyGeneratorService/ResidentialPropertyGeneratorService.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Caching.Distributed; +using ResidentialProperty.Domain.Entities; +using System.Text.Json; + +namespace ResidentialProperty.Api.Services.ResidentialPropertyGeneratorService; + +/// +/// Сервис получения объекта жилого строительства: сначала ищет в кэше, при промахе — генерирует новый и сохраняет +/// +public class ResidentialPropertyGeneratorService( + IDistributedCache cache, + ResidentialPropertyGenerator generator, + IConfiguration configuration, + ILogger logger) : IResidentialPropertyGeneratorService +{ + private readonly int _expirationMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + + /// + /// Возвращает объект жилого строительства по идентификатору. + /// Если объект найден в кэше — возвращается из него; иначе генерируется, сохраняется в кэш и возвращается. + /// + /// Идентификатор объекта + /// Токен отмены операции + /// Объект жилого строительства + public async Task GetByIdAsync(int id, CancellationToken cancellationToken = default) + { + logger.LogInformation("Attempting to retrieve residential property {Id} from cache", id); + + var cacheKey = $"residential-property-{id}"; + + // Получаем объект из кэша + var property = await GetFromCacheAsync(cacheKey, id, cancellationToken); + + if (property != null) + { + return property; + } + + // Если в кэше нет или ошибка — генерируем новый объект + logger.LogInformation("Property {Id} not found in cache or cache unavailable, generating a new one", id); + property = generator.Generate(); + property.Id = id; + + // Попытка сохранить в кэш + await SaveToCacheAsync(cacheKey, property, cancellationToken); + + return property; + } + + private async Task GetFromCacheAsync(string cacheKey, int id, CancellationToken cancellationToken) + { + try + { + var cachedData = await cache.GetStringAsync(cacheKey, cancellationToken); + + if (!string.IsNullOrEmpty(cachedData)) + { + var property = JsonSerializer.Deserialize(cachedData); + + if (property != null) + { + logger.LogInformation("Residential property {Id} found in cache", id); + return property; + } + + logger.LogWarning("Property {Id} was found in cache but could not be deserialized. Generating a new one", id); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to retrieve property {Id} from cache (error ignored)", id); + } + + return null; + } + + private async Task SaveToCacheAsync(string cacheKey, ResidentialPropertyEntity property, CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Saving property {Id} to cache", property.Id); + + var cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_expirationMinutes) + }; + + await cache.SetStringAsync( + cacheKey, + JsonSerializer.Serialize(property), + cacheOptions, + cancellationToken); + + logger.LogInformation( + "Residential property generated and cached: Id={Id}, Address={Address}, Type={PropertyType}, TotalArea={TotalArea}, CadastralValue={CadastralValue}", + property.Id, + property.Address, + property.PropertyType, + property.TotalArea, + property.CadastralValue); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to save property {Id} to cache (error ignored)", property.Id); + } + } +} \ No newline at end of file diff --git a/ResidentialProperty.Api/appsettings.Development.json b/ResidentialProperty.Api/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ResidentialProperty.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ResidentialProperty.Api/appsettings.json b/ResidentialProperty.Api/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/ResidentialProperty.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/ResidentialProperty.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs b/ResidentialProperty.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs new file mode 100644 index 00000000..6c22f5d8 --- /dev/null +++ b/ResidentialProperty.ApiGateway/LoadBalancing/WeightedRoundRobinBalancer.cs @@ -0,0 +1,68 @@ +using Ocelot.LoadBalancer.Errors; +using Ocelot.LoadBalancer.Interfaces; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ResidentialProperty.ApiGateway.LoadBalancing; + +/// +/// Weighted Round Robin балансировщик нагрузки для Ocelot. +/// +public class WeightedRoundRobinBalancer(Func>> servicesProvider, Dictionary hostPortWeights) : ILoadBalancer +{ + private static int _currentIndex = -1; + private static int _remainingRequests = 0; + private static readonly object _lock = new(); + + public string Type => "WeightedRoundRobin"; + + public async Task> LeaseAsync(HttpContext httpContext) + { + var services = await servicesProvider(); + + if (services == null || services.Count == 0) + { + return new ErrorResponse( + new ServicesAreEmptyError("No services available")); + } + + var availableServices = services + .Where(s => + { + var hostPort = $"{s.HostAndPort.DownstreamHost}:{s.HostAndPort.DownstreamPort}"; + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; + return weight > 0; + }) + .ToList(); + + if (availableServices.Count == 0) + { + return new ErrorResponse( + new ServicesAreEmptyError("No services with positive weight available")); + } + + ServiceHostAndPort selectedService; + + lock (_lock) + { + if (_remainingRequests <= 0) + { + _currentIndex = (_currentIndex + 1) % availableServices.Count; + + var service = availableServices[_currentIndex]; + var hostPort = $"{service.HostAndPort.DownstreamHost}:{service.HostAndPort.DownstreamPort}"; + + var weight = hostPortWeights.TryGetValue(hostPort, out var w) ? w : 1; + _remainingRequests = weight; + } + + var currentService = availableServices[_currentIndex]; + selectedService = currentService.HostAndPort; + + _remainingRequests--; + } + return new OkResponse(selectedService); + } + + public void Release(ServiceHostAndPort hostAndPort) { } +} diff --git a/ResidentialProperty.ApiGateway/Program.cs b/ResidentialProperty.ApiGateway/Program.cs new file mode 100644 index 00000000..5cf36699 --- /dev/null +++ b/ResidentialProperty.ApiGateway/Program.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using ResidentialProperty.ApiGateway.LoadBalancing; +using ResidentialProperty.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true); + +var replicaNames = builder.Configuration.GetSection("GeneratorServices").Get() ?? []; +var replicaWeights = builder.Configuration + .GetSection("ReplicaWeights") + .Get>() ?? []; + +var configOverrides = new List>(); +var downstreamAddressWeights = new Dictionary(); + +for (var i = 0; i < replicaNames.Length; i++) +{ + var replicaName = replicaNames[i]; + var serviceUrl = builder.Configuration[$"services:{replicaName}:http:0"]; + + string downstreamHost, downstreamPort; + if (!string.IsNullOrEmpty(serviceUrl) && Uri.TryCreate(serviceUrl, UriKind.Absolute, out var uri)) + { + downstreamHost = uri.Host; + downstreamPort = uri.Port.ToString(); + configOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Host", downstreamHost)); + configOverrides.Add(new($"Routes:0:DownstreamHostAndPorts:{i}:Port", downstreamPort)); + } + else + { + downstreamHost = builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Host"] ?? "localhost"; + downstreamPort = builder.Configuration[$"Routes:0:DownstreamHostAndPorts:{i}:Port"] ?? "0"; + } + + if (replicaWeights.TryGetValue(replicaName, out var weight)) + { + downstreamAddressWeights[$"{downstreamHost}:{downstreamPort}"] = weight; + } +} + +if (configOverrides.Count > 0) + builder.Configuration.AddInMemoryCollection(configOverrides); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((serviceProvider, route, serviceDiscovery) => + new WeightedRoundRobinBalancer(serviceDiscovery.GetAsync, downstreamAddressWeights)); + +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); +app.UseHealthChecks("/health"); +app.UseHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); +app.UseCors("AllowAll"); +await app.UseOcelot(); + +app.Run(); \ No newline at end of file diff --git a/ResidentialProperty.ApiGateway/Properties/launchSettings.json b/ResidentialProperty.ApiGateway/Properties/launchSettings.json new file mode 100644 index 00000000..30dcb386 --- /dev/null +++ b/ResidentialProperty.ApiGateway/Properties/launchSettings.json @@ -0,0 +1,15 @@ + +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7297;http://localhost:5099", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/ResidentialProperty.ApiGateway/ResidentialProperty.ApiGateway.csproj b/ResidentialProperty.ApiGateway/ResidentialProperty.ApiGateway.csproj new file mode 100644 index 00000000..1c65f436 --- /dev/null +++ b/ResidentialProperty.ApiGateway/ResidentialProperty.ApiGateway.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/ResidentialProperty.ApiGateway/appsettings.Development.json b/ResidentialProperty.ApiGateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ResidentialProperty.ApiGateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ResidentialProperty.ApiGateway/appsettings.json b/ResidentialProperty.ApiGateway/appsettings.json new file mode 100644 index 00000000..f2f6f19e --- /dev/null +++ b/ResidentialProperty.ApiGateway/appsettings.json @@ -0,0 +1,20 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ResidentialProperty.ApiGateway.LoadBalancing": "Debug" + } + }, + "AllowedHosts": "*", + "GeneratorServices": [ + "residentialproperty-api-0", + "residentialproperty-api-1", + "residentialproperty-api-2" + ], + "ReplicaWeights": { + "residentialproperty-api-0": 6, + "residentialproperty-api-1": 2, + "residentialproperty-api-2": 2 + } +} diff --git a/ResidentialProperty.ApiGateway/ocelot.json b/ResidentialProperty.ApiGateway/ocelot.json new file mode 100644 index 00000000..fd366d34 --- /dev/null +++ b/ResidentialProperty.ApiGateway/ocelot.json @@ -0,0 +1,30 @@ +{ + "Routes": [ + { + "DownstreamPathTemplate": "/api/ResidentialProperty", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { + "Host": "localhost", + "Port": 7283 + }, + { + "Host": "localhost", + "Port": 7284 + }, + { + "Host": "localhost", + "Port": 7285 + } + ], + "UpstreamPathTemplate": "/api/ResidentialProperty", + "UpstreamHttpMethod": [ "Get" ], + "LoadBalancerOptions": { + "Type": "WeightedRoundRobinBalancer" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "https://localhost:7297" + } + } diff --git a/ResidentialProperty.AppHost/AppHost.cs b/ResidentialProperty.AppHost/AppHost.cs new file mode 100644 index 00000000..d59df986 --- /dev/null +++ b/ResidentialProperty.AppHost/AppHost.cs @@ -0,0 +1,53 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Добавляем Redis +var redis = builder.AddRedis("redis") + .WithRedisCommander(); + + +var api0 = builder.AddProject("residentialproperty-api-0") + .WithReference(redis) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:7283") + .WithEndpoint("http", endpoint => + { + endpoint.IsProxied = false; + endpoint.Port = 7283; + endpoint.TargetPort = 7283; + }) + .WaitFor(redis); + +var api1 = builder.AddProject("residentialproperty-api-1") + .WithReference(redis) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:7284") + .WithEndpoint("http", endpoint => + { + endpoint.IsProxied = false; + endpoint.Port = 7284; + endpoint.TargetPort = 7284; + }) + .WaitFor(redis); + +var api2 = builder.AddProject("residentialproperty-api-2") + .WithReference(redis) + .WithEnvironment("ASPNETCORE_URLS", "http://localhost:7285") + .WithEndpoint("http", endpoint => + { + endpoint.IsProxied = false; + endpoint.Port = 7285; + endpoint.TargetPort = 7285; + }) + .WaitFor(redis); + +var gateway = builder.AddProject("residentialproperty-apigateway") + .WithReference(api0) + .WithReference(api1) + .WithReference(api2); + + +// Добавляем клиентский проект +builder.AddProject("client-wasm") + .WithReference(gateway) + .WaitFor(gateway); + + +builder.Build().Run(); \ No newline at end of file diff --git a/ResidentialProperty.AppHost/Properties/launchSettings.json b/ResidentialProperty.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..ccfb9c9b --- /dev/null +++ b/ResidentialProperty.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:17085;http://localhost:15162", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21046", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23227", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22078" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15162", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19155", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18234", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20241" + } + } + } +} diff --git a/ResidentialProperty.AppHost/ResidentialProperty.AppHost.csproj b/ResidentialProperty.AppHost/ResidentialProperty.AppHost.csproj new file mode 100644 index 00000000..474dfc3f --- /dev/null +++ b/ResidentialProperty.AppHost/ResidentialProperty.AppHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net8.0 + enable + enable + true + c0442ce5-33ff-46f9-8634-132dc4b6abb8 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ResidentialProperty.AppHost/appsettings.Development.json b/ResidentialProperty.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ResidentialProperty.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ResidentialProperty.AppHost/appsettings.json b/ResidentialProperty.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/ResidentialProperty.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/ResidentialProperty.Domain/Entities/ResidentialProperty.cs b/ResidentialProperty.Domain/Entities/ResidentialProperty.cs new file mode 100644 index 00000000..237542a6 --- /dev/null +++ b/ResidentialProperty.Domain/Entities/ResidentialProperty.cs @@ -0,0 +1,57 @@ +namespace ResidentialProperty.Domain.Entities; + +/// +/// Объект жилого строительства +/// +public class ResidentialPropertyEntity +{ + /// + /// Идентификатор в системе + /// + public required int Id { get; set; } + + /// + /// Адрес + /// + public required string Address { get; set; } + + /// + /// Тип недвижимости (Квартира, ИЖС, Апартаменты, Офис и т.д.) + /// + public required string PropertyType { get; set; } + + /// + /// Год постройки (не может быть позже текущего) + /// + public int YearBuilt { get; set; } + + /// + /// Общая площадь (округляется до двух знаков после запятой) + /// + public double TotalArea { get; set; } + + /// + /// Жилая площадь (не может быть больше общей, округляется до двух знаков) + /// + public double LivingArea { get; set; } + + /// + /// Этаж (не указывается для ИЖС, не может быть больше этажности) + /// + public int? Floor { get; set; } + + /// + /// Этажность здания (не может быть меньше 1) + /// + public int TotalFloors { get; set; } + + /// + /// Кадастровый номер (формат: **.**.**.******.****) + /// + public required string CadastralNumber { get; set; } + + /// + /// Кадастровая стоимость (округляется до двух знаков, пропорциональна площади) + /// + public decimal CadastralValue { get; set; } +} \ No newline at end of file diff --git a/ResidentialProperty.Domain/ResidentialProperty.Domain.csproj b/ResidentialProperty.Domain/ResidentialProperty.Domain.csproj new file mode 100644 index 00000000..32cc1e0e --- /dev/null +++ b/ResidentialProperty.Domain/ResidentialProperty.Domain.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + diff --git a/ResidentialProperty.ServiceDefaults/Extensions.cs b/ResidentialProperty.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..f1e201aa --- /dev/null +++ b/ResidentialProperty.ServiceDefaults/Extensions.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace ResidentialProperty.ServiceDefaults; + +/// +/// Расширения для настройки сервисов Aspire (структурное логирование, телеметрия, health checks) +/// +public static class Extensions +{ + /// + /// Добавляет стандартные настройки Aspire для сервисов + /// + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.AddDefaultHealthChecks(); + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + return builder; + } + + /// + /// Настройка OpenTelemetry для структурного логирования и телеметрии + /// + 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() + .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(); + } + + return builder; + } + + /// + /// Добавляет стандартные health checks + /// + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Настройка стандартных endpoints (health checks) + /// + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/ResidentialProperty.ServiceDefaults/ResidentialProperty.ServiceDefaults.csproj b/ResidentialProperty.ServiceDefaults/ResidentialProperty.ServiceDefaults.csproj new file mode 100644 index 00000000..2b50d0b2 --- /dev/null +++ b/ResidentialProperty.ServiceDefaults/ResidentialProperty.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + +