为基于Relay的Monorepo项目构建感知GraphQL Schema变化的CI工作流


项目 CI 的执行时间从稳定的 15 分钟悄然爬升到了 25 分钟,偶尔甚至触及 30 分钟的阈值,这是我们必须正视的问题。在一个包含多个前端应用、共享组件库和一个中心化 GraphQL schema 的 pnpm monorepo 中,根本的瓶颈并非测试执行本身,而是前置的 relay-compiler 步骤。每一次微小的代码提交,无论是否触及数据获取逻辑,都会触发对整个项目范围的 Relay 代码生成,这不仅耗时,而且冗余。更棘手的是,一旦 schema.graphql 文件发生变更,下游所有依赖该 schema 的组件测试都可能因类型不匹配而连锁失败,定位问题变得异常困难。

我们需要一个更智能的工作流。这个工作流必须能够区分代码变更和 Schema 变更,并据此执行不同的策略:

  1. 对于纯应用代码变更: 最大化利用缓存,跳过不必要的 relay-compiler,只运行受影响项目的单元测试。
  2. 对于 GraphQL Schema 变更: 识别这是一个高风险操作,强制重新执行 relay-compiler,更新所有相关的类型定义,并运行一个完整的、覆盖所有应用的集成测试套件,确保契约没有被破坏。

这个工作流的核心将围绕 GitHub Actions 构建,利用其缓存和条件执行能力,结合 pnpm 对 monorepo 的高效管理,以及 Jest 对 Relay 环境的模拟测试能力,打造一个真正为现代前端工程服务的 CI 管道。

项目结构与基础设定

我们的起点是一个典型的 pnpm monorepo 结构。这种结构对于依赖管理和代码复用至关重要,但同时也给 CI 带来了复杂性。

# 文件结构概览
.
├── .github/
│   └── workflows/
│       └── ci.yml          # 我们将要构建的核心工作流
├── packages/
│   ├── app-alpha/          # 第一个前端应用
│   │   ├── src/
│   │   │   └── components/
│   │   │       └── UserProfile.tsx
│   │   │       └── __generated__/
│   │   ├── package.json
│   │   └── jest.config.js
│   ├── app-beta/           # 第二个前端应用
│   │   ├── ...
│   ├── design-system/      # 共享UI组件库
│   │   ├── ...
│   └── graphql-schema/     # 中心化的GraphQL Schema定义
│       ├── package.json
│       └── schema.graphql
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── relay.config.js

pnpm-workspace.yaml 定义了工作区的范围:

# pnpm-workspace.yaml
packages:
  - 'packages/*'

relay.config.jsrelay-compiler 的心脏,它指定了 schema 的位置和生成的代码存放路径。

// relay.config.js
module.exports = {
  // ...
  src: './packages',
  schema: './packages/graphql-schema/schema.graphql',
  exclude: ['**/node_modules/**', '**/__mocks__/**', '**/__generated__/**'],
  language: 'typescript',
  artifactDirectory: './__generated__', // 在每个组件目录下生成
};

在根 package.json 中,我们定义了全局脚本。注意 relay 脚本,它是我们优化的主要目标。

// package.json
{
  "scripts": {
    "relay": "pnpm -r --filter './packages/**' exec relay-compiler",
    "test": "pnpm -r test",
    "build": "pnpm -r build"
  },
  "devDependencies": {
    "relay-compiler": "^15.0.0",
    "typescript": "^5.0.0",
    "jest": "^29.0.0",
    // ... 其他依赖
  }
}

第一阶段:一个朴素但低效的CI工作流

在优化之前,我们先看一下当前的工作流。它很简单,也很低效,但它是我们改进的基准。

# .github/workflows/ci-naive.yml
name: Naive CI

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  build_and_test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PNPM
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Run Relay Compiler
        run: pnpm relay

      - name: Run tests
        run: pnpm test

这个工作流的问题显而易见:

  1. 无差别编译: 每次 push 都会执行 pnpm relay,即使没有任何 GraphQL 查询或 Schema 变更。在一个大型项目中,这一步可能耗时数分钟。
  2. 无差别测试: pnpm test 会运行所有包的测试,即便改动只发生在一个独立的包里。
  3. 缓存利用不足: 虽然 setup-node 提供了 pnpm 的依赖缓存,但它没有缓存 relay-compiler 的产物。

第二阶段:引入智能缓存与路径过滤

优化的第一步是引入更精细的缓存机制,并根据文件变更路径来决定执行哪些任务。我们将工作流拆分为两个核心作业:detect-changesbuild-and-test

