运用图数据库与Turbopack为内部开发者平台构建实时Monorepo依赖分析器


我们团队的 Monorepo 已经膨胀到了一个临界点:超过两千个内部包,横跨前端应用、共享组件、Node.js 服务和工具库。一个看似无害的底层工具函数变更,竟然触发了长达45分钟的CI流水线,影响了上百个包的构建和测试。最可怕的是,这种“蝴蝶效应”在变更发生前几乎无法预测。传统的 npm lsyarn workspaces list 在这种规模下已经慢到无法忍受,而且输出的文本信息对于理解复杂的依赖关系网几乎毫无帮助。我们迫切需要一个工具,一个能让我们“看见”整个代码宇宙的星图,一个实时的、交互式的依赖分析器。这便是我们为内部开发者平台(IDP)构建这个新组件的开端。

技术选型:一场拥抱未来的豪赌

最初的构想很简单:一个 Node.js 脚本扫描所有 package.json,生成一个巨大的 JSON 文件,然后前端加载并渲染。但这很快就被否决了。当依赖关系达到数十万条时,前端一次性加载和解析这个 JSON 会导致浏览器卡死。更重要的是,我们想做的远不止是展示,我们想进行深度查询:“如果我废弃这个包,会影响哪些团队的哪些核心应用?”或者“这两个包之间最短的依赖路径是什么?”这些查询对于 JSON 来说是场灾难,但对于图数据库来说,却是它们的原生语言。

1. 数据层:为什么是 Neo4j?

我们选择了 Neo4j,一个原生的图数据库。原因很简单:它对世界的建模方式和我们的问题域完全一致。每一个 package 都是一个节点(Node),dependenciesdevDependencies 则是指向其他节点的关系(Relationship)。

  • 节点(Node): (:Package {name: 'string', version: 'string', path: 'string'})
  • 关系(Relationship): (a)-[:DEPENDS_ON {type: 'dev' | 'prod'}]->(b)

这种模型下,之前那些复杂的查询就变成了极其优雅的 Cypher 语句。比如,查找某个包的所有下游依赖(深度为5层):

MATCH (start:Package {name: '@my-scope/core-utils'})-[:DEPENDS_ON*1..5]->(downstream)
RETURN DISTINCT start.name, downstream.name

这彻底改变了游戏规则。我们不再是在一堆数据中挣扎,而是在一个富有表现力的知识图谱上进行探索。

2. 构建工具:为什么是 Turbopack?

这个工具是为开发者服务的,它的开发体验本身也必须是一流的。我们的 IDP 前端已经相当复杂,使用 Webpack 时,冷启动需要近一分钟,热更新(HMR)也常常要等上好几秒。当我们决定要在这个平台上添加一个如此数据密集和交互复杂的应用时,我们知道不能再忍受这种延迟。

我们选择了 Turbopack。是的,它当时还处于 alpha 阶段,但它所承诺的——基于 Rust 的原生速度、几乎瞬时的 HMR——正是我们所需要的。将它集成到我们的 Next.js 应用中,风险是存在的,但潜在的回报是巨大的:一个能让开发者沉浸其中、无缝迭代的开发环境。这是一场对未来前端工具链的投资。

3. 状态管理:为什么需要“定制”?

我们的前端需要展示一个可能包含成百上千个节点和边的交互式图形。Redux 或 Zustand 这类常见的状态管理库,它们的数据结构通常是树状的,或者至少是面向普通对象设计的。直接将整个图(可能有多处循环引用)塞进它们的 store 里,不仅不直观,还可能导致序列化问题和严重的性能瓶셔。

我们需要一个为图结构优化的状态管理方案。这个方案的核心是:

  • 节点和边使用 Map 存储,以实现 O(1) 的查找效率。
  • 状态更新应该是原子化的、精细的,比如“只更新节点A的位置”或“高亮路径B”,而不是每次都生成一个新的巨大 state 对象。
  • 能够轻松地处理“按需加载”,即当用户点击一个节点时,才去后端请求并加载它的邻居节点,而不是一次性加载整个宇宙。

