feat: 初始化 Gateway 仓库
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 构建输出
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logs/
|
||||||
22
Configuration/GatewayOpenTelemetryOptions.cs
Normal file
22
Configuration/GatewayOpenTelemetryOptions.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace TakeoutSaaS.ApiGateway.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网关 OpenTelemetry 导出配置。
|
||||||
|
/// </summary>
|
||||||
|
public class GatewayOpenTelemetryOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用 OpenTelemetry。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 服务名称,用于 Resource 标识。
|
||||||
|
/// </summary>
|
||||||
|
public string ServiceName { get; set; } = "TakeoutSaaS.ApiGateway";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OTLP 导出端点(http/https)。
|
||||||
|
/// </summary>
|
||||||
|
public string? OtlpEndpoint { get; set; }
|
||||||
|
}
|
||||||
27
Configuration/GatewayRateLimitOptions.cs
Normal file
27
Configuration/GatewayRateLimitOptions.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace TakeoutSaaS.ApiGateway.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 网关限流参数配置。
|
||||||
|
/// </summary>
|
||||||
|
public class GatewayRateLimitOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否开启固定窗口限流。
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 固定窗口内允许的最大请求数。
|
||||||
|
/// </summary>
|
||||||
|
public int PermitLimit { get; set; } = 300;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 固定窗口长度(秒)。
|
||||||
|
/// </summary>
|
||||||
|
public int WindowSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 排队等待的最大请求数。
|
||||||
|
/// </summary>
|
||||||
|
public int QueueLimit { get; set; } = 100;
|
||||||
|
}
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||||
215
Program.cs
Normal file
215
Program.cs
Normal file
@@ -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<ForwardedHeadersOptions>(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<GatewayRateLimitOptions>(builder.Configuration.GetSection("Gateway:RateLimiting"));
|
||||||
|
var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get<GatewayRateLimitOptions>() ?? new();
|
||||||
|
|
||||||
|
if (rateLimitOptions.Enabled)
|
||||||
|
{
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(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<GatewayOpenTelemetryOptions>() ?? 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<IExceptionHandlerFeature>();
|
||||||
|
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<ILoggerFactory>().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();
|
||||||
12
Properties/launchSettings.json
Normal file
12
Properties/launchSettings.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"TakeoutSaaS.ApiGateway": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:2677;http://localhost:2683"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
README.md
Normal file
14
README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# TakeoutSaaS.Gateway
|
||||||
|
|
||||||
|
> 基于 YARP 的 API 网关,用于统一路由、CORS、限流与可观测性。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet run --project TakeoutSaaS.ApiGateway.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
- `appsettings.json`
|
||||||
|
- `appsettings.Development.json`
|
||||||
BIN
TakeoutSaaS.ApiGateway.csproj
Normal file
BIN
TakeoutSaaS.ApiGateway.csproj
Normal file
Binary file not shown.
38
appsettings.Development.json
Normal file
38
appsettings.Development.json
Normal file
@@ -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/" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
appsettings.json
Normal file
72
appsettings.json
Normal file
@@ -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": "*"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user