我们团队维护着一个庞大的 TypeScript Monorepo,代码量早已突破三百万行。随之而来的一个巨大痛点是 CI 流水线中的静态分析阶段。最初,我们依赖 ESLint 执行代码规范和一些简单的逻辑检查,但随着业务复杂度的提升,我们需要检测一些跨文件、跨模块的深层依赖问题,例如:
- 检测是否存在超过 N 层的“服务A -> 服务B -> … -> 服务A”的循环依赖。
- 识别出“废弃模块”被新业务代码意外引用的情况。
- 追踪某个核心类型定义的所有最终消费点,以评估变更影响。
传统的 ESLint 规则在处理这类问题时力不从心。它们的执行模型是基于单个文件的 AST(抽象语法树),执行跨文件分析通常需要复杂的、性能低下的自定义处理器。我们的 CI lint 阶段一度超过了 45 分钟,这严重拖慢了开发迭代速度。问题的根源在于,每次 CI 都需要从零开始,在海量文件中重新构建依赖关系图,这是一项巨大的重复性计算。
为了彻底解决这个问题,我们决定将分析任务从 CI 阶段剥离,构建一个独立的、异步的代码分析平台。其核心思想是:将整个代码库的结构——文件、函数、类、接口、依赖关系——建模成一个大型图数据库,然后提供一个高性能的查询服务来执行那些复杂的分析任务。CI 中的 ESLint 规则将被改造得非常轻量,只负责调用这个外部服务。
技术选型决策的艰难权衡
这个构想落地需要一个相当特殊的技术栈。
数据存储: ArangoDB
我们评估了多种图数据库,最终选择了 ArangoDB。原因在于其多模型特性。我们可以用 Document 集合存储每个文件的元数据(路径、大小、最近修改者),用另一个 Document 集合存储代码中的实体(函数、类),然后用 Edge 集合来表示它们之间的关系(import
,call
,implement
)。这种混合模型比纯图数据库更灵活。ArangoDB 的查询语言 AQL 语法富有表现力,非常适合进行复杂的图遍历查询,例如查找任意深度的路径和环。高性能查询服务: Zig
这是最大胆的决定。查询服务的核心任务是接收请求,构造复杂的 AQL,发送给 ArangoDB,然后对返回的巨大结果集进行处理、聚合和格式化。这个服务必须是内存高效和计算高效的。- 为什么不是 Go/Rust? Go 的 GC 在处理从数据库返回的 GB 级 JSON 响应时,可能会引发不可预测的 STW(Stop-The-World)暂停,影响 P99 延迟。Rust 固然优秀,但其陡峭的学习曲线和复杂的生命周期管理,对于我们这个需要快速迭代的小团队来说,时间成本过高。
- 为什么是 Zig? Zig 提供了我们想要的一切:C 语言级别的性能和控制力,但拥有更现代、更安全的语言特性。它的
comptime
(编译期执行)能力让我们可以生成高度优化的代码。最关键的是,它的手动内存管理(通过 Allocator)让我们能精确控制处理大规模数据时的内存分配与释放,完全避免了 GC 带来的不确定性。同时,与 C 库的无缝集成也意味着我们可以直接使用 ArangoDB 官方或社区的 C 驱动程序。
前端可视化: Qwik
我们需要一个内部仪表盘来可视化分析结果。这些结果可能是巨大的依赖图或长长的文件列表。我们不希望用户打开页面后等待一个庞大的 JS 包下载、解析和执行。Qwik 的 “Resumability” (可恢复性) 概念在这里完美契合。服务端渲染出 HTML,浏览器无需执行任何 JavaScript 即可交互。只有当用户真正点击某个按钮或进行交互时,相关的极小部分代码才会被下载执行。这对于一个数据展示密集、但交互相对简单的内部工具来说,是极致的首屏加载体验。可观测性: Zipkin
整个分析流程是分布式的:ESLint插件 -> API网关 -> Zig查询服务 -> ArangoDB
。当一个查询变慢时,我们需要立刻知道瓶颈在哪里。是在 AQL 执行?还是在 Zig 服务的数据处理阶段?因此,从第一天起就引入分布式链路追踪是必须的。我们选择 Zipkin 是因为它简单、成熟,并且有大量的社区库支持,即使 Zig 没有官方库,我们也可以通过 HTTP 协议轻松地将 Span 数据上报给 Collector。入口: 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 服务在处理结果时消耗了过多时间,使得性能优化有了明确的方向。
当然,这个方案并非完美,仍有一些局限性和待优化的点:
- 数据同步延迟: 目前的数据导入是离线批处理的,这意味着分析结果可能不是最新的。更理想的方案是基于 Git Hooks 实现增量、实时的更新 ArangoDB 中的数据。
- Zig 服务健壮性: 当前的 Zig 服务实现还比较初级,错误处理、连接池管理、优雅停机等生产级特性仍需完善。直接使用 HTTP 调用 ArangoDB 也有性能开销,后续可以考虑集成 VelocyStream C Driver 来获得更好的性能。
- 查询能力: 并非所有复杂的代码分析都能轻易地用 AQL 表达。对于一些需要程序化、状态化遍历的分析场景,可能需要在 Zig 服务中实现更复杂的图算法,而不仅仅是转发查询。
- 运维成本: 引入了一套新的服务栈(ArangoDB, Zig Service, Zipkin),增加了系统的运维复杂度和成本。这需要团队在 SRE 能力上有所投入。
接下来的迭代方向将聚焦于实现数据的实时同步,以及丰富 Zig 服务的分析能力,让它不仅仅是一个查询代理,而是一个真正强大的、通用的代码图计算引擎。