一、先聊聊为什么要折腾文档导航

上个月我们组来了个新同学,我给他安排了个小任务:先去看一眼订单模块的源码,理一理调用关系。结果他对着 Javadoc 愣了半天,最后跑来问我:“哥,订单模块到底在哪个包里?” 我凑过去一看,屏幕上是密密麻麻的包名,按字母顺序排得整整齐齐,有 com.shop.commoncom.shop.order.apicom.shop.order.daocom.shop.user.service…… 说实话,我第一次看这页面的时候也是这样,眼睛扫了三分钟,硬是没找到自己想看的业务模块。

这不是新鲜事,几乎所有团队都会遇到类似问题。Javadoc 能把类的文档生成得很规范,但它默认的“包列表”只是把包名按字母顺序罗列出来,完全不考虑包和包之间的业务归属。新人本来就不熟悉系统结构,面对这种平铺的列表,别说理解功能地图,连自己想找的类在哪个包都得靠猜。

后来我们团队花了一个下午,做了一件简单但特别管用的事:按业务模块重新组织 Javadoc 的导航层级。简单说,就是让文档首页不再是一张字母序包名表,而是一张业务模块功能地图。新人在读源码之前,先看这张地图,知道“订单功能对应哪些包”“用户功能对应哪些包”,然后再往下钻。这个改动看起来不起眼,但带来的体验提升肉眼可见。至少我们组再也没人问我“订单模块在哪”这种问题了。

这件事让我意识到,文档建设不是非要做多宏大的平台,把信息组织好,让人更快找到它,就是巨大的价值。尤其是 Javadoc 这种团队里每天都在用的东西,稍微调整一下导航结构,就能把团队知识门槛降下来,促进新老成员之间的知识传递。长期来看,这点投入非常值得。

二、传统 Javadoc 的痛点在哪

先说说 Javadoc 默认导航长什么样。它通常包含一个 overview-tree(全部包和类的继承关系树)以及一个按字母排序的所有包列表。这两个入口都有各自的用途,但对新人非常不友好。

第一,包名是平铺的。com.shop.order.apicom.shop.order.service 明明都属于订单业务,但在字母序列表里,api 排在前面,service 排在中后段,中间还隔着 com.shop.user.apicom.shop.product.api 等一堆别的包。新人要手动把散落的包名在脑子里按业务线重新拼装起来,这个过程很容易出错。

第二,包名本身只能表达“代码目录结构”,表达不了“业务归属”。比如 com.shop.order.api 包里有 OrderControllerOrderService,但在 Javadoc 首页,你看到它们是散在列表里的,不会有一个“订单中心”的标题把相关的类拢在一起。新人想知道订单和支付怎么联动,往往得自己靠经验猜,或者追着老员工问。

第三,新人对系统的全局功能没有概念。他打开文档,想的是“我要看下单流程”,但页面没有给他任何关于“下单流程涉及哪些包、哪些类”的提示。他只能通过搜索框搜类名,如果搜不到,就陷入大海捞针。时间久了,新人干脆不看 Javadoc,直接去翻源码目录,反而更高效。这显然浪费了 Javadoc 本身的文档价值。

三、按业务模块重新组织的思路

解决思路很朴素:把每一个包都打上一个“业务模块”的标签,然后按标签分组展示。比如,我们项目有订单、用户、商品、支付四个大业务。如果包名前缀是 com.shop.order,就归到“订单中心”;前缀是 com.shop.user,就归到“用户中心”。这样生成出来的导航层级就是:

订单中心
  ├─ com.shop.order.api
  ├─ com.shop.order.dao
  └─ com.shop.order.service
用户中心
  ├─ com.shop.user.api
  └─ com.shop.user.service

其实 Javadoc 本身提供了一个 -group 参数,可以在生成文档时对包做分组。但老实讲,那个参数用起来有点笨重,只能做一层分组,而且分组的条件得一行行手写,如果项目包很多,配置会变得又长又难维护。更重要的是,它不支持嵌套,没法做“业务模块 -> 子模块 -> 包”这种多级结构,也不方便在导航里附带模块说明。

所以,我们决定自己写一个小工具,用它来读取 Javadoc 生成的包列表,再结合一份“包前缀到模块”的映射配置,自动生成我们想要的导航树。这个工具不用多高级,能跑、能维护、能交给 CI/CD 执行就行。

四、用 Node.js 写一个自动生成导航的小工具

下面的示例统一使用 JavaScript(Node.js)技术栈。这个选择很随意,因为 Node.js 在团队里很多人都会,而且处理文件、解析 JSON 都特别方便,不需要额外装环境。

4.1 准备素材:Javadoc 的包列表和模块映射

Javadoc 在生成文档时,会输出一个叫 package-list 的文件,里面每一行是一个包名,没有别的额外信息。为了演示,我准备了一个类似的文件,内容是这样:

com.shop.common
com.shop.order.api
com.shop.order.dao
com.shop.order.service
com.shop.product.api
com.shop.user.api
com.shop.user.service

注意,这个文件在真实项目里由 Javadoc 自动生成,我们不需要手写。

