p2_wire_spring_di.py 14.2 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 第三步:把 Dubbo XML 装配改成进程内 Spring DI。

## 为什么是机械替换

老工程的服务实现**本来就带 Spring 注解**(`@Service("com.zteits.bill.biz.vipcard
.VipCardCustInfoServiceImpl")` 这种 FQN 当 bean 名的写法),装配靠
`@ComponentScan(basePackages={"com.zteits.park", ...})` + Dubbo 的
`<dubbo:service ref="...">` 暴露。因此去 Dubbo 要做的只有两件事:

1. **provider 侧**:少数只写在 `<bean id= class=>` 里、类上没有注解的实现,补注解,
   让它们仍被 component-scan 注册。(348/390 的 provider 已经有注解了。)
2. **consumer 侧**:老代码用 `private XxxService xxxService;` 裸字段,
   Spring 是靠 Dubbo `<dubbo:reference id="xxxService">` 生成 bean 后按**变量名**注入的
   (`@Autowired` 缺失是因为老版本 `<context:component-scan>` 配了自动装配,
   或干脆就是靠 Dubbo 的 ref 装配)。现在要在字段上补 `@Autowired`。

## 为什么用 @Autowired 而不是 @Resource

实测 390 个 provider 接口**没有一个存在多实现**(见脚本末尾的校验),
所以按类型注入无歧义;而按名字注入要求 bean 名与字段名严格一致,
老代码里字段名与 XML 的 `ref`/`id` 并不总是同名(例如 `@Component("com.zteits
.sys.biz.SysNoticeServiceImpl")` 的 bean 名是 FQN,字段名却是 `sysNoticeService`)。
按类型注入能同时覆盖这两种情况。

## 幂等

