构建服务于 iOS 与 Angular 的统一 API 网关 整合遗留 Rails 单体与 Azure AKS 微服务


团队接手了一个棘手的局面:一个稳定运行多年的 Ruby on Rails 单体应用,承载着核心业务逻辑,服务于一个 Objective-C 写成的老旧 iOS 客户端。现在,业务要求快速迭代,我们需要开发一个全新的 Angular 管理后台,并计划将新功能以微服务形式部署到 Azure Kubernetes Service (AKS) 上。同时,一些异步的、事件驱动的任务,例如图像处理和通知推送,最适合用 Azure Functions 这类 Serverless 方案来处理。

问题随之而来:我们即将拥有三个截然不同的后端服务形态:

  1. 遗留 Rails 单体: 运行在虚拟机上,使用传统的 Session + Cookie 认证。
  2. AKS 上的新微服务: 基于 Go 或 .NET 构建,计划采用 JWT 进行无状态认证。
  3. 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>

这段策略代码是整个架构的核心。它清晰地定义了请求处理流水线:

  1. CORS: 首先处理跨域请求,这是 Angular 应用必需的。
  2. JWT 验证: 这是最关键的一步。它使用 OpenID Connect 元数据自动获取公钥,验证 Authorization 头中 JWT 的签名、颁发者 (issuer)、受众 (audience) 和必要的声明 (claims)。任何无效的请求都会在这里被拒绝,返回 401 Unauthorized,根本不会到达后端服务。这里的坑在于,openid-config 的 URL 必须是公网可访问的,并且要确保 audienceissuer 的值与你的身份提供商(如 Azure AD B2C)配置完全一致。
  3. 动态路由: <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.");
    }
}

这里的关键点在于,HttpTriggerAuthorizationLevel 被设置为 Anonymous。这看起来不安全,但实际上安全性是由两层保障的:

  1. APIM: 只有携带有效 JWT 的请求才能通过网关。
  2. 网络层: 在 Azure 中配置网络规则,确保这个 Function App 只接受来自 APIM 实例虚拟 IP 的入站流量。任何绕过网关直接访问 Function URL 的尝试都会在网络层面被拒绝。

架构的局限性与未来演进

这个基于统一 API 网关的架构并非银弹。在真实项目中,我们需要警惕几个陷阱。

首先,APIM 网关本身可能会成为性能瓶颈或一个复杂的“配置泥潭”。随着 API 数量和策略复杂度的增加,管理这些 XML 配置会变得困难。必须建立严格的 Code Review 和自动化测试流程来管理这些策略的变更,将其视为“基础设施即代码”的一部分。

其次,过度依赖网关进行逻辑编排,比如在策略中调用多个后端并合并结果,会让网关变成一个事实上的“业务逻辑黑盒”,难以调试和维护。网关的职责应严格限制在路由、认证、限流等横切关注点上。

最后,本地开发体验会受到影响。开发者在本地运行一个微服务时,无法直接复现经过网关处理后的请求环境(如注入的 Headers)。这需要通过工具模拟或提供一个轻量级的本地网关/代理来解决。

未来的演进方向可以考虑:当 AKS 内部的微服务数量和交互变得极其复杂时,可以在 AKS 集群内部引入服务网格(如 Linkerd 或 Istio),用于处理服务间的 mTLS、重试、精细化流量控制等。此时,APIM 仍然作为外部流量的入口和安全边界,而服务网格则负责集群内部的“东西向”流量管理,二者形成互补。


  目录