接下来,我们需要一份“模块映射配置”。它负责定义每个业务模块名,以及该模块包含哪些包前缀。我们把它放到一个 JSON 文件里,取名 module-map.json,内容如下:

{
  "modules": [
    {
      "name": "订单中心",
      "prefixes": ["com.shop.order"]
    },
    {
      "name": "用户中心",
      "prefixes": ["com.shop.user"]
    },
    {
      "name": "商品中心",
      "prefixes": ["com.shop.product"]
    },
    {
      "name": "公共基础",
      "prefixes": ["com.shop.common"]
    }
  ]
}

这份配置很好理解,prefixes 里写的是包名起始部分,脚本会把所有以该前缀开头的包归到对应模块里。想调整分组时,只需要改这个 JSON,不用改代码。

4.2 核心脚本:读取、解析、生成导航树

下面这段脚本是整个方案的关键。它读取上面说的两个文件,按模块把包分好类,然后在控制台打印出一棵导航树。为了让大家看得清楚,我在代码里加了详细的注释。

// ------------------------------------------------------------------
// 文件:generate-nav.js
// 作用:读取 Javadoc 的 package-list,结合模块映射,生成导航树
// 运行:node generate-nav.js
// 依赖:Node.js 自带 fs 和 path,无需安装第三方包
// ------------------------------------------------------------------

const fs = require('fs');
const path = require('path');

// 1. 读取 Javadoc 标准输出的包列表文件
//    真实项目中,这个文件在 Javadoc 生成目录里,比如 ./docs/package-list
const packageListFile = path.join(__dirname, 'package-list');
const packageListContent = fs.readFileSync(packageListFile, 'utf-8');

// 2. 把文件内容按换行拆开,去掉空行,得到包名数组
const packages = packageListContent
  .split('\n')                     // 按换行切成多行
  .map(line => line.trim())        // 去掉每行首尾的空格
  .filter(line => line !== '');    // 过滤掉空行

// 3. 读取模块映射配置(就是前面的 module-map.json)
const moduleMapFile = path.join(__dirname, 'module-map.json');
const moduleMap = JSON.parse(fs.readFileSync(moduleMapFile, 'utf-8'));

// 4. 按模块前缀,把包名分组
function groupPackages(packages, moduleMap) {
  // 先把每个模块准备好,都给一个空的“包名列表”
  const categories = moduleMap.modules.map(mod => ({
    name: mod.name,
    packages: []
  }));

  // 用来存放“没有匹配到任何模块”的包,避免信息丢失
  const unmatched = [];

  // 遍历每一个包名,去 modules 里找有没有匹配的前缀
  packages.forEach(pkg => {
    const matchedModule = moduleMap.modules.find(mod =>
      // 如果包名等于前缀,或者以前缀加一个点开头,就认为匹配成功
      mod.prefixes.some(prefix =>
        pkg === prefix || pkg.startsWith(prefix + '.')
      )
    );

    if (matchedModule) {
      // 找到对应模块,就把包名塞进那个模块的数组里
      const category = categories.find(cat => cat.name === matchedModule.name);
      category.packages.push(pkg);
    } else {
      // 没匹配到的,统一放到“未分类”里
      unmatched.push(pkg);
    }
  });

  // 如果确实有没有归属的包,就追加一个“未分类”模块
  if (unmatched.length > 0) {
    categories.push({ name: '未分类', packages: unmatched });
  }

  // 把空模块过滤掉,避免展示一个空标题
  return categories.filter(cat => cat.packages.length > 0);
}

// 5. 把分组结果渲染成文本导航树(也可以改成 HTML)
function buildNavTree(packages, moduleMap) {
  const groups = groupPackages(packages, moduleMap);
  const lines = ['业务模块导航树'];

  groups.forEach(group => {
    // 每个模块一行,显示出包含几个包
    lines.push(`├─ ${group.name} (${group.packages.length} 个包)`);

    // 遍历该模块下的每个包,画上连接线
    group.packages.forEach((pkg, index) => {
      // 最后一个包用“└─”,前面的用“├─”,这样看起来像树
      const prefix = index === group.packages.length - 1 ? '│   └─ ' : '│   ├─ ';
      lines.push(prefix + pkg);
    });
  });

  return lines.join('\n');
}

// 6. 执行,把导航树打印到控制台
const navText = buildNavTree(packages, moduleMap);
console.log(navText);

这段脚本看起来很简陋,但实际用起来很顺手。它把包和模块的关系剥离开来,模块映射只存在于 module-map.json 里,包列表只存在于 Javadoc 的输出里,两者互不干扰。

4.3 运行脚本,看到效果

在命令行里执行 node generate-nav.js(这里不额外展示命令,代码注释里已经写了),控制台会打印出这样的内容:

业务模块导航树
├─ 订单中心 (3 个包)
│   ├─ com.shop.order.api
│   ├─ com.shop.order.dao
│   └─ com.shop.order.service
├─ 用户中心 (2 个包)
│   ├─ com.shop.user.api
│   └─ com.shop.user.service
├─ 商品中心 (1 个包)
│   └─ com.shop.product.api
└─ 公共基础 (1 个包)
    └─ com.shop.common

