基于 Vercel Functions 与 Dart 构建轻量级可插拔 API 网关的架构实践


一个现实的技术挑战摆在面前:团队维护着一套混合架构系统,包含一个陈旧的 PHP Monolith API 和数个新兴的 Node.js 微服务。前端应用需要同时与这些异构的后端交互,导致认证逻辑分散、API 路径不统一、跨域问题频发。我们需要一个统一的入口,一个 API 网关,来解决这些问题。但预算和运维资源有限,我们不想引入 Kong 或 Traefik 这样重量级的解决方案。团队主力技术栈是 Vercel 和 Flutter,这意味着我们对 Serverless 和 Dart 有着深厚的积累。

一个大胆但合乎逻辑的构想应运而生:能否在 Vercel Functions 上,使用 Dart 构建一个轻量级、可插拔的 API 网关?这不仅能复用团队现有技术栈,还能将网关的运维成本降至最低。挑战在于,Vercel Functions 的官方运行时并不直接支持 Dart。这意味着我们必须将 Dart 编译为 JavaScript。

这正是我们要攻克的路径。我们将设计一个完全在 Serverless 环境中运行的代理网关,其核心逻辑由 Dart 编写,通过中间件管道实现功能插拔,并利用 Vitest 为其提供坚实的质量保障。

架构设计与技术选型决策

