为 Ruby on Rails 单体应用构建基于 Vercel Functions 的 BFF 绞杀层


我们的 Rails 单体应用已经稳定运行了五年,它庞大、可靠,但也日益成为前端团队创新的瓶颈。前端技术栈早已迁移到基于 React 和 Styled-components 的组件化体系,而这个体系渴望的是精炼、聚合、为其量身定制的 API 数据结构。然而,Rails 这边提供的依然是 RESTful 风格的、富含各种嵌套关联的“数据富矿”,前端不得不在客户端进行大量的数据裁剪和重组。每次前端组件的小幅调整,都可能引发一场与后端团队关于 API 修改的漫长协调。这个摩擦成本,正在拖慢整个产品的迭代速度。

初步构想是,与其直接在庞大的 Rails 代码库里“开刀”新增大量视图驱动的 API,不如在前端和后端之间插入一个灵活的薄层。这个薄层,即“后端为前端”(Backend-for-Frontend, BFF),将专门负责数据聚合与裁剪。技术选型上,Vercel Functions 成为首选,因为它与我们托管在 Vercel 上的 Next.js 前端应用天然共存,能实现极低的调用延迟,并且享受 Serverless 带来的弹性伸缩和零运维心智负担。这本质上是绞杀者模式(Strangler Fig Pattern)的一种实践:用新的 Vercel Functions 逐步包裹、替代旧单体的 API 接口,最终让旧接口自然凋亡。

第一个试点改造目标是一个用户个人资料页面。该页面需要展示用户信息、最近发布的文章列表以及用户的统计数据(如文章总数、评论数)。在旧架构中,这需要前端发起至少三次独立的 API 请求到 Rails 后端,分别获取 /users/:id/users/:id/articles/users/:id/stats。我们的目标是创建一个单一的 Vercel Function /api/profile,由它来代理这三个请求,并返回一个为页面组件精确设计的 JSON 结构。

架构流程设计

在动手之前,清晰的架构图是必要的。整个请求的生命周期将遵循以下路径:

sequenceDiagram
    participant Client as 浏览器 (React + Styled-components)
    participant Vercel as Vercel Edge Network
    participant BFF as Vercel Function (/api/profile)
    participant Rails as Rails 单体应用 (内部 API)

    Client->>Vercel: GET /profile/123 (携带用户 JWT)
    Vercel->>BFF: 触发函数执行, 传递请求
    BFF->>BFF: 1. 验证用户 JWT 的有效性
    BFF-->>Rails: GET /internal/v1/users/123 (携带内部 API Key)
    Rails-->>BFF: 返回用户信息
    BFF-->>Rails: GET /internal/v1/users/123/articles?limit=5 (携带内部 API Key)
    Rails-->>BFF: 返回文章列表
    BFF-->>Rails: GET /internal/v1/users/123/stats (携带内部 API Key)
    Rails-->>BFF: 返回统计数据
    BFF->>BFF: 2. 聚合与转换数据
    BFF-->>Client: 3. 返回为前端优化的单一 JSON 响应

这里的关键决策点是认证机制。客户端通过 JWT (JSON Web Token) 向 BFF 发起请求,证明用户身份。然而,BFF 调用 Rails 内部 API 时,不应直接透传用户 JWT。这会使 Rails 的认证逻辑变得复杂,需要同时处理面向公网的用户认证和面向内部服务的服务认证。更稳妥的方案是,BFF 使用一个独立的、高权限的 API Key 来与 Rails 通信。这个 Key 作为环境变量存储在 Vercel 中,Rails 则需要开辟一个专用的、受此 Key 保护的内部 API 命名空间(如 /internal/v1)。

Rails 内部 API 改造

首先,我们需要对 Rails 应用进行改造,使其能够响应 BFF 的内部调用。这涉及到两部分:一个用于验证 API Key 的 Concern,以及专门提供原始数据的内部控制器。

app/controllers/concerns/internal_api_auth.rb 文件中,我们定义认证逻辑:

# app/controllers/concerns/internal_api_auth.rb

