基于Terraform和PHP GraphQL网关的Svelte与Vue.js微前端联邦架构实践


一个陈旧但稳定的Vue.js 2.x单体应用,承载着核心业务,正在面临新的挑战:一个新成立的团队希望使用Svelte构建一个高性能、交互密集的营销活动模块,并将其无缝集成到现有系统中。直接在旧代码库中用Svelte重写或混编,无异于在泥潭中前行,技术债和团队协作成本会急剧上升。彻底重构整个应用又非短期可行之策。我们需要的是一种架构,能够让新旧技术栈独立演进、独立部署,同时又能为用户提供统一、连贯的体验。

定义技术问题:解耦、自治与统一

问题的核心在于如何在保持团队自治和技术栈独立性的前提下,实现应用的逻辑统一与数据一致性。具体的技术挑战可以分解为:

  1. 部署隔离: Vue团队和Svelte团队必须拥有独立的CI/CD流水线,能够独立部署他们的前端应用,互不阻塞。一次Svelte组件的文本修改,不应触发整个Vue单体的重新构建和部署。
  2. 数据契约: 两个前端应用需要一个统一、类型安全的数据来源。避免各自直接调用后端RESTful API导致的数据冗余、接口膨胀和前端逻辑重复。
  3. 运行时集成: 应用需要在浏览器中被“组装”起来,而不是在构建时。用户在浏览时,应该感觉不到自己正在跨越两个不同的技术栈。
  4. 基础设施一致性: 为两个独立的前端应用和后端服务配置、部署和管理基础设施,必须是自动化的、可重复的,以避免环境差异带来的问题。

方案权衡:从客户端集成到服务端网关

方案A:客户端集成(如Webpack Module Federation)

这是一种流行的微前端实现方式。通过Webpack 5的模块联邦功能,一个应用(Host)可以在运行时动态加载另一个应用(Remote)导出的模块。

  • 优势:

    • 可以实现非常精细的组件级共享。
    • 共享依赖项,减少总体积。
    • 相对流畅的用户体验。
  • 劣势:

    • 强耦合于构建工具: 双方团队必须在构建配置上达成高度一致,这对于技术栈差异巨大的团队(Vue CLI vs Vite/Rollup)来说是个巨大的挑战。
    • 版本地狱: 共享库(如Vue, Lodash)的版本必须严格对齐,否则极易引发运行时错误。在我们的场景中,一个用Vue 2,一个用Svelte,几乎没有可共享的运行时库。
    • 运维复杂性: 管理联邦模块的部署和版本兼容性,需要一套复杂的运维策略。对于追求团队自治的目标而言,这反而增加了新的协调成本。

在真实项目中,这种方案对于技术栈统一的团队更为友好。对于我们这种异构技术栈并存的场景,其带来的运维和协作成本超过了收益。

方案B:基于GraphQL网关的运行时集成

这个方案将集成的重心后移。前端应用保持完全独立,作为纯静态资源部署。它们通过一个统一的GraphQL网关获取数据,由一个轻量的“应用外壳”(Shell)在运行时按需加载和渲染。

  • 优势:

    • 极致解耦: 前端应用之间零依赖,甚至不知道彼此的存在。它们唯一的共同点就是与GraphQL网关的通信契约。
    • 技术栈自由: Vue、Svelte、React或未来任何框架都能以相同的方式集成进来。
    • 独立部署: 各团队可以随时将自己的静态资源部署到CDN或对象存储,与他人完全无关。
    • 数据一致性: GraphQL Schema成为前后端之间、以及前端应用之间的“唯一真相来源”,强类型特性带来了极高的健壮性。
  • 劣劣:

    • 冷启动开销: 首次加载时,除了应用外壳,还需要额外请求具体的前端应用资源,可能会有轻微的延迟。
    • 跨应用通信: 应用间的直接通信变得困难,必须通过事件总线、自定义事件或URL等方式进行,需要预先约定。

对于我们追求团队自治和长期可维护性的目标,方案B显然是更优的选择。它牺牲了微不足道的首次加载性能,换来了架构的极度灵活性和低耦合度。我们将使用Terraform自动化基础设施,PHP(使用webonyx/graphql-php)构建GraphQL网关,Vue和Svelte应用则独立开发并部署。