我们最终决定基于轻量级的原子状态库(如 Jotai)构建我们自己的状态管理层,它能让我们更精细地控制组件的重新渲染。

核心实现:从代码库到交互式星图

第一步:数据采集与注入

我们编写了一个健壮的 Node.js 脚本,它作为 CI 的一部分或者通过 Webhook 触发。它的任务是扫描整个 monorepo,解析依赖,并将它们同步到 Neo4j。

// scripts/sync-dependencies.js
import { glob } from 'glob';
import { readFile } from 'fs/promises';
import path from 'path';
import neo4j from 'neo4j-driver';

// 从环境变量读取配置,确保安全性
const NEO4J_URI = process.env.NEO4J_URI;
const NEO4J_USER = process.env.NEO4J_USER;
const NEO4J_PASSWORD = process.env.NEO4J_PASSWORD;

if (!NEO4J_URI || !NEO4J_USER || !NEO4J_PASSWORD) {
  console.error('Missing Neo4j connection details in environment variables.');
  process.exit(1);
}

const driver = neo4j.driver(NEO4J_URI, neo4j.auth.basic(NEO4J_USER, NEO4J_PASSWORD));

async function main() {
  const session = driver.session({ database: 'neo4j' });
  try {
    console.log('Starting dependency synchronization...');

    // 清理旧数据,确保图谱的实时性
    console.log('Clearing existing graph...');
    await session.executeWrite(tx => tx.run('MATCH (n) DETACH DELETE n'));

    const packageJsonPaths = await glob('packages/**/package.json', { ignore: '**/node_modules/**' });
    
    // 第一次遍历:创建所有包节点
    console.log(`Found ${packageJsonPaths.length} packages. Creating nodes...`);
    for (const p of packageJsonPaths) {
      const content = await readFile(p, 'utf-8');
      const pkg = JSON.parse(content);
      if (!pkg.name) continue;

      // 使用 MERGE 避免重复创建,它是幂等的
      await session.executeWrite(tx =>
        tx.run(
          `MERGE (p:Package {name: $name})
           ON CREATE SET p.version = $version, p.path = $path`,
          { name: pkg.name, version: pkg.version, path: path.dirname(p) }
        )
      );
    }

    // 第二次遍历:创建关系
    console.log('Creating dependency relationships...');
    for (const p of packageJsonPaths) {
      const content = await readFile(p, 'utf-8');
      const pkg = JSON.parse(content);
      if (!pkg.name) continue;

      const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
      for (const depName in allDeps) {
        // 只处理 monorepo 内部的依赖
        if (depName.startsWith('@my-scope/')) {
          const type = pkg.dependencies?.[depName] ? 'PROD' : 'DEV';
          await session.executeWrite(tx =>
            tx.run(
              `MATCH (p1:Package {name: $pkgName})
               MATCH (p2:Package {name: $depName})
               MERGE (p1)-[r:DEPENDS_ON {type: $type}]->(p2)
               RETURN r`,
              { pkgName: pkg.name, depName: depName, type: type }
            )
          );
        }
      }
    }
    console.log('Synchronization complete!');
  } catch (error) {
    console.error('Synchronization failed:', error);
  } finally {
    await session.close();
    await driver.close();
  }
}

main();

这个脚本最酷的地方在于它的健壮性。使用 MERGE 而不是 CREATE 保证了操作的幂等性,即使脚本中断重跑也不会产生重复数据。

第二步:API 层 - GraphQL 与 Cypher 的优雅结合

我们使用 Apollo Server 和 graphql-yoga 构建了一个 GraphQL API。GraphQL 的查询结构与我们探索依赖关系的需求完美契合。

// src/graphql/schema.ts
import { createSchema } from 'graphql-yoga';