module InternalApiAuth
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_internal_request!
  end

  private

  def authenticate_internal_request!
    # 从环境变量中获取预设的内部 API Key
    # 在生产环境中,这应通过 Rails secrets 或其他安全的配置管理方式注入
    expected_key = ENV['INTERNAL_API_KEY']

    # 简单的防御性编程,确保 Key 已被配置
    if expected_key.blank?
      Rails.logger.error("FATAL: INTERNAL_API_KEY is not configured.")
      render json: { error: 'Internal server configuration error' }, status: :internal_server_error
      return
    end

    # 从请求头中获取传入的 Key
    provided_key = request.headers['X-Internal-API-Key']

    # 使用 ActiveSupport::SecurityUtils.secure_compare 进行固定时间复杂度的比较
    # 以防止时序攻击 (Timing Attacks)
    unless ActiveSupport::SecurityUtils.secure_compare(provided_key.to_s, expected_key)
      # 记录未授权的访问尝试,但返回一个模糊的错误信息
      Rails.logger.warn("Unauthorized internal API access attempt from IP: #{request.remote_ip}")
      render json: { error: 'Unauthorized' }, status: :unauthorized
    end
  end
end

这里的核心是使用 secure_compare,这是一个常见的安全实践,可以避免因字符串比较时间不同而泄露信息的时序攻击漏洞。

接下来,我们创建一个基控制器 Api::Internal::BaseController 来应用这个 Concern,并关闭 Rails 默认的 CSRF 保护,因为它不适用于 API 场景。

# app/controllers/api/internal/base_controller.rb

module Api
  module Internal
    # 所有内部API控制器的基类
    class BaseController < ActionController::API
      # 引入认证逻辑
      include InternalApiAuth

      # 在这里可以添加通用的错误处理,例如 Rescue_from
      rescue_from ActiveRecord::RecordNotFound do |e|
        render json: { error: "Resource not found", message: e.message }, status: :not_found
      end
    end
  end
end

现在,我们可以实现具体的内部 API 控制器,它们只负责提供纯粹的数据,不掺杂任何视图逻辑。

# app/controllers/api/internal/v1/users_controller.rb

module Api
  module Internal
    module V1
      class UsersController < BaseController
        before_action :set_user

        # GET /internal/v1/users/:id
        def show
          # 使用 aams gem 或者 Jbuilder 来序列化数据
          # 这里为了简洁直接调用 as_json
          render json: @user.as_json(only: [:id, :username, :email, :created_at])
        end

        # GET /internal/v1/users/:id/articles
        def articles
          articles = @user.articles.order(created_at: :desc).limit(5)
          render json: articles.as_json(only: [:id, :title, :published_at])
        end

        # GET /internal/v1/users/:id/stats
        def stats
          stats_data = {
            articles_count: @user.articles.count,
            comments_count: @user.comments.count,
            last_login_at: @user.last_sign_in_at
          }
          render json: stats_data
        end

        private

        def set_user
          @user = User.find(params[:id])
        end
      end
    end
  end
end

最后,在 config/routes.rb 中定义这些路由:

# config/routes.rb

Rails.application.routes.draw do
  # ... 其他路由

  namespace :api do
    namespace :internal do
      namespace :v1 do
        resources :users, only: [:show] do
          member do
            get :articles
            get :stats
          end
        end
      end
    end
  end
end

至此,Rails 端的改造完成。它现在有了一个安全的、仅供内部服务调用的数据接口。

Vercel Function BFF 的实现

现在转向 BFF 层的实现。我们在 Next.js 项目的 pages/api/profile/[id].ts 创建这个函数。使用 TypeScript 可以带来类型安全的好处,这在处理复杂数据转换时尤为重要。

// pages/api/profile/[id].ts

