p2_audit_placeholders.py
22.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 审计脚本:把存量代码真正需要的 `${占位符}` 与「新工程提供的配置」对账。
## 为什么需要它
老工程的 `application*.properties` 是**按模块各一份**的(`zteits-park` / `zteits-order` /
`zteits-pay` / `zteits-charge` … 共十几份),而搬迁红线第 9 条「禁止修改
application-*.properties 中的环境配置」+ `tools/p2_import_legacy.py` 的策略,
决定了这些文件**故意不搬** —— 环境配置以新工程 `parking-server/src/main/resources/*.yaml` 为准。
于是必然出现缺口:存量代码里 `@Value("${xxx}")` 的**业务参数**(不是连接串)在新工程里没人提供,
启动报
java.lang.IllegalArgumentException: Could not resolve placeholder 'xxx' in value "${xxx}"
而且**一次只报第一个** —— 靠启动试错要启 N 次。本脚本一次列全,并给出老工程里的原值。
## 三类假阳性(都是本脚本第一版踩过的坑,缺一不可)
| 假阳性 | 例子 | 为什么不是缺口 |
|---|---|---|
| 注解被注释掉了 | `AliOssClientConfig` 里 7 个 `ali.oss.*` 被 `/* */` 整段注释(实际改从数据库 `SysCodeValueCacheUtil` 读) | 代码里根本没有这个注解 |
| `@Value` 标在 **static** 字段上 | `Wechatpayapiv3Config` 的 4 个 `wechatpay.apiv3.*` | Spring **不会**注入 static 字段,值来自字段初始化表达式 |
| 类根本不是 Bean | 包不在启动类扫描范围内、或类上没有组件注解、或已被 `excludeFilters` 排除 | 容器里没有这个类,`@Value` 永远不会被解析 |
所以判定链是:**注解真的存在(去注释)→ 不是 static → 类会被注册成 Bean → 才是「必需」**。
## 扫描范围是怎么算出来的
启动类 `ParkingServerApplication` 的 `@ComponentScan.basePackages`(含 `${parking.info.base-package}`
这类占位符,用 `application.yaml` 里的值代换)+ **源码里嵌套的 `@ComponentScan`**(递归到不动点,
无参数的那种按「本类所在包」算)。这套计算同时也是对手写扫描列表的一次校验:
`--show-scan` 会把它解析出的包逐个打出来。
## 用法
python3 tools/p2_audit_placeholders.py # 只列「必需但缺失」与「有默认值需核对」
python3 tools/p2_audit_placeholders.py --all # 连「已提供」「判为无需配置」一起列出
python3 tools/p2_audit_placeholders.py --emit /tmp/legacy-params.properties
# 生成可直接搬进 parking-server 的配置草稿
"""
import argparse
import csv
import glob
import os
import re
import sys
from collections import defaultdict
CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LEGACY = "/Users/wangbiao/IdeaProjects/parking/zteits-clouds"
SCAN_DIRS = [
"parking-server", "parking-framework", "parking-module-api", "parking-module-infra",
"parking-module-system", "parking-module-park", "parking-module-order",
"parking-module-pay", "parking-module-bill", "parking-module-charge",
"parking-module-business",
]
APP_JAVA = "parking-server/src/main/java/com/zteits/parking/server/ParkingServerApplication.java"
VALUE_RE = re.compile(r'@Value\s*\(\s*(?:value\s*=\s*)?"\$\{([^}]*)\}"')
NESTED_RE = re.compile(r'\$\{([^}]*)\}')
PKG_RE = re.compile(r'^\s*package\s+([\w.]+)\s*;', re.M)
YAML_KV_RE = re.compile(r'^(\s*)([A-Za-z0-9_.\-]+):(\s|$)')
PROP_KV_RE = re.compile(r'^\s*([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$')
STEREOTYPES = ("@Component", "@Configuration", "@Service", "@Repository", "@Controller",
"@RestController", "@ConfigurationProperties")
SCAN_ANNO_RE = re.compile(
r'@ComponentScans?\s*\(' # 只处理带参数的;无参数的单独处理
)
# 值像「凭据」的键 → 不把老工程的值写进仓库,改成环境变量占位
SECRET_HINTS = ("password", "passwd", "secret", "accesskey", "privatekey", "apiv3key",
"token", "cert.pass", "appid", "private-key", "sdkkey", "mchkey")
# --------------------------------------------------------------------------- #
# 基础工具
# --------------------------------------------------------------------------- #
def strip_comments(src):
"""把注释内容换成空格(保留换行与偏移,行号仍然可用),字符串字面量原样保留。
只处理 // 与 /* */;不处理字符串里的转义细节 —— 本脚本只关心 @Value("${...}"),
这种精度足够。"""
out = []
i, n = 0, len(src)
while i < n:
c = src[i]
if c == '"' or c == "'": # 字符串/字符字面量,整段原样保留
q = c
out.append(c)
i += 1
while i < n:
if src[i] == "\\":
out.append(src[i:i + 2])
i += 2
continue
out.append(src[i])
if src[i] == q:
i += 1
break
i += 1
continue
if c == "/" and i + 1 < n and src[i + 1] == "/":
while i < n and src[i] != "\n":
out.append(" ")
i += 1
continue
if c == "/" and i + 1 < n and src[i + 1] == "*":
out.append(" ")
i += 2
while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"):
out.append("\n" if src[i] == "\n" else " ")
i += 1
out.append(" ")
i += 2
continue
out.append(c)
i += 1
return "".join(out)
def balanced(text, open_idx):
"""open_idx 指向 '(',返回配对的 ')' 之后的位置,以及括号内部文本。"""
depth = 0
for i in range(open_idx, len(text)):
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
return i + 1, text[open_idx + 1:i]
return len(text), text[open_idx + 1:]
def brace_span(text, open_idx):
"""open_idx 指向 '{',配对着色后返回 (内部文本, 结束位置)。
⚠️ 不能用 `\\{(.*?)\\}` 这种正则:值里常有 `${...}` 占位符,
**非贪婪匹配会在第一个 `}`(也就是 `${...}` 的闭合括号)就截断**,
这个坑本脚本踩过 —— 表现为「明明有 basePackages 却一个包都没解析出来」。"""
depth = 0
for i in range(open_idx, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[open_idx + 1:i], i + 1
return text[open_idx + 1:], len(text)
def split_placeholder(expr):
"""`a:b` → ('a','b');`a` → ('a', None)。与 Spring 的 PlaceholderHelper 行为一致。"""
if ":" in expr:
k, v = expr.split(":", 1)
return k.strip(), v
return expr.strip(), None
# --------------------------------------------------------------------------- #
# 1) 新工程的配置提供方(yaml / properties)
# --------------------------------------------------------------------------- #
def flatten_yaml(path):
"""极简 YAML 展平:只处理「缩进 + key: value」和 `key:` 开块。手写规整缩进够用。"""
out, stack = {}, []
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
if not line.strip() or line.lstrip().startswith("#"):
continue
m = YAML_KV_RE.match(line)
if not m:
continue
indent, key = len(m.group(1)), m.group(2)
while stack and stack[-1][0] >= indent:
stack.pop()
full = ".".join([k for _, k in stack] + [key])
rest = line.split(":", 1)[1].split(" #", 1)[0].strip()
if rest and rest not in ("|", ">", "|-", ">-"):
out[full] = rest
stack.append((indent, key))
return out
def scan_provided():
"""新工程「已提供」的键 = 它自己的 yaml + 这些 yaml 用 spring.config.import 引进来的文件。
⚠️ 第二个来源是后补的,缺了它就是本脚本踩过的坑:
脚本原本只 glob `application*.yaml`,而它自己的 `--emit` 生成的
`legacy-params.properties` 是**通过 `spring.config.import` 生效**的 ——
「自己开出的方子自己不认」,于是那 36 个键全被判成「必需但未提供」,
退出码恒为 1(假阳性),而应用其实启动得好好的。
所以这里按 `spring.config.import` 实际声明的文件名去读,而不是把文件名写死:
import 列表改了,这里跟着改。
"""
provided = defaultdict(list)
res_dir = os.path.join(CLOUD, "parking-server/src/main/resources")
yamls = []
for pat in ("application.yaml", "application.yml", "application-*.yaml", "application-*.yml"):
yamls += sorted(glob.glob(os.path.join(res_dir, pat)))
flats = {}
for p in yamls:
flats[p] = flatten_yaml(p)
for k, v in flats[p].items():
provided[k].append((os.path.basename(p), v))
for p in yamls:
for raw in flats[p].get("spring.config.import", "").split(","):
name = strip_location(raw)
full = os.path.join(res_dir, name) if name else ""
if not name or not os.path.isfile(full):
continue
keys = (flatten_yaml(full) if name.endswith((".yaml", ".yml"))
else read_properties(full))
for k, v in keys.items():
provided[k].append((name, v))
return provided
def strip_location(loc):
"""`classpath:legacy-params.properties` / `optional:classpath:x.yml` → 去前缀去引号。"""
loc = loc.strip().strip("'\"")
while True:
for pfx in ("optional:", "classpath*:", "classpath:", "file:"):
if loc.startswith(pfx):
loc = loc[len(pfx):]
break
else:
return loc
def read_properties(p):
out = {}
with open(p, encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if not s or s[0] in "#!":
continue
m = PROP_KV_RE.match(line)
if m:
out[m.group(1)] = m.group(2).split(" #", 1)[0].strip()
return out
def scan_legacy():
"""→ {key: {(模块, 文件): 值}}(含所有 profile 文件,用于发现「同键不同值」)"""
legacy = defaultdict(dict)
for p in glob.glob(os.path.join(LEGACY, "*", "src/main/resources/application*.properties")):
rel = os.path.relpath(p, LEGACY)
module, fname = rel.split(os.sep)[0], os.path.basename(p)
with open(p, encoding="utf-8", errors="replace") as fh:
for line in fh:
s = line.strip()
if not s or s[0] in "#!":
continue
m = PROP_KV_RE.match(line)
if m:
legacy[m.group(1)][(module, fname)] = m.group(2).strip()
return legacy
def legacy_baseline(legacy, key):
"""老工程的基线值:优先 `application.properties`(那是默认环境),否则取任一。"""
d = legacy.get(key)
if not d:
return None, []
for (module, fname), v in d.items():
if fname == "application.properties":
return (module, v), sorted({x for x in d.values()})
# 没有默认环境文件(如 zteits-charge 只有 application-danyang.properties)→ 取第一个
((module, fname), v) = sorted(d.items())[0]
return (module, v), sorted({x for x in d.values()})
# --------------------------------------------------------------------------- #
# 2) 扫描范围(哪些包里的类会成为 Bean)
# --------------------------------------------------------------------------- #
def resolve_placeholder_value(expr, provided):
"""把 `${parking.info.base-package}.server` 用新工程 yaml 里的值代换成 `com.zteits.parking.server`。
解不出来就原样返回(带 `${}` 的字符串匹配不上任何包名,等于不算在扫描范围内 —— 宁可少算,
由 --show-scan 打印出来人工核对)。"""
m = re.fullmatch(r'\$\{([^}]*)\}(.*)', expr)
if not m:
return expr
key, default = split_placeholder(m.group(1))
suffix = m.group(2)
for _, v in provided.get(key, []):
if not v.startswith("${"):
return v.strip("'\"") + suffix
if default is not None:
return default + suffix
return expr
def java_sources():
for d in SCAN_DIRS:
root = os.path.join(CLOUD, d)
for dirpath, _, files in os.walk(root):
if os.sep + "target" + os.sep in dirpath + os.sep:
continue
for f in files:
if f.endswith(".java"):
yield os.path.join(dirpath, f)
def scan_config():
"""→ (扫描包集合, 被 excludeFilters 排除的 FQCN 集合, 每个文件的包与是否为组件)"""
provided = scan_provided()
excluded = set()
packs = set()
def read_scan(text):
"""从一段源码里抽出 @ComponentScan 的 basePackages(无参数则返回 None=本类包)"""
found = []
for m in SCAN_ANNO_RE.finditer(text):
end, inner = balanced(text, m.end() - 1)
bp = re.search(r'basePackages\s*=\s*\{', inner)
if bp:
content, _ = brace_span(inner, bp.end() - 1)
for raw in re.findall(r'"([^"]*)"', content):
found.append(resolve_placeholder_value(raw, provided))
else:
# 无参数(扫本类所在包)或 basePackageClasses 写法 —— 都交给调用方按「本类包」处理,
# 后者会在 --show-scan 的结果里显得多算了几个包,人工核对即可
found.append(None)
if "excludeFilters" in inner:
for cl in re.finditer(r'classes\s*=\s*\{', inner):
content, _ = brace_span(inner, cl.end() - 1)
for fq in re.findall(r'([A-Z][\w.]*\.[A-Z][\w.]*)\.class', content):
excluded.add(fq)
return found
app_path = os.path.join(CLOUD, APP_JAVA)
app_text = strip_comments(open(app_path, encoding="utf-8").read())
app_pkg = PKG_RE.search(app_text).group(1)
for p in read_scan(app_text):
packs.add(p or app_pkg)
# 递归到不动点:扫描范围内的类若自己带 @ComponentScan(老代码里真有,见 AGENTS.md 坑 14)
changed = True
while changed:
changed = False
for path in java_sources():
text = strip_comments(open(path, encoding="utf-8", errors="replace").read())
pm = PKG_RE.search(text)
if not pm:
continue
pkg = pm.group(1)
if not any(pkg == p or pkg.startswith(p + ".") for p in packs):
continue
for target in read_scan(text):
t = target or pkg
if t not in packs:
packs.add(t)
changed = True
return packs, excluded
def class_is_bean(text, pkg, excluded, packs):
"""类会成为 Bean 吗?(粗判:包在扫描范围内 + 有组件注解 + 未被排除)"""
if not any(pkg == p or pkg.startswith(p + ".") for p in packs):
return False, "包不在扫描范围内"
cls = re.search(r'\b(?:class|interface)\s+(\w+)', text)
fqn = f"{pkg}.{cls.group(1)}" if cls else None
if fqn in excluded:
return False, f"已被 excludeFilters 排除({fqn})"
head = text[:cls.start()] if cls else text
if not any(s in head for s in STEREOTYPES):
return False, "类上没有组件注解"
return True, ""
# --------------------------------------------------------------------------- #
# 3) 收集 @Value 需求
# --------------------------------------------------------------------------- #
def scan_required(packs, excluded):
"""→ required{key: [entry]}, skipped[entry], with_default[entry]"""
required, skipped, with_default = defaultdict(list), [], []
for path in java_sources():
raw = open(path, encoding="utf-8", errors="replace").read()
text = strip_comments(raw)
pm = PKG_RE.search(text)
if not pm:
continue
pkg = pm.group(1)
is_bean, why = class_is_bean(text, pkg, excluded, packs)
rel = os.path.relpath(path, CLOUD)
for m in VALUE_RE.finditer(text):
key, default = split_placeholder(m.group(1))
if not key:
continue
lineno = text[:m.start()].count("\n") + 1
# 注解之后到第一个 ';' 的片段里出现 static → Spring 不注入 static 字段
tail = text[m.end():m.end() + 400]
decl = tail.split(";", 1)[0]
on_static = bool(re.search(r'\bstatic\b', decl))
entry = dict(key=key, default=default, where=f"{rel}:{lineno}", why="")
if on_static:
entry["why"] = "注解标在 static 字段上,Spring 不注入"
skipped.append(entry)
elif not is_bean:
entry["why"] = f"所在类不是 Bean:{why}"
skipped.append(entry)
elif default is not None:
with_default.append(entry)
else:
required[key].append(entry)
return required, skipped, with_default
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--all", action="store_true")
ap.add_argument("--show-scan", action="store_true")
ap.add_argument("--emit", metavar="PATH", help="生成配置草稿(.properties 格式)")
args = ap.parse_args()
packs, excluded = scan_config()
if args.show_scan:
print("扫描包(含递归得到的嵌套 @ComponentScan):")
for p in sorted(packs):
print(f" {p}")
print(f"被 excludeFilters 排除的类:{sorted(excluded) or '(无)'}\n")
required, skipped, with_default = scan_required(packs, excluded)
provided = scan_provided()
legacy = scan_legacy()
# 值里还可能引用别的占位符(如 ${local.base.url}),递归补进「需要」集合
needed, queue = {}, list(required)
referenced = set()
while queue:
e = queue.pop()
key = e["key"] if isinstance(e, dict) else e
if key in needed:
continue
needed[key] = e
base, _ = legacy_baseline(legacy, key)
value = base[1] if base else None
if not value:
continue
for ref in NESTED_RE.findall(value):
rk, _ = split_placeholder(ref)
if rk and rk not in needed:
referenced.add(rk)
queue.append(dict(key=rk, default=None, where=f"被 {key} 的值引用",
why="", referenced_from=key))
missing = {k: v for k, v in needed.items() if k not in provided}
conflicts = {}
for k in needed:
_, values = legacy_baseline(legacy, k)
if len(values) > 1:
conflicts[k] = values
print("=" * 78)
print(f"【1】必需但新工程未提供({len(missing)} 个)")
print(" 判据:注解存在(去注释)+ 不在 static 字段上 + 所在类是 Bean + 无默认值")
print("=" * 78)
for k in sorted(missing):
e = missing[k]
base, values = legacy_baseline(legacy, k)
tag = "(由别的值引用)" if k in referenced else ""
print(f"\n ${k}{tag}")
if base:
print(f" 老工程里:{base[0]} → {base[1]!r}")
if len(values) > 1:
print(f" ⚠️ 老工程多个 profile 值不同:{values}")
else:
print(" ⚠️ 老工程也没有定义(当初靠服务器上的外部配置/-D 参数注入,需部署时提供)")
print(f" 引用处:{e['where'] if isinstance(e, dict) else e}")
print(f"\n{'=' * 78}\n【2】有代码默认值、新工程未配({len(with_default)} 个,需核对老工程值)\n{'=' * 78}")
for e in sorted(with_default, key=lambda x: x["key"]):
base, _ = legacy_baseline(legacy, e["key"])
old = base[1] if base else "(老工程无)"
flag = " ⚠️ 与老值不同 → 行为静默改变" if base and base[1] != e["default"] else ""
print(f" {e['key']:<48} 代码默认={e['default']!r} 老值={old!r}{flag}")
if args.all:
print(f"\n{'=' * 78}\n【3】判为「无需配置」({len(skipped)} 个,全是假阳性)\n{'=' * 78}")
for e in sorted(skipped, key=lambda x: x["key"]):
print(f" {e['key']:<48} {e['why']}")
print(f"\n{'=' * 78}\n【4】新工程已提供({len(provided)} 个键中,被代码引用的)\n{'=' * 78}")
for k in sorted(set(needed) & set(provided)):
print(f" {k:<48} {provided[k][0][0]} = {provided[k][0][1]}")
if args.emit:
lines = [
"# 存量业务参数(从老工程各模块的 application*.properties 迁回;老工程见 AGENTS.md §3)",
"# 生成脚本:tools/p2_audit_placeholders.py --emit",
"# ⚠️ 值里的 ${...} 是**嵌套占位符**,由 Spring 在解析时再展开,不是笔误。",
"# ⚠️ 凭据类键一律写成 ${环境变量名:},真实值不进仓库(红线第 11 条)。",
"",
]
secrets = []
for k in sorted(missing):
base, values = legacy_baseline(legacy, k)
env = re.sub(r'[^A-Za-z0-9]+', '_', k).upper()
if any(h in k.lower() for h in SECRET_HINTS) or (base and _looks_secret(base[1])):
lines.append(f"{k}=${{{env}:}}")
secrets.append((k, base[1] if base else None, env))
elif base:
lines.append(f"{k}={base[1]}")
else:
lines.append(f"#{k}= # 老工程未定义,需部署时按实际环境提供")
with open(args.emit, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print(f"\n已写出草稿:{args.emit}({len(missing)} 个键)")
if secrets:
print("\n⚠️ 下列键的**老值被判定为凭据,未写入仓库**,改成了环境变量占位,"
"部署时必须注入(同时应把这些值加进「待轮换密钥」清单):")
for k, v, env in secrets:
print(f" {k:<40} ← 老值 {v!r} 环境变量 ${env}")
print(f"\n合计:需要 {len(needed)} 个键(含被引用);缺失 {len(missing)} 个;"
f"有默认值待核对 {len(with_default)} 个;判为假阳性 {len(skipped)} 个。")
return 1 if missing else 0
def _looks_secret(v):
"""凭据的启发式判据:长且含混合字符,且不像 URL / 路径 / 明文开关。"""
if not v or v.startswith("${") or v.startswith("http") or "/" in v:
return False
if len(v) < 24:
return False
return bool(re.search(r'[a-z]', v) and re.search(r'[A-Z0-9]', v))
if __name__ == "__main__":
sys.exit(main())