利用 Zig 构建与 ArangoDB 集成的分布式代码分析引擎并集成 Zipkin 链路追踪


我们团队维护着一个庞大的 TypeScript Monorepo,代码量早已突破三百万行。随之而来的一个巨大痛点是 CI 流水线中的静态分析阶段。最初,我们依赖 ESLint 执行代码规范和一些简单的逻辑检查,但随着业务复杂度的提升,我们需要检测一些跨文件、跨模块的深层依赖问题,例如:

  1. 检测是否存在超过 N 层的“服务A -> 服务B -> … -> 服务A”的循环依赖。
  2. 识别出“废弃模块”被新业务代码意外引用的情况。
  3. 追踪某个核心类型定义的所有最终消费点,以评估变更影响。

传统的 ESLint 规则在处理这类问题时力不从心。它们的执行模型是基于单个文件的 AST(抽象语法树),执行跨文件分析通常需要复杂的、性能低下的自定义处理器。我们的 CI lint 阶段一度超过了 45 分钟,这严重拖慢了开发迭代速度。问题的根源在于,每次 CI 都需要从零开始,在海量文件中重新构建依赖关系图,这是一项巨大的重复性计算。

为了彻底解决这个问题,我们决定将分析任务从 CI 阶段剥离,构建一个独立的、异步的代码分析平台。其核心思想是:将整个代码库的结构——文件、函数、类、接口、依赖关系——建模成一个大型图数据库,然后提供一个高性能的查询服务来执行那些复杂的分析任务。CI 中的 ESLint 规则将被改造得非常轻量,只负责调用这个外部服务。

技术选型决策的艰难权衡

这个构想落地需要一个相当特殊的技术栈。

  1. 数据存储: ArangoDB
    我们评估了多种图数据库,最终选择了 ArangoDB。原因在于其多模型特性。我们可以用 Document 集合存储每个文件的元数据(路径、大小、最近修改者),用另一个 Document 集合存储代码中的实体(函数、类),然后用 Edge 集合来表示它们之间的关系(import, call, implement)。这种混合模型比纯图数据库更灵活。ArangoDB 的查询语言 AQL 语法富有表现力,非常适合进行复杂的图遍历查询,例如查找任意深度的路径和环。

  2. 高性能查询服务: Zig
    这是最大胆的决定。查询服务的核心任务是接收请求,构造复杂的 AQL,发送给 ArangoDB,然后对返回的巨大结果集进行处理、聚合和格式化。这个服务必须是内存高效和计算高效的。

    • 为什么不是 Go/Rust? Go 的 GC 在处理从数据库返回的 GB 级 JSON 响应时,可能会引发不可预测的 STW(Stop-The-World)暂停,影响 P99 延迟。Rust 固然优秀,但其陡峭的学习曲线和复杂的生命周期管理,对于我们这个需要快速迭代的小团队来说,时间成本过高。
    • 为什么是 Zig? Zig 提供了我们想要的一切:C 语言级别的性能和控制力,但拥有更现代、更安全的语言特性。它的 comptime(编译期执行)能力让我们可以生成高度优化的代码。最关键的是,它的手动内存管理(通过 Allocator)让我们能精确控制处理大规模数据时的内存分配与释放,完全避免了 GC 带来的不确定性。同时,与 C 库的无缝集成也意味着我们可以直接使用 ArangoDB 官方或社区的 C 驱动程序。
  3. 前端可视化: Qwik
    我们需要一个内部仪表盘来可视化分析结果。这些结果可能是巨大的依赖图或长长的文件列表。我们不希望用户打开页面后等待一个庞大的 JS 包下载、解析和执行。Qwik 的 “Resumability” (可恢复性) 概念在这里完美契合。服务端渲染出 HTML,浏览器无需执行任何 JavaScript 即可交互。只有当用户真正点击某个按钮或进行交互时,相关的极小部分代码才会被下载执行。这对于一个数据展示密集、但交互相对简单的内部工具来说,是极致的首屏加载体验。

  4. 可观测性: Zipkin
    整个分析流程是分布式的:ESLint插件 -> API网关 -> Zig查询服务 -> ArangoDB。当一个查询变慢时,我们需要立刻知道瓶颈在哪里。是在 AQL 执行?还是在 Zig 服务的数据处理阶段?因此,从第一天起就引入分布式链路追踪是必须的。我们选择 Zipkin 是因为它简单、成熟,并且有大量的社区库支持,即使 Zig 没有官方库,我们也可以通过 HTTP 协议轻松地将 Span 数据上报给 Collector。

  5. 入口: ESLint
    我们保留 ESLint 作为流程的入口。开发者体验保持不变,他们仍然是在代码编辑器或者 git commit 时得到反馈。我们编写了一个自定义的 ESLint 规则,它的唯一作用就是将当前文件的上下文信息(路径、AST 摘要)发送给我们的分析平台,并等待结果。

