From 2b6402058dfddc3aed6d5ff8052dcd68cf4ee632 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 29 Jan 2026 05:25:02 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Gateway?= =?UTF-8?q?=20=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 ++ Configuration/GatewayOpenTelemetryOptions.cs | 22 ++ Configuration/GatewayRateLimitOptions.cs | 27 +++ Dockerfile | 25 +++ Program.cs | 215 +++++++++++++++++++ Properties/launchSettings.json | 12 ++ README.md | 14 ++ TakeoutSaaS.ApiGateway.csproj | Bin 0 -> 2130 bytes appsettings.Development.json | 38 ++++ appsettings.json | 72 +++++++ 10 files changed, 437 insertions(+) create mode 100644 .gitignore create mode 100644 Configuration/GatewayOpenTelemetryOptions.cs create mode 100644 Configuration/GatewayRateLimitOptions.cs create mode 100644 Dockerfile create mode 100644 Program.cs create mode 100644 Properties/launchSettings.json create mode 100644 README.md create mode 100644 TakeoutSaaS.ApiGateway.csproj create mode 100644 appsettings.Development.json create mode 100644 appsettings.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae37fd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 构建输出 +bin/ +obj/ + +# IDE +.vscode/ +.idea/ +*.user +*.suo + +# 日志 +logs/ diff --git a/Configuration/GatewayOpenTelemetryOptions.cs b/Configuration/GatewayOpenTelemetryOptions.cs new file mode 100644 index 0000000..97064fb --- /dev/null +++ b/Configuration/GatewayOpenTelemetryOptions.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.ApiGateway.Configuration; + +/// +/// 网关 OpenTelemetry 导出配置。 +/// +public class GatewayOpenTelemetryOptions +{ + /// + /// 是否启用 OpenTelemetry。 + /// + public bool Enabled { get; set; } = true; + + /// + /// 服务名称,用于 Resource 标识。 + /// + public string ServiceName { get; set; } = "TakeoutSaaS.ApiGateway"; + + /// + /// OTLP 导出端点(http/https)。 + /// + public string? OtlpEndpoint { get; set; } +} diff --git a/Configuration/GatewayRateLimitOptions.cs b/Configuration/GatewayRateLimitOptions.cs new file mode 100644 index 0000000..cf633a4 --- /dev/null +++ b/Configuration/GatewayRateLimitOptions.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.ApiGateway.Configuration; + +/// +/// 网关限流参数配置。 +/// +public class GatewayRateLimitOptions +{ + /// + /// 是否开启固定窗口限流。 + /// + public bool Enabled { get; set; } = true; + + /// + /// 固定窗口内允许的最大请求数。 + /// + public int PermitLimit { get; set; } = 300; + + /// + /// 固定窗口长度(秒)。 + /// + public int WindowSeconds { get; set; } = 60; + + /// + /// 排队等待的最大请求数。 + /// + public int QueueLimit { get; set; } = 100; +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..beac7d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# 1. 先只复制 csproj 文件 (利用 Docker 缓存) +COPY ["src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj", "src/Gateway/TakeoutSaaS.ApiGateway/"] + +# 2. 还原依赖 (如果 csproj 没变,这一步会直接使用缓存,瞬间完成) +RUN dotnet restore "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj" + +# 3. 再复制剩余的所有源代码 +COPY . . + +# 4. 发布 +RUN dotnet publish "src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj" -c Release -o /app/publish + +# --- 运行时环境 --- +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/publish . + +# 显式声明端口 +EXPOSE 5000 +ENV ASPNETCORE_URLS=http://+:5000 + +ENTRYPOINT ["dotnet", "TakeoutSaaS.ApiGateway.dll"] \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..6011bd9 --- /dev/null +++ b/Program.cs @@ -0,0 +1,215 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.HttpOverrides; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Serilog; +using System.Diagnostics; +using System.Threading.RateLimiting; +using TakeoutSaaS.ApiGateway.Configuration; + +const string CorsPolicyName = "GatewayCors"; + +// 1. 创建构建器并配置 Serilog +var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSerilog((context, services, loggerConfiguration) => +{ + loggerConfiguration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext(); +}); + +// 2. 配置 YARP 反向代理 +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +// 3. 转发头部配置 +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); +}); + +// 4. 配置 CORS +builder.Services.AddCors(options => +{ + options.AddPolicy(CorsPolicyName, policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + +// 5. 配置网关限流 +builder.Services.Configure(builder.Configuration.GetSection("Gateway:RateLimiting")); +var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get() ?? new(); + +if (rateLimitOptions.Enabled) +{ + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var remoteIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(remoteIp, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = Math.Max(1, rateLimitOptions.PermitLimit), + Window = TimeSpan.FromSeconds(Math.Max(1, rateLimitOptions.WindowSeconds)), + QueueLimit = Math.Max(0, rateLimitOptions.QueueLimit), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + }); + }); +} + +// 6. 配置 OpenTelemetry +var otelOptions = builder.Configuration.GetSection("OpenTelemetry").Get() ?? new(); +if (otelOptions.Enabled) +{ + builder.Services.AddOpenTelemetry() + // 1. 配置统一的 Resource,便于追踪定位。 + .ConfigureResource(resource => resource.AddService(otelOptions.ServiceName ?? "TakeoutSaaS.ApiGateway")) + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + + if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint)) + { + metrics.AddOtlpExporter(options => + { + options.Endpoint = new Uri(otelOptions.OtlpEndpoint); + }); + } + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + }) + .AddHttpClientInstrumentation(); + + if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint)) + { + tracing.AddOtlpExporter(options => + { + options.Endpoint = new Uri(otelOptions.OtlpEndpoint); + }); + } + }); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeScopes = true; + logging.ParseStateValues = true; + if (!string.IsNullOrWhiteSpace(otelOptions.OtlpEndpoint)) + { + logging.AddOtlpExporter(options => + { + options.Endpoint = new Uri(otelOptions.OtlpEndpoint); + }); + } + }); +} + +// 7. 构建应用 +var app = builder.Build(); + +// 8. 转发头中间件 +app.UseForwardedHeaders(); + +// 9. 全局异常处理中间件 +app.UseExceptionHandler(errorApp => +{ + // 1. 捕获所有未处理异常并返回统一结构。 + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var traceId = Activity.Current?.Id ?? context.TraceIdentifier; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var payload = new + { + success = false, + code = 500, + message = "Gateway internal error", + traceId + }; + + var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway"); + logger.LogError(feature?.Error, "网关异常 {TraceId}", traceId); + await context.Response.WriteAsJsonAsync(payload, cancellationToken: context.RequestAborted); + }); +}); + +// 10. 请求日志 +app.UseSerilogRequestLogging(options => +{ + options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒"; + options.GetLevel = (httpContext, elapsed, ex) => ex is not null ? Serilog.Events.LogEventLevel.Error : Serilog.Events.LogEventLevel.Information; + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("TraceId", Activity.Current?.Id ?? httpContext.TraceIdentifier); + diagnosticContext.Set("ClientIp", httpContext.Connection.RemoteIpAddress?.ToString()); + }; +}); + +// 11. CORS 与限流 +app.UseCors(CorsPolicyName); + +if (rateLimitOptions.Enabled) +{ + app.UseRateLimiter(); +} + +// 12. 透传请求头并保证 Trace +app.Use(async (context, next) => +{ + // 1. 确保请求拥有可追踪的 ID。 + if (!context.Request.Headers.ContainsKey("X-Request-Id")) + { + context.Request.Headers["X-Request-Id"] = Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); + } + + // 2. 透传租户等标识头,方便下游继续使用。 + var tenantId = context.Request.Headers["X-Tenant-Id"]; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + context.Request.Headers["X-Tenant-Id"] = tenantId; + } + + var tenantCode = context.Request.Headers["X-Tenant-Code"]; + if (!string.IsNullOrWhiteSpace(tenantCode)) + { + context.Request.Headers["X-Tenant-Code"] = tenantCode; + } + + await next(context); +}); + +// 13. 映射反向代理与健康接口 +app.MapReverseProxy(); + +app.MapGet("/", () => Results.Json(new +{ + Service = "TakeoutSaaS.ApiGateway", + Status = "OK", + Timestamp = DateTimeOffset.UtcNow +})); + +app.MapGet("/healthz", () => Results.Json(new +{ + Service = "TakeoutSaaS.ApiGateway", + Status = "Healthy", + Timestamp = DateTimeOffset.UtcNow +})); + +app.Run(); diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..3956499 --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TakeoutSaaS.ApiGateway": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:2677;http://localhost:2683" + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dafb38 --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# TakeoutSaaS.Gateway + +> 基于 YARP 的 API 网关,用于统一路由、CORS、限流与可观测性。 + +## 本地运行 + +```bash +dotnet run --project TakeoutSaaS.ApiGateway.csproj +``` + +## 配置 + +- `appsettings.json` +- `appsettings.Development.json` diff --git a/TakeoutSaaS.ApiGateway.csproj b/TakeoutSaaS.ApiGateway.csproj new file mode 100644 index 0000000000000000000000000000000000000000..207e70312c191ac92f77e62c7f21fdb09c6e57e8 GIT binary patch literal 2130 zcmdUwOHbQS5QWbgiT_}+#}JBGg`ldUg+&38K-Fb%9F!6q%XWDD`Ic|yVn_*bRVh?- z;paT=nKQ3nKik^Vovt-jq#LCwRBEJ&GVN+jEydd886nA(@PB|b)1^zNsx;I(T07|M zsiU?|@G#L2^b@3!x>#Jf_E#(umzHP@k;XdGzIr@^R;+K>UEr&uRjpthJ$cL>Tcvw_ zaUWCVGg?8jr#`YvG%J1PtxsI3ZaJ+0LkHX@X%Ue{)z$`QH(S&wg8w5;;c1M`1@{z> z#!CHV{^m7)_izZJ99v^z1eP;r+xRSB@z$(ed$w}}>&+asb;yY+2o^JVLNp0n^_|62 zw1-@U$C+@C@L_c@hKT{+RwKtmWIXh^8!N_nh0Q%I?^9d3^KT~?{8_9WVyMuzIvab{ zox9b`ifn1yGiY_WF;@j|5rd)6o;suC^aC)K#2LE5kh}-1mK{S?kRQuK#(UpUT1UtF zh%~24EkmC?FX_AC`A^snz+8d;f8qL+92oy2&&0n(-Sw^+(L)pZ`0uhFW@A0|m(yJ@ z@pQdwuBgS>tI%rJI(|_t?x1IzsO7m|4yNz)ML{*ivME<@zVvaD-Z8yi&)60gAGpF+ zoW){uxM{*JXa1gndEh-Arf(Od*6}uXW6nS3K05)M=Tn{Sd1jlUdEPkno)7!lHqry3 ChFZn| literal 0 HcmV?d00001 diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..56dd41a --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,38 @@ +{ + "OpenTelemetry": { + "Enabled": false + }, + "ReverseProxy": { + "Routes": { + "admin-route": { + "ClusterId": "admin", + "Match": { "Path": "/api/admin/{**catch-all}" } + }, + "mini-route": { + "ClusterId": "mini", + "Match": { "Path": "/api/mini/{**catch-all}" } + }, + "user-route": { + "ClusterId": "user", + "Match": { "Path": "/api/user/{**catch-all}" } + } + }, + "Clusters": { + "admin": { + "Destinations": { + "d1": { "Address": "http://localhost:5001/" } + } + }, + "mini": { + "Destinations": { + "d1": { "Address": "http://localhost:5002/" } + } + }, + "user": { + "Destinations": { + "d1": { "Address": "http://localhost:5003/" } + } + } + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..0c5d718 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,72 @@ +{ + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning", + "Yarp": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext" ], + "Properties": { + "Application": "TakeoutSaaS.ApiGateway" + } + }, + "Gateway": { + "RateLimiting": { + "Enabled": true, + "PermitLimit": 300, + "WindowSeconds": 60, + "QueueLimit": 100 + } + }, + "OpenTelemetry": { + "Enabled": true, + "ServiceName": "TakeoutSaaS.ApiGateway", + "OtlpEndpoint": "http://localhost:4317" + }, + "ReverseProxy": { + "Routes": { + "admin-route": { + "ClusterId": "admin", + "Match": { "Path": "/api/admin/{**catch-all}" } + }, + "mini-route": { + "ClusterId": "mini", + "Match": { "Path": "/api/mini/{**catch-all}" } + }, + "user-route": { + "ClusterId": "user", + "Match": { "Path": "/api/user/{**catch-all}" } + } + }, + "Clusters": { + "admin": { + "Destinations": { + "primary": { "Address": "http://49.7.179.246:7801/" } + } + }, + "mini": { + "Destinations": { + "primary": { "Address": "http://49.7.179.246:7701/" } + } + }, + "user": { + "Destinations": { + "primary": { "Address": "http://49.7.179.246:7901/" } + } + } + } + }, + "AllowedHosts": "*" +}