团队接手了一个棘手的局面:一个稳定运行多年的 Ruby on Rails 单体应用,承载着核心业务逻辑,服务于一个 Objective-C 写成的老旧 iOS 客户端。现在,业务要求快速迭代,我们需要开发一个全新的 Angular 管理后台,并计划将新功能以微服务形式部署到 Azure Kubernetes Service (AKS) 上。同时,一些异步的、事件驱动的任务,例如图像处理和通知推送,最适合用 Azure Functions 这类 Serverless 方案来处理。
问题随之而来:我们即将拥有三个截然不同的后端服务形态:
- 遗留 Rails 单体: 运行在虚拟机上,使用传统的 Session + Cookie 认证。
- AKS 上的新微服务: 基于 Go 或 .NET 构建,计划采用 JWT 进行无状态认证。
- Azure Functions (Serverless): 用于处理特定事件,需要轻量级的 API Key 或 JWT 认证。
如果放任自流,Angular 和新版 iOS 客户端(未来计划用 Swift 重构)将需要直接与这三个不同的后端打交道。这意味着客户端需要管理多个 API endpoint、处理多种认证机制、适配风格迥异的数据契约。这不仅会急剧增加客户端的开发复杂性,更是一场运维和安全的噩梦。
方案 A:为每个前端构建专用的后端 (BFF)
一个直接的思路是采用 Backend For Frontend (BFF) 模式。我们可以为 Angular Web 应用构建一个 BFF,再为 iOS 客户端构建另一个。
graph TD
subgraph "前端应用"
A[Angular Web App]
B[iOS App]
end
subgraph "BFF 层"
BFF_Web[Web BFF on AKS]
BFF_iOS[iOS BFF on AKS]
end
subgraph "后端服务"
Rails[Legacy Rails Monolith]
Microservices[New Microservices on AKS]
Functions[Azure Functions]
end
A --> BFF_Web
B --> BFF_iOS
BFF_Web --> Rails
BFF_Web --> Microservices
BFF_Web --> Functions
BFF_iOS --> Rails
BFF_iOS --> Microservices
BFF_iOS --> Functions
优势:
- API 裁剪: 每个 BFF 可以为其前端提供量身定制的 API,聚合多个下游服务的数据,减少客户端的网络请求次数。
- 技术隔离: Web BFF 团队可以使用 Node.js,而 iOS BFF 团队可以选择 Swift Vapor,技术栈灵活。
- 关注点分离: BFF 将前端适配逻辑与核心业务逻辑解耦。
劣势:
- 逻辑冗余: 认证、授权、限流等通用逻辑很可能在两个 BFF 中被重复实现。在真实项目中,这种重复很快就会导致不一致和维护困难。
- 运维成本: 我们需要维护和部署两个(或更多)额外的服务,增加了 CI/CD 流水线和可观测性系统的复杂度。
- “胶水代码”泛滥: BFF 很容易退化成一个脆弱的代理层,充斥着大量数据转换的“胶水代码”,缺乏核心业务价值。
对于我们这个规模和阶段的团队来说,引入 BFF 模式会显著增加人力和运维开销。逻辑冗余的风险尤其致命,一个常见的错误是在不同 BFF 中对同一业务规则做出不同解读,最终导致数据不一致。
方案 B:统一 API 网关
另一个方案是在所有客户端和后端服务之间插入一个统一的 API 网关。这个网关将成为所有流量的唯一入口,负责处理路由、认证、安全策略和协议转换等横切关注点。
graph TD
subgraph "前端应用"
A[Angular Web App]
B[iOS App]
end
subgraph "统一入口"
Gateway[Azure API Management]
end
subgraph "后端服务"
Rails[Legacy Rails Monolith]
Microservices[New Microservices on AKS]
Functions[Azure Functions]
end
A --> Gateway
B --> Gateway
Gateway -- Path-based Routing --> Rails
Gateway -- Path-based Routing --> Microservices
Gateway -- Path-based Routing --> Functions
优势:
- 单一入口: 客户端只需与一个域名交互,简化了配置和网络策略。
- 集中化管理: 认证、授权、限流、熔断、日志记录等策略可以在网关层统一配置和实施,避免在每个后端服务中重复开发。
- 解耦: 后端服务的地址、协议和技术栈对客户端透明。我们可以重构或替换后端服务,而无需修改客户端代码。
- 安全性: 网关提供了一个额外的安全边界,可以抵御常见的 Web 攻击,隐藏后端服务的网络拓扑。
劣势:
- 潜在的单点故障: 如果网关宕机,整个系统将不可用。这要求网关本身必须是高可用的。
- 性能瓶颈: 所有流量都经过网关,其性能和延迟至关重要。
- 配置复杂性: 随着后端服务的增多,网关的路由和策略配置可能会变得非常复杂,成为管理的难点。
经过权衡,我们选择了方案 B。在当前阶段,集中化管理带来的运维效率和安全性提升,远大于其潜在风险。Azure API Management (APIM) 提供了高可用性保障和强大的策略引擎,可以很好地 mitigare 方案 B 的劣势。这里的关键在于,要将网关严格限定在处理横切关注点上,严禁在其中嵌入任何业务逻辑。
核心实现:使用 Azure API Management 统一流量与认证
我们的目标是实现一个无缝的体验:无论请求最终被路由到 Rails、AKS 还是 Function,都使用同一套 JWT 认证方案,并由 APIM 强制执行。
1. 定义路由策略
我们约定了基于 URL 路径的路由规则:
-
/api/v1/*-> 遗留 Rails 单体 -
/api/v2/*-> AKS 上的新微服务 -
/events/-> 用于异步任务的 Azure Function
在 Azure APIM 中,这通过策略(Policy)XML 来定义。
<!--
APIM Global Policy
This policy applies to all incoming requests to the API gateway.
-->
<policies>
<inbound>
<base />
<!-- Step 1: CORS Policy for Angular App -->
<cors allow-credentials="true">
<allowed-origins>
<origin>https://your-angular-app.domain.com</origin>
</allowed-origins>
<allowed-methods>
<method>GET</method>
<method>POST</method>
<method>PUT</method>
<method>DELETE</method>
<method>OPTIONS</method>
</allowed-methods>
<allowed-headers>
<header>*</header>
</allowed-headers>
</cors>
<!-- Step 2: Centralized JWT Validation -->
<!-- This is the core of our security model. -->
<!-- It validates the token before it reaches any backend service. -->
<validate-jwt header-name="Authorization" failed-validation-httpcode="401" failed-validation-error-message="Unauthorized. Access token is missing or invalid.">
<!-- Obtain the OpenID Connect configuration from your identity provider (e.g., Azure AD B2C) -->
<openid-config url="https://{your-b2c-tenant-name}.b2clogin.com/{your-b2c-tenant-name}.onmicrosoft.com/{your-policy-name}/v2.0/.well-known/openid-configuration" />
<audiences>
<!-- The audience claim must match your API's client ID -->
<audience>{your-api-client-id}</audience>
</audiences>
<issuers>
<!-- The issuer claim must match your identity provider's issuer URL -->
<issuer>https://{your-b2c-tenant-name}.b2clogin.com/{guid}/v2.0/</issuer>
</issuers>
<required-claims>
<!-- Ensure critical claims exist in the token -->
<claim name="sub" match="any" />
</required-claims>
</validate-jwt>
<!-- Step 3: Dynamic Routing Logic -->
<choose>
<!-- Route to new AKS microservices -->
<when condition="@(context.Request.Url.Path.StartsWith("/api/v2"))">
<!-- Rewrite URL to remove the version prefix before forwarding -->
<rewrite-uri template="@(context.Request.Url.Path.Substring(7))" />
<!-- Set the backend service to the Kubernetes cluster -->
<set-backend-service backend-id="aks-backend-pool" />
</when>
<!-- Route to async Serverless functions -->
<when condition="@(context.Request.Url.Path.StartsWith("/events"))">
<set-backend-service backend-id="azure-functions-backend" />
</when>
<!-- Default route to the legacy Rails monolith -->
<otherwise>
<!-- The monolith's API is not versioned, so we forward as is -->
<set-backend-service backend-id="rails-monolith-vm" />
</otherwise>
</choose>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
</outbound>
<on-error>
<base />
</on-error>
</policies>
这段策略代码是整个架构的核心。它清晰地定义了请求处理流水线:
- CORS: 首先处理跨域请求,这是 Angular 应用必需的。
- JWT 验证: 这是最关键的一步。它使用 OpenID Connect 元数据自动获取公钥,验证
Authorization头中 JWT 的签名、颁发者 (issuer)、受众 (audience) 和必要的声明 (claims)。任何无效的请求都会在这里被拒绝,返回401 Unauthorized,根本不会到达后端服务。这里的坑在于,openid-config的 URL 必须是公网可访问的,并且要确保audience和issuer的值与你的身份提供商(如 Azure AD B2C)配置完全一致。 - 动态路由:
<choose>语句根据 URL 路径将请求转发到正确的后端。rewrite-uri是一个实用操作,它能剥离掉客户端看到的路径前缀(如/api/v2),让后端服务接收到更干净的 URL,无需关心上游的路由逻辑。
2. 后端服务适配
在网关完成了统一认证后,后端服务的实现就变得极其简单:它们只需要信任从网关转发过来的请求,并从请求头中获取用户信息。
AKS 微服务 (示例: Go with Gin)
这个服务运行在 AKS 中,它假定请求头中已经包含了验证过的用户信息。
package main
import (
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
// In a real project, APIM would pass the validated user's 'sub' claim (user ID)
// in a custom header like 'X-Authenticated-User-Id'.
// This is configured in the APIM policy using <set-header>.
const UserIdHeader = "X-Authenticated-User-Id"
func main() {
router := gin.Default()
// Health check endpoint for Kubernetes liveness/readiness probes
router.GET("/healthz", func(c *gin.Context) {
c.Status(http.StatusOK)
})
v2 := router.Group("/products")
{
v2.GET("", getProducts)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Starting server on port %s", port)
if err := router.Run(":" + port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
func getProducts(c *gin.Context) {
// The service *trusts* the gateway. It doesn't re-validate the JWT.
// It simply consumes the user identity passed in the header.
userID := c.GetHeader(UserIdHeader)
if userID == "" {
// This should theoretically never happen if APIM is configured correctly.
// Log this as a security anomaly.
log.Printf("Anomaly: Request reached service without %s header", UserIdHeader)
c.JSON(http.StatusForbidden, gin.H{"error": "User identity not provided by gateway"})
return
}
log.Printf("Fetching products for user: %s", userID)
// ... Database logic to fetch products specific to the user ...
c.JSON(http.StatusOK, gin.H{
"products": []map[string]interface{}{
{"id": "prod_123", "name": "Modern Widget"},
{"id": "prod_456", "name": "Advanced Gadget"},
},
"retrieved_for_user": userID,
})
}
在 APIM 中,我们需要添加一个策略来设置这个 X-Authenticated-User-Id 头:
<set-header name="X-Authenticated-User-Id" exists-action="override">
<value>@(context.User.Id)</value>
</set-header>
这行代码需要加在 <validate-jwt> 之后,它会从已验证的 JWT 上下文中提取用户 ID (通常是 sub 声明),并将其放入一个可靠的请求头中。
遗留 Rails 单体应用
对于 Rails 应用,我们不能直接修改其核心认证逻辑。但我们可以写一个简单的中间件 (Middleware) 来适配来自网关的请求。
# In config/application.rb
# config.middleware.insert_before 0, "Rack::Cors" do ... end
# config.middleware.use GatewayAuthMiddleware
# lib/middleware/gateway_auth_middleware.rb
class GatewayAuthMiddleware
def initialize(app)
@app = app
end
def call(env)
# The gateway forwards the validated user ID in a specific header.
user_id_from_gateway = env['HTTP_X_AUTHENTICATED_USER_ID']
if user_id_from_gateway.present?
# In a real application, you would lookup the user from the database.
# This is a simplified example.
user = User.find_by(id: user_id_from_gateway.to_i)
if user
# This is the crucial part: we are injecting the user object
# into the request context, simulating a successful session-based login.
# The rest of the Rails application (e.g., Pundit for authorization)
# can now work without any changes.
env['warden'].set_user(user, scope: :user)
else
# This case indicates a problem - a valid user ID from the gateway
# does not exist in our database. This should be logged as a critical error.
Rails.logger.warn "Gateway provided a valid user ID (#{user_id_from_gateway}) that was not found in the database."
end
end
@app.call(env)
end
end
这个中间件的思路非常务实:它检查是否存在网关添加的特殊 Header。如果存在,它就“伪造”一个登录会话,让 Rails 的 current_user 和其他认证相关的辅助方法能够正常工作。这样,我们就不需要对成百上千行业务控制器进行侵入式修改。
Azure Function
Azure Function 的适配同样简单。
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
public static class ImageUploadProcessor
{
[FunctionName("ProcessImageUpload")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "events/process-upload")] HttpRequest req,
ILogger log)
{
// IMPORTANT: AuthorizationLevel is 'Anonymous' because APIM is handling auth.
// We must configure the Function App's networking to only accept traffic from the APIM instance's IP.
// Trust the header passed by the gateway.
if (!req.Headers.TryGetValue("X-Authenticated-User-Id", out var userIdValues))
{
log.LogWarning("Request received without X-Authenticated-User-Id header.");
return new UnauthorizedResult();
}
var userId = userIdValues.FirstOrDefault();
log.LogInformation($"Processing image upload event for user: {userId}");
// ... logic to process the image from the request body ...
// For example, save to blob storage with metadata linking to the user.
string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
dynamic data = JsonConvert.DeserializeObject(requestBody);
string imageName = data?.imageName;
return new OkObjectResult($"Image '{imageName}' for user '{userId}' is being processed.");
}
}
这里的关键点在于,HttpTrigger 的 AuthorizationLevel 被设置为 Anonymous。这看起来不安全,但实际上安全性是由两层保障的:
- APIM: 只有携带有效 JWT 的请求才能通过网关。
- 网络层: 在 Azure 中配置网络规则,确保这个 Function App 只接受来自 APIM 实例虚拟 IP 的入站流量。任何绕过网关直接访问 Function URL 的尝试都会在网络层面被拒绝。
架构的局限性与未来演进
这个基于统一 API 网关的架构并非银弹。在真实项目中,我们需要警惕几个陷阱。
首先,APIM 网关本身可能会成为性能瓶颈或一个复杂的“配置泥潭”。随着 API 数量和策略复杂度的增加,管理这些 XML 配置会变得困难。必须建立严格的 Code Review 和自动化测试流程来管理这些策略的变更,将其视为“基础设施即代码”的一部分。
其次,过度依赖网关进行逻辑编排,比如在策略中调用多个后端并合并结果,会让网关变成一个事实上的“业务逻辑黑盒”,难以调试和维护。网关的职责应严格限制在路由、认证、限流等横切关注点上。
最后,本地开发体验会受到影响。开发者在本地运行一个微服务时,无法直接复现经过网关处理后的请求环境(如注入的 Headers)。这需要通过工具模拟或提供一个轻量级的本地网关/代理来解决。
未来的演进方向可以考虑:当 AKS 内部的微服务数量和交互变得极其复杂时,可以在 AKS 集群内部引入服务网格(如 Linkerd 或 Istio),用于处理服务间的 mTLS、重试、精细化流量控制等。此时,APIM 仍然作为外部流量的入口和安全边界,而服务网格则负责集群内部的“东西向”流量管理,二者形成互补。