看到这个输出,新人的第一反应就不再是“包名好乱”,而是“哦,原来系统分这几个模块,每个模块有这些包”。这就是功能地图带来的价值。

4.4 怎么把它挂到 Javadoc 首页

文本树只是演示,真正要用起来,最好生成一个 HTML 页面。我们可以在脚本里把 navText 转换成一组 <ul><li> 标签,然后保存成 nav.html,再把它嵌进 Javadoc 的 overview.html 里。因为生成的导航本质上只是一棵静态树,嵌入方式很自由。有了这一步,新人打开 Javadoc 首页,第一眼看到的就是这张业务地图,而不是冷冰冰的包名列表。

五、这套方案能用到哪些场景

首先是新人入职。新人一般对业务不熟,对代码更不熟。给他们一张业务导航图,能直接告诉他“你想看退单逻辑,就去订单中心的 com.shop.order.api 找类”,这个输入足以让他直接开始读代码。

其次是老系统梳理。很多老项目包名混乱,甚至有一些历史遗留的奇怪前缀。我们可以先只配置一小块映射,把核心业务模块重新整理出来,等导航跑顺了,再慢慢调整代码包结构。这样不用大动干戈,就能先把文档梳理清楚。

再就是微服务或者多模块项目。这类项目往往有几十个包,分散在不同组件里。通过模块映射,我们可以定义“用户服务”“订单服务”“网关”等业务边界,让文档上的层级和真实的系统架构一一对应。

还有文档建设。领导问文档体系做得怎么样时,我们能把“业务导航首页”拿出来展示。虽然它只是一个小工具,但它的确解决了“找不到东西”这个核心问题,比单纯写一堆没人看的文章更有说服力。

六、技术优缺点分析

6.1 优点

它最大的优点是自动化程度高。我们只需要维护一份 JSON 配置文件,每次跑完 Javadoc,再跑一下这个脚本,就能得到一份最新的业务导航。配置改起来也很快,新增一个模块,加几行 JSON 就行。

第二个优点是可定制性强。我们可以按自己的需要添加模块描述、负责人、链接地址,甚至可以扩展脚本生成多个层级的导航。Javadoc 原生分组只能做一层,而这个脚本理论上可以无限嵌套。

第三个优点是代码极其简单。整段脚本不到一百行,没有引入任何第三方依赖,任何一个会一点 Node.js 的同事都能看懂并修改。团队才好维护。

6.2 缺点

缺点也很明显。它需要维护一份模块前缀配置,如果包名频繁调整,这份配置就容易过期。另外,它只能在宏观上按包分组,不能自动识别每一个类到底属于哪个业务。如果一个包里的类横跨多个业务,这个方案就无能为力了。

还有就是,它毕竟是我们自己写的“私货”,官方不支持,升级 Javadoc 后的输出格式变化可能导致脚本需要同步调整。不过我目前在真实项目里用着,没遇到什么大问题。

七、注意事项

第一,module-map.json 一定要放在代码仓库里,跟着源码一起做版本管理。千万不要只在某个人电脑上存着,否则别人改包名时不会想到去更新这个映射,过不了几天它就和真实结构脱节了。

第二,把脚本接入到 CI/CD 里,让它和 Javadoc 一起自动生成。这样每次代码更新后,导航也会自动更新,不会出现“文档是上周的,代码是今天的”这种尴尬。

第三,分组粒度不要弄得太细。一个模块包含几个包就够了,如果每个类都要单独分组,那等于又回到了平铺列表,只是换了一种说法。导航的意义是提供全景,而不是替代类索引。

第四,不要把修改原始 Javadoc 作为目标。我们的脚本只是额外生成一个“地图入口”,原始包列表仍然保留,该有的信息一个都不少。这样既安全,也不会影响其他依赖 Javadoc 的工具。

第五,脚本注释要写清楚,文件名和输出路径要统一。虽然代码简单,但没有注释的话,后人还得花时间猜逻辑。我特意在代码里写了中文注释,就是为了让团队里任何一个人都能接手。

八、总结

当年我面对那一屏包名列表时,心里闪过一个念头:“这文档是给人看的吗?”现在回头想想,问题不在于 Javadoc,而在于没有人给文档搭建一个便于理解的导航结构。按业务模块重新组织 Javadoc 导航,本质上就是把散落在代码包里的业务线索都收拾起来,画成一张地图。新人在读源码前先看地图,知道自己站在哪里、目标在哪里,就不会迷失在由字母组成的迷宫里。

这件事投入的时间成本很低,我们团队从写映射配置到跑通脚本,一个下午就完成了。但它带来的收益每天都在发生:新同学少问几遍“这个功能在哪个包”,老同学不用反复给新人讲模块结构。团队的知识传递,也就在这一刻被提速了。文档建设不需要总想着做大平台、搞炫酷功能,认认真真把信息组织好,让后来人更快地上手,就已经是很大的价值了。