detect-changes 作业的唯一目标是识别出变更的文件类型。我们将特别关注 schema.graphql 的变化。

# .github/workflows/ci.yml (片段 1 - 变更检测)
name: Smart CI for Relay Monorepo

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      schema_changed: ${{ steps.filter.outputs.schema_changed }}
      # 可以扩展输出更多变更类型,比如 design-system_changed
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # 获取完整历史以进行比较

      # 使用 dorny/paths-filter 来检测文件路径变更
      # 这是一个非常强大的 action,可以定义多个过滤规则
      - name: Check for file changes
        id: filter
        uses: dorny/paths-filter@v2
        with:
          filters: |
            schema_changed:
              - 'packages/graphql-schema/schema.graphql'

现在,build-and-test 作业可以利用 detect-changes 的输出结果来决定其行为。这里的关键是 relay-compiler 产物的缓存策略。缓存的 key 不仅要依赖 pnpm-lock.yaml,更要依赖 schema.graphql 文件的内容哈希。这样,只有在 schema 变化时,缓存才会失效。

# .github/workflows/ci.yml (片段 2 - 构建与测试作业)
  build-and-test:
    needs: detect-changes
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PNPM
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      # 缓存 Relay 编译产物
      # key 的设计是这里的核心
      - name: Cache Relay artifacts
        id: cache-relay
        uses: actions/cache@v3
        with:
          path: '**/__generated__'
          key: ${{ runner.os }}-relay-${{ hashFiles('**/schema.graphql') }}-${{ hashFiles('**/relay.config.js') }}
          restore-keys: |
            ${{ runner.os }}-relay-${{ hashFiles('**/schema.graphql') }}-

      # 只有在 Schema 变更或缓存未命中时,才运行 relay-compiler
      # 这是一个重要的优化点
      - name: Run Relay Compiler
        if: needs.detect-changes.outputs.schema_changed == 'true' || steps.cache-relay.outputs.cache-hit != 'true'
        run: |
          echo "Schema changed or cache miss. Running relay-compiler..."
          pnpm relay
        # 这里的错误处理很重要,如果编译失败,必须终止工作流
        continue-on-error: false

      # 如果 schema 没变且缓存命中,我们跳过编译,这能节省大量时间
      - name: Skip Relay Compiler
        if: needs.detect-changes.outputs.schema_changed == 'false' && steps.cache-relay.outputs.cache-hit == 'true'
        run: echo "Schema unchanged and cache hit. Skipping relay-compiler."

      # 测试阶段可以进一步优化,比如只测试变更的包
      # pnpm 提供了这样的能力: pnpm --filter "...[<since>]" test
      - name: Run tests for changed packages
        run: pnpm --filter "...[origin/main]" --filter '!./packages/graphql-schema' test

      # 如果 Schema 发生变化,我们强制运行所有测试,作为一种安全保障
      - name: Run all tests on schema change (Safety Net)
        if: needs.detect-changes.outputs.schema_changed == 'true'
        run: pnpm test

这个版本的 CI 已经智能很多。它理解了 schema.graphql 的重要性,并围绕它构建了缓存和执行策略。下面是这个工作流的逻辑图:

graph TD
    A[Start: Push/PR] --> B{Detect Changes};
    B -- Schema Changed --> C[Invalidate Relay Cache];
    B -- Code Changed Only --> D[Use Relay Cache];
    C --> E[Run relay-compiler];
    E --> F[Run ALL Tests];
    D --> G[Skip relay-compiler];
    G --> H[Run tests for CHANGED packages only];
    F --> I[End];
    H --> I;

第三阶段:在Jest中可靠地测试Relay组件

CI 流程优化后,测试本身的可靠性成为下一个关键点。测试依赖 Relay 的组件(使用 useFragment, useLazyLoadQuery 等 hooks)时,不能发起真实的网络请求。我们需要一个稳定、可控的测试环境。relay-test-utils 提供了 createMockEnvironment,这是编写健壮测试的基石。

首先,确保 Jest 配置正确,能够处理 Relay 生成的文件和 GraphQL 标签。

// packages/app-alpha/jest.config.js
module.exports = {
  // ...
  testEnvironment: 'jsdom',
  transform: {
    '^.+\\.(ts|tsx)$': 'ts-jest',
  },
  // Jest 需要知道如何处理 `graphql` 标签
  moduleNameMapper: {
    '\\.(css|less)$': 'identity-obj-proxy',
    // 确保能找到 Relay 运行时
    '^relay-runtime$': '<rootDir>/node_modules/relay-runtime',
  },
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
};