架构与实现细节

下面是整个系统的架构图和关键代码实现。

graph TD
    subgraph "CI / Developer Machine"
        A[ESLint Plugin]
    end

    subgraph "Code Analysis Platform"
        B(API Gateway)
        C{Zig Query Service}
        D[(ArangoDB)]
        E((Zipkin Collector))
    end

    subgraph "Visualization"
        F[Qwik Frontend]
    end

    A -- "POST /analyze" --> B
    B -- "Forward Request" --> C
    C -- "AQL Query" --> D
    C -- "Report Spans" --> E
    D -- "Query Result" --> C
    B -- "Return Result" --> A
    F -- "Load Data" --> B

1. 数据模型与导入

我们设计了两个 Document Collection (code_files, code_entities) 和一个 Edge Collection (dependencies)。

一个 Node.js 脚本负责解析整个代码库,并填充 ArangoDB。它使用 @typescript-eslint/parser 生成 AST。

// simplified_importer.js
const { ArangoDB } = require('arangojs');
const fs = require('fs');
const path = require('path');
const { parse } = require('@typescript-eslint/parser');
const ESTreeWalker = require('estree-walker');

// ArangoDB client setup (error handling omitted for brevity)
const db = new ArangoDB({ url: "http://localhost:8529" });
db.useDatabase('code_analysis');
db.useBasicAuth('root', 'password');

const filesCollection = db.collection('code_files');
const entitiesCollection = db.collection('code_entities');
const depsCollection = db.edgeCollection('dependencies');

async function processFile(filePath) {
    const code = fs.readFileSync(filePath, 'utf-8');
    const relativePath = path.relative(process.cwd(), filePath);
    const fileDocKey = Buffer.from(relativePath).toString('hex');
    
    // Create or update file document
    await filesCollection.save({ _key: fileDocKey, path: relativePath, last_processed: new Date() });
    
    const ast = parse(code, { sourceType: 'module', filePath });

    const walker = new ESTreeWalker.Walker();
    walker.walk(ast, {
        enter(node) {
            if (node.type === 'ImportDeclaration') {
                const source = node.source.value;
                // Naive resolution for example purposes
                const targetPath = path.resolve(path.dirname(filePath), source) + '.ts';
                if (fs.existsSync(targetPath)) {
                    const targetRelativePath = path.relative(process.cwd(), targetPath);
                    const targetDocKey = Buffer.from(targetRelativePath).toString('hex');

                    // Create dependency edge
                    depsCollection.save({
                        _from: `code_files/${fileDocKey}`,
                        _to: `code_files/${targetDocKey}`,
                        type: 'static_import'
                    }).catch(err => { /* handle duplicates */ });
                }
            }
        }
    });
}

// Main function to walk directory and process files
async function runImporter() {
    // ... logic to walk project directory ...
    // await processFile('path/to/some/file.ts');
}

runImporter();

这个脚本是离线运行的,比如每天晚上定时全量更新,或者通过 Git Hook 触发增量更新。

2. Zig 高性能查询服务

这是系统的核心。我们使用 Zig 的标准库和社区的 zig-json 库。为了简单起见,这里使用 Zig 的 HTTP client 向 ArangoDB 的 REST API 发送 AQL 查询,并手动上报 Span 到 Zipkin。

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const exe = b.addExecutable(.{
        .name = "query-service",
        .root_source_file = .{ .path = "src/main.zig" },
        .target = target,
        .optimize = optimize,
    });

    // In a real project, you would add dependencies here
    // For example:
    // const json_dep = b.dependency("zig-json", .{...});
    // exe.addModule("json", json_dep.module("json"));

    b.installArtifact(exe);
}

src/main.zig:

const std = @import("std");
const http = std.http;
const json = std.json;
const net = std.net;
const mem = std.mem;
const time = std.time;

// A simple Arena-based allocator for request scope memory management.
const RequestAllocator = std.heap.ArenaAllocator;