核心目标是创建一个代理,它能接收所有前端请求,然后根据预设规则,智能地将请求转发到不同的上游服务。同时,它必须能在请求转发前后执行一系列操作,例如身份验证、日志记录、请求头改写等。

  1. 运行时: Vercel Functions 的 Node.js 运行时是我们的目标平台。因此,Dart 代码必须通过 dart2js 工具链编译成高效的 JavaScript 代码。这要求我们使用 Dart 的 JS 互操作库 (package:js) 来与 Vercel 的请求和响应对象进行交互。

  2. 核心逻辑 (Dart): 网关的核心将是一个中间件管道(Middleware Pipeline)。每个进入的请求都会流经这个管道。管道中的每个中间件都是一个独立的 Dart 函数,负责单一职责。这种设计模式使得添加、删除或重排网关功能变得极其简单。

  3. 路由与配置: Vercel 的 vercel.json 文件天然提供了强大的路径重写和路由能力。我们将用它来捕获所有指向 /api/gateway/* 的请求,并将它们导向我们用 Dart 编写的单一 Serverless Function。具体的上游服务映射关系,我们将硬编码在 Dart 代码中作为路由表,对于我们当前的规模,这比引入外部配置中心更简单直接。

  4. 测试策略 (Vitest): 由于最终产物是 JavaScript,使用一个现代的 JS 测试框架是最佳选择。Vitest 速度快、API 与 Jest 兼容,且对 TypeScript/ESM 有良好支持,非常适合测试编译后的 Dart 代码。我们将编写两类测试:

    • 单元测试: 针对单个中间件的逻辑进行独立测试。
    • 集成测试: 模拟完整的 Vercel Function 调用,测试整个中间件管道和路由转发逻辑。

下面是整个请求流程的架构图:

sequenceDiagram
    participant Client as 客户端
    participant VercelEdge as Vercel 边缘网络
    participant GatewayFunc as 网关 Function (Dart -> JS)
    participant UpstreamA as 上游服务A (PHP Monolith)
    participant UpstreamB as 上游服务B (Node.js Microservice)

    Client->>VercelEdge: POST /api/gateway/users
    VercelEdge->>GatewayFunc: 触发函数,传递 Request 对象
    GatewayFunc->>GatewayFunc: 1. 进入中间件管道
    GatewayFunc->>GatewayFunc: 2. 执行 LoggingMiddleware
    GatewayFunc->>GatewayFunc: 3. 执行 AuthMiddleware (JWT 校验)
    GatewayFunc->>GatewayFunc: 4. 路由解析: '/users' -> UpstreamA
    GatewayFunc->>GatewayFunc: 5. 执行 HeaderRewriteMiddleware
    GatewayFunc->>UpstreamA: 转发修改后的请求
    UpstreamA-->>GatewayFunc: 返回响应
    GatewayFunc-->>VercelEdge: 返回最终响应
    VercelEdge-->>Client: 返回响应

项目搭建与核心实现

项目结构如下:

.
├── api/
│   └── gateway.dart      # Serverless Function 入口
├── lib/
│   ├── core/
│   │   ├── gateway_request.dart
│   │   ├── gateway_response.dart
│   │   └── pipeline.dart
│   ├── middlewares/
│   │   ├── auth_middleware.dart
│   │   ├── logging_middleware.dart
│   │   └── proxy_middleware.dart
│   └── services/
│       └── router.dart
├── test/
│   ├── middlewares/
│   │   └── auth_middleware.test.ts
│   └── gateway.integration.test.ts
├── package.json
├── pubspec.yaml
├── tsconfig.json
├── vercel.json
└── vitest.config.ts

1. 环境配置

package.json 定义了我们的构建和测试脚本。关键在于 build 命令,它调用 dart2js

{
  "name": "dart-gateway-on-vercel",
  "version": "1.0.0",
  "scripts": {
    "build": "dart compile js api/gateway.dart -o api/index.js -O4",
    "test": "vitest",
    "test:ci": "vitest run"
  },
  "devDependencies": {
    "@types/node": "^20.8.9",
    "@vercel/node": "^3.0.7",
    "typescript": "^5.2.2",
    "vitest": "^0.34.6"
  }
}
  • -O4: 开启最高级别的优化,这对于 Serverless 函数的性能至关重要。
  • api/index.js: 这是 Vercel 约定的 Node.js 函数入口文件名。

vercel.json 负责将所有流量导向我们的网关。

{
  "version": 2,
  "builds": [
    {
      "src": "package.json",
      "use": "@vercel/static-build",
      "config": { "buildCommand": "npm run build" }
    }
  ],
  "routes": [
    {
      "src": "/api/gateway/(.*)",
      "dest": "/api/index.js"
    }
  ]
}

2. Dart 与 JS 的桥梁

我们需要定义 Dart 类来与 Vercel 的 VercelRequestVercelResponse 对象交互。这里使用 package:js

lib/core/gateway_request.dart:

()
library gateway_request;

import 'package:js/js.dart';

// 使用 @JS() 注解来映射 Vercel/Node.js 的 Request 对象
// 这里的结构需要与实际的 JS 对象匹配
()

class GatewayRequest {
  external String get method;
  external String get url;
  external dynamic get body;
  external Map<String, dynamic> get headers;
  external Map<String, dynamic> get query;
}

// 为 Response 对象也创建类似的定义
()

class GatewayResponse {
  external void status(int code);
  external void send(dynamic body);
  external void json(dynamic jsonBody);
  external void setHeader(String name, String value);
}

这是一个关键步骤,它允许我们的强类型 Dart 代码安全地操作动态的 JavaScript 对象。

3. 中间件管道

这是网关架构的核心。管道负责按顺序执行一系列异步的中间件函数。

lib/core/pipeline.dart:

import 'dart:async';
import 'gateway_request.dart';
import 'gateway_response.dart';

// 定义上下文,在中间件之间传递数据
class GatewayContext {
  final GatewayRequest request;
  final GatewayResponse response;
  final Map<String, dynamic> _data = {};

  // 用于存储上游服务的 URL
  Uri? targetUri;

  GatewayContext(this.request, this.response);

  void set(String key, dynamic value) {
    _data[key] = value;
  }

  dynamic get(String key) {
    return _data[key];
  }
}

// 定义中间件函数的类型签名
typedef Middleware = Future<void> Function(GatewayContext context, Future<void> Function() next);

class Pipeline {
  final List<Middleware> _middlewares = [];

  // 添加中间件到管道
  Pipeline add(Middleware middleware) {
    _middlewares.add(middleware);
    return this;
  }

  // 执行管道
  Future<void> run(GatewayContext context) {
    // 从最后一个中间件开始,递归地构建调用链
    Future<void> dispatch(int index) {
      if (index >= _middlewares.length) {
        // 如果所有中间件都执行完毕,返回一个已完成的 Future
        return Future.value();
      }
      final middleware = _middlewares[index];
      // 调用当前中间件,并将下一个中间件的调用作为 next 函数传入
      return middleware(context, () => dispatch(index + 1));
    }

    return dispatch(0);
  }
}

Pipeline.run 的实现是一个经典的洋葱模型。每个中间件都可以决定是在调用 next() 之前还是之后执行操作,这为实现请求/响应日志、错误处理等功能提供了极大的灵活性。

4. 编写具体中间件

让我们实现几个有代表性的中间件。

lib/middlewares/auth_middleware.dart:

import 'dart:async';
import '../core/pipeline.dart';
// 伪代码,实际项目中会使用一个真正的 JWT 库
import '../services/jwt_validator.dart'; 

Future<void> authMiddleware(GatewayContext context, Future<void> Function() next) async {
  final authHeader = context.request.headers['authorization'] as String?;

  if (authHeader == null || !authHeader.startsWith('Bearer ')) {
    context.response.status(401);
    context.response.json({'error': 'Unauthorized', 'reason': 'Missing Bearer token'});
    return; // 中断管道执行
  }

  final token = authHeader.substring(7);
  try {
    final claims = await JwtValidator.validate(token);
    // 可以在上下文中存储解析后的用户信息,供后续中间件使用
    context.set('user_claims', claims); 
    
    // 验证通过,继续执行下一个中间件
    await next(); 
  } catch (e) {
    context.response.status(403);
    context.response.json({'error': 'Forbidden', 'reason': 'Invalid token'});
    // 同样中断管道
  }
}

这个中间件演示了如何终止请求链并立即返回响应。

lib/middlewares/proxy_middleware.dart:
这个中间件是管道的终点,它负责将请求真正地发送到上游服务。

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../core/pipeline.dart';
import '../services/router.dart';

Future<void> proxyMiddleware(GatewayContext context, Future<void> Function() next) async {
  final originalPath = Uri.parse(context.request.url!).path;

  // 从路径中剥离网关前缀 /api/gateway/
  final servicePath = originalPath.replaceFirst('/api/gateway/', '/');
  
  // 使用路由服务查找上游目标
  final targetBaseUrl = routeToUpstream(servicePath);

  if (targetBaseUrl == null) {
    context.response.status(404);
    context.response.json({'error': 'Service not found for path: $servicePath'});
    return;
  }

  final targetUri = Uri.parse('$targetBaseUrl$servicePath');
  context.targetUri = targetUri; // 存入上下文,方便日志记录

  final client = http.Client();
  try {
    final request = http.Request(context.request.method, targetUri);
    
    // 复制请求头,并可以添加/修改头
    // 生产环境中需要过滤掉 host, connection 等头
    final headers = Map<String, String>.from(
        (context.request.headers as Map).cast<String, String>());
    headers.remove('host');
    headers['X-Forwarded-For'] = context.request.headers['x-forwarded-for'] as String? ?? 'unknown';
    request.headers.addAll(headers);

    if (context.request.body != null) {
      request.body = jsonEncode(context.request.body);
    }
    
    // 发送请求到上游
    final response = await client.send(request);

    // 将上游响应写回客户端
    context.response.status(response.statusCode);
    response.headers.forEach((key, value) {
      // 过滤一些不应透传的头
      if (key.toLowerCase() != 'transfer-encoding' && key.toLowerCase() != 'content-encoding') {
        context.response.setHeader(key, value);
      }
    });
    
    final responseBody = await response.stream.bytesToString();
    context.response.send(responseBody);

  } catch (e) {
    // 真实的错误处理需要更完善
    context.response.status(502);
    context.response.json({'error': 'Bad Gateway', 'details': e.toString()});
  } finally {
    client.close();
  }

  // 代理中间件通常是管道的最后一个实际操作者,
  // 但我们仍然调用 next() 以保持模式一致性,尽管它后面可能没有其他中间件了。
  await next();
}

5. 组装入口函数

最后,在 api/gateway.dart 中,我们将所有部分组装起来。

()
library gateway;

import 'package:js/js.dart';
import '../lib/core/gateway_request.dart';
import '../lib/core/gateway_response.dart';
import '../lib/core/pipeline.dart';
import '../lib/middlewares/auth_middleware.dart';
import '../lib/middlewares/logging_middleware.dart';
import '../lib/middlewares/proxy_middleware.dart';

// Vercel 要求导出一个默认函数
('default')
external set _default(void Function(GatewayRequest, GatewayResponse) f);

void main() {
  _default = allowInterop((req, res) async {
    final context = GatewayContext(req, res);
    
    // 构建我们的中间件管道
    final pipeline = Pipeline()
      .add(loggingMiddleware) // 第一个执行
      .add(authMiddleware)
      .add(proxyMiddleware); // 最后一个执行

    try {
      await pipeline.run(context);
    } catch (e) {
      // 兜底的全局错误处理
      if (!context.response.headersSent) { // 避免重复发送响应头
         context.response.status(500);
         context.response.json({'error': 'Internal Server Error'});
      }
    }
  });
}

allowInterop 是一个关键函数,它将一个 Dart 函数包装成一个 JavaScript 可以调用的函数。

使用 Vitest 进行测试

测试是保证网关稳定性的基石。

1. 单元测试中间件

我们可以独立测试 authMiddleware

test/middlewares/auth_middleware.test.ts:

import { describe, it, expect, vi } from 'vitest';
// 我们需要模拟 Dart 编译后的 JS 模块
// 实际操作中,你需要先运行 build,然后从 api/index.js 导入
// 这里为了演示,我们假设有模拟的 authMiddleware 函数
import { authMiddleware } from '../../api/__mocks__/auth_middleware'; 

// 模拟 GatewayContext 和 next 函数
const createMockContext = (headers: Record<string, string>) => {
  const mockResponse = {
    status: vi.fn(),
    json: vi.fn(),
  };
  const mockRequest = { headers };
  
  return {
    // @ts-ignore
    request: mockRequest,
    // @ts-ignore
    response: mockResponse,
    set: vi.fn(),
    get: vi.fn(),
  };
};

describe('authMiddleware', () => {
  it('should call next() if a valid token is provided', async () => {
    // 模拟 JWT 验证器
    vi.doMock('../../api/services/jwt_validator', () => ({
      JwtValidator: {
        validate: vi.fn().mockResolvedValue({ userId: '123' }),
      },
    }));

    const context = createMockContext({ authorization: 'Bearer valid.token' });
    const next = vi.fn();
    
    await authMiddleware(context, next);
    
    expect(next).toHaveBeenCalledOnce();
    expect(context.response.status).not.toHaveBeenCalled();
    expect(context.set).toHaveBeenCalledWith('user_claims', { userId: '123' });
  });

  it('should return 401 if no authorization header is present', async () => {
    const context = createMockContext({});
    const next = vi.fn();

    await authMiddleware(context, next);

    expect(next).not.toHaveBeenCalled();
    expect(context.response.status).toHaveBeenCalledWith(401);
    expect(context.response.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Unauthorized' }));
  });

  it('should return 403 if token is invalid', async () => {
     vi.doMock('../../api/services/jwt_validator', () => ({
      JwtValidator: {
        validate: vi.fn().mockRejectedValue(new Error('Invalid signature')),
      },
    }));

    const context = createMockContext({ authorization: 'Bearer invalid.token' });
    const next = vi.fn();
    
    await authMiddleware(context, next);
    
    expect(next).not.toHaveBeenCalled();
    expect(context.response.status).toHaveBeenCalledWith(403);
    expect(context.response.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Forbidden' }));
  });
});

2. 集成测试整个网关

集成测试会调用编译后的 index.js 文件,模拟一个完整的 Vercel Function 调用。

test/gateway.integration.test.ts:

import { describe, it, expect, vi, beforeAll } from 'vitest';
import handler from '../../api/index.js'; // 直接导入编译后的产物
import http from 'http';

// 我们需要模拟 'http' 模块来拦截出站请求
vi.mock('http', async (importOriginal) => {
  const actualHttp = await importOriginal<typeof http>();
  const mockRequest = {
      on: vi.fn(),
      write: vi.fn(),
      end: vi.fn(),
  };
  return {
      ...actualHttp,
      request: vi.fn(() => mockRequest),
  };
});

// 模拟 Vercel 的请求和响应对象
const createVercelMocks = (method: string, url: string, headers: Record<string, string>, body?: any) => {
  const req = { method, url, headers, body, query: {} };
  
  let statusCode: number;
  let responseBody: any;
  const res = {
    status: vi.fn((code) => { statusCode = code; }),
    setHeader: vi.fn(),
    send: vi.fn((b) => { responseBody = b; }),
    json: vi.fn((b) => { responseBody = JSON.stringify(b); }),
  };

  return { req, res, getResult: () => ({ statusCode, body: responseBody }) };
};

describe('Gateway Integration Test', () => {

  // 在所有测试前,确保模拟是干净的
  beforeAll(() => {
    vi.mock('../../api/services/jwt_validator', () => ({
      JwtValidator: {
        validate: vi.fn().mockResolvedValue({ userId: 'test-user' }),
      },
    }));
  });

  it('should proxy a valid request to the correct upstream', async () => {
    const { req, res, getResult } = createVercelMocks(
      'GET', 
      '/api/gateway/legacy/posts/1',
      { authorization: 'Bearer valid-token' }
    );
    
    await handler(req, res);

    const mockedHttpRequest = vi.mocked(http.request);
    
    // 验证请求是否被正确转发
    expect(mockedHttpRequest).toHaveBeenCalledOnce();
    const requestOptions = mockedHttpRequest.mock.calls[0][0];
    
    // 假设路由规则将 /legacy/* 路由到 'http://php-monolith.internal'
    expect(requestOptions.hostname).toBe('php-monolith.internal');
    expect(requestOptions.path).toBe('/legacy/posts/1');
    expect(requestOptions.method).toBe('GET');
    expect(requestOptions.headers).toHaveProperty('x-forwarded-for');
  });

  it('should block a request without a token', async () => {
    const { req, res, getResult } = createVercelMocks('GET', '/api/gateway/some/path', {});

    await handler(req, res);

    const { statusCode, body } = getResult();
    expect(statusCode).toBe(401);
    expect(JSON.parse(body)).toEqual({ error: 'Unauthorized', reason: 'Missing Bearer token' });
    
    // 确认没有向上游发送请求
    expect(vi.mocked(http.request)).not.toHaveBeenCalled();
  });
});

这种测试方法让我们对整个系统的行为充满信心,从请求入口到中间件逻辑,再到最终的代理转发。

局限性与未来展望

这个基于 Dart 和 Vercel Functions 的轻量级网关方案在我们的场景中表现出色,但它并非银弹。

首先,性能是一个考量点。dart2js 编译出的 JavaScript 代码性能优异,但与手写的、高度优化的原生 Node.js 代码或 Go/Rust 编写的网关相比,在极端高并发下可能存在差距。此外,Vercel Functions 的冷启动延迟是所有 Serverless 应用都需要面对的问题,对于延迟敏感的网关场景,需要开启 Pro/Enterprise 计划的预热功能。

其次,当前的路由和中间件配置是硬编码在 Dart 代码中的。随着业务复杂度的增加,我们可能需要一个更动态的配置系统,例如从 Vercel Edge Config 或外部数据库读取路由规则和中间件链。这将增加系统的复杂性。

最后,这个方案高度依赖 Vercel 平台。虽然核心的 Dart 逻辑是可移植的,但与 Vercel 请求/响应对象的交互部分是平台相关的。迁移到其他 Serverless 平台(如 AWS Lambda)需要进行适配工作。

未来的迭代方向可能包括:实现一个更复杂的中间件用于请求限流(需要借助 Upstash Redis 等外部服务),引入基于配置的动态路由,以及对编译产物进行更精细的性能剖析与优化。


  目录