已有 `@Autowired`/`@Resource` 的字段跳过;已有 Spring 原型注解的类跳过。
可用 `--dry-run` 只出报告不改文件。
"""

import argparse
import os
import re
import sys
import collections

CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LEGACY = os.path.join(os.path.dirname(CLOUD), "zteits-clouds")

# 只处理已迁入的模块(portal 未搬,政务 P6 再说)
MODS = [
    "parking-module-park",
    "parking-module-order",
    "parking-module-pay",
    "parking-module-bill",
    "parking-module-charge",
    "parking-module-business",
    "parking-module-system",
    "parking-framework/parking-legacy-common",
]

API_SERVICE_PREFIX = "com.zteits.clouds.api.service."
SPRING_STEREOTYPES = ("Service", "Component", "Repository", "Configuration", "RestController", "Controller")

COMMENT = re.compile(r"<!--.*?-->", re.S)
TAG_BEAN = re.compile(r"<bean\b([^>]*?)/?>", re.S)
TAG_DUBBO_SERVICE = re.compile(r"<dubbo:service\b([^>]*?)/?>", re.S)
TAG_DUBBO_REF = re.compile(r"<dubbo:reference\b([^>]*?)/?>", re.S)
IMPORT = re.compile(r"^import\s+(?:static\s+)?([\w.$]+)\s*;", re.M)


def attrs(s):
    # ⚠️ 必须 strip:老 XML 里存在 ref=" com.zteits.park.biz...ServiceImpl" 这种
    #    属性值前带空格的写法(zteits-park provider 第 704 行),不 strip 会去找
    #    一个首字符是空格的类名。
    return {
        k: v.strip() for k, v in re.findall(r"([\w:.-]+)\s*=\s*\"([^\"]*)\"", s)
    }


def _glob(pat):
    import glob as g

    return g.glob(pat)


def read(path):
    return open(path, encoding="utf-8", errors="surrogateescape").read()


# ---------------------------------------------------------------------------
# 源码索引
# ---------------------------------------------------------------------------
def build_index():
    """FQN -> 文件路径"""
    idx = {}
    for mod in MODS:
        base = os.path.join(CLOUD, mod, "src/main/java")
        for root, _, files in os.walk(base):
            for f in files:
                if f.endswith(".java"):
                    fqn = os.path.relpath(os.path.join(root, f), base)[:-5].replace(os.sep, ".")
                    idx[fqn] = os.path.join(root, f)
    return idx


IDX = build_index()

# 简单类名索引:兜底解析「XML 里 ref 写错但类名对得上」的情况
# (实测:zteits-sys provider 里 ref 写成
#   com.zteits.clouds.api.service.sys.WxcpMsgSendConfigService.WxcpMsgSendConfigServiceImpl,
#   真实实现是 com.zteits.sys.biz.WxcpMsgSendConfigServiceImpl)
SIMPLE_INDEX = collections.defaultdict(list)
for _fqn in IDX:
    SIMPLE_INDEX[_fqn.rsplit(".", 1)[1]].append(_fqn)


def resolve_impl(impl):
    """把 XML 里的 ref/class 值解析成 (真实 FQN, 源文件路径),失败返回 None。"""
    p = IDX.get(impl)
    if p:
        return impl, p
    cands = SIMPLE_INDEX.get(impl.rsplit(".", 1)[1], [])
    if len(cands) == 1:
        return cands[0], IDX[cands[0]]
    return None


IMPORT_LINE = re.compile(r"^import\s+(?:static\s+)?[\w.$]+\s*;[ \t]*$", re.M)


def add_import(txt, fqn):
    """插入 import,位置跟在最后一个已存在 import 之后(保持字典序)。
    已存在则原样返回。"""
    line = f"import {fqn};"
    if re.search(r"^\s*" + re.escape(line) + r"\s*$", txt, re.M):
        return txt
    last = None
    for m in IMPORT_LINE.finditer(txt):
        last = m
    if last is None:
        return txt
    # 按字典序插入到合适位置:找第一个比 fqn 大的 import
    target = None
    for m in IMPORT_LINE.finditer(txt):
        if m.group(0).strip() > f"import {fqn};":
            target = m
            break
    anchor = target or last
    insert_at = anchor.start() if target else anchor.end()
    if target:
        return txt[:insert_at] + line + "\n" + txt[insert_at:]
    return txt[:insert_at] + "\n" + line + txt[insert_at:]


# ---------------------------------------------------------------------------
# provider 侧
# ---------------------------------------------------------------------------
def scan_providers():
    """返回 (impl_fqn -> {interfaces}), (孤儿 bean: beanid -> class)"""
    impl_ifaces = collections.defaultdict(set)
    all_beans = {}            # beanId -> class FQN
    orphan_beans = {}         # beanId -> class FQN(没有被任何 dubbo:service 引用)
    for p in _glob(f"{LEGACY}/zteits-*/src/main/resources/dubbo/*provider*.xml"):
        txt = COMMENT.sub("", read(p))
        beans = {}
        for m in TAG_BEAN.finditer(txt):
            a = attrs(m.group(1))
            if a.get("id") and a.get("class"):
                beans[a["id"]] = a["class"]
        all_beans.update(beans)
        refs = set()
        for m in TAG_DUBBO_SERVICE.finditer(txt):
            a = attrs(m.group(1))
            i, r = a.get("interface"), a.get("ref")
            if not i or not r:
                continue
            refs.add(r)
            impl_ifaces[beans.get(r, r)].add(i)
        for bid, cls in beans.items():
            if bid not in refs:
                orphan_beans[bid] = cls
    return impl_ifaces, orphan_beans


# ---------------------------------------------------------------------------
# 类声明定位(含前置注解)
# ---------------------------------------------------------------------------
def class_decl(txt, simple):
    return re.search(
        r"^(?P<anns>(?:@[\w.]+(?:\([^)]*\))?\s*)*)"
        r"public\s+(?P<mods>final\s+|abstract\s+)?class\s+" + re.escape(simple) + r"\b",
        txt,
        re.M,
    )


def has_stereotype(anns):
    return any(re.search(r"@" + s + r"\b", anns) for s in SPRING_STEREOTYPES)


# ---------------------------------------------------------------------------
# consumer 侧:字段注入
# ---------------------------------------------------------------------------
class_field = re.compile(
    r"^(?P<indent>[ \t]*)"
    r"(?P<anns>(?:@[\w.]+(?:\([^)]*\))?[ \t]*\n[ \t]*)*)"
    r"(?P<mods>(?:private|protected|public)[ \t]+)"
    r"(?!static|final)"
    r"(?P<type>[\w.$]+)[ \t]+(?P<name>\w+)[ \t]*;",
    re.M,
)

INJECTED = ("@Autowired", "@Resource", "@Inject")


def wire_fields(path, api_ifaces):
    """给类型属于 api_ifaces 的裸字段补 @Autowired。返回改动列表。"""
    txt = read(path)
    if API_SERVICE_PREFIX not in txt:
        return []  # 快速跳过:全仓 7000+ 文件,绝大多数不含老契约 import
    imap = {}
    for m in IMPORT.finditer(txt):
        fqn = m.group(1)
        if fqn.startswith(API_SERVICE_PREFIX) or fqn.startswith("com.zteits.clouds.api."):
            imap[fqn.rsplit(".", 1)[1]] = fqn

    hits = []

    def repl(m):
        t = m.group("type")
        fqn = imap.get(t) or (t if t.startswith("com.zteits.clouds.api.") else None)
        if not fqn or fqn not in api_ifaces:
            return m.group(0)
        if any(a in m.group("anns") for a in INJECTED):
            return m.group(0)
        hits.append((m.group("name"), fqn))
        indent = m.group("indent")
        anns = m.group("anns")
        return f"{indent}@Autowired\n{indent}{m.group('mods')}{t} {m.group('name')};"

    new = class_field.sub(repl, txt)
    if new != txt:
        open(path, "w", encoding="utf-8", errors="surrogateescape").write(new)
    return hits


# ---------------------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    impl_ifaces, orphan_beans = scan_providers()
    print(f"provider impl 类:{len(impl_ifaces)}")

    # 前提校验:同一接口不得有多个实现,否则 @Autowired 按类型注入会歧义
    owners = collections.defaultdict(set)
    for impl, ifaces in impl_ifaces.items():
        for i in ifaces:
            owners[i].add(impl)
    multi = {i: v for i, v in owners.items() if len(v) > 1}
    if multi:
        print(f"\n!! 以下接口存在多实现,@Autowired 会歧义,需人工处理:")
        for i, v in sorted(multi.items()):
            print(f"   {i}\n      {sorted(v)}")
    else:
        print("✔ 前提校验通过:没有任何接口存在多实现,按类型注入安全")

    # ---- A. provider 侧补注解 + import ----
    # TODO 列表元素:(真实FQN, 路径, 注解类型, 注解值)
    todo, no_src = [], []
    for impl, ifaces in sorted(impl_ifaces.items()):
        r = resolve_impl(impl)
        if not r:
            no_src.append((impl, sorted(ifaces)[0]))
            continue
        real, p = r
        txt = read(p)
        m = class_decl(txt, real.rsplit(".", 1)[1])
        if not m:
            no_src.append((impl, f"<找不到类声明 {real}>"))
            continue
        todo.append((real, p, "Service", real, impl))

    # 孤儿 bean(provider XML 里声明了但没被 dubbo:service 引用)
    for bid, cls in sorted(orphan_beans.items()):
        r = resolve_impl(cls)
        if not r:
            no_src.append((cls, f'<孤儿 bean id="{bid}">'))
            continue
        real, p = r
        todo.append((real, p, "Component", real, cls))

    need, done = [], 0
    for real, p, kind, value, raw in todo:
        txt = read(p)
        m = class_decl(txt, real.rsplit(".", 1)[1])
        if not m:
            continue
        anns = m.group("anns")
        # ① 已经有本脚本加的那种注解 → 只可能缺 import(上一次跑漏了)
        # ② 已经有别的 Spring 原型注解(老代码自己的 @Component/@Service)→ 不动它
        # ③ 什么注解都没有 → 补注解 + import
        if re.search(r"@" + kind + r"\b", anns):
            ann_missing, imp_missing = False, (
                f"import org.springframework.stereotype.{kind};" not in txt
            )
        elif has_stereotype(anns):
            continue
        else:
            ann_missing, imp_missing = True, True
        if ann_missing or imp_missing:
            need.append((real, p, kind, value, raw, ann_missing, imp_missing))

    print(f"\n【provider】需补 Spring 原型注解/import 的实现类:{len(need)}")
    for real, p, kind, value, raw, am, im_ in need:
        flags = []
        if am:
            flags.append(f'@ {kind}("{value}")')
        if im_:
            flags.append(f"import {kind}")
        extra = "" if real == raw else f"   (XML 里写的是 {raw})"
        print(
            f'   + {" / ".join(flags)}   {os.path.relpath(p, CLOUD)}{extra}'
        )
    if no_src:
        print(f"\n【provider】新工程里解析不出源码(未随迁 / 死配置):{len(no_src)}")
        for impl, iface in no_src:
            print(f"   ?  {impl}\n        iface={iface}")

    if not args.dry_run:
        for real, p, kind, value, raw, am, im_ in need:
            txt = read(p)
            if im_:
                txt = add_import(txt, f"org.springframework.stereotype.{kind}")
                done += 1
            if am:
                m = class_decl(txt, real.rsplit(".", 1)[1])
                if m and not re.search(r"@" + kind + r"\b", m.group("anns")):
                    txt = (
                        txt[: m.start()]
                        + f'@{kind}("{value}")\n'
                        + txt[m.start():]
                    )
                    done += 1
            open(p, "w", encoding="utf-8", errors="surrogateescape").write(txt)
        print(f"\n✅ provider 侧写入 {done} 处")

    # ---- B. consumer 侧补 @Autowired ----
    consumer_ifaces = set()
    for p in _glob(f"{LEGACY}/zteits-*/src/main/resources/dubbo/*consumer*.xml"):
        txt = COMMENT.sub("", read(p))
        for m in TAG_DUBBO_REF.finditer(txt):
            a = attrs(m.group(1))
            if a.get("interface"):
                consumer_ifaces.add(a["interface"])
    # 以「provider 提供的接口 ∪ consumer 引用的接口」为准
    provided = {i for s in impl_ifaces.values() for i in s}
    targets = {
        t for t in (provided | consumer_ifaces) if t.startswith(API_SERVICE_PREFIX)
    }
    print(f"\n【consumer】需要注入的契约接口:{len(targets)}")

    total_fields, total_files = 0, 0
    by_mod = collections.Counter()
    for mod in MODS:
        base = os.path.join(CLOUD, mod, "src/main/java")
        for root, _, files in os.walk(base):
            for f in files:
                if not f.endswith(".java"):
                    continue
                p = os.path.join(root, f)
                hits = wire_fields(p, targets)
                if hits:
                    total_fields += len(hits)
                    total_files += 1
                    by_mod[mod] += len(hits)
                    print(f"   {os.path.relpath(p, CLOUD)}  <- {len(hits)}: {', '.join(n for n, _ in hits)}")
    print(f"\n✅ consumer 侧共 {total_fields} 个字段 / {total_files} 个文件")
    for k, v in by_mod.most_common():
        print(f"   {k}: {v}")


if __name__ == "__main__":
    main()