// Simplified Zipkin Span structure
const Span = struct {
    traceId: []const u8,
    id: []const u8,
    name: []const u8,
    timestamp: u64,
    duration: u64,
    localEndpoint: struct {
        serviceName: []const u8 = "zig-query-service",
    },
    tags: std.StringHashMap([]const u8),

    pub fn init(allocator: mem.Allocator, trace_id: []const u8, name: []const u8) !Span {
        var self: Span = .{
            .traceId = trace_id,
            .id = try std.fmt.allocPrint(allocator, "{x}", .{std.crypto.random.int(u64)}),
            .name = name,
            .timestamp = @intCast(time.timestamp()),
            .duration = 0,
            .localEndpoint = .{},
            .tags = std.StringHashMap([]const u8).init(allocator),
        };
        return self;
    }
};

// Function to report spans to Zipkin collector
fn reportToZipkin(allocator: mem.Allocator, spans: []const Span) !void {
    // In a real app, use a dedicated client. Here we do a simple HTTP POST.
    var client = http.Client{ .allocator = allocator };
    defer client.deinit();

    var uri = try std.Uri.parse("http://localhost:9411/api/v2/spans");
    var req = try client.request(.POST, uri, .{ .allocator = allocator });
    defer req.deinit();

    req.headers.append("Content-Type", "application/json") catch unreachable;

    var json_buffer = std.ArrayList(u8).init(allocator);
    defer json_buffer.deinit();

    var writer = json_buffer.writer();
    try json.stringify(spans, .{}, writer);

    try req.start();
    try req.wait();
}

fn handleRequest(allocator: *RequestAllocator, stream: net.Stream) !void {
    const gpa = allocator.allocator();

    // In a real server, parse the request properly.
    // Here we hardcode the logic for finding circular dependencies.
    const file_key = "hex_encoded_file_key_from_request"; // Example key

    // --- Zipkin Tracing Starts ---
    var spans = std.ArrayList(Span).init(gpa);
    const trace_id = try std.fmt.allocPrint(gpa, "{x}", .{std.crypto.random.int(u64)});
    var root_span = try Span.init(gpa, trace_id, "handle_request");
    const start_time = try time.Instant.now();

    // --- ArangoDB Query Logic ---
    var arango_span = try Span.init(gpa, trace_id, "arango_query");
    const arango_start = try time.Instant.now();
    
    const aql_query = 
    \\FOR v, e, p IN 2..10 OUTBOUND 'code_files/{s}' dependencies
    \\  FILTER v._id == 'code_files/{s}'
    \\  RETURN p
    ;
    const formatted_aql = try std.fmt.allocPrint(gpa, aql_query, .{ .s = file_key });
    
    // Imagine calling ArangoDB and getting a result
    // const arango_result = try executeArangoQuery(gpa, formatted_aql);
    const arango_result = "{\"result\":[]}"; // Placeholder
    
    const arango_end = try time.Instant.now();
    arango_span.duration = @intCast((arango_end.since(arango_start)));
    try arango_span.tags.put("db.statement", formatted_aql);
    try spans.append(arango_span);

    // --- Business Logic / Data Processing ---
    var processing_span = try Span.init(gpa, trace_id, "process_result");
    const proc_start = try time.Instant.now();

    // Parse `arango_result` and process it...
    // This is where Zig's performance shines: parsing huge JSON without GC.
    std.time.sleep(10 * time.ns_per_ms); // Simulate work

    const proc_end = try time.Instant.now();
    processing_span.duration = @intCast(proc_end.since(proc_start));
    try spans.append(processing_span);

    // --- Finalize Tracing and Respond ---
    const end_time = try time.Instant.now();
    root_span.duration = @intCast(end_time.since(start_time));
    try spans.append(root_span);

    // Asynchronously report spans to Zipkin.
    // In a production system, this would be done in a background thread or queue.
    reportToZipkin(gpa, spans.items) catch |err| {
        std.log.err("Failed to report to zipkin: {}", .{err});
    };
    
    // --- Send HTTP response ---
    const response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"status\":\"ok\"}";
    _ = try stream.writeAll(response);
}

pub fn main() !void {
    var listener = try net.tcpListen("127.0.0.1", 8080);
    defer listener.deinit();

    std.log.info("Server listening on 8080...", .{});

    while (true) {
        const conn = try listener.accept();

        // One-shot arena allocator per connection.
        // All memory for a single request is allocated here and freed all at once.
        // This is a simple but powerful memory management strategy.
        var arena = RequestAllocator.init(std.heap.page_allocator);
        defer arena.deinit();

        handleRequest(&arena, conn.stream) catch |err| {
            std.log.err("Error handling request: {}", .{err});
        };
        conn.close();
    }
}