export const schema = createSchema({
  typeDefs: /* GraphQL */ `
    type Package {
      name: String!
      version: String
    }

    type Edge {
      from: String!
      to: String!
      type: String!
    }

    type Graph {
      nodes: [Package!]!
      edges: [Edge!]!
    }

    type Query {
      getDependencies(packageName: String!, depth: Int = 2): Graph
    }
  `,
  resolvers: {
    Query: {
      getDependencies: async (_, { packageName, depth }, { driver }) => {
        const session = driver.session();
        try {
          const result = await session.run(
            `
            MATCH path = (p:Package {name: $packageName})-[r:DEPENDS_ON*1..${depth}]->(dep)
            WITH collect(path) as paths
            UNWIND nodes(path) as node
            UNWIND relationships(path) as rel
            RETURN
              collect(DISTINCT { name: node.name, version: node.version }) as nodes,
              collect(DISTINCT {
                from: startNode(rel).name,
                to: endNode(rel).name,
                type: rel.type
              }) as edges
            `,
            { packageName }
          );

          if (result.records.length === 0) {
            return { nodes: [], edges: [] };
          }
          
          // result.records[0] 可能为空,需要健壮性处理
          const record = result.records[0];
          const nodes = record.get('nodes');
          const edges = record.get('edges');
          
          // 确保起始节点也被包含
          if (!nodes.some(n => n.name === packageName)) {
              // 实际应用中可能需要单独查询起始节点信息
              nodes.push({name: packageName, version: '...'});
          }

          return { nodes, edges };
        } finally {
          await session.close();
        }
      },
    },
  },
});

这个 resolver 展示了 Cypher 的强大之处。一个查询就能返回指定深度下的所有节点和关系,并且 UNWINDcollect 的组合拳让数据塑造成了前端友好的格式。

第三步:前端状态管理与渲染

这是项目的核心挑战。我们使用 jotai 来创建原子化的状态,并结合 vis-network 这个库来渲染图形。

// src/store/graph-store.ts
import { atom } from 'jotai';
import { atomWithImmer } from 'jotai-immer';

type Node = { id: string; label: string; version?: string };
type Edge = { from: string; to: string; type: string };

interface GraphState {
  nodes: Map<string, Node>;
  edges: Map<string, Edge>;
  isLoading: boolean;
  error: string | null;
  selectedNode: string | null;
}

// 使用 immer 来简化 Map 的不可变更新
export const graphStateAtom = atomWithImmer<GraphState>({
  nodes: new Map(),
  edges: new Map(),
  isLoading: false,
  error: null,
  selectedNode: null,
});

// 派生原子,用于 vis-network 消费
export const visGraphDataAtom = atom((get) => {
  const state = get(graphStateAtom);
  return {
    nodes: Array.from(state.nodes.values()),
    edges: Array.from(state.edges.values()).map(e => ({...e, arrows: 'to'})),
  };
});

// 异步 action 原子,用于加载数据
export const fetchGraphDataAtom = atom(
  null,
  async (get, set, packageName: string) => {
    set(graphStateAtom, (draft) => {
      draft.isLoading = true;
      draft.error = null;
    });

    try {
      // 这里的 fetchGraphQL 是一个封装好的 GraphQL 请求函数
      const data = await fetchGraphQL(`
        query GetDeps($name: String!) {
          getDependencies(packageName: $name, depth: 2) {
            nodes { name, version }
            edges { from, to, type }
          }
        }
      `, { name: packageName });

      set(graphStateAtom, (draft) => {
        // 清空旧数据,也可以实现合并逻辑
        draft.nodes.clear();
        draft.edges.clear();
        data.getDependencies.nodes.forEach(node => {
          draft.nodes.set(node.name, { id: node.name, label: node.name, version: node.version });
        });
        data.getDependencies.edges.forEach(edge => {
          const edgeId = `${edge.from}->${edge.to}`;
          draft.edges.set(edgeId, edge);
        });
        draft.isLoading = false;
      });
    } catch (e: any) {
      set(graphStateAtom, (draft) => {
        draft.isLoading = false;
        draft.error = e.message || 'Failed to fetch graph data';
      });
    }
  }
);

React 组件的实现就变得异常清爽:

// src/components/DependencyGraph.tsx
import { useAtom, useSetAtom } from 'jotai';
import Graph from 'react-graph-vis';
import { visGraphDataAtom, fetchGraphDataAtom, graphStateAtom } from '../store/graph-store';
import { useEffect } from 'react';

