一个HarmonyOS工程拆成多个HAR之后,开发者通常会盯着每个模块有没有正确填写oh-package.json5,却很少从整个仓库的角度问一句:这些模块连在一起,会不会绕回自己?这篇讨论的不是网络版本冲突,也不是编译器替我们解决一切,而是多模块发布前的一道轻量级关系检查。示例工程HarCycleGate,批次HAR-3010,输入是已经整理好的本地生产依赖边表,输出是带完整路径的循环诊断,便于在运行ohpm和正式构建之前先定位配置上的结构问题。

一、先把检查范围限定好

华为开发者文档明确区分oh-package.json5中的dependencies、devDependencies和dynamicDependencies。它们的用途、构建处理方式并不完全相同。尤其不能把“某两个名字互相出现在配置中”一律判断为构建错误。本文的审计器只检查业务配置整理后的本地生产依赖图:每个节点对应一个可识别的本地模块,每条边对应需要随生产代码一起交付的依赖。远程依赖、仅开发期依赖、动态导入的HSP,不在这次输入数据中自动扩展。

这个取舍很关键。图形算法可以保证“给定这些边时是否存在有向环”,但并不能替代ohpm针对不同依赖类别和SDK版本的完整校验,也不能证明产物一定可以发布。如果把仓库中的所有文本引用无差别拼成一张图,得到的误报会让开发者失去信任。这里明确规定边表由CI准备,数据源可来自自定义解析后的配置清单;审计器本身不直接用JSON.parse硬啃带注释的JSON5文件,更不会自称是ohpm内部解析器。

本例安排三份独立样本:P-1001是entry→ui→core的正常链;P-1002是a→b→a的生产依赖环;P-1003是logger→logger的自引用。即使前两个情况肉眼能看出结果,样本依然值得保留,因为未来某仓库出现十几个本地HAR时,工程师需要的是一段确定、可重复的输出,而不是把所有oh-package文件逐个打开猜测。

二、把仓库变成一张有方向的图

边表不包含模块路径是有意的。路径解析和真实模块名映射应该在上游完成,以便解决大小写、符号链接或同名目录引起的歧义。审计开始之前,各节点已经有唯一名称,依赖关系按名称连接。这里直接用JavaScript对象表达测试输入,CI中也可以把它们放进普通JSON文件,并在加载时校验结构。

const fixtures = [
  {
    id: 'P-1001',
    edges: { entry: ['ui'], ui: ['core'], core: [] }
  },
  {
    id: 'P-1002',
    edges: { a: ['b'], b: ['a'] }
  },
  {
    id: 'P-1003',
    edges: { logger: ['logger'] }
  }
];

定义数据契约时,有三件事最好先讨论清楚:未声明的目标节点算什么,空数组是不是合法,以及同一条边出现两次怎么处理。我的建议是,缺失节点返回UNKNOWN_NODE并阻断输出,空数组表示末端模块,重复边在图的构建阶段去重,不把它当作新的依赖关系。这样能让业务诊断和拓扑诊断互相独立,不会因为重复配置而输出两条一模一样的闭环路径。

这里还要区分直接自引用与普通环路:logger依赖logger,属于一条边就能闭合;a依赖b、b再依赖a,则需要遍历路径才能识别。两者最终都会阻止本次自定义门禁通过,但报错文本不应混成一个模糊的ERROR。当开发者看到SELF_REF,通常应直接去看当前模块的配置;看到CYCLE,则先检查路径中的每个节点为何要在生产阶段引入下一层。

三、DFS的关键是“当前路径”,不是访问过就报错

判断有向环时,我更倾向于使用深度优先搜索。done表示某节点已经完整检查过,active表示它此刻还在递归调用栈中。只有再次走到active里的节点,才能断言当前路径闭环。共享同一个基础库是正常的菱形依赖,不能因为两条路径都访问到了core就说它是循环依赖。

function inspect(edges) {
  const graph = new Map(
    Object.entries(edges).map(([name, deps]) => [name, [...new Set(deps)]])
  );
  const active = new Set();
  const done = new Set();
  let missing = '';

  function visit(node, path) {
    if (!graph.has(node)) { missing = node; return null; }
    if (active.has(node)) {
      const i = path.indexOf(node);
      return [...path.slice(i), node];
    }
    if (done.has(node)) return null;
    active.add(node);
    for (const next of graph.get(node)) {
      const cycle = visit(next, [...path, node]);
      if (missing || cycle) return cycle;
    }
    active.delete(node);
    done.add(node);
    return null;
  }
  for (const node of graph.keys()) {
    const cycle = visit(node, []);
    if (missing) return { status: 'UNKNOWN_NODE', path: missing };
    if (cycle) {
      const status = cycle.length === 2 ? 'SELF_REF' : 'CYCLE';
      return { status, path: cycle.join(' -> ') };
    }
  }
  return { status: 'PASS', path: '-' };
}

