diff --git a/.gitignore b/.gitignore index ce892922..f272ea46 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,13 @@ FodyWeavers.xsd *.msix *.msm *.msp + +vehicle-publish/ +publish/ +fileservice-publish/ + +*.zip + +# Yandex Cloud — артефакты сборки и деплоя +cloud/build/ +Client.Wasm/wwwroot/appsettings.Production.json \ No newline at end of file diff --git a/Client.Wasm/Client.Wasm.csproj b/Client.Wasm/Client.Wasm.csproj index 0ba9f90c..40edaeee 100644 --- a/Client.Wasm/Client.Wasm.csproj +++ b/Client.Wasm/Client.Wasm.csproj @@ -16,6 +16,7 @@ + diff --git a/Client.Wasm/Components/DataCard.razor b/Client.Wasm/Components/DataCard.razor index c646a839..20b06fa8 100644 --- a/Client.Wasm/Components/DataCard.razor +++ b/Client.Wasm/Components/DataCard.razor @@ -1,5 +1,5 @@ -@inject IConfiguration Configuration -@inject HttpClient Client +@using Client.Wasm +@inject VehicleApiClient ApiClient @@ -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 ApiClient.GetVehicleAsync(Id); StateHasChanged(); } } diff --git a/Client.Wasm/Components/StudentCard.razor b/Client.Wasm/Components/StudentCard.razor index 661f1181..1598d77b 100644 --- a/Client.Wasm/Components/StudentCard.razor +++ b/Client.Wasm/Components/StudentCard.razor @@ -1,13 +1,13 @@ - + - Лабораторная работа + Лабораторная работа - Номер №X "Название лабораторной" - Вариант №Х "Название варианта" - Выполнена Фамилией Именем 65ХХ - Ссылка на форк + Номер №4 "Переход на облачную инфраструктуру" + Вариант №29 "Транспортное средство" + Выполнена Нестеренко Андреем 6512 + Ссылка на репозиторий diff --git a/Client.Wasm/Program.cs b/Client.Wasm/Program.cs index a182a920..87c7cad9 100644 --- a/Client.Wasm/Program.cs +++ b/Client.Wasm/Program.cs @@ -9,7 +9,12 @@ builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddHttpClient(client => +{ + var baseAddress = builder.Configuration["BaseAddress"] + ?? throw new InvalidOperationException("BaseAddress not found in configuration."); + client.BaseAddress = new Uri(baseAddress); +}); builder.Services.AddBlazorise(options => { options.Immediate = true; }) .AddBootstrapProviders() .AddFontAwesomeIcons(); diff --git a/Client.Wasm/VehicleApiClient.cs b/Client.Wasm/VehicleApiClient.cs new file mode 100644 index 00000000..80fab7a9 --- /dev/null +++ b/Client.Wasm/VehicleApiClient.cs @@ -0,0 +1,12 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; + +namespace Client.Wasm; + +public class VehicleApiClient(HttpClient httpClient) +{ + public async Task GetVehicleAsync(int id) + { + return await httpClient.GetFromJsonAsync($"api/vehicle/{id}"); + } +} diff --git a/Client.Wasm/wwwroot/appsettings.json b/Client.Wasm/wwwroot/appsettings.json index d1fe7ab3..f1a09156 100644 --- a/Client.Wasm/wwwroot/appsettings.json +++ b/Client.Wasm/wwwroot/appsettings.json @@ -6,5 +6,5 @@ } }, "AllowedHosts": "*", - "BaseAddress": "" + "BaseAddress": "http://localhost:5200/" } diff --git a/CloudDevelopment.sln b/CloudDevelopment.sln index cb48241d..fc0e6a1f 100644 --- a/CloudDevelopment.sln +++ b/CloudDevelopment.sln @@ -1,10 +1,27 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36811.4 +VisualStudioVersion = 17.13.35931.197 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}") = "ProjectApp.Api", "ProjectApp.Api\ProjectApp.Api.csproj", "{E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Domain", "ProjectApp.Domain\ProjectApp.Domain.csproj", "{CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.ServiceDefaults", "ProjectApp.ServiceDefaults\ProjectApp.ServiceDefaults.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.AppHost", "ProjectApp.AppHost\ProjectApp.AppHost.csproj", "{2A5FB573-9376-4FEB-9289-A8387F435C13}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Gateway", "ProjectApp.Gateway\ProjectApp.Gateway.csproj", "{EBCDE049-6A3B-431E-ACBA-DD9C90898F49}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService", "ProjectApp.FileService\ProjectApp.FileService.csproj", "{FBAF0E91-832A-4594-9515-C9D42EA8A25B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Tests", "ProjectApp.Tests\ProjectApp.Tests.csproj", "{38936714-96E7-4B0C-904A-0B01D8E96B2D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.Api.Function", "ProjectApp.Api.Function\ProjectApp.Api.Function.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProjectApp.FileService.Function", "ProjectApp.FileService.Function\ProjectApp.FileService.Function.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +32,42 @@ 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 + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7D4CA8B-53EA-9676-D96D-BE2F0CB11054}.Release|Any CPU.Build.0 = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC5A9873-4CC3-4B71-83AF-E4FD09F7B1AD}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A5FB573-9376-4FEB-9289-A8387F435C13}.Release|Any CPU.Build.0 = Release|Any CPU + {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBCDE049-6A3B-431E-ACBA-DD9C90898F49}.Release|Any CPU.Build.0 = Release|Any CPU + {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FBAF0E91-832A-4594-9515-C9D42EA8A25B}.Release|Any CPU.Build.0 = Release|Any CPU + {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38936714-96E7-4B0C-904A-0B01D8E96B2D}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectApp.Api.Function/Handler.cs b/ProjectApp.Api.Function/Handler.cs new file mode 100644 index 00000000..fcf1c478 --- /dev/null +++ b/ProjectApp.Api.Function/Handler.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Runtime; +using Amazon.SQS; +using Amazon.SQS.Model; + +namespace ApiFunction; + +/// +/// Cloud Function генерации транспортного средства по идентификатору. +/// При наличии кэша в Object Storage возвращает сохранённые данные; +/// иначе генерирует новые и публикует их в Message Queue. +/// +public class Handler +{ + private static readonly Random _rng = Random.Shared; + private static readonly HttpClient _http = new(); + + private static readonly string[] _brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"]; + private static readonly string[] _models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"]; + private static readonly string[] _bodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"]; + private static readonly string[] _fuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"]; + private static readonly string[] _colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"]; + + private static readonly JsonSerializerOptions _camelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + /// + /// Точка входа Cloud Function. Принимает HTTP-запрос от API Gateway, + /// возвращает JSON транспортного средства с CORS-заголовками. + /// + /// Сериализованный запрос от API Gateway + public string FunctionHandler(string input) + { + FunctionRequest? req = null; + try { req = JsonSerializer.Deserialize(input ?? "{}"); } catch { } + + string? idRaw = null; + req?.PathParams?.TryGetValue("id", out idRaw); + if (string.IsNullOrEmpty(idRaw)) + { + var url = req?.Url ?? req?.Path ?? ""; + idRaw = url.Split('?')[0].Split('/', StringSplitOptions.RemoveEmptyEntries) + .LastOrDefault(p => !p.StartsWith('{')); + } + + if (!int.TryParse(idRaw, out var id) || id <= 0) + return Envelope(400, """{"error":"Identifier must be a positive number."}"""); + + var cached = TryGetCachedAsync(id).GetAwaiter().GetResult(); + if (cached != null) + return Envelope(200, cached); + + var vehicle = new + { + id, + vin = GenerateVin(), + brand = _brands[_rng.Next(_brands.Length)], + model = _models[_rng.Next(_models.Length)], + year = _rng.Next(2010, 2025), + bodyType = _bodyTypes[_rng.Next(_bodyTypes.Length)], + fuelType = _fuelTypes[_rng.Next(_fuelTypes.Length)], + color = _colors[_rng.Next(_colors.Length)], + mileage = Math.Round(_rng.NextDouble() * 200000, 1), + lastServiceDate = DateTime.UtcNow.AddDays(-_rng.Next(30, 730)).ToString("yyyy-MM-dd") + }; + + var json = JsonSerializer.Serialize(vehicle, _camelCase); + + Task.Run(() => TryPublishAsync(json)); + + return Envelope(200, json); + } + + private static string Envelope(int status, string body) => + JsonSerializer.Serialize(new + { + statusCode = status, + headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" + }, + body + }); + + private static string GenerateVin() + { + const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789"; + return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[_rng.Next(chars.Length)])); + } + + private static async Task TryGetCachedAsync(int id) + { + try + { + var bucket = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicle-data-store"; + var url = $"https://storage.yandexcloud.net/{bucket}/vehicle-{id}.json"; + var resp = await _http.GetAsync(url); + if (resp.IsSuccessStatusCode) + return await resp.Content.ReadAsStringAsync(); + } + catch { } + return null; + } + + private static async Task TryPublishAsync(string messageBody) + { + try + { + var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? ""; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return; + + var credentials = new BasicAWSCredentials(accessKey, secretKey); + var config = new AmazonSQSConfig + { + ServiceURL = "https://message-queue.api.cloud.yandex.net", + AuthenticationRegion = "ru-central1" + }; + using var sqs = new AmazonSQSClient(credentials, config); + await sqs.SendMessageAsync(new SendMessageRequest { QueueUrl = queueUrl, MessageBody = messageBody }); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}"); + } + } +} + +/// +/// Модель входящего запроса от Yandex API Gateway. +/// +public class FunctionRequest +{ + [JsonPropertyName("httpMethod")] public string HttpMethod { get; set; } = ""; + [JsonPropertyName("headers")] public Dictionary? Headers { get; set; } + [JsonPropertyName("path")] public string? Path { get; set; } + [JsonPropertyName("queryStringParameters")] public Dictionary? QueryStringParameters { get; set; } + [JsonPropertyName("pathParameters")] public Dictionary? PathParameters { get; set; } + [JsonPropertyName("pathParams")] public Dictionary? PathParams { get; set; } + [JsonPropertyName("url")] public string? Url { get; set; } + [JsonPropertyName("body")] public string? Body { get; set; } + [JsonPropertyName("isBase64Encoded")] public bool IsBase64Encoded { get; set; } +} diff --git a/ProjectApp.Api.Function/Models/FunctionModels.cs b/ProjectApp.Api.Function/Models/FunctionModels.cs new file mode 100644 index 00000000..c8a8ce2e --- /dev/null +++ b/ProjectApp.Api.Function/Models/FunctionModels.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; + +namespace ApiFunction.Models; + +public class FunctionRequest +{ + [JsonPropertyName("httpMethod")] + public string HttpMethod { get; set; } = ""; + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("queryStringParameters")] + public Dictionary? QueryStringParameters { get; set; } + + [JsonPropertyName("pathParameters")] + public Dictionary? PathParameters { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("isBase64Encoded")] + public bool IsBase64Encoded { get; set; } +} + +public class FunctionResponse +{ + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + [JsonPropertyName("headers")] + public Dictionary? Headers { get; set; } + + [JsonPropertyName("body")] + public string Body { get; set; } = ""; + + [JsonPropertyName("isBase64Encoded")] + public bool IsBase64Encoded { get; set; } +} diff --git a/ProjectApp.Api.Function/ModuleInit.cs b/ProjectApp.Api.Function/ModuleInit.cs new file mode 100644 index 00000000..d3090060 --- /dev/null +++ b/ProjectApp.Api.Function/ModuleInit.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace ApiFunction; + +/// +/// Инициализатор модуля — регистрирует обработчик AssemblyResolve до обращения к любому классу. +/// +public static class ModuleInit +{ + [ModuleInitializer] + public static void Initialize() + { + AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly; + } + + private static Assembly? ResolveAssembly(object? sender, ResolveEventArgs args) + { + try + { + var name = new AssemblyName(args.Name).Name; + if (string.IsNullOrEmpty(name)) return null; + + var assemblyDir = Path.GetDirectoryName(typeof(ModuleInit).Assembly.Location) ?? ""; + var dllPath = Path.Combine(assemblyDir, name + ".dll"); + + if (File.Exists(dllPath)) + { + Console.WriteLine($"[ModuleInit] Resolved {name} from {dllPath}"); + return Assembly.LoadFrom(dllPath); + } + + Console.WriteLine($"[ModuleInit] Could not resolve {name} (looked in {assemblyDir})"); + } + catch (Exception ex) + { + Console.WriteLine($"[ModuleInit] ResolveAssembly error: {ex.Message}"); + } + + return null; + } +} diff --git a/ProjectApp.Api.Function/Program.cs b/ProjectApp.Api.Function/Program.cs new file mode 100644 index 00000000..4a882f6d --- /dev/null +++ b/ProjectApp.Api.Function/Program.cs @@ -0,0 +1,6 @@ +namespace ApiFunction; + +public partial class Program +{ + public static void Main(string[] args) { } +} diff --git a/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj new file mode 100644 index 00000000..4acedef0 --- /dev/null +++ b/ProjectApp.Api.Function/ProjectApp.Api.Function.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + ApiFunction + ApiFunction + + + + + + + diff --git a/ProjectApp.Api.Function/Services/VehicleFaker.cs b/ProjectApp.Api.Function/Services/VehicleFaker.cs new file mode 100644 index 00000000..eba87331 --- /dev/null +++ b/ProjectApp.Api.Function/Services/VehicleFaker.cs @@ -0,0 +1 @@ +namespace ApiFunction.Services; diff --git a/ProjectApp.Api.Function/VehicleProcessor.cs b/ProjectApp.Api.Function/VehicleProcessor.cs new file mode 100644 index 00000000..a503dac3 --- /dev/null +++ b/ProjectApp.Api.Function/VehicleProcessor.cs @@ -0,0 +1,131 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ApiFunction.Models; + +namespace ApiFunction; + +/// +/// Бизнес-логика генерации и публикации транспортного средства. +/// +internal static class VehicleProcessor +{ + private static readonly Random Rng = Random.Shared; + private static readonly HttpClient Http = new(); + + private static readonly string[] Brands = ["Toyota", "BMW", "Mercedes", "Audi", "Ford", "Honda", "Volkswagen", "Hyundai", "Kia", "Nissan"]; + private static readonly string[] Models = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover", "Pickup", "Minivan", "Wagon"]; + private static readonly string[] BodyTypes = ["Sedan", "SUV", "Hatchback", "Coupe", "Crossover"]; + private static readonly string[] FuelTypes = ["Petrol", "Diesel", "Electric", "Hybrid"]; + private static readonly string[] Colors = ["White", "Black", "Silver", "Blue", "Red", "Grey", "Green"]; + + public static async Task ProcessAsync(int id) + { + var vehicle = GenerateVehicle(id); + var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + _ = TryPublishToQueueAsync(json); + + return new FunctionResponse + { + StatusCode = 200, + Headers = new() + { + ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" + }, + Body = json + }; + } + + private static object GenerateVehicle(int id) => new + { + id, + vin = GenerateVin(), + brand = Brands[Rng.Next(Brands.Length)], + model = Models[Rng.Next(Models.Length)], + year = Rng.Next(2010, 2025), + bodyType = BodyTypes[Rng.Next(BodyTypes.Length)], + fuelType = FuelTypes[Rng.Next(FuelTypes.Length)], + color = Colors[Rng.Next(Colors.Length)], + mileage = Math.Round(Rng.NextDouble() * 200000, 1), + lastServiceDate = DateTime.UtcNow.AddDays(-Rng.Next(30, 730)).ToString("yyyy-MM-dd") + }; + + private static string GenerateVin() + { + const string chars = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789"; + return string.Concat(Enumerable.Range(0, 17).Select(_ => chars[Rng.Next(chars.Length)])); + } + + private static async Task TryPublishToQueueAsync(string messageBody) + { + try + { + var queueUrl = Environment.GetEnvironmentVariable("SQS_QUEUE_URL") ?? ""; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + if (string.IsNullOrEmpty(queueUrl) || string.IsNullOrEmpty(accessKey)) return; + + var uri = new Uri(queueUrl); + var endpoint = $"{uri.Scheme}://{uri.Host}"; + var path = uri.AbsolutePath; + + var body = $"Action=SendMessage&MessageBody={Uri.EscapeDataString(messageBody)}&Version=2012-11-05"; + var now = DateTime.UtcNow; + var dateStamp = now.ToString("yyyyMMdd"); + var amzDate = now.ToString("yyyyMMddTHHmmssZ"); + + var payloadHash = Sha256Hash(body); + var headers = $"content-type:application/x-www-form-urlencoded\nhost:{uri.Host}\nx-amz-date:{amzDate}\n"; + var signedHeaders = "content-type;host;x-amz-date"; + var canonicalRequest = $"POST\n{path}\n\n{headers}\n{signedHeaders}\n{payloadHash}"; + + var region = "ru-central1"; + var service = "sqs"; + var credentialScope = $"{dateStamp}/{region}/{service}/aws4_request"; + var stringToSign = $"AWS4-HMAC-SHA256\n{amzDate}\n{credentialScope}\n{Sha256Hash(canonicalRequest)}"; + + var signingKey = GetSigningKey(secretKey, dateStamp, region, service); + var signature = HmacSha256Hex(signingKey, stringToSign); + + var authHeader = $"AWS4-HMAC-SHA256 Credential={accessKey}/{credentialScope}, SignedHeaders={signedHeaders}, Signature={signature}"; + + var request = new HttpRequestMessage(HttpMethod.Post, $"{endpoint}{path}") + { + Content = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded") + }; + request.Headers.TryAddWithoutValidation("Authorization", authHeader); + request.Headers.TryAddWithoutValidation("x-amz-date", amzDate); + + var resp = await Http.SendAsync(request); + if (!resp.IsSuccessStatusCode) + Console.WriteLine($"[WARN] SQS returned {resp.StatusCode}"); + } + catch (Exception ex) + { + Console.WriteLine($"[WARN] SQS publish failed: {ex.Message}"); + } + } + + private static string Sha256Hash(string data) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(data)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static byte[] HmacSha256(byte[] key, string data) + => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data)); + + private static string HmacSha256Hex(byte[] key, string data) + => Convert.ToHexString(HmacSha256(key, data)).ToLowerInvariant(); + + private static byte[] GetSigningKey(string secret, string date, string region, string service) + { + var kSecret = Encoding.UTF8.GetBytes("AWS4" + secret); + var kDate = HmacSha256(kSecret, date); + var kRegion = HmacSha256(kDate, region); + var kService = HmacSha256(kRegion, service); + return HmacSha256(kService, "aws4_request"); + } +} diff --git a/ProjectApp.Api/Controllers/VehicleController.cs b/ProjectApp.Api/Controllers/VehicleController.cs new file mode 100644 index 00000000..a19c0cf6 --- /dev/null +++ b/ProjectApp.Api/Controllers/VehicleController.cs @@ -0,0 +1,50 @@ +using ProjectApp.Api.Services.SqsProducer; +using ProjectApp.Api.Services.VehicleGeneratorService; +using ProjectApp.Domain.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace ProjectApp.Api.Controllers; + +/// +/// Контроллер для генерации и получения характеристик транспортных средств +/// +[Route("api/[controller]")] +[ApiController] +public class VehicleController( + IVehicleGeneratorService vehicleService, + ISqsProducer sqsProducer, + ILogger logger) : ControllerBase +{ + /// + /// Возвращает сгенерированное транспортное средство по его уникальному идентификатору + /// + /// Идентификатор транспортного средства + /// Токен отмены + [HttpGet("{id:int}")] + [ProducesResponseType(typeof(Vehicle), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById([FromRoute] int id, CancellationToken cancellationToken) + { + logger.LogInformation("Request received for vehicle id {Id}", id); + + if (id <= 0) + { + logger.LogWarning("Invalid vehicle id {Id} received", id); + return BadRequest("Identifier must be a positive number."); + } + + var vehicle = await vehicleService.FetchByIdAsync(id, cancellationToken); + + try + { + await sqsProducer.SendVehicleAsync(vehicle, cancellationToken); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send vehicle {Id} to SQS", id); + } + + return Ok(vehicle); + } +} diff --git a/ProjectApp.Api/Program.cs b/ProjectApp.Api/Program.cs new file mode 100644 index 00000000..8cc11e93 --- /dev/null +++ b/ProjectApp.Api/Program.cs @@ -0,0 +1,65 @@ +using Amazon.SQS; +using LocalStack.Client.Extensions; +using ProjectApp.Api.Services.SqsProducer; +using ProjectApp.Api.Services.VehicleGeneratorService; +using ProjectApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddRedisDistributedCache("cache"); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); + +builder.Services.AddSingleton(); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + ActivatorUtilities.CreateInstance(sp, + sp.GetRequiredService())); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new Microsoft.OpenApi.OpenApiInfo + { + Title = "Vehicle Generator API" + }); + + 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, "ProjectApp.Domain.xml"); + if (File.Exists(domainXmlPath)) + { + options.IncludeXmlComments(domainXmlPath); + } +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} + +app.MapControllers(); +app.MapDefaultEndpoints(); + +app.Run(); + +public partial class Program; \ No newline at end of file diff --git a/ProjectApp.Api/ProjectApp.Api.csproj b/ProjectApp.Api/ProjectApp.Api.csproj new file mode 100644 index 00000000..eeb35d8e --- /dev/null +++ b/ProjectApp.Api/ProjectApp.Api.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + diff --git a/ProjectApp.Api/Properties/launchSettings.json b/ProjectApp.Api/Properties/launchSettings.json new file mode 100644 index 00000000..bbe9ee66 --- /dev/null +++ b/ProjectApp.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:46825", + "sslPort": 44333 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7170;http://localhost:5179", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs new file mode 100644 index 00000000..e1e8e724 --- /dev/null +++ b/ProjectApp.Api/Services/SqsProducer/ISqsProducer.cs @@ -0,0 +1,14 @@ +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.SqsProducer; + +/// +/// Сервис отправки данных транспортного средства в очередь SQS +/// +public interface ISqsProducer +{ + /// + /// Отправляет данные транспортного средства в очередь + /// + Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default); +} diff --git a/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs new file mode 100644 index 00000000..047b0ed7 --- /dev/null +++ b/ProjectApp.Api/Services/SqsProducer/SqsProducer.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using Amazon.SQS; +using Amazon.SQS.Model; +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.SqsProducer; + +/// +/// Реализация отправки данных транспортного средства в SQS +/// +public class SqsProducer( + IAmazonSQS sqsClient, + IConfiguration configuration, + ILogger logger) : ISqsProducer +{ + private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue"; + private string? _queueUrl; + + /// + /// Отправляет сериализованные данные транспортного средства в очередь SQS + /// + public async Task SendVehicleAsync(Vehicle vehicle, CancellationToken cancellationToken = default) + { + var queueUrl = await GetQueueUrlAsync(cancellationToken); + var body = JsonSerializer.Serialize(vehicle); + + await sqsClient.SendMessageAsync(new SendMessageRequest + { + QueueUrl = queueUrl, + MessageBody = body + }, cancellationToken); + + logger.LogInformation("Vehicle {Id} sent to SQS", vehicle.Id); + } + + private async Task GetQueueUrlAsync(CancellationToken ct) + { + if (_queueUrl != null) + return _queueUrl; + + var response = await sqsClient.CreateQueueAsync(_queueName, ct); + _queueUrl = response.QueueUrl; + return _queueUrl; + } +} diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs new file mode 100644 index 00000000..b1f5c1d9 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/CachedVehicleGeneratorService.cs @@ -0,0 +1,67 @@ +using ProjectApp.Domain.Entities; +using Microsoft.Extensions.Caching.Distributed; +using System.Text.Json; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +/// +/// Декоратор над генератором транспортных средств с поддержкой кэширования через Redis +/// +public class CachedVehicleGeneratorService( + IVehicleGeneratorService innerService, + IDistributedCache cache, + IConfiguration configuration, + ILogger logger) : IVehicleGeneratorService +{ + private readonly int _ttlMinutes = configuration.GetValue("CacheSettings:ExpirationMinutes", 10); + + /// + /// Возвращает транспортное средство из кэша или генерирует новое и сохраняет в кэш + /// + public async Task FetchByIdAsync(int id, CancellationToken cancellationToken = default) + { + var key = $"vehicle-{id}"; + + try + { + var raw = await cache.GetStringAsync(key, cancellationToken); + if (!string.IsNullOrEmpty(raw)) + { + var vehicle = JsonSerializer.Deserialize(raw); + if (vehicle != null) + { + logger.LogInformation("Vehicle {Id} retrieved from cache", id); + return vehicle; + } + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Cache read failed for vehicle {Id}. Falling back to generation.", id); + } + + var generatedVehicle = await innerService.FetchByIdAsync(id, cancellationToken); + + try + { + var opts = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_ttlMinutes) + }; + + await cache.SetStringAsync( + key, + JsonSerializer.Serialize(generatedVehicle), + opts, + cancellationToken); + + logger.LogInformation("Vehicle {Id} successfully stored in cache.", id); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to store vehicle {Id} in cache.", id); + } + + return generatedVehicle; + } +} diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs new file mode 100644 index 00000000..671ca786 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/IVehicleGeneratorService.cs @@ -0,0 +1,14 @@ +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +/// +/// Интерфейс сервиса получения характеристик машины +/// +public interface IVehicleGeneratorService +{ + /// + /// Запрашивает машину по ID + /// + public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default); +} diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs new file mode 100644 index 00000000..82a79981 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleFaker.cs @@ -0,0 +1,55 @@ +using Bogus; +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +/// +/// Генератор тестовых данных транспортных средств на основе Bogus +/// +public class VehicleFaker +{ + private static readonly string[] _fuelTypes = ["Бензин", "Дизель", "Электро", "Гибрид", "Газ"]; + + private static readonly string[] _bodyTypes = ["Седан", "Хэтчбек", "Универсал", "Кроссовер", "Внедорожник", "Купе", "Минивэн", "Пикап"]; + + private static readonly Dictionary _brandModels = new() + { + ["Toyota"] = ["Camry", "Corolla", "RAV4", "Land Cruiser", "Yaris", "Highlander", "Prado"], + ["BMW"] = ["3 Series", "5 Series", "7 Series", "X3", "X5", "X6", "M4"], + ["Mercedes"] = ["C-Class", "E-Class", "S-Class", "GLE", "GLC", "A-Class", "CLA"], + ["Volkswagen"] = ["Passat", "Golf", "Tiguan", "Polo", "Touareg", "Jetta", "ID.4"], + ["Hyundai"] = ["Solaris", "Tucson", "Santa Fe", "Creta", "Elantra", "Sonata", "i30"], + ["Ford"] = ["Focus", "Mondeo", "Explorer", "Kuga", "Puma", "Mustang", "Ranger"], + ["Kia"] = ["Rio", "Sportage", "Sorento", "Ceed", "K5", "Seltos", "Stinger"], + ["Audi"] = ["A3", "A4", "A6", "Q3", "Q5", "Q7", "TT"], + ["Nissan"] = ["Qashqai", "X-Trail", "Altima", "Leaf", "Juke", "Murano", "Note"], + ["Renault"] = ["Logan", "Duster", "Sandero", "Megane", "Arkana", "Captur", "Laguna"], + ["Lada"] = ["Granta", "Vesta", "Largus", "Niva Travel", "XRAY", "4x4"], + ["Skoda"] = ["Octavia", "Superb", "Kodiaq", "Karoq", "Fabia", "Scala", "Kamiq"], + ["Mazda"] = ["Mazda3", "Mazda6", "CX-5", "CX-9", "MX-5", "CX-30"], + ["Honda"] = ["Civic", "Accord", "CR-V", "HR-V", "Jazz", "Pilot"], + ["Subaru"] = ["Outback", "Forester", "Impreza", "XV", "Legacy", "WRX"], + }; + + private readonly Faker _faker; + + public VehicleFaker() + { + var currentYear = DateTime.Now.Year; + + _faker = new Faker("ru") + .RuleFor(v => v.Id, f => f.IndexFaker + 1) + .RuleFor(v => v.Vin, f => f.Vehicle.Vin()) + .RuleFor(v => v.Brand, f => f.PickRandom(_brandModels.Keys.ToArray())) + .RuleFor(v => v.Model, (f, v) => f.PickRandom(_brandModels[v.Brand])) + .RuleFor(v => v.Year, f => f.Random.Int(1984, currentYear)) + .RuleFor(v => v.BodyType, f => f.PickRandom(_bodyTypes)) + .RuleFor(v => v.FuelType, f => f.PickRandom(_fuelTypes)) + .RuleFor(v => v.Color, f => f.Commerce.Color()) + .RuleFor(v => v.Mileage, (f, v) => Math.Round(f.Random.Double(0, 500_000), 1)) + .RuleFor(v => v.LastServiceDate, (f, v) => + f.Date.BetweenDateOnly(new DateOnly(v.Year, 1, 1), DateOnly.FromDateTime(DateTime.Now))); + } + + public Vehicle Generate() => _faker.Generate(); +} diff --git a/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs new file mode 100644 index 00000000..edc42a36 --- /dev/null +++ b/ProjectApp.Api/Services/VehicleGeneratorService/VehicleGeneratorService.cs @@ -0,0 +1,22 @@ +using ProjectApp.Domain.Entities; + +namespace ProjectApp.Api.Services.VehicleGeneratorService; + +/// +/// Сервис генерации данных транспортных средств на основе Bogus +/// +public class VehicleGeneratorService( + VehicleFaker faker, + ILogger logger) : IVehicleGeneratorService +{ + /// + /// Генерирует новые данные транспортного средства для заданного идентификатора + /// + public Task FetchByIdAsync(int id, CancellationToken cancellationToken = default) + { + logger.LogInformation("Generating new vehicle data for id {Id}", id); + var vehicle = faker.Generate(); + vehicle.Id = id; + return Task.FromResult(vehicle); + } +} diff --git a/ProjectApp.Api/appsettings.Development.json b/ProjectApp.Api/appsettings.Development.json new file mode 100644 index 00000000..b642d7aa --- /dev/null +++ b/ProjectApp.Api/appsettings.Development.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 + } +} diff --git a/ProjectApp.Api/appsettings.json b/ProjectApp.Api/appsettings.json new file mode 100644 index 00000000..407e8dbf --- /dev/null +++ b/ProjectApp.Api/appsettings.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "CacheSettings": { + "ExpirationMinutes": 10 + }, + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "test", + "AwsAccessKeySecret": "test", + "RegionName": "us-east-1" + }, + "Config": { + "LocalStackHost": "localhost", + "EdgePort": 9324 + } + }, + "Sqs": { + "QueueName": "vehicle-queue" + } +} diff --git a/ProjectApp.AppHost/Program.cs b/ProjectApp.AppHost/Program.cs new file mode 100644 index 00000000..dca08b53 --- /dev/null +++ b/ProjectApp.AppHost/Program.cs @@ -0,0 +1,34 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var redis = builder.AddRedis("cache") + .WithRedisCommander(); + +var minio = builder.AddMinioContainer("minio"); + +var sqs = builder.AddContainer("sqs", "softwaremill/elasticmq-native") + .WithHttpEndpoint(port: 9324, targetPort: 9324, name: "sqs"); + +var fileService = builder.AddProject("projectapp-fileservice") + .WithReference(minio) + .WaitFor(minio) + .WaitFor(sqs); + +var gateway = builder.AddProject("projectapp-gateway") + .WithEndpoint("http", e => e.Port = 5200); + +for (var i = 0; i < 3; i++) +{ + var api = builder.AddProject($"projectapp-api-{i + 1}") + .WithReference(redis) + .WaitFor(redis) + .WaitFor(sqs) + .WithEndpoint("http", e => e.Port = 5180 + i) + .WithEndpoint("https", e => e.Port = 7170 + i); + + gateway = gateway.WithReference(api).WaitFor(api); +} + +builder.AddProject("client") + .WaitFor(gateway); + +builder.Build().Run(); diff --git a/ProjectApp.AppHost/ProjectApp.AppHost.csproj b/ProjectApp.AppHost/ProjectApp.AppHost.csproj new file mode 100644 index 00000000..f5cc2982 --- /dev/null +++ b/ProjectApp.AppHost/ProjectApp.AppHost.csproj @@ -0,0 +1,27 @@ + + + + + + Exe + net8.0 + enable + enable + true + b8f3eae0-771a-4f3a-8df3-ef0a21b09b55 + + + + + + + + + + + + + + + + diff --git a/ProjectApp.AppHost/Properties/launchSettings.json b/ProjectApp.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000..fa890ea9 --- /dev/null +++ b/ProjectApp.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17214;http://localhost:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21185", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22273" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15105", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19190", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20136" + } + } + } +} diff --git a/ProjectApp.AppHost/appsettings.Development.json b/ProjectApp.AppHost/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.AppHost/appsettings.json b/ProjectApp.AppHost/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/ProjectApp.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/ProjectApp.Domain/Entities/Vehicle.cs b/ProjectApp.Domain/Entities/Vehicle.cs new file mode 100644 index 00000000..250ec9a3 --- /dev/null +++ b/ProjectApp.Domain/Entities/Vehicle.cs @@ -0,0 +1,37 @@ +namespace ProjectApp.Domain.Entities; + +/// +/// Сущность транспортного средства +/// +public class Vehicle +{ + /// Уникальный идентификатор транспортного средства в системе + public required int Id { get; set; } + + /// VIN-номер транспортного средства + public required string Vin { get; set; } + + /// Производитель транспортного средства + public required string Brand { get; set; } + + /// Модель транспортного средства + public required string Model { get; set; } + + /// Год выпуска транспортного средства + public int Year { get; set; } + + /// Тип корпуса (кузова) + public required string BodyType { get; set; } + + /// Тип используемого топлива + public required string FuelType { get; set; } + + /// Цвет корпуса + public required string Color { get; set; } + + /// Пробег в километрах + public double Mileage { get; set; } + + /// Дата последнего техобслуживания + public DateOnly LastServiceDate { get; set; } +} diff --git a/ProjectApp.Domain/ProjectApp.Domain.csproj b/ProjectApp.Domain/ProjectApp.Domain.csproj new file mode 100644 index 00000000..fcfb2654 --- /dev/null +++ b/ProjectApp.Domain/ProjectApp.Domain.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + enable + enable + true + $(NoWarn);1591 + + + diff --git a/ProjectApp.FileService.Function/Handler.cs b/ProjectApp.FileService.Function/Handler.cs new file mode 100644 index 00000000..60f6f7e3 --- /dev/null +++ b/ProjectApp.FileService.Function/Handler.cs @@ -0,0 +1,129 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; + +namespace FileServiceFunction; + +/// +/// Cloud Function файлового сервиса. Получает пакет сообщений из Yandex Message Queue +/// и сохраняет данные каждого транспортного средства в Yandex Object Storage. +/// +public class Handler +{ + private readonly IAmazonS3 _s3Client; + private readonly string _bucketName; + + public Handler() + { + var endpoint = Environment.GetEnvironmentVariable("S3_ENDPOINT") + ?? "https://storage.yandexcloud.net"; + var accessKey = Environment.GetEnvironmentVariable("AWS_ACCESS_KEY_ID") ?? ""; + var secretKey = Environment.GetEnvironmentVariable("AWS_SECRET_ACCESS_KEY") ?? ""; + _bucketName = Environment.GetEnvironmentVariable("S3_BUCKET") ?? "vehicles-storage"; + + var credentials = new BasicAWSCredentials(accessKey, secretKey); + var config = new AmazonS3Config + { + ServiceURL = endpoint, + ForcePathStyle = true + }; + _s3Client = new AmazonS3Client(credentials, config); + } + + /// + /// Точка входа Cloud Function. Обрабатывает пакет сообщений из очереди + /// и сохраняет каждое транспортное средство как JSON-файл в Object Storage. + /// + /// Сериализованное событие триггера Message Queue + public string FunctionHandler(string input) + { + QueueTriggerEvent? trigger = null; + try { trigger = JsonSerializer.Deserialize(input ?? "{}"); } catch { } + + var messages = trigger?.Messages ?? []; + foreach (var message in messages) + { + try { ProcessMessage(message); } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] {message.Details?.Message?.MessageId}: {ex.Message}"); + } + } + + return "{}"; + } + + private void ProcessMessage(QueueMessage message) + { + var rawBody = message.Details?.Message?.Body ?? ""; + + string json; + try + { + var decoded = Convert.FromBase64String(rawBody); + json = Encoding.UTF8.GetString(decoded); + } + catch + { + json = rawBody; + } + + using var doc = JsonDocument.Parse(json); + var idProp = doc.RootElement.TryGetProperty("id", out var idEl) ? idEl + : doc.RootElement.TryGetProperty("Id", out var idEl2) ? idEl2 : default; + var id = idProp.ValueKind == JsonValueKind.Number ? idProp.GetInt32() : 0; + var objectName = $"vehicle-{id}.json"; + + var bytes = Encoding.UTF8.GetBytes(json); + using var stream = new MemoryStream(bytes); + + _s3Client.PutObjectAsync(new PutObjectRequest + { + BucketName = _bucketName, + Key = objectName, + InputStream = stream, + ContentType = "application/json" + }).GetAwaiter().GetResult(); + + Console.WriteLine($"[INFO] Saved {objectName}"); + } +} + +public class QueueTriggerEvent +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public class QueueMessage +{ + [JsonPropertyName("event_metadata")] + public EventMetadata? EventMetadata { get; set; } + + [JsonPropertyName("details")] + public MessageDetails? Details { get; set; } +} + +public class EventMetadata +{ + [JsonPropertyName("event_id")] public string EventId { get; set; } = ""; + [JsonPropertyName("event_type")] public string EventType { get; set; } = ""; + [JsonPropertyName("created_at")] public string CreatedAt { get; set; } = ""; +} + +public class MessageDetails +{ + [JsonPropertyName("queue_id")] public string QueueId { get; set; } = ""; + [JsonPropertyName("message")] public SqsMessage? Message { get; set; } +} + +public class SqsMessage +{ + [JsonPropertyName("message_id")] public string MessageId { get; set; } = ""; + [JsonPropertyName("md5_of_body")] public string Md5OfBody { get; set; } = ""; + [JsonPropertyName("body")] public string Body { get; set; } = ""; + [JsonPropertyName("attributes")] public Dictionary? Attributes { get; set; } +} diff --git a/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs new file mode 100644 index 00000000..60e065d7 --- /dev/null +++ b/ProjectApp.FileService.Function/Models/QueueTriggerEvent.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace FileServiceFunction.Models; + +public class QueueTriggerEvent +{ + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public class QueueMessage +{ + [JsonPropertyName("event_metadata")] + public EventMetadata EventMetadata { get; set; } = new(); + + [JsonPropertyName("details")] + public MessageDetails Details { get; set; } = new(); +} + +public class EventMetadata +{ + [JsonPropertyName("event_id")] + public string EventId { get; set; } = ""; + + [JsonPropertyName("event_type")] + public string EventType { get; set; } = ""; + + [JsonPropertyName("created_at")] + public string CreatedAt { get; set; } = ""; +} + +public class MessageDetails +{ + [JsonPropertyName("queue_id")] + public string QueueId { get; set; } = ""; + + [JsonPropertyName("message")] + public SqsMessage Message { get; set; } = new(); +} + +public class SqsMessage +{ + [JsonPropertyName("message_id")] + public string MessageId { get; set; } = ""; + + [JsonPropertyName("md5_of_body")] + public string Md5OfBody { get; set; } = ""; + + [JsonPropertyName("body")] + public string Body { get; set; } = ""; + + [JsonPropertyName("attributes")] + public Dictionary? Attributes { get; set; } +} diff --git a/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj new file mode 100644 index 00000000..86e37129 --- /dev/null +++ b/ProjectApp.FileService.Function/ProjectApp.FileService.Function.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + FileServiceFunction + FileServiceFunction + + + + + + + diff --git a/ProjectApp.FileService/Program.cs b/ProjectApp.FileService/Program.cs new file mode 100644 index 00000000..32cb739b --- /dev/null +++ b/ProjectApp.FileService/Program.cs @@ -0,0 +1,48 @@ +using Amazon.SQS; +using LocalStack.Client.Extensions; +using Minio; +using ProjectApp.FileService.Services; +using ProjectApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddLocalStack(builder.Configuration); +builder.Services.AddAwsService(); + +builder.Services.AddSingleton(sp => +{ + var configuration = sp.GetRequiredService(); + + // When run via Aspire, WithReference(minio) sets ConnectionStrings:minio = "http://host:port" + // For local run without Aspire, fall back to Minio:Endpoint setting + string minioEndpoint; + var connectionString = configuration.GetConnectionString("minio"); + if (connectionString is not null) + { + var uri = new Uri(connectionString); + minioEndpoint = $"{uri.Host}:{uri.Port}"; + } + else + { + minioEndpoint = configuration["Minio:Endpoint"] ?? "localhost:9000"; + } + + return new MinioClient() + .WithEndpoint(minioEndpoint) + .WithCredentials( + configuration["Minio:AccessKey"] ?? "minioadmin", + configuration["Minio:SecretKey"] ?? "minioadmin") + .WithSSL(false) + .Build(); +}); + +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/ProjectApp.FileService/ProjectApp.FileService.csproj b/ProjectApp.FileService/ProjectApp.FileService.csproj new file mode 100644 index 00000000..1b024de7 --- /dev/null +++ b/ProjectApp.FileService/ProjectApp.FileService.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/ProjectApp.FileService/Properties/launchSettings.json b/ProjectApp.FileService/Properties/launchSettings.json new file mode 100644 index 00000000..2d1b79c6 --- /dev/null +++ b/ProjectApp.FileService/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:17853", + "sslPort": 44303 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5166", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7072;http://localhost:5166", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.FileService/Services/MinioStorageService.cs b/ProjectApp.FileService/Services/MinioStorageService.cs new file mode 100644 index 00000000..62df2e71 --- /dev/null +++ b/ProjectApp.FileService/Services/MinioStorageService.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; +using Minio; +using Minio.DataModel.Args; +using ProjectApp.Domain.Entities; + +namespace ProjectApp.FileService.Services; + +/// +/// Сервис сохранения и чтения файлов транспортных средств в MinIO +/// +public class MinioStorageService( + IMinioClient minioClient, + IConfiguration configuration, + ILogger logger) +{ + private readonly string _bucketName = configuration["Minio:BucketName"] ?? "vehicles"; + + /// + /// Создаёт бакет, если он ещё не существует + /// + public async Task EnsureBucketExistsAsync(CancellationToken ct = default) + { + var exists = await minioClient.BucketExistsAsync( + new BucketExistsArgs().WithBucket(_bucketName), ct); + + if (!exists) + { + await minioClient.MakeBucketAsync( + new MakeBucketArgs().WithBucket(_bucketName), ct); + logger.LogInformation("Bucket {Bucket} created", _bucketName); + } + } + + /// + /// Сохраняет данные транспортного средства в виде JSON-файла + /// + public async Task SaveVehicleAsync(Vehicle vehicle, CancellationToken ct = default) + { + var json = JsonSerializer.Serialize(vehicle, new JsonSerializerOptions { WriteIndented = true }); + await SaveRawAsync(vehicle.Id, json, ct); + } + + /// + /// Сохраняет сырой JSON напрямую в MinIO без повторной сериализации + /// + public async Task SaveRawAsync(int id, string rawJson, CancellationToken ct = default) + { + var bytes = Encoding.UTF8.GetBytes(rawJson); + using var stream = new MemoryStream(bytes); + var objectName = $"vehicle-{id}.json"; + + await minioClient.PutObjectAsync(new PutObjectArgs() + .WithBucket(_bucketName) + .WithObject(objectName) + .WithStreamData(stream) + .WithObjectSize(stream.Length) + .WithContentType("application/json"), ct); + + logger.LogInformation("Vehicle {Id} saved as {Object}", id, objectName); + } + + /// + /// Читает данные транспортного средства из MinIO по идентификатору + /// + public async Task GetVehicleAsync(int id, CancellationToken ct = default) + { + var objectName = $"vehicle-{id}.json"; + using var ms = new MemoryStream(); + + await minioClient.GetObjectAsync(new GetObjectArgs() + .WithBucket(_bucketName) + .WithObject(objectName) + .WithCallbackStream(stream => stream.CopyTo(ms)), ct); + + ms.Position = 0; + return await JsonSerializer.DeserializeAsync(ms, cancellationToken: ct); + } +} diff --git a/ProjectApp.FileService/Services/SqsConsumerService.cs b/ProjectApp.FileService/Services/SqsConsumerService.cs new file mode 100644 index 00000000..cfe2ba4b --- /dev/null +++ b/ProjectApp.FileService/Services/SqsConsumerService.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using Amazon.SQS; +using Amazon.SQS.Model; + +namespace ProjectApp.FileService.Services; + +/// +/// Фоновый сервис для получения сообщений из SQS и сохранения данных в MinIO +/// +public class SqsConsumerService( + IAmazonSQS sqsClient, + MinioStorageService storageService, + IConfiguration configuration, + ILogger logger) : BackgroundService +{ + private readonly string _queueName = configuration["Sqs:QueueName"] ?? "vehicle-queue"; + private string? _queueUrl; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await InitializeAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var response = await sqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = _queueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 1 + }, stoppingToken); + + foreach (var message in response.Messages) + { + await ProcessMessageAsync(message, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error polling SQS"); + await Task.Delay(3000, stoppingToken); + } + } + } + + private async Task InitializeAsync(CancellationToken ct) + { + var response = await sqsClient.CreateQueueAsync(_queueName, ct); + _queueUrl = response.QueueUrl; + logger.LogInformation("SQS queue {Queue} ready at {Url}", _queueName, _queueUrl); + + await storageService.EnsureBucketExistsAsync(ct); + } + + private async Task ProcessMessageAsync(Message message, CancellationToken ct) + { + try + { + using var doc = JsonDocument.Parse(message.Body); + var id = doc.RootElement.GetProperty("Id").GetInt32(); + + await storageService.SaveRawAsync(id, message.Body, ct); + logger.LogInformation("Vehicle {Id} saved to MinIO from SQS message {MessageId}", id, message.MessageId); + + await sqsClient.DeleteMessageAsync(_queueUrl, message.ReceiptHandle, ct); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process message {MessageId}", message.MessageId); + } + } +} diff --git a/ProjectApp.FileService/appsettings.Development.json b/ProjectApp.FileService/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.FileService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.FileService/appsettings.json b/ProjectApp.FileService/appsettings.json new file mode 100644 index 00000000..18af9f67 --- /dev/null +++ b/ProjectApp.FileService/appsettings.json @@ -0,0 +1,30 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "LocalStack": { + "UseLocalStack": true, + "Session": { + "AwsAccessKeyId": "test", + "AwsAccessKeySecret": "test", + "RegionName": "us-east-1" + }, + "Config": { + "LocalStackHost": "localhost", + "EdgePort": 9324 + } + }, + "Sqs": { + "QueueName": "vehicle-queue" + }, + "Minio": { + "Endpoint": "localhost:9000", + "AccessKey": "minioadmin", + "SecretKey": "minioadmin", + "BucketName": "vehicles" + } +} diff --git a/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs new file mode 100644 index 00000000..ea8c48c0 --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/QueryBasedLoadBalancer.cs @@ -0,0 +1,54 @@ +using Ocelot.Configuration; +using Ocelot.LoadBalancer.LoadBalancers; +using Ocelot.Responses; +using Ocelot.Values; + +namespace ProjectApp.Gateway.LoadBalancing; + +/// +/// Балансировщик нагрузки, распределяющий запросы на основе значения параметра id +/// из строки запроса или пути. Один и тот же id всегда направляется на один и тот же хост. +/// +public class QueryBasedLoadBalancer(DownstreamRoute route) : ILoadBalancer +{ + private readonly List _hosts = route.DownstreamAddresses + .Select(a => new ServiceHostAndPort(a.Host, a.Port)) + .ToList(); + + /// + /// Тип используемого балансировщика нагрузки. + /// + public string Type => nameof(QueryBasedLoadBalancer); + + /// + /// Выбирает downstream-хост на основе идентификатора из запроса. + /// + public Task> LeaseAsync(HttpContext httpContext) + { + if (_hosts.Count == 0) + return Task.FromResult>( + new ErrorResponse( + new ServicesAreEmptyError("No downstream hosts configured"))); + + var idRaw = httpContext.Request.Query["id"].FirstOrDefault() + ?? ExtractIdFromPath(httpContext.Request.Path); + + var index = 0; + if (int.TryParse(idRaw, out var id)) + index = Math.Abs(id) % _hosts.Count; + + return Task.FromResult>( + new OkResponse(_hosts[index])); + } + + /// + /// Освобождает ранее выданный хост (балансировщик без состояния). + /// + public void Release(ServiceHostAndPort hostAndPort) { } + + private static string? ExtractIdFromPath(PathString path) + { + var segments = path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + return segments?.LastOrDefault(); + } +} diff --git a/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs new file mode 100644 index 00000000..ebef4b1c --- /dev/null +++ b/ProjectApp.Gateway/LoadBalancing/ServicesAreEmptyError.cs @@ -0,0 +1,5 @@ +using Ocelot.Errors; + +namespace ProjectApp.Gateway.LoadBalancing; + +public class ServicesAreEmptyError(string message) : Error(message, OcelotErrorCode.UnknownError, 503); diff --git a/ProjectApp.Gateway/Program.cs b/ProjectApp.Gateway/Program.cs new file mode 100644 index 00000000..f42be308 --- /dev/null +++ b/ProjectApp.Gateway/Program.cs @@ -0,0 +1,37 @@ +using Ocelot.DependencyInjection; +using Ocelot.Middleware; +using ProjectApp.Gateway.LoadBalancing; +using ProjectApp.ServiceDefaults; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Configuration + .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) + .AddJsonFile($"ocelot.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(); + +var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? ["http://localhost:5127"]; +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins(allowedOrigins) + .WithMethods("GET") + .WithHeaders("Content-Type"); + }); +}); + +builder.Services + .AddOcelot(builder.Configuration) + .AddCustomLoadBalancer((route, _) => new QueryBasedLoadBalancer(route)); + +var app = builder.Build(); + +app.UseCors(); +app.MapDefaultEndpoints(); + +await app.UseOcelot(); + +app.Run(); diff --git a/ProjectApp.Gateway/ProjectApp.Gateway.csproj b/ProjectApp.Gateway/ProjectApp.Gateway.csproj new file mode 100644 index 00000000..afa35b75 --- /dev/null +++ b/ProjectApp.Gateway/ProjectApp.Gateway.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + Always + + + + diff --git a/ProjectApp.Gateway/Properties/launchSettings.json b/ProjectApp.Gateway/Properties/launchSettings.json new file mode 100644 index 00000000..595cd1bd --- /dev/null +++ b/ProjectApp.Gateway/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:64048", + "sslPort": 44366 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7286;http://localhost:5130", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/ProjectApp.Gateway/appsettings.Development.json b/ProjectApp.Gateway/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/ProjectApp.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/ProjectApp.Gateway/appsettings.json b/ProjectApp.Gateway/appsettings.json new file mode 100644 index 00000000..5860f55c --- /dev/null +++ b/ProjectApp.Gateway/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Ocelot": "Information" + } + }, + "AllowedHosts": "*", + "AllowedOrigins": [ + "http://localhost:5127" + ] +} diff --git a/ProjectApp.Gateway/ocelot.json b/ProjectApp.Gateway/ocelot.json new file mode 100644 index 00000000..be45093c --- /dev/null +++ b/ProjectApp.Gateway/ocelot.json @@ -0,0 +1,21 @@ +{ + "Routes": [ + { + "UpstreamPathTemplate": "/api/vehicle/{id}", + "UpstreamHttpMethod": [ "GET" ], + "DownstreamPathTemplate": "/api/vehicle/{id}", + "DownstreamScheme": "http", + "DownstreamHostAndPorts": [ + { "Host": "localhost", "Port": 5180 }, + { "Host": "localhost", "Port": 5181 }, + { "Host": "localhost", "Port": 5182 } + ], + "LoadBalancerOptions": { + "Type": "QueryBasedLoadBalancer" + } + } + ], + "GlobalConfiguration": { + "BaseUrl": "http://localhost:5200" + } +} diff --git a/ProjectApp.ServiceDefaults/Extensions.cs b/ProjectApp.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000..9f167125 --- /dev/null +++ b/ProjectApp.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 ProjectApp.ServiceDefaults; + + /// + /// Вспомогательный класс для настройки общих сервисов (логи, метрики, проверки состояния) + /// +public static class Extensions +{ + /// + /// Регистрация базовой конфигурации для микросервисов + /// + 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 для мониторинга доступности + /// + 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/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj new file mode 100644 index 00000000..bf9f33a7 --- /dev/null +++ b/ProjectApp.ServiceDefaults/ProjectApp.ServiceDefaults.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + diff --git a/ProjectApp.Tests/Fixtures/ServiceFixture.cs b/ProjectApp.Tests/Fixtures/ServiceFixture.cs new file mode 100644 index 00000000..94d29827 --- /dev/null +++ b/ProjectApp.Tests/Fixtures/ServiceFixture.cs @@ -0,0 +1,133 @@ +using Amazon.SQS; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Minio; +using StackExchange.Redis; +using Xunit; + +namespace ProjectApp.Tests.Fixtures; + +/// +/// Фикстура для интеграционных тестов — поднимает контейнеры Redis, MinIO и ElasticMQ +/// +public class ServiceFixture : IAsyncLifetime +{ + private IContainer _redis = null!; + private IContainer _minio = null!; + private IContainer _elasticMq = null!; + + public WebApplicationFactory ApiFactory { get; private set; } = null!; + public IAmazonSQS SqsClient { get; private set; } = null!; + public IMinioClient MinioClient { get; private set; } = null!; + public IConnectionMultiplexer RedisConnection { get; private set; } = null!; + + public string SqsServiceUrl => $"http://localhost:{_elasticMq.GetMappedPublicPort(9324)}"; + public string MinioEndpoint => $"localhost:{_minio.GetMappedPublicPort(9000)}"; + public string RedisConnectionString => $"localhost:{_redis.GetMappedPublicPort(6379)},abortConnect=false"; + + public async Task InitializeAsync() + { + _redis = new ContainerBuilder() + .WithImage("redis:7-alpine") + .WithPortBinding(6379, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(6379)) + .Build(); + + _minio = new ContainerBuilder() + .WithImage("minio/minio") + .WithCommand("server", "/data") + .WithEnvironment("MINIO_ROOT_USER", "minioadmin") + .WithEnvironment("MINIO_ROOT_PASSWORD", "minioadmin") + .WithPortBinding(9000, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9000)) + .Build(); + + _elasticMq = new ContainerBuilder() + .WithImage("softwaremill/elasticmq-native") + .WithPortBinding(9324, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(9324)) + .Build(); + + await Task.WhenAll( + _redis.StartAsync(), + _minio.StartAsync(), + _elasticMq.StartAsync()); + + SqsClient = new AmazonSQSClient("test", "test", new AmazonSQSConfig + { + ServiceURL = SqsServiceUrl + }); + + MinioClient = new Minio.MinioClient() + .WithEndpoint(MinioEndpoint) + .WithCredentials("minioadmin", "minioadmin") + .WithSSL(false) + .Build(); + + RedisConnection = await ConnectionMultiplexer.ConnectAsync(RedisConnectionString); + + var redisConnStr = RedisConnectionString; + var sqsPort = _elasticMq.GetMappedPublicPort(9324); + + ApiFactory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:cache"] = redisConnStr, + ["LocalStack:UseLocalStack"] = "true", + ["LocalStack:Session:AwsAccessKeyId"] = "test", + ["LocalStack:Session:AwsAccessKeySecret"] = "test", + ["LocalStack:Session:RegionName"] = "us-east-1", + ["LocalStack:Config:LocalStackHost"] = "localhost", + ["LocalStack:Config:EdgePort"] = sqsPort.ToString(), + ["Sqs:QueueName"] = "vehicle-queue" + }); + }); + + builder.ConfigureServices(services => + { + services.AddSingleton( + ConnectionMultiplexer.Connect(redisConnStr)); + }); + }); + } + + /// + /// Сброс состояния между тестами: очистка Redis и SQS + /// + public async Task ResetAsync() + { + var db = RedisConnection.GetDatabase(); + await db.ExecuteAsync("FLUSHDB"); + + try + { + var queueUrl = (await SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl; + await SqsClient.PurgeQueueAsync(new Amazon.SQS.Model.PurgeQueueRequest + { + QueueUrl = queueUrl + }); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[WARN] Failed to purge SQS queue during reset: {ex.Message}"); + } + } + + public async Task DisposeAsync() + { + RedisConnection?.Dispose(); + ApiFactory?.Dispose(); + + await Task.WhenAll( + _redis.DisposeAsync().AsTask(), + _minio.DisposeAsync().AsTask(), + _elasticMq.DisposeAsync().AsTask()); + } +} diff --git a/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs new file mode 100644 index 00000000..7250ac11 --- /dev/null +++ b/ProjectApp.Tests/IntegrationTests/VehicleIntegrationTests.cs @@ -0,0 +1,221 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Amazon.SQS.Model; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel.Args; +using ProjectApp.Domain.Entities; +using ProjectApp.FileService.Services; +using ProjectApp.Tests.Fixtures; +using Xunit; + +namespace ProjectApp.Tests.IntegrationTests; + +/// +/// Интеграционные тесты для проверки совместной работы сервисов +/// +public class VehicleIntegrationTests : IClassFixture, IAsyncLifetime +{ + private readonly ServiceFixture _fixture; + private readonly HttpClient _httpClient; + + public VehicleIntegrationTests(ServiceFixture fixture) + { + _fixture = fixture; + _httpClient = fixture.ApiFactory.CreateClient(); + } + + public async Task InitializeAsync() + { + await _fixture.ResetAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task GetVehicle_ReturnsValidData() + { + var response = await _httpClient.GetAsync("/api/vehicle/1"); + + response.EnsureSuccessStatusCode(); + var vehicle = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(vehicle); + Assert.Equal(1, vehicle.Id); + Assert.False(string.IsNullOrEmpty(vehicle.Vin)); + Assert.False(string.IsNullOrEmpty(vehicle.Brand)); + Assert.False(string.IsNullOrEmpty(vehicle.Model)); + Assert.InRange(vehicle.Year, 1984, DateTime.Now.Year); + Assert.True(vehicle.Mileage >= 0); + } + + [Fact] + public async Task GetVehicle_SecondRequest_ReturnsCachedData() + { + var first = await _httpClient.GetFromJsonAsync("/api/vehicle/42"); + var second = await _httpClient.GetFromJsonAsync("/api/vehicle/42"); + + Assert.NotNull(first); + Assert.NotNull(second); + Assert.Equal(first.Vin, second.Vin); + Assert.Equal(first.Brand, second.Brand); + Assert.Equal(first.Model, second.Model); + Assert.Equal(first.Year, second.Year); + Assert.Equal(first.Mileage, second.Mileage); + } + + [Fact] + public async Task GetVehicle_PublishesMessageToSqs() + { + await _httpClient.GetAsync("/api/vehicle/7"); + + await Task.Delay(700); + + var queueUrl = (await _fixture.SqsClient.CreateQueueAsync("vehicle-queue")).QueueUrl; + + var messages = await _fixture.SqsClient.ReceiveMessageAsync(new ReceiveMessageRequest + { + QueueUrl = queueUrl, + MaxNumberOfMessages = 10, + WaitTimeSeconds = 5 + }); + + Assert.NotEmpty(messages.Messages); + + var vehicle = JsonSerializer.Deserialize(messages.Messages[0].Body); + Assert.NotNull(vehicle); + Assert.Equal(7, vehicle.Id); + } + + [Fact] + public async Task MinioStorage_SavesAndRetrievesFile() + { + var minio = _fixture.MinioClient; + var bucketName = "test-vehicles"; + + var exists = await minio.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucketName)); + if (!exists) + await minio.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucketName)); + + var vehicle = new Vehicle + { + Id = 99, + Vin = "TEST12345678VIN01", + Brand = "Toyota", + Model = "Camry", + Year = 2020, + BodyType = "Седан", + FuelType = "Бензин", + Color = "white", + Mileage = 15000, + LastServiceDate = new DateOnly(2023, 6, 15) + }; + + var json = JsonSerializer.Serialize(vehicle); + var bytes = Encoding.UTF8.GetBytes(json); + using var uploadStream = new MemoryStream(bytes); + + await minio.PutObjectAsync(new PutObjectArgs() + .WithBucket(bucketName) + .WithObject("vehicle-99.json") + .WithStreamData(uploadStream) + .WithObjectSize(uploadStream.Length) + .WithContentType("application/json")); + + using var downloadStream = new MemoryStream(); + await minio.GetObjectAsync(new GetObjectArgs() + .WithBucket(bucketName) + .WithObject("vehicle-99.json") + .WithCallbackStream(s => s.CopyTo(downloadStream))); + + downloadStream.Position = 0; + var loaded = await JsonSerializer.DeserializeAsync(downloadStream); + + Assert.NotNull(loaded); + Assert.Equal(vehicle.Id, loaded.Id); + Assert.Equal(vehicle.Vin, loaded.Vin); + Assert.Equal(vehicle.Brand, loaded.Brand); + } + + [Fact] + public async Task Redis_CachesVehicleData() + { + await _httpClient.GetAsync("/api/vehicle/55"); + + using var scope = _fixture.ApiFactory.Services.CreateScope(); + var cache = scope.ServiceProvider.GetRequiredService(); + var cached = await cache.GetStringAsync("vehicle-55"); + + Assert.NotNull(cached); + + var vehicle = JsonSerializer.Deserialize(cached); + Assert.NotNull(vehicle); + Assert.Equal(55, vehicle.Id); + } + + [Fact] + public async Task GetVehicle_WithInvalidId_ReturnsBadRequest() + { + var response = await _httpClient.GetAsync("/api/vehicle/-1"); + + Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Проверяет, что объект из API совпадает с тем, что сохранено в объектном хранилище. + /// + [Fact] + public async Task EndToEnd_VehicleFlow_ApiResponseMatchesMinioStorage() + { + const int vehicleId = 88; + + var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Sqs:ServiceUrl"] = _fixture.SqsServiceUrl, + ["Sqs:QueueName"] = "vehicle-queue", + ["Minio:BucketName"] = "vehicles" + }) + .Build(); + + var minioStorage = new MinioStorageService( + _fixture.MinioClient, + config, + loggerFactory.CreateLogger()); + + await minioStorage.EnsureBucketExistsAsync(); + + var consumer = new SqsConsumerService( + _fixture.SqsClient, + minioStorage, + config, + loggerFactory.CreateLogger()); + + using var cts = new CancellationTokenSource(); + _ = consumer.StartAsync(cts.Token); + + var apiVehicle = await _httpClient.GetFromJsonAsync($"/api/vehicle/{vehicleId}"); + + await Task.Delay(4000); + + var savedVehicle = await minioStorage.GetVehicleAsync(vehicleId, CancellationToken.None); + + await cts.CancelAsync(); + await consumer.StopAsync(CancellationToken.None); + await Task.Delay(500); + + Assert.NotNull(apiVehicle); + Assert.NotNull(savedVehicle); + Assert.Equal(apiVehicle.Id, savedVehicle.Id); + Assert.Equal(apiVehicle.Vin, savedVehicle.Vin); + Assert.Equal(apiVehicle.Brand, savedVehicle.Brand); + Assert.Equal(apiVehicle.Model, savedVehicle.Model); + Assert.Equal(apiVehicle.Year, savedVehicle.Year); + } +} diff --git a/ProjectApp.Tests/ProjectApp.Tests.csproj b/ProjectApp.Tests/ProjectApp.Tests.csproj new file mode 100644 index 00000000..71f58620 --- /dev/null +++ b/ProjectApp.Tests/ProjectApp.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index dcaa5eb7..ae07a11f 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,165 @@ -# Современные технологии разработки программного обеспечения -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1an43o-iqlq4V_kDtkr_y7DC221hY9qdhGPrpII27sH8/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта микросервисного бекенда. - -### Задачи -* Реализация межсервисной коммуникации, -* Изучение работы с брокерами сообщений, -* Изучение архитектурных паттернов, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Интеграционное тестирование. - -### Лабораторные работы -
-1. «Кэширование» - Реализация сервиса генерации контрактов, кэширование его ответов -
- -В рамках первой лабораторной работы необходимо: -* Реализовать сервис генерации контрактов на основе Bogus, -* Реализовать кеширование при помощи IDistributedCache и Redis, -* Реализовать структурное логирование сервиса генерации, -* Настроить оркестрацию Aspire. - -
-
-2. «Балансировка нагрузки» - Реализация апи гейтвея, настройка его работы -
- -В рамках второй лабораторной работы необходимо: -* Настроить оркестрацию на запуск нескольких реплик сервиса генерации, -* Реализовать апи гейтвей на основе Ocelot, -* Имплементировать алгоритм балансировки нагрузки согласно варианту. - -
-
-
-3. «Интеграционное тестирование» - Реализация файлового сервиса и объектного хранилища, интеграционное тестирование бекенда -
- -В рамках третьей лабораторной работы необходимо: -* Добавить в оркестрацию объектное хранилище, -* Реализовать файловый сервис, сериализующий сгенерированные данные в файлы и сохраняющий их в объектном хранилище, -* Реализовать отправку генерируемых данных в файловый сервис посредством брокера, -* Реализовать интеграционные тесты, проверяющие корректность работы всех сервисов бекенда вместе. - -
-
-
-4. (Опционально) «Переход на облачную инфраструктуру» - Перенос бекенда в Yandex Cloud -
- -В рамках четвертой лабораторной работы необходимо перенестиервисы на облако все ранее разработанные сервисы: -* Клиент - в хостинг через отдельный бакет Object Storage, -* Сервис генерации - в Cloud Function, -* Апи гейтвей - в Serverless Integration как API Gateway, -* Брокер сообщений - в Message Queue, -* Файловый сервис - в Cloud Function, -* Объектное хранилище - в отдельный бакет Object Storage, - -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview). -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus). -* Реализация тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Перенос бекенда на облачную инфраструктуру Yandex Cloud - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/cloud-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма -Современные_технологии_разработки_ПО_drawio -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1WGmLYwffTTaAj4TgFCk5bUyW3XKbFMiBm-DHZrfFWr4/edit?usp=sharing) -[Список предметных областей и алгоритмов балансировки](https://docs.google.com/document/d/1PLn2lKe4swIdJDZhwBYzxqFSu0AbY2MFY1SUPkIKOM4/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. -Не укладываетесь в дедлайн - получаете минимально возможный балл. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соответствующим разделом дискуссий](https://github.com/itsecd/cloud-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/cloud-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/cloud-development/discussions/categories/ideas). +# Лабораторная работа №1 — «Кэширование» +**Вариант:** №29 — «Транспортное средство» +**Балансировка:** Query Based +**Брокер:** SQS +**Хостинг S3:** Minio +**Выполнил:** Нестеренко Андрей, 6512 + +## Что реализовано + +- Генерация сущности «Транспортное средство» через Bogus. +- Кэширование результатов генерации через IDistributedCache (Redis) с TTL 10 минут. +- Структурное логирование запросов и результатов генерации. +- Оркестрация сервисов через .NET Aspire. +- REST endpoint: `GET /api/vehicle/{id}`. + +# Лабораторная работа №2 — «Балансировка нагрузки» + +## Описание + +API-шлюз на основе Ocelot с кастомным алгоритмом балансировки нагрузки Query Based. Клиентские запросы распределяются по репликам сервиса генерации на основе параметра `id` из строки запроса. + +## Что реализовано + +### Репликация сервиса генерации +- Aspire AppHost запускает 3 реплики `ProjectApp.Api` на портах 5180, 5181, 5182 +- Гейтвей ожидает готовности всех реплик перед стартом (`.WaitFor()`) + +### API Gateway (Ocelot) +- Проект `ProjectApp.Gateway` — единая точка входа для клиента +- Маршрутизация настроена через `ocelot.json` +- CORS-политика вынесена в конфигурацию (`AllowedOrigins`) + +### Кастомный балансировщик — `QueryBasedLoadBalancer` +- Реализует интерфейс `ILoadBalancer` из Ocelot +- Алгоритм: `index = id % N`, где `N` — число реплик +- При отсутствии параметра `id` запрос направляется на первую реплику + +## Характеристики генерируемого транспортного средства + +1. Идентификатор в системе — `int` +2. VIN-номер — `string` +3. Производитель — `string` +4. Модель — `string` +5. Год выпуска — `int` +6. Тип корпуса — `string` +7. Тип топлива — `string` +8. Цвет корпуса — `string` +9. Пробег — `double` +10. Дата последнего техобслуживания — `DateOnly` + +## Правила генерации + +- VIN-номер: берётся из раздела Vehicle (Bogus). +- Производитель: случайно выбирается из фиксированного списка популярных марок. +- Модель: выбирается из набора моделей, соответствующих производителю. +- Год выпуска: от 1984 до текущего года включительно. +- Тип корпуса: берётся из раздела Vehicle (Bogus). +- Тип топлива: берётся из раздела Vehicle (Bogus); выбирается из списка (Бензин, Дизель, Электро, Гибрид, Газ). +- Цвет корпуса: берётся из раздела Commerce (Bogus). +- Пробег: от 0 до 500 000 км, не может быть меньше нуля. +- Дата последнего техобслуживания: не ранее 1 января года выпуска и не позже сегодняшней даты. + +# Лабораторная работа №3 — «Интеграционное тестирование» + +## Описание + +Добавлен файловый сервис, объектное хранилище MinIO и очередь сообщений SQS (ElasticMQ). Написаны интеграционные тесты для проверки совместной работы всех компонентов. + +## Что реализовано + +### Объектное хранилище (MinIO) +- В оркестрацию Aspire добавлен контейнер MinIO (`minio/minio`) +- MinIO доступен на порту 9000 (API) и 9001 (Console) +- Учётные данные по умолчанию: `minioadmin` / `minioadmin` +- Бакет `vehicles` создаётся автоматически при старте файлового сервиса + +### Очередь сообщений (SQS → ElasticMQ) +- В оркестрацию добавлен контейнер ElasticMQ (`softwaremill/elasticmq-native`) — легковесная SQS-совместимая очередь +- ElasticMQ доступен на порту 9324 +- Очередь `vehicle-queue` создаётся автоматически + +### Файловый сервис (`ProjectApp.FileService`) +- `SqsConsumerService` — фоновый сервис (`BackgroundService`), который опрашивает очередь SQS +- `MinioStorageService` — сохраняет десериализованные данные транспортного средства в MinIO как JSON-файлы +- Файлы хранятся в бакете `vehicles` с именами вида `vehicle-{id}.json` + +### Отправка данных через SQS +- `SqsPublisher` в проекте `ProjectApp.Api` отправляет JSON-сериализованные данные транспортного средства в очередь после генерации +- Ошибки отправки в SQS не блокируют ответ клиенту + +### Интеграционные тесты (`ProjectApp.Tests`) +- Фикстура `ServiceFixture` поднимает контейнеры Redis, MinIO и ElasticMQ через Testcontainers +- `WebApplicationFactory` запускает API с подменёнными подключениями к тестовым контейнерам +- Между тестами выполняется сброс состояния (flush Redis, purge SQS) + +### Список тестов + +| Тест | Что проверяет | +|------|---------------| +| `GetVehicle_ReturnsValidData` | Генерация транспортного средства возвращает корректные поля | +| `GetVehicle_SecondRequest_ReturnsCachedData` | Повторный запрос с тем же ID возвращает закэшированные данные | +| `GetVehicle_PublishesMessageToSqs` | После генерации сообщение попадает в очередь SQS | +| `GetVehicle_WithInvalidId_ReturnsBadRequest` | Запрос с невалидным ID (≤ 0) возвращает 400 Bad Request | +| `MinioStorage_SavesAndRetrievesFile` | Запись и чтение JSON-файла из MinIO | +| `Redis_CachesVehicleData` | Данные сохраняются в Redis после генерации | + +# Лабораторная работа №4 — «Переход на облачную инфраструктуру» + +## Описание + +Все сервисы перенесены в Yandex Cloud. Локальная инфраструктура (Aspire, Docker-контейнеры) заменена облачными managed-сервисами. Клиентское приложение размещено в Object Storage, сервисы генерации и обработки файлов развёрнуты как Cloud Functions, маршрутизация настроена через Serverless API Gateway, очередь сообщений перенесена в Yandex Message Queue, а объектное хранилище файлов — в отдельный бакет Object Storage. + +## Что реализовано + +### Клиент (`Client.Wasm`) → Object Storage +- Blazor WebAssembly собирается в Release с конфигурацией `appsettings.Production.json` +- Статические файлы загружаются в бакет `vehicles-client` +- Включён режим статического сайта с `index.html` по умолчанию +- `BaseAddress` клиента указывает на URL API Gateway + +### Сервис генерации (`ProjectApp.Api.Function`) → Cloud Function +- Проект `ProjectApp.Api.Function` — самодостаточная Cloud Function (без Aspire, без Redis) +- Точка входа: `ApiFunction.Handler`, метод `FunctionHandler(string input) : string` +- Runtime: `dotnet8`, память: 256 МБ, таймаут: 30 с +- Деплой через архив с исходным кодом (`.cs` + `.csproj`); YC компилирует на своей стороне +- Получает HTTP-запрос от API Gateway, парсит `id` из поля `pathParams` +- Генерирует транспортное средство случайным образом, публикует JSON в очередь через `AWSSDK.SQS` +- Возвращает JSON-ответ с CORS-заголовками в формате `{ statusCode, headers, body }` +- Конфигурация через переменные окружения: `SQS_QUEUE_URL`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` + +### API Gateway → Serverless API Gateway +- OpenAPI 3.0 спецификация (`cloud/api-gateway.yaml`) +- Маршрут `GET /api/vehicle/{id}` интегрирован с Cloud Function через расширение `x-yc-apigateway-integration: type: cloud_functions` +- CORS настроен на уровне Gateway (`x-yc-apigateway: cors`) +- Балансировка нагрузки при масштабировании осуществляется платформой автоматически + +### Брокер сообщений → Yandex Message Queue +- Очередь `vehicle-queue` создаётся через boto3 (SQS-совместимый API) +- Endpoint: `https://message-queue.api.cloud.yandex.net`, регион `ru-central1` +- Триггер `vehicle-mq-trigger` связывает очередь с Cloud Function файлового сервиса (batch size 10, окно 10 с) + +### Файловый сервис (`ProjectApp.FileService.Function`) → Cloud Function +- Проект `ProjectApp.FileService.Function` — Cloud Function с триггером на Message Queue +- Точка входа: `FileServiceFunction.Handler`, метод `FunctionHandler(string input) : string` +- Runtime: `dotnet8`, память: 256 МБ, таймаут: 60 с +- Деплой через архив с исходным кодом; зависимость `AWSSDK.S3` устанавливается YC при сборке +- Получает пакет сообщений из очереди, декодирует тело (base64 или plain) и сохраняет в Object Storage +- Конфигурация через переменные окружения: `S3_ENDPOINT`, `S3_BUCKET`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` + +### Объектное хранилище → Object Storage +- Бакет `vehicle-data-store` — хранит JSON-файлы транспортных средств (`vehicle-{id}.json`) +- Бакет создаётся через boto3 от имени сервисного аккаунта (владелец — `vehicle-sa`) +- Доступ через `AWSSDK.S3` (YC Object Storage совместим с S3 API), endpoint: `https://storage.yandexcloud.net` + + +## Ресурсы + +| Ресурс | Значение | +|--------|----------| +| API Gateway URL | `https://d5djaicgufnt5rijrt0u.p8361f8z.apigw.yandexcloud.net` | +| Клиент | `http://vehicles-client.website.yandexcloud.net` | +| Очередь | `vehicle-queue` | +| Бакет файлов | `vehicle-data-store` | +| Бакет клиента | `vehicles-client` | \ No newline at end of file diff --git a/cloud/api-gateway.yaml b/cloud/api-gateway.yaml new file mode 100644 index 00000000..1f0576a4 --- /dev/null +++ b/cloud/api-gateway.yaml @@ -0,0 +1,62 @@ +openapi: "3.0.0" +info: + title: Vehicle API Gateway + version: "1.0" + +x-yc-apigateway: + cors: + origin: '*' + methods: '*' + allowedHeaders: 'Content-Type,Authorization' + +paths: + /api/vehicle/{id}: + get: + summary: Получить транспортное средство по идентификатору + operationId: getVehicleById + parameters: + - name: id + in: path + required: true + schema: + type: integer + x-yc-apigateway-integration: + type: cloud_functions + function_id: ${FUNCTION_ID} + service_account_id: ${SERVICE_ACCOUNT_ID} + tag: "$latest" + responses: + '200': + description: Транспортное средство успешно получено + content: + application/json: + schema: + $ref: '#/components/schemas/Vehicle' + '400': + description: Некорректный идентификатор + +components: + schemas: + Vehicle: + type: object + properties: + id: + type: integer + vin: + type: string + brand: + type: string + model: + type: string + year: + type: integer + bodyType: + type: string + fuelType: + type: string + color: + type: string + mileage: + type: number + lastServiceDate: + type: string diff --git a/cloud/deploy.sh b/cloud/deploy.sh new file mode 100755 index 00000000..41b9149e --- /dev/null +++ b/cloud/deploy.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +source "$SCRIPT_DIR/env.sh" + +BUILD_DIR="$SCRIPT_DIR/build" +mkdir -p "$BUILD_DIR" + +log() { echo -e "\033[1;34m[INFO]\033[0m $*"; } +ok() { echo -e "\033[1;32m[OK]\033[0m $*"; } +err() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; } + +command -v yc >/dev/null 2>&1 || err "yc CLI не установлен. Запустите: curl -sSL https://storage.yandexcloud.net/yandexcloud-yc/install.sh | bash" +command -v dotnet >/dev/null 2>&1 || err "dotnet SDK не найден" +command -v zip >/dev/null 2>&1 || err "zip не найден" + +log "Используем каталог: $YC_FOLDER_ID" + +# 1. Сервисный аккаунт +log "Создание сервисного аккаунта $SA_NAME..." +SA_ID=$(yc iam service-account list --folder-id "$YC_FOLDER_ID" \ + --format json | python3 -c " +import json,sys +data=json.load(sys.stdin) +sa=[x for x in data if x['name']=='$SA_NAME'] +print(sa[0]['id'] if sa else '') +") + +if [ -z "$SA_ID" ]; then + SA_ID=$(yc iam service-account create \ + --name "$SA_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + ok "Сервисный аккаунт создан: $SA_ID" +else + ok "Сервисный аккаунт уже существует: $SA_ID" +fi + +for ROLE in storage.admin ymq.admin serverless.functions.invoker; do + yc resource-manager folder add-access-binding "$YC_FOLDER_ID" \ + --role "$ROLE" \ + --subject "serviceAccount:$SA_ID" \ + --quiet 2>/dev/null || true +done +ok "Роли назначены сервисному аккаунту" + +KEY_FILE="$BUILD_DIR/sa-key.json" +if [ ! -f "$KEY_FILE" ]; then + yc iam access-key create --service-account-id "$SA_ID" --folder-id "$YC_FOLDER_ID" \ + --format json > "$BUILD_DIR/sa-access-key-raw.json" + ACCESS_KEY_ID=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['access_key']['key_id'])") + ACCESS_KEY_SECRET=$(python3 -c "import json; d=json.load(open('$BUILD_DIR/sa-access-key-raw.json')); print(d['secret'])") + echo "{\"key_id\":\"$ACCESS_KEY_ID\",\"secret\":\"$ACCESS_KEY_SECRET\"}" > "$KEY_FILE" + ok "IAM ключ создан" +else + ACCESS_KEY_ID=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['key_id'])") + ACCESS_KEY_SECRET=$(python3 -c "import json; print(json.load(open('$KEY_FILE'))['secret'])") + ok "IAM ключ загружен из кэша" +fi + +# 2. Бакет для файлов транспортных средств +log "Создание бакета Object Storage: $STORAGE_BUCKET..." +python3 - </dev/null || ok "Бакет $CLIENT_BUCKET уже существует" + +yc storage bucket update --name "$CLIENT_BUCKET" \ + --website-settings '{"index":"index.html","error":"index.html"}' \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || true + +yc storage bucket set-https --name "$CLIENT_BUCKET" --folder-id "$YC_FOLDER_ID" 2>/dev/null || true +ok "Бакеты настроены" + +# 4. Message Queue +log "Создание Message Queue: $QUEUE_NAME..." +QUEUE_URL=$(python3 - </dev/null || ok "$API_FUNCTION_NAME уже существует" + +API_FUNCTION_ID=$(yc serverless function get "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + +yc serverless function version create \ + --function-name "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --runtime dotnet8 \ + --entrypoint "ApiFunction.Handler" \ + --memory 256m \ + --execution-timeout 30s \ + --source-path "$BUILD_DIR/api-function.zip" \ + --environment "SQS_ENDPOINT=https://message-queue.api.cloud.yandex.net" \ + --environment "SQS_QUEUE_URL=$QUEUE_URL" \ + --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \ + --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \ + --service-account-id "$SA_ID" + +yc serverless function allow-unauthenticated-invoke "$API_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || true + +ok "Cloud Function $API_FUNCTION_NAME развёрнута: $API_FUNCTION_ID" + +# 7. Архив исходного кода vehicle-file-service +log "Подготовка исходного кода $FILE_FUNCTION_NAME..." +FILE_SRC_DIR="$ROOT_DIR/ProjectApp.FileService.Function" +rm -f "$BUILD_DIR/file-function.zip" +cd "$FILE_SRC_DIR" +zip -r "$BUILD_DIR/file-function.zip" \ + Handler.cs \ + ProjectApp.FileService.Function.csproj \ + -q +cd "$ROOT_DIR" +ok "Архив создан: $BUILD_DIR/file-function.zip ($(du -sh "$BUILD_DIR/file-function.zip" | cut -f1))" + +# 8. Развёртывание Cloud Function vehicle-file-service +log "Развёртывание Cloud Function $FILE_FUNCTION_NAME..." +yc serverless function create --name "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" 2>/dev/null || ok "$FILE_FUNCTION_NAME уже существует" + +FILE_FUNCTION_ID=$(yc serverless function get "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + +yc serverless function version create \ + --function-name "$FILE_FUNCTION_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --runtime dotnet8 \ + --entrypoint "FileServiceFunction.Handler" \ + --memory 256m \ + --execution-timeout 60s \ + --source-path "$BUILD_DIR/file-function.zip" \ + --environment "S3_ENDPOINT=https://storage.yandexcloud.net" \ + --environment "S3_BUCKET=$STORAGE_BUCKET" \ + --environment "AWS_ACCESS_KEY_ID=$ACCESS_KEY_ID" \ + --environment "AWS_SECRET_ACCESS_KEY=$ACCESS_KEY_SECRET" \ + --service-account-id "$SA_ID" + +ok "Cloud Function $FILE_FUNCTION_NAME развёрнута: $FILE_FUNCTION_ID" + +# 9. Триггер Message Queue → vehicle-file-service +log "Создание триггера Message Queue → $FILE_FUNCTION_NAME..." +TRIGGER_EXISTS=$(yc serverless trigger list --folder-id "$YC_FOLDER_ID" --format json 2>/dev/null \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print('yes' if any(x.get('name')=='vehicle-mq-trigger' for x in d) else '')" 2>/dev/null || echo "") + +if [ -z "$TRIGGER_EXISTS" ]; then + yc serverless trigger create message-queue \ + --name "vehicle-mq-trigger" \ + --folder-id "$YC_FOLDER_ID" \ + --queue "$QUEUE_URL" \ + --queue-service-account-id "$SA_ID" \ + --invoke-function-id "$FILE_FUNCTION_ID" \ + --invoke-function-service-account-id "$SA_ID" \ + --batch-size 10 \ + --batch-cutoff 10s 2>&1 | grep -v "^$" || ok "Триггер создан с предупреждением" + ok "Триггер создан" +else + ok "Триггер уже существует" +fi + +# 10. API Gateway +log "Развёртывание API Gateway $API_GATEWAY_NAME..." +GATEWAY_SPEC="$BUILD_DIR/api-gateway-rendered.yaml" +sed -e "s|\${FUNCTION_ID}|$API_FUNCTION_ID|g" \ + -e "s|\${SERVICE_ACCOUNT_ID}|$SA_ID|g" \ + "$SCRIPT_DIR/api-gateway.yaml" > "$GATEWAY_SPEC" + +GATEWAY_ID=$(yc serverless api-gateway list --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c " +import json,sys +data=json.load(sys.stdin) +gw=[x for x in data if x['name']=='$API_GATEWAY_NAME'] +print(gw[0]['id'] if gw else '') +") + +if [ -z "$GATEWAY_ID" ]; then + GATEWAY_ID=$(yc serverless api-gateway create \ + --name "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --spec "$GATEWAY_SPEC" \ + --format json | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])") + ok "API Gateway создан: $GATEWAY_ID" +else + yc serverless api-gateway update "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" \ + --spec "$GATEWAY_SPEC" 2>/dev/null || true + ok "API Gateway обновлён: $GATEWAY_ID" +fi + +GATEWAY_DOMAIN=$(yc serverless api-gateway get "$API_GATEWAY_NAME" \ + --folder-id "$YC_FOLDER_ID" --format json \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('domain',''))") + +API_GATEWAY_URL="https://$GATEWAY_DOMAIN" +ok "API Gateway URL: $API_GATEWAY_URL" + +# 11. Сборка и деплой клиентского приложения +log "Сборка Blazor WASM клиента..." +CLIENT_SETTINGS="$ROOT_DIR/Client.Wasm/wwwroot/appsettings.Production.json" +cat > "$CLIENT_SETTINGS" < "$BUILD_DIR/deployment-info.json" <