const GraphComponent = ({ initialPackage }) => {
  const [visData] = useAtom(visGraphDataAtom);
  const [{ isLoading, error }] = useAtom(graphStateAtom);
  const fetchGraph = useSetAtom(fetchGraphDataAtom);

  useEffect(() => {
    if (initialPackage) {
      fetchGraph(initialPackage);
    }
  }, [initialPackage, fetchGraph]);
  
  const options = { /* ... vis-network 配置 ... */ };

  if (isLoading) return <div>Loading graph...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <Graph
      graph={visData}
      options={options}
      // style={{ height: "800px" }}
    />
  );
};

这种结构的美妙之处在于,状态逻辑和视图逻辑完全分离。graphStateAtom 负责存储真实的数据结构(Map),而 visGraphDataAtom 负责将它转换为渲染库所需的格式(Array)。组件只需要消费最终的数据,并调用 action 来触发数据获取。这就是我们想要的“定制化”状态管理——既高效又清晰。

第四步:集成 Prettier 作为平台能力

IDP 不仅是观察工具,更是治理工具。我们利用 Prettier 的 Node.js API,提供了一个“代码格式化”功能,以推广统一的代码风格。

// src/api/format-code.ts
import { format } from 'prettier';
import type { NextApiRequest, NextApiResponse } from 'next';

// 假设我们已经有了一个基础的 prettier 配置
const prettierConfig = {
  semi: true,
  singleQuote: true,
  parser: 'typescript',
};

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method Not Allowed' });
  }

  try {
    const { code } = req.body;
    if (typeof code !== 'string') {
      return res.status(400).json({ message: 'Invalid code input' });
    }
    
    const formattedCode = await format(code, prettierConfig);
    res.status(200).json({ formattedCode });
  } catch (error: any) {
    // Prettier 在解析失败时会抛出错误
    res.status(400).json({ message: 'Formatting failed', error: error.message });
  }
}

这个简单的 API 端点,却是整个平台理念的点睛之笔。它表明我们的 IDP 正从一个被动的观察者,演变为一个主动的、提升工程质量的赋能者。

最终架构与成果

我们最终的系统架构如下:

graph TD
    subgraph Monorepo
        A[packages/*] -- File System Scan --> B(Node.js Sync Script)
    end
    
    B -- Cypher over Bolt --> C[Neo4j Database]
    
    subgraph IDP Backend
        D[GraphQL Server] -- Cypher Query --> C
    end

    subgraph "IDP Frontend (Next.js + Turbopack)"
        E[React UI] -- GraphQL Request --> D
        E -- Uses --> F(Custom Jotai State)
        F -- Renders via --> G(vis-network)
    end
    
    H[Developer's Browser] -- Interacts with --> E
    I[CI/CD Pipeline] -- Triggers --> B

最令人兴奋的成果是 Turbopack 带来的开发体验。在如此复杂的应用中,代码的修改几乎是瞬时反馈在浏览器上,没有任何可感知的延迟。这让我们团队在迭代这个工具时,能够保持极高的心流状态。而最终用户——公司的其他开发者——现在只需在我们的 IDP 中输入一个包名,就能立即看到一张动态、可交互的依赖星图,极大地降低了变更前的风险评估成本。

局限与未来的迭代方向

这套方案并非完美。Neo4j 的运维和资源消耗需要专门的知识,对于小团队可能是一个负担。我们的前端可视化在面对超过1000个节点时,即使有优化的状态管理,浏览器渲染本身也会成为瓶颈,未来需要引入 WebGL 渲染引擎或者实现更智能的节点聚合与层级细节(LOD)展示。

Turbopack 虽然快,但生态系统和插件支持与 Webpack 相比仍在追赶,我们在集成一些特定分析工具时遇到了一些阻碍。这提醒我们,选择前沿技术总是在享受其带来的极致性能的同时,也承担着生态不成熟的风险。

下一步,我们计划将这个静态的依赖图与运行时的服务调用链数据(来自 OpenTelemetry)结合起来,构建一个更加立体、动态的系统“数字孪生”。那将是一个更酷的挑战。


  目录