这段实现有一个容易误读的细节:发现错误时提前return,没有清空全部active集合。它不是一个能无限复用的全局单例,每次调用都会新建Map和Set,发现第一个问题便返回结果,因此不会把残留状态带到下一份样本。若你希望同一份图找出所有环路,需要另外实现错误路径收集、去重和搜索恢复,不能直接把这个提前返回删掉了事。

使用Map是为了保持节点查找行为清晰,Set用于“本次搜索活动路径”和“已完成路径”。代码没有借助HarmonyOS私有API;Node.js是本地预检查脚本环境,而非应用运行在手机端后的Node.js能力。放在CI阶段运行还有一个优点:失败就让流水线退出,避免将错误关系等到发布前一天才由构建日志揭露。

四、把错误码、路径和CI退出码绑定

只在控制台打印“有环”没有太大价值。运维、研发和审校人员拿到日志时,至少要知道是哪份模块关系清单出了问题,环路从哪里绕回去,以及本次是否允许继续构建。这里让样本检查报告保留完整路径,并使用进程退出码形成门禁。

const results = fixtures.map((item) => ({
  id: item.id,
  ...inspect(item.edges)
}));
for (const r of results) {
  console.log(`${r.id} ${r.status} ${r.path}`);
}
const passed = results.filter((r) => r.status === 'PASS').length;
const blocked = passed !== results.length;
console.log(`${blocked ? 'BLOCKED' : 'READY'} ${passed}/${results.length} PASS`);
process.exitCode = blocked ? 1 : 0;

样本的确定性输出是P-1001 PASS、P-1002 CYCLE a -> b -> a、P-1003 SELF_REF logger -> logger,汇总BLOCKED 1/3 PASS。

五、进工程之前还需要三层护栏

第一层是输入可信度。示例对象直接写在代码里,正式版本通常要读取多个模块的清单。如果某文件解析失败、某模块名为空,或者一个依赖指向尚未建立唯一身份的节点,应该先报告数据不完整,而不是勉强算出PASS。尤其不要把所有未知依赖静默丢掉:这样虽然程序运行得很顺,却可能漏掉真正的问题。应在预处理层明确标注外部依赖与本地依赖,只有符合契约的本地边进入图算法。

第二层是性能与超大仓库。DFS对节点数V和边数E的复杂度大体为O(V+E),对普通多模块工程足够。可是递归深度会跟最长路径增长,极端大的自动生成依赖图可能触发JavaScript调用栈限制。生产工具可以改成显式栈,并为路径长度设置合理的诊断上限。不能简单以“普通工程不会那么大”为理由忽略边界;CI一旦出现超长链,应明确输出GRAPH_TOO_DEEP一类的自定义工具错误,不要伪装成模块存在依赖环。

第三层是版本和类别。不同版本的同名包,不能被粗暴地合并成一个点;本地模块名如果带版本,应在图准备阶段生成稳定标识,再决定哪些边属于同一个发布单元。对devDependencies或dynamicDependencies,应按照官方文档和当前工程配置分别解释,不要把本文这个只分析生产边的函数当成任何项目都能直接使用的依赖规则。图审计是在构建之前排疑,不是构建结果的替代品。

六、开发界面与运行界面各承担什么信息

页面里最醒目的不是结果色块,而是路径:a→b→a能让维护人员快速判断哪一层反向依赖不合理;logger→logger则提示当前模块配置存在自引用。若只在界面上展示BLOCKED,排查还得回终端;如果同时显示路径、任务ID和样本类型,截图与日志可以互相验证,而不是只提供一张看起来正确的示意画。

七、从示例走向团队约束

可以先安排四组测试:将P-1001改成菱形无环依赖,必须保持PASS;将某个依赖指向不存在的节点,必须得到UNKNOWN_NODE;交换a和b在对象中的声明顺序,仍应报告同一组模块构成的环;最后加入重复边,检查输出不会重复报同一条回路。这些测试可以使用Node.js原生断言在CI运行。对于仓库级质量门禁,建议同时保存输入清单摘要和审计结果,方便以后复现一次构建为何被拦下。

应用上线质量往往不是单个模块的问题,而是几个看似合理的局部配置放在一起产生了意外关系。HarCycleGate保留了这种关系的路径信息,让错误从“构建失败,不知道为什么”变成“哪两个生产模块不应该彼此反向依赖”。如果以后要接入真实oh-package.json5配置解析,请先锁定仓库、版本和JSON5解析规则,再复用这一层纯图计算;不要把文本解析、依赖解释、环路判断和发布政策全部塞进同一个函数。

参考资料:华为官方oh-package.json5配置说明 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ide-hmos-oh-package-json5 ,添加依赖项指南 https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/ide-hmos-hvigor-dependencies 。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