这段代码展示了核心思路:

  • 为每个请求创建一个 Arena Allocator,所有该请求的内存都在此分配,请求结束时一次性释放,简单高效。
  • 手动创建和填充 Zipkin Span 结构体,包含 traceId, spanId, name, duration 和 tags。
  • 关键操作(数据库查询、业务处理)都被包裹在独立的 Span 中,以便精确定位耗时。
  • 通过一个简单的 HTTP POST 请求将 Span 数据批量上报给 Zipkin Collector。

3. Qwik 前端与 ESLint 插件

Qwik 前端部分主要是一个数据获取和展示的组件。

src/routes/analysis/index.tsx:

import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';

// routeLoader$ runs on the server to fetch data.
export const useAnalysisData = routeLoader$(async (requestEv) => {
  const filePath = requestEv.query.get('file');
  if (!filePath) return { error: 'File path required' };

  // This call goes to our Zig backend.
  // The fetch call needs to propagate trace context headers (e.g., b3)
  // for the Zipkin trace to be connected.
  const response = await fetch(`http://localhost:8080/analyze/circular-deps?file=${filePath}`, {
    headers: {
        // Propagate trace context if available
        'X-B3-TraceId': requestEv.headers.get('X-B3-TraceId') || '...',
        'X-B3-SpanId': '...'
    }
  });
  if (!response.ok) {
    return { error: `Failed to fetch analysis: ${response.statusText}` };
  }
  return response.json();
});

export default component$(() => {
  const data = useAnalysisData();

  return (
    <div>
      <h1>Analysis Result</h1>
      {data.value.error ? (
        <p class="error">{data.value.error}</p>
      ) : (
        <ul>
          {data.value.paths?.map((path: any) => (
            <li>{path.join(' -> ')}</li>
          ))}
        </ul>
      )}
    </div>
  );
});

自定义的 ESLint 规则变得非常简单:

rules/check-complex-deps.js:

module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "Check for complex dependencies via remote service",
    },
    fixable: null,
    schema: [],
  },
  create: function (context) {
    let hasChecked = false;
    return {
      // We run this once per file on Program exit.
      "Program:exit": function (node) {
        if (hasChecked) return;
        hasChecked = true;
        
        const filePath = context.getFilename();
        
        // In a real implementation, this would be a synchronous call
        // or a more sophisticated async bridge. For simplicity, we use a sync-request lib.
        const request = require('sync-request');

        try {
          const res = request('POST', 'http://localhost:8080/analyze', {
            json: {
              filePath,
              rule: 'circular-dependency',
            },
          });
          const result = JSON.parse(res.getBody('utf8'));

          if (result.hasViolation) {
            context.report({
              node: node,
              message: `Complex dependency violation found: ${result.message}`,
            });
          }
        } catch (e) {
            // Handle service unavailable error, maybe just warn.
            console.warn(`Code analysis service request failed: ${e.message}`);
        }
      },
    };
  },
};

这个 ESLint 规则本身几乎不做任何计算,它将分析的重担完全委托给了 Zig 后端服务。

成果与遗留问题

这套系统上线后,效果立竿见影。CI 中的 lint 阶段从 45 分钟缩短到了 3 分钟以内,因为它只做最基本的语法检查和对我们分析平台的几次 API 调用。开发者现在可以通过 Qwik 仪表盘主动探索代码库的依赖关系,极大地提升了重构和评估技术债务的效率。当出现慢查询时,我们能通过 Zipkin 的火焰图清晰地看到是 AQL 查询本身慢,还是 Zig 服务在处理结果时消耗了过多时间,使得性能优化有了明确的方向。

当然,这个方案并非完美,仍有一些局限性和待优化的点:

  1. 数据同步延迟: 目前的数据导入是离线批处理的,这意味着分析结果可能不是最新的。更理想的方案是基于 Git Hooks 实现增量、实时的更新 ArangoDB 中的数据。
  2. Zig 服务健壮性: 当前的 Zig 服务实现还比较初级,错误处理、连接池管理、优雅停机等生产级特性仍需完善。直接使用 HTTP 调用 ArangoDB 也有性能开销,后续可以考虑集成 VelocyStream C Driver 来获得更好的性能。
  3. 查询能力: 并非所有复杂的代码分析都能轻易地用 AQL 表达。对于一些需要程序化、状态化遍历的分析场景,可能需要在 Zig 服务中实现更复杂的图算法,而不仅仅是转发查询。
  4. 运维成本: 引入了一套新的服务栈(ArangoDB, Zig Service, Zipkin),增加了系统的运维复杂度和成本。这需要团队在 SRE 能力上有所投入。

接下来的迭代方向将聚焦于实现数据的实时同步,以及丰富 Zig 服务的分析能力,让它不仅仅是一个查询代理,而是一个真正强大的、通用的代码图计算引擎。


  目录