接下来是核心的测试环境搭建。我们创建一个辅助函数来简化 RelayMockEnvironment 的创建和使用。

// packages/app-alpha/src/test-utils/createTestEnvironment.ts
import {
  commitLocalUpdate,
  createOperationDescriptor,
  getRequest,
  GraphQLResponse,
  IEnvironment,
  RelayMockEnvironment,
} from 'relay-test-utils';
import { ConcreteRequest } from 'relay-runtime';

/**
 * 创建一个用于测试的 Relay 环境
 * @returns RelayMockEnvironment 实例
 */
export function createTestEnvironment(): RelayMockEnvironment {
  const { createMockEnvironment } = require('relay-test-utils');
  return createMockEnvironment();
}

/**
 * 模拟一个 GraphQL 操作的成功响应
 * @param environment Relay Mock 环境
 * @param operationName 操作名称
 * @param data 模拟的返回数据
 */
export function mockQuerySuccess(
  environment: RelayMockEnvironment,
  operationName: string,
  data: object
) {
  environment.mock.resolveMostRecentOperation(operation => {
    // 验证操作是否是期望的查询
    expect(operation.request.node.operation.name).toBe(operationName);
    return { data };
  });
}

/**
 * 模拟一个 GraphQL 操作的失败响应
 * @param environment Relay Mock 环境
 * @param operationName 操作名称
 * @param error 模拟的错误对象
 */
export function mockQueryError(
  environment: RelayMockEnvironment,
  operationName: string,
  error: Error
) {
  environment.mock.rejectMostRecentOperation(operation => {
    expect(operation.request.node.operation.name).toBe(operationName);
    return error;
  });
}

现在,我们可以为一个使用 useFragmentUserProfile 组件编写测试。

假设有这样一个组件和 fragment 定义:

# UserProfile.tsx
fragment UserProfile_user on User {
  id
  name
  email
}
// packages/app-alpha/src/components/UserProfile.tsx
import React from 'react';
import { useFragment, graphql } from 'react-relay';
import { UserProfile_user$key } from './__generated__/UserProfile_user.graphql';

interface Props {
  user: UserProfile_user$key;
}

export const UserProfile: React.FC<Props> = ({ user }) => {
  const data = useFragment(
    graphql`
      fragment UserProfile_user on User {
        id
        name
        email
      }
    `,
    user
  );

  return (
    <div>
      <h1>{data.name}</h1>
      <p>ID: {data.id}</p>
      <p>Email: {data.email}</p>
    </div>
  );
};

对应的测试文件将如下所示。这里的关键是使用 RelayEnvironmentProvider 将 mock environment 注入到组件树中。

// packages/app-alpha/src/components/UserProfile.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { UserProfile } from './UserProfile';
import { createTestEnvironment } from '../test-utils/createTestEnvironment';
import { UserProfile_user$key } from './__generated__/UserProfile_user.graphql';

describe('UserProfile', () => {
  let environment: ReturnType<typeof createTestEnvironment>;

  beforeEach(() => {
    // 每个测试用例都使用一个全新的 mock 环境
    // 避免测试用例之间的状态污染
    environment = createTestEnvironment();
  });

  it('renders user information correctly', () => {
    // 伪造一个 fragment reference。
    // 在真实应用中,这个对象是由父组件的查询返回的。
    // 在测试中,我们只需要一个包含正确 $fragmentType 的对象即可。
    const mockUserFragmentRef: UserProfile_user$key = {
      // @ts-ignore - The fragment type is the only thing that matters for the hook
      ' $fragmentType': 'UserProfile_user',
      id: 'user-123',
      name: 'John Doe',
      email: '[email protected]',
    };

    render(
      <RelayEnvironmentProvider environment={environment}>
        <UserProfile user={mockUserFragmentRef} />
      </RelayEnvironmentProvider>
    );

    // 断言组件是否正确渲染了数据
    expect(screen.getByRole('heading', { name: 'John Doe' })).toBeInTheDocument();
    expect(screen.getByText('ID: user-123')).toBeInTheDocument();
    expect(screen.getByText('Email: [email protected]')).toBeInTheDocument();
  });

  // 这里的坑在于,useFragment 的数据是同步可用的,它假设数据已经被父查询加载。
  // 因此,测试 useFragment 非常直接。
  // 测试 useLazyLoadQuery 则需要模拟异步操作,如下面的例子。
});

