dubbo_dependency_scan.py 10.1 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Dubbo 依赖对账:**每一个 `<dubbo:reference>` 的实现方,是否都会进单 Jar**。

为什么必须有这一步:合成单 Jar 后 Dubbo 注册中心没了,跨模块调用变成进程内 Spring DI。
如果有哪个 reference 的实现类所在模块**没有**被并入 reactor,那么老代码里所有
`<dubbo:reference>` 都会被翻译成一个 `@Resource` 字段,但**永远没有对应的 Bean** ——
编译能过、启动能过,一调用就是 `NoSuchBeanDefinitionException`,属于"上线才发现"的故障。

产出:把每个 reference 的「消费方模块 → 提供方模块」列出来,标出提供方不在并入清单里的。

用法:
  python3 dubbo_dependency_scan.py <老仓库根目录> <输出目录>
"""

import os
import re
import sys
from collections import defaultdict

# P2 计划并入单 Jar 的模块(其余模块的实现**不在**这个清单里 → 必须随迁或另作处理)
MERGED_MODULES = {
    'zteits-sys', 'zteits-park', 'zteits-order', 'zteits-pay',
    'zteits-bill', 'zteits-charge',
    # 方案 §4.6 的随迁例外
    'zteits-business', 'zteits-data-collection',
    'zteits-framework', 'zteits-api',
}

DUBBO_SERVICE = re.compile(
    r'<dubbo:service\b[^>]*?interface\s*=\s*"([^"]+)"[^>]*?(?:ref\s*=\s*"([^"]+)")?',
    re.IGNORECASE | re.DOTALL)
DUBBO_REFERENCE = re.compile(
    r'<dubbo:reference\b[^>]*?interface\s*=\s*"([^"]+)"',
    re.IGNORECASE | re.DOTALL)
JAVA_SERVICE = re.compile(r'@Service\s*\(\s*"([\w.$]+)"\s*\)')
# `class Xxx implements A, B<C>` —— 用于兜底识别"有实现类但没在 provider XML 里声明"的接口
JAVA_IMPLEMENTS = re.compile(r'\bclass\s+\w+[^{;]*?\bimplements\s+([^{]+)', re.DOTALL)


def fqn(raw):
    """
    取属性里的接口全限定名。

    ⚠️ 老仓库的 provider XML 写成 `interface=" com.zteits...Xxx"`——**引号内带前导空格**
    (dubbo-sys-provider.xml 第 95 行等)。不 strip 的话字典键就是带空格的字符串,
    与 consumer 侧干净的 FQN 永远匹配不上,会把大批"其实有实现方"的接口误报成"找不到提供方"。
    """
    return (raw or '').strip()


def module_of(rel):
    parts = rel.replace(os.sep, '/').split('/')
    for p in parts:
        if p.startswith('zteits-'):
            return p
    return '(unknown)'


def main():
    if len(sys.argv) != 3:
        print(__doc__)
        return 2
    root, outdir = sys.argv[1], sys.argv[2]
    os.makedirs(outdir, exist_ok=True)

    providers = defaultdict(set)   # interface FQN -> {模块}(来自 provider XML / @Service)
    impls = defaultdict(set)       # 接口简名 -> {模块}(来自 `implements`,兜底)
    refs = []                      # (消费方模块, interface FQN, 文件)
    provider_xml_seen = 0
    beanless = 0

    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames
                       if d not in ('node_modules', 'target', '.git', 'dist')]
        for fn in filenames:
            if not fn.endswith(('.xml', '.java')):
                continue
            full = os.path.join(dirpath, fn)
            rel = os.path.relpath(full, root)
            if 'dubbo' not in rel and 'dubbo' not in fn:
                if not fn.endswith('.java'):
                    continue
            module = module_of(rel)
            try:
                text = open(full, encoding='utf-8', errors='ignore').read()
            except OSError:
                continue

            low = rel.lower()
            if fn.endswith('.xml') and 'dubbo' in low:
                # ⚠️ 必须先去掉 XML 注释:老仓库大量 `<dubbo:reference>` 是被 `<!-- -->` 包起来的
                #    历史残留(如 dubbo-order-consumer.xml 第 63、74 行整条注释掉),
                #    不去注释会把它们当成"活跃引用",凭空多出一批"找不到提供方"的假告警。
                text = re.sub(r'<!--.*?-->', ' ', text, flags=re.DOTALL)
                if 'provider' in low:
                    provider_xml_seen += 1
                    for m in DUBBO_SERVICE.finditer(text):
                        providers[fqn(m.group(1))].add(module)
                        if not m.group(2):
                            beanless += 1
                elif 'consumer' in low:
                    for m in DUBBO_REFERENCE.finditer(text):
                        refs.append((module, fqn(m.group(1)), rel))
            elif fn.endswith('.java'):
                for m in JAVA_SERVICE.finditer(text):
                    providers[fqn(m.group(1))].add(module)
                # 实现类兜底:接口 FQN 取 import 里的全名,简名在这里拼不出来,
                # 所以只按**简名**登记一份,引用侧比对时同时看全名与简名。
                for m in JAVA_IMPLEMENTS.finditer(text):
                    for raw in m.group(1).split(','):
                        name = raw.strip().split('<')[0].strip()
                        if name and name[0].isupper():
                            impls[name].add(module)

    # 消费方 -> 提供方 -> 接口
    gaps = defaultdict(list)      # 提供方模块 -> [(消费方, 接口)]
    ok = defaultdict(int)
    resolvable = []               # 有实现类、但没在 provider XML 声明 → 单体内按类型能解析
    unknown = []

    for consumer, iface, rel in refs:
        pmods = providers.get(iface)
        if pmods:
            for pmod in pmods:
                if pmod in MERGED_MODULES:
                    ok[pmod] += 1
                else:
                    gaps[pmod].append((consumer, iface))
            continue
        # 没在 provider XML 里声明,看看有没有实现类
        impl_mods = impls.get(iface.rsplit('.', 1)[-1])
        if impl_mods:
            resolvable.append((consumer, iface, sorted(impl_mods)))
        else:
            unknown.append((consumer, iface, rel))

    # 对账:实际提供方模块集合
    all_provider_mods = defaultdict(set)
    for iface, mods in providers.items():
        for m in mods:
            all_provider_mods[m].add(iface)

    print('reference 总数: %d(去重接口 %d)' % (len(refs), len({r[1] for r in refs})))
    print('provider XML: %d 份;@Service 注解接口 %d 个'
          % (provider_xml_seen, sum(1 for v in providers.values())))
    print('缺 <bean> 的 <dubbo:service>(ref 直接写类名): %d' % beanless)
    print('实现模块不在并入清单的 reference: %d' % sum(len(v) for v in gaps.values()))
    print('未声明但存在实现类(单体内按类型可解析): %d' % len(resolvable))
    print('找不到任何实现方的 reference: %d' % len(unknown))

    md = os.path.join(outdir, 'dubbo-dependency-report.md')
    with open(md, 'w', encoding='utf-8') as fh:
        w = fh.write
        w('# Dubbo 依赖对账(单 Jar 化前的必查项)\n\n')
        w('> 生成:`tools/dubbo_dependency_scan.py`\n\n')
        w('`<dubbo:reference>` 共 **%d** 处(已剔除 XML 注释里的历史残留),去重后 **%d** 个接口;\n'
          % (len(refs), len({r[1] for r in refs})))
        w('提供方在并入清单内 **%d** 处,**不在清单内 %d 处**,\n'
          % (sum(ok.values()), sum(len(v) for v in gaps.values())))
        w('**有实现类但没在 provider XML 声明 %d 处**,完全找不到实现方 %d 处。\n\n'
          % (len(resolvable), len(unknown)))

        w('## 1. ⚠️ 实现模块不在并入清单(必须处理,否则调用即 500)\n\n')
        if not gaps:
            w('无。\n')
        else:
            for pmod in sorted(gaps, key=lambda k: -len(gaps[k])):
                w('\n### `%s` —— 被引用 %d 处\n\n' % (pmod, len(gaps[pmod])))
                w('| 消费方 | 接口 |\n|---|---|\n')
                for consumer, iface in sorted(set(gaps[pmod])):
                    w('| `%s` | `%s` |\n' % (consumer, iface))

        w('\n## 2. 提供方模块分布(清单内的)\n\n')
        w('| 提供方模块 | 被引用的接口数 |\n|---|---:|\n')
        for pmod in sorted(ok, key=lambda k: -ok[k]):
            w('| `%s` | %d |\n' % (pmod, ok[pmod]))

        w('\n## 3. 有实现类但没在 provider XML 声明(单体内会自动修好,但要验证)\n\n')
        w('这些接口**在老的 Dubbo 世界里根本没人暴露**——`<dubbo:reference>` 指向一个不存在的\n')
        w('provider。老系统靠 `check="false"` 起得来,真正调用时才失败,属于**存量潜伏缺陷**。\n')
        w('合成单体后 `<dubbo:reference>` 变成 `@Resource` 按类型注入,实现类只要在同一个\n')
        w('ApplicationContext 里就会被解析到 —— **合并顺手修好了它**,但必须逐个确认\n')
        w('"实现类确实被扫进 Spring 容器",否则就是把原来的运行期失败换了个位置。\n\n')
        w('| 消费方 | 接口 | 实现类所在模块 |\n|---|---|---|\n')
        for consumer, iface, mods in sorted(set((c, i, tuple(m)) for c, i, m in resolvable)):
            w('| `%s` | `%s` | %s |\n'
              % (consumer, iface, '、'.join('`%s`' % m for m in mods)))

        w('\n## 4. 找不到实现方的 reference(多是跨仓契约,需人工确认)\n\n')
        w('| 消费方 | 接口 | 位置 |\n|---|---|---|\n')
        for consumer, iface, rel in sorted(unknown)[:200]:
            w('| `%s` | `%s` | `%s` |\n' % (consumer, iface, rel))
        if len(unknown) > 200:
            w('\n… 另有 %d 处,见 CSV。\n' % (len(unknown) - 200))

    print('写出 %s' % md)

    csv_path = os.path.join(outdir, 'dubbo-references.csv')
    with open(csv_path, 'w', encoding='utf-8') as fh:
        fh.write('消费方模块,接口,提供方模块,状态\n')
        for consumer, iface, _ in sorted(refs):
            pmods = providers.get(iface)
            if not pmods:
                fh.write('%s,%s,,NO_PROVIDER\n' % (consumer, iface))
            else:
                for pmod in sorted(pmods):
                    state = 'OK' if pmod in MERGED_MODULES else 'NOT_MERGED'
                    fh.write('%s,%s,%s,%s\n' % (consumer, iface, pmod, state))
    print('写出 %s' % csv_path)
    return 0


if __name__ == '__main__':
    sys.exit(main())