核心实现:用代码定义架构

我们的目标架构如下所示:

graph TD
    subgraph "AWS Infrastructure (Managed by Terraform)"
        A[CloudFront] -->|/app1/*| B1[S3 Bucket: Vue App]
        A -->|/app2/*| B2[S3 Bucket: Svelte App]
        A -->|/graphql| C[API Gateway]
        C --> D[AWS Fargate Service: PHP GraphQL]
        D --> E[RDS/DynamoDB]
    end

    U[User Browser] --> A
    subgraph "Browser Runtime"
        F[Shell Application]
        F -- Loads --> G1[Vue App JS/CSS]
        F -- Loads --> G2[Svelte App JS/CSS]
        G1 -- Fetches Data --> A
        G2 -- Fetches Data --> A
    end

    U --> F

1. 基础设施即代码:Terraform定义资源

我们首先用Terraform定义所有必需的云资源。这种方式确保了环境的可复现性和一致性。

terraform/modules/mfe_hosting/main.tf - 可复用的微前端托管模块

# modules/mfe_hosting/main.tf

variable "app_name" {
  description = "The unique name of the micro-frontend application."
  type        = string
}

variable "tags" {
  description = "A map of tags to assign to resources."
  type        = map(string)
  default     = {}
}

resource "aws_s3_bucket" "app_bucket" {
  bucket = "${var.app_name}-mfe-assets"
  # In a real project, you would not use bucket ACLs.
  # Use OAC with CloudFront instead. This is simplified for clarity.
  acl    = "public-read" 
}

resource "aws_s3_bucket_website_configuration" "app_bucket_website" {
  bucket = aws_s3_bucket.app_bucket.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "index.html" # For SPAs that use client-side routing
  }
}

resource "aws_s3_bucket_policy" "allow_public_access" {
  bucket = aws_s3_bucket.app_bucket.id
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [
      {
        Effect    = "Allow",
        Principal = "*",
        Action    = "s3:GetObject",
        Resource  = "${aws_s3_bucket.app_bucket.arn}/*"
      }
    ]
  })
}


output "bucket_website_endpoint" {
  value = aws_s3_bucket_website_configuration.app_bucket_website.website_endpoint
}

output "bucket_name" {
  value = aws_s3_bucket.app_bucket.id
}

terraform/main.tf - 根模块,组合所有资源

# main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

# --- Micro-Frontend Hosting ---
module "vue_legacy_app" {
  source   = "./modules/mfe_hosting"
  app_name = "vue-legacy"
  tags = {
    Project = "MFE-Federation"
    Team    = "Core"
  }
}

module "svelte_marketing_app" {
  source   = "./modules/mfe_hosting"
  app_name = "svelte-marketing"
  tags = {
    Project = "MFE-Federation"
    Team    = "Marketing"
  }
}

# --- PHP GraphQL Backend (Simplified to show intent) ---
# In a production setup, this would define an ECS/Fargate service,
# task definition, load balancer, and API Gateway integration.

resource "aws_ecr_repository" "graphql_api" {
  name = "php-graphql-gateway"
}

# Placeholder for Fargate, ALB, API Gateway resources
# resource "aws_ecs_service" "api_service" { ... }
# resource "aws_lb" "api_lb" { ... }
# resource "aws_api_gateway_v2_api" "http_api" { ... }

output "vue_app_endpoint" {
  value = module.vue_legacy_app.bucket_website_endpoint
}

output "svelte_app_endpoint" {
  value = module.svelte_marketing_app.bucket_website_endpoint
}

这里的关键在于,每个前端应用都有自己独立的S3 Bucket,通过一个可复用的Terraform模块创建。这保证了资源配置的一致性,同时又实现了物理上的隔离。CI/CD流水线只需要知道自己目标的Bucket名称即可。

2. 后端核心:PHP GraphQL 网关

我们使用 webonyx/graphql-php 库来构建网关。这个网关是所有前端应用的数据中枢。

api/schema.graphql

# The central data contract for all frontends

type Query {
  "Get user profile information"
  user(id: ID!): User
  "Get product details for a given SKU"
  product(sku: String!): Product
}

type Mutation {
  "Submit a lead for a marketing campaign"
  submitMarketingLead(email: String!, campaignId: String!): LeadSubmissionStatus!
}

type User {
  id: ID!
  name: String!
  email: String
  accountType: String
}

type Product {
  sku: String!
  name: String!
  description: String
  price: Float
}

type LeadSubmissionStatus {
  success: Boolean!
  message: String
}

api/server.php - GraphQL 服务入口

<?php
// api/server.php

require_once __DIR__ . '/vendor/autoload.php';

use GraphQL\GraphQL;
use GraphQL\Type\Schema;
use GraphQL\Error\DebugFlag;

// In a real application, these resolvers would be in their own classes
// and dependency injected. They would interact with databases, other services etc.
$resolvers = [
    'Query' => [
        'user' => function ($root, $args) {
            // Faking a database call
            if ($args['id'] == '1') {
                return ['id' => '1', 'name' => 'Legacy User', 'email' => '[email protected]', 'accountType' => 'PREMIUM'];
            }
            return null;
        },
        'product' => function ($root, $args) {
            // Faking another data source
            if ($args['sku'] == 'VUE-PROD-001') {
                return ['sku' => 'VUE-PROD-001', 'name' => 'Vue Legacy Product', 'description' => 'A solid, reliable product.', 'price' => 99.99];
            }
            return null;
        }
    ],
    'Mutation' => [
        'submitMarketingLead' => function($root, $args) {
            // Input validation and error handling are crucial here
            if (!filter_var($args['email'], FILTER_VALIDATE_EMAIL)) {
                 throw new \Exception("Invalid email address provided.");
            }
            // Logic to save the lead...
            error_log("New lead for campaign {$args['campaignId']}: {$args['email']}");
            return ['success' => true, 'message' => 'Thank you for your submission!'];
        }
    ]
];

try {
    $schema = Schema::build(file_get_contents(__DIR__ . '/schema.graphql'));

    $rawInput = file_get_contents('php://input');
    $input = json_decode($rawInput, true);
    $query = $input['query'];
    $variableValues = isset($input['variables']) ? $input['variables'] : null;

    $result = GraphQL::executeQuery($schema, $query, null, null, $variableValues, null, $resolvers);
    
    // Enable debugging for development environments
    $debug = DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE;
    $output = $result->toArray($debug);

} catch (\Exception $e) {
    // A robust error handling mechanism is critical for production
    error_log($e->getMessage());
    $output = [
        'errors' => [
            [
                'message' => 'An internal server error occurred.'
            ]
        ]
    ];
    http_response_code(500);
}

header('Content-Type: application/json; charset=UTF--g');
echo json_encode($output);

这里的PHP代码清晰地定义了数据获取和操作的逻辑。schema.graphql 文件是核心契约,任何前端开发者都可以基于它生成类型定义,实现类型安全。

3. 前端实现:Vue.js 与 Svelte 的 GraphQL 客户端

Vue Legacy App (vue-legacy-app)

我们将使用 vue-apollo 来与网关通信。

src/main.js - Apollo Client 初始化

// src/main.js
import { createApp, h, provide } from 'vue'
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client/core'
import { DefaultApolloClient } from '@vue/apollo-composable'
import App from './App.vue'

const httpLink = createHttpLink({
  // This URL would come from an environment variable injected at build time
  uri: 'http://<your-api-gateway-url>/graphql',
});

const cache = new InMemoryCache();

const apolloClient = new ApolloClient({
  link: httpLink,
  cache,
});

const app = createApp({
  setup() {
    provide(DefaultApolloClient, apolloClient)
  },
  render: () => h(App),
})

app.mount('#app');

src/components/ProductDisplay.vue - 数据获取组件

<template>
  <div class="product-card">
    <h2>Product Information (from Vue)</h2>
    <div v-if="loading" class="loading">Loading...</div>
    <div v-if="error" class="error">
      Error fetching product: {{ error.message }}
    </div>
    <div v-if="result && result.product">
      <h3>{{ result.product.name }}</h3>
      <p>SKU: {{ result.product.sku }}</p>
      <p>{{ result.product.description }}</p>
      <strong>Price: ${{ result.product.price }}</strong>
    </div>
  </div>
</template>

<script setup>
import { useQuery } from '@vue/apollo-composable';
import gql from 'graphql-tag';

const PRODUCT_QUERY = gql`
  query GetProduct($sku: String!) {
    product(sku: $sku) {
      sku
      name
      description
      price
    }
  }
`;

// In a real app, the SKU would be a prop or come from the route
const { result, loading, error } = useQuery(PRODUCT_QUERY, {
  sku: 'VUE-PROD-001'
});
</script>

<style scoped>
.product-card { border: 1px solid #42b983; padding: 1rem; margin: 1rem; }
.error { color: red; }
</style>

Svelte Marketing App (svelte-marketing-app)

这里我们使用 svelte-apollo 库。

src/apollo.js - Apollo Client 初始化

// src/apollo.js
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client/core';

const client = new ApolloClient({
    cache: new InMemoryCache(),
    link: createHttpLink({
        uri: 'http://<your-api-gateway-url>/graphql',
    }),
});

export default client;

src/lib/LeadForm.svelte - 数据变更组件

<script>
    import { getClient } from 'svelte-apollo';
    import { gql } from '@apollo/client/core';

    const client = getClient();

    let email = '';
    let campaignId = 'SUMMER2023';
    let loading = false;
    let error = null;
    let successMessage = null;

    const SUBMIT_LEAD_MUTATION = gql`
        mutation SubmitLead($email: String!, $campaignId: String!) {
            submitMarketingLead(email: $email, campaignId: $campaignId) {
                success
                message
            }
        }
    `;

    async function handleSubmit() {
        loading = true;
        error = null;
        successMessage = null;

        try {
            const result = await client.mutate({
                mutation: SUBMIT_LEAD_MUTATION,
                variables: {
                    email,
                    campaignId,
                }
            });

            if (result.data.submitMarketingLead.success) {
                successMessage = result.data.submitMarketingLead.message;
                email = '';
            } else {
                throw new Error(result.data.submitMarketingLead.message || 'Submission failed.');
            }
        } catch (e) {
            error = e.message;
        } finally {
            loading = false;
        }
    }
</script>

<div class="lead-form-card">
    <h2>Join Campaign (from Svelte)</h2>
    <form on:submit|preventDefault={handleSubmit}>
        <input type="email" bind:value={email} placeholder="Enter your email" required disabled={loading} />
        <button type="submit" disabled={loading}>
            {#if loading}Submitting...{:else}Submit{/if}
        </button>
    </form>

    {#if error}
        <p class="error">Error: {error}</p>
    {/if}
    {#if successMessage}
        <p class="success">{successMessage}</p>
    {/if}
</div>

<style>
    .lead-form-card { border: 1px solid #ff3e00; padding: 1rem; margin: 1rem; }
    .error { color: red; }
    .success { color: green; }
</style>

两个前端应用的代码库完全独立,它们只关心schema.graphql这个契约。一个负责查询,一个负责变更,完美地体现了关注点分离。

架构的扩展性与局限性

这种架构的扩展性极强。当需要引入第三个由React团队维护的微前端时,我们只需:

  1. 在Terraform根模块中再调用一次mfe_hosting模块。
  2. React团队使用他们熟悉的Apollo Client for React,指向同一个GraphQL网关。
  3. 在应用外壳中增加一个挂载点。
    整个过程无需触碰任何现有Vue或Svelte应用的代码。

对于认证和授权,可以在API Gateway层面或GraphQL网关内部实现。前端应用获取JWT后,通过HTTP Header的Authorization字段传递给网关,由网关统一进行校验和解析,并将用户信息注入到GraphQL的上下文中,供下游解析器使用。

当然,该架构也存在局限性。首先,前端应用间的细粒度、高频状态共享变得复杂,它强制我们将共享状态提升到URL或后端。这通常是件好事,能避免前端应用变得过于臃肿,但也确实给某些场景带来了挑战。其次,UI/UX的一致性需要依赖一个独立于具体框架的Design System,并通过NPM包或原生Web Components来分发,这本身就是一个不小的工程挑战。最后,整个系统的端到端测试链路变长,需要更强大的自动化测试策略来保障质量。


  目录