// 假设我们有一个使用 useLazyLoadQuery 的页面级组件
// const UserPage = () => {
//   const data = useLazyLoadQuery(graphql`...`, {});
//   return <UserProfile user={data.user} />;
// }
// 它的测试会是这样的:
/*
it('handles loading and error states for a query', async () => {
  render(
    <RelayEnvironmentProvider environment={environment}>
      <React.Suspense fallback={<div>Loading...</div>}>
        <UserPage />
      </React.Suspense>
    </RelayEnvironmentProvider>
  );

  // 1. 初始状态应该是 loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // 2. 模拟一个错误的响应
  mockQueryError(environment, 'UserPageQuery', new Error('Network Failed'));

  // 3. 等待并断言错误边界捕获了错误
  // (假设你有一个 ErrorBoundary 组件)
  // const errorDisplay = await screen.findByText(/Network Failed/);
  // expect(errorDisplay).toBeInTheDocument();
});
*/

这段测试代码展示了如何在隔离环境中验证 Relay 组件的行为,确保其逻辑的正确性,而这一切都可以在 CI 中快速、可靠地运行。

最终的工作流集成

现在,我们将所有部分整合在一起,形成最终的 ci.yml 文件。

# .github/workflows/ci.yml
name: Smart CI for Relay Monorepo

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      schema_changed: ${{ steps.filter.outputs.schema_changed }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check for file changes
        id: filter
        uses: dorny/paths-filter@v2
        with:
          filters: |
            schema_changed:
              - 'packages/graphql-schema/schema.graphql'

  build-and-test:
    needs: detect-changes
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PNPM
        uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Cache Relay artifacts
        id: cache-relay
        uses: actions/cache@v3
        with:
          path: '**/__generated__'
          key: ${{ runner.os }}-relay-v2-${{ hashFiles('**/schema.graphql') }}-${{ hashFiles('**/relay.config.js') }}
          restore-keys: |
            ${{ runner.os }}-relay-v2-${{ hashFiles('**/schema.graphql') }}-

      - name: Run Relay Compiler
        if: needs.detect-changes.outputs.schema_changed == 'true' || steps.cache-relay.outputs.cache-hit != 'true'
        run: |
          echo "Schema changed or cache miss. Running relay-compiler..."
          pnpm relay
        continue-on-error: false

      - name: Skip Relay Compiler
        if: needs.detect-changes.outputs.schema_changed == 'false' && steps.cache-relay.outputs.cache-hit == 'true'
        run: echo "Schema unchanged and cache hit. Skipping relay-compiler."

      - name: Lint and Type-check
        run: |
          pnpm -r lint
          pnpm -r typecheck

      # 只有在非 Schema 变更时,才执行增量测试
      - name: Run tests for changed packages
        if: needs.detect-changes.outputs.schema_changed == 'false'
        run: pnpm --filter "...[origin/${{ github.base_ref || 'main' }}]" --filter '!./packages/graphql-schema' test

      # 如果 Schema 发生变化,我们强制运行所有测试,这是最安全的策略
      - name: Run all tests on schema change (Safety Net)
        if: needs.detect-changes.outputs.schema_changed == 'true'
        run: pnpm test

      - name: Build all packages
        run: pnpm build

这套工作流通过感知 schema.graphql 的变化,实现了 CI 资源的智能调度。它在确保代码质量和契约一致性的同时,大幅缩短了非 schema 变更场景下的反馈周期,将开发者的等待时间从近半小时缩短到了几分钟。

局限与未来展望

当前方案并非完美。当 schema.graphql 变更时,我们采取了“运行所有测试”这一相对保守的策略。在超大型项目中,这依然可能是一个时间瓶颈。未来的迭代可以探索更精细的依赖分析:通过解析 GraphQL Schema 变更的具体内容(例如,哪个 Type 或 Field 被修改),并结合代码静态分析,来确定哪些组件的查询真正受到了影响,从而实现更小范围的“影响面测试”。这需要引入更复杂的工具链,比如 GraphQL Inspector 结合自定义脚本,来生成一个精确的测试子集。

此外,对于 relay-compiler 本身,虽然我们缓存了其产物,但当缓存失效时,全量编译的耗时问题依然存在。探索 Relay 团队未来可能推出的增量编译功能,或者在大型项目中考虑将 schema 按领域拆分为多个子 schema(如果业务允许),可能是进一步优化的方向。


  目录