import type { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';

// 定义从 Rails API 返回的原始数据类型
interface RailsUser {
  id: number;
  username: string;
  email: string;
  created_at: string;
}

interface RailsArticle {
  id: number;
  title: string;
  published_at: string;
}

interface RailsStats {
  articles_count: number;
  comments_count: number;
  last_login_at: string;
}

// 定义 BFF 输出给前端的聚合数据类型
// 注意其结构是为前端组件量身定制的
interface ProfileData {
  user: {
    id: number;
    name: string;
    memberSince: string;
  };
  recentArticles: {
    id: number;
    title: string;
    publishedDate: string;
  }[];
  activity: {
    totalPosts: number;
    totalComments: number;
    lastSeen: string;
  };
}

// 定义错误响应的结构
type ErrorResponse = {
  message: string;
  details?: string;
};

const RAILS_API_BASE_URL = process.env.RAILS_API_BASE_URL;
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY;
const JWT_SECRET = process.env.JWT_SECRET;

// 一个简单的日志包装器,可以方便地添加前缀或扩展
const logger = {
  info: (message: string, context?: object) => console.log(JSON.stringify({ level: 'info', message, ...context })),
  error: (message: string, context?: object) => console.error(JSON.stringify({ level: 'error', message, ...context })),
};

/**
 * 封装的 fetch 函数,自动添加内部认证头和错误处理
 * @param endpoint Rails API 的内部端点
 * @returns Promise<T> 解析后的 JSON 数据
 */
async function fetchFromRails<T>(endpoint: string, requestId: string): Promise<T> {
  const url = `${RAILS_API_BASE_URL}${endpoint}`;
  const headers = {
    'Content-Type': 'application/json',
    'X-Internal-API-Key': INTERNAL_API_KEY!,
    'X-Request-ID': requestId, // 传递请求 ID,便于全链路追踪
  };
  
  logger.info(`Fetching from Rails`, { url, requestId });

  const response = await fetch(url, { headers });

  if (!response.ok) {
    const errorBody = await response.text();
    logger.error(`Rails API request failed`, { url, status: response.status, body: errorBody, requestId });
    throw new Error(`Failed to fetch from Rails API: ${response.status} ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}


export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<ProfileData | ErrorResponse>
) {
  // 生产级实践:为每个请求生成唯一ID,用于日志追踪
  const requestId = Math.random().toString(36).substring(2);

  // 1. 认证与授权
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'Authorization header missing or malformed' });
  }
  
  const token = authHeader.split(' ')[1];
  try {
    // 验证 JWT
    jwt.verify(token, JWT_SECRET!);
    // 在真实项目中,你可能还需要从 token payload 中提取 userID,
    // 并检查其是否有权限访问目标 [id] 的数据
  } catch (err) {
    logger.error('JWT validation failed', { error: (err as Error).message, requestId });
    return res.status(401).json({ message: 'Invalid or expired token' });
  }

  // 2. 参数校验
  const { id } = req.query;
  if (typeof id !== 'string' || !/^\d+$/.test(id)) {
    return res.status(400).json({ message: 'Invalid user ID format' });
  }
  const userId = id;

  try {
    // 3. 并行数据获取
    // 使用 Promise.all 并行发起对 Rails API 的所有请求,减少总等待时间
    const [user, articles, stats] = await Promise.all([
      fetchFromRails<RailsUser>(`/api/internal/v1/users/${userId}`, requestId),
      fetchFromRails<RailsArticle[]>(`/api/internal/v1/users/${userId}/articles`, requestId),
      fetchFromRails<RailsStats>(`/api/internal/v1/users/${userId}/stats`, requestId),
    ]);

    // 4. 数据转换与聚合 (The "T" in BFF)
    // 这是 BFF 的核心价值:将后端的原始数据模型转换为前端友好的视图模型
    const profileData: ProfileData = {
      user: {
        id: user.id,
        name: user.username,
        memberSince: new Date(user.created_at).toLocaleDateString(),
      },
      recentArticles: articles.map(article => ({
        id: article.id,
        title: article.title,
        publishedDate: new Date(article.published_at).toISOString().split('T')[0],
      })),
      activity: {
        totalPosts: stats.articles_count,
        totalComments: stats.comments_count,
        lastSeen: new Date(stats.last_login_at).toUTCString(),
      },
    };
    
    // 5. 成功响应
    // 设置缓存头,对于不常变化的数据可以提高性能,降低函数调用成本
    res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=120');
    return res.status(200).json(profileData);

  } catch (error) {
    // 6. 统一错误处理
    logger.error('BFF execution failed', { error: (error as Error).message, userId, requestId });
    
    // 返回一个对客户端友好的通用错误信息,避免泄露内部实现细节
    return res.status(502).json({ 
        message: 'Failed to fetch profile data from upstream service.',
        details: 'The service responsible for providing data is currently unavailable.'
    });
  }
}

这段代码体现了生产级 Serverless 函数的几个要点:

  • 环境变量管理: RAILS_API_BASE_URL, INTERNAL_API_KEY, JWT_SECRET 等敏感信息通过环境变量注入,而不是硬编码。
  • 强类型: TypeScript 接口定义了清晰的数据契约,无论是对上游(Rails)还是下游(Frontend)。
  • 并行请求: Promise.all 是优化 BFF 性能的关键,它将串行请求变为并行,总耗时取决于最慢的那个内部请求,而不是所有请求耗时之和。
  • 健壮的错误处理: try...catch 块捕获了所有可能发生的异常(网络问题、API 逻辑错误),并返回了标准的 HTTP 错误码和对用户友好的信息。
  • 结构化日志: 使用简单的 logger 对象输出 JSON 格式的日志,这对于在 Vercel Logs 或其他日志聚合服务中进行查询和分析至关重要。加入了 requestId 使得追踪一次完整的用户请求在 BFF 和 Rails 之间的调用链成为可能。
  • 认证与校验: 在执行核心逻辑前,严格检查了用户认证和输入参数。
  • 缓存策略: 通过设置 Cache-Control 头,可以利用 Vercel 的边缘缓存,对不频繁变更的数据(如用户资料)提供更快的响应并减少函数执行次数。

前端组件的消费

最后,前端 React 组件现在可以用一个极其简洁的方式来获取数据。之前需要多个 useQueryuseEffect 的地方,现在只需要一个。

// components/UserProfile.tsx

import React from 'react';
import useSWR from 'swr';
import styled from 'styled-components';

// 使用 Styled-components 定义组件样式
const ProfileWrapper = styled.div`
  border: 1px solid #eee;
  padding: 24px;
  border-radius: 8px;
  max-width: 600px;
  margin: 0 auto;
`;

const UserName = styled.h2`
  color: #333;
`;

const StatsGrid = styled.div`
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin: 16px 0;
`;

// SWR 的 fetcher 函数
const fetcher = (url: string) => fetch(url, {
  headers: {
    // 假设 token 存储在某个地方
    'Authorization': `Bearer ${localStorage.getItem('user_token')}`
  }
}).then(res => {
  if (!res.ok) {
    throw new Error('Failed to fetch data');
  }
  return res.json();
});

const UserProfile = ({ userId }) => {
  // 单一的数据获取点,URL 指向我们的 BFF
  const { data, error } = useSWR(`/api/profile/${userId}`, fetcher);

  if (error) return <div>Failed to load profile.</div>;
  if (!data) return <div>Loading...</div>;

  // data 的结构与 BFF 的 ProfileData 完全匹配
  const { user, recentArticles, activity } = data;

  return (
    <ProfileWrapper>
      <UserName>{user.name}</UserName>
      <p>Member since: {user.memberSince}</p>
      
      <StatsGrid>
        <div><strong>{activity.totalPosts}</strong> Posts</div>
        <div><strong>{activity.totalComments}</strong> Comments</div>
        <div>Last seen: {activity.lastSeen}</div>
      </StatsGrid>
      
      <h3>Recent Articles</h3>
      <ul>
        {recentArticles.map(article => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </ProfileWrapper>
  );
};

export default UserProfile;

前端代码的简洁性直观地反映了 BFF 模式的成功。Styled-components 在这里的作用是消费这些为它量身定制的数据,构建出视觉丰富的界面,而无需关心数据源的复杂性。数据获取逻辑和展现逻辑被清晰地分离开来。

局限性与未来路径

这个基于 Vercel Functions 的 BFF 绞杀层方案并非银弹。一个显而易见的局限是引入了额外的网络跃点。BFF 必须与 Rails 应用保持低延迟的网络连接,如果它们部署在相距遥远的云区域,性能会受到影响。在我们的案例中,Vercel Serverless Functions 和托管 Rails 应用的 Heroku 都选择了 us-east-1 区域,从而将延迟降至最低。

另一个考量是冷启动(Cold Start)。尽管 Vercel 平台对此做了大量优化,但偶尔的冷启动延迟仍然可能发生。对于对延迟极其敏感的应用,需要评估这个风险,或者采用预热(Provisioned Concurrency)等策略,但这会增加成本。

从长远来看,这个 BFF 层是迁移过程的“脚手架”。当某个业务领域(比如文章管理)的所有读写 API 都被对应的 Vercel Functions 覆盖后,我们就可以考虑将这部分业务逻辑和数据模型从 Rails 单体中彻底剥离出来,形成一个独立的微服务。BFF 的存在使得这个过程对前端完全透明,前端调用的始终是 /api/profile,而其背后的数据源则可以从 Rails 无缝切换到新的微服务。这个架构为单体应用的渐进式、低风险现代化改造铺平了道路。


  目录