在内网多环境部署场景下,开发/测试服务器往往无法直接访问生产环境的 API。本文介绍一种基于 Spring AOP 切面 + 反射调用 + 白名单注解的方法级代理方案,实现对业务代码零侵入的透明远程调用转发。
一、问题背景
在企业级 DevOps 平台中,我们经常面临这样的网络拓扑:
flowchart LR
Dev[开发服务器<br/>10.x.x.123]
Proxy[代理服务器<br/>10.x.x.44]
Prod[生产 Rancher<br/>/ K8s API]
Dev -- "╳ 不通" --> Prod
Dev -- "HTTP 可达" --> Proxy
Proxy -- "HTTP 可达" --> Prod
style Dev fill:#e1f5ff
style Proxy fill:#fff4e1
style Prod fill:#ffe1e1
开发服务器(123) 无法直连生产环境的 Rancher/K8s API,但 代理服务器(44) 可以。两台服务器部署的是同一套代码。
最直观的做法是在每个调用 Rancher/K8s API 的方法里都加 if (needProxy) { 转发到代理服务器 } else { 直接调用 },但这意味着:
- 几十个方法都需要修改,重复代码爆炸
- 每新增一个方法都要记得加判断,极易遗漏
- 业务逻辑和网络转发逻辑耦合,违反单一职责
我们需要的是一种对业务代码完全透明的方案——调用方无需关心是本地执行还是远程代理,AOP 自动决策。
二、整体架构
最终方案由四个核心组件构成:
| 组件 | 职责 |
|---|---|
@ProxyAllowed 注解 | 标记哪些方法允许被远程代理调用 |
MyAspect 切面 | 拦截工具类方法调用,判断是否需要代理转发 |
ProxyController 控制器 | 代理服务器接收转发请求,反射调用真实方法 |
ProxyUtils 工具类 | 封装反射调用逻辑 |
完整调用流程如下:
sequenceDiagram
participant Controller as Controller<br/>(开发服务器 123)
participant Aspect as MyAspect<br/>(开发服务器 123)
participant Proxy as ProxyController<br/>(代理服务器 44)
participant Utils as RancherUtils<br/>(代理服务器 44)
participant API as Rancher API
Controller->>Aspect: RancherUtils.queryPodList()
activate Aspect
alt needProxy=true && proxyUrl 已配置
Aspect->>Aspect: 序列化参数
Aspect->>Proxy: POST /proxy/rancher/queryPodList
activate Proxy
Proxy->>Proxy: 白名单校验 ✓
Proxy->>Proxy: 反序列化参数
Proxy->>Proxy: 设置 needProxy=false(防循环)
Proxy->>Utils: ProxyUtils.invokeMethod()
activate Utils
Utils->>Utils: 反射调用 RancherUtils.queryPodList()
Utils->>API: HTTP Request
activate API
API-->>Utils: Response
deactivate API
Utils-->>Proxy: 返回 JSON
deactivate Utils
Proxy-->>Aspect: HTTP Response (JSON body)
deactivate Proxy
Aspect->>Aspect: 反序列化响应
else 无需代理
Aspect->>Aspect: 直接执行 jp.proceed()
end
Aspect-->>Controller: 返回结果
deactivate Aspect
三、核心实现详解
3.1 @ProxyAllowed — 白名单注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ProxyAllowed {
}
这是整个安全体系的基石。只有两行代码,但意义重大:
- 运行时保留(
RUNTIME),因为需要在启动时通过反射扫描 - 方法级别(
METHOD),精确控制到每个方法 - 无属性,标记即白名单,简洁明了
使用方式:
public class RancherUtils {
@ProxyAllowed
public static List<Pod> queryDefaultPodList(Rancher rancher, String projectId) {
// 业务实现...
}
// 未标注 @ProxyAllowed 的方法不允许被远程代理调用
public static void internalHelper(Rancher rancher) {
// 内部辅助方法...
}
}
在实际项目中,RancherUtils 有 24 个方法标注了 @ProxyAllowed,K8sUtils 有 17 个。新增的工具方法只需加上注解即可自动纳入代理体系,无需修改任何其他代码。
3.2 MyAspect — AOP 切面(调用方)
切面是整个方案的"灵魂",它让所有业务代码无需感知代理的存在。
切入点定义
@Pointcut("execution(* com.lenovo.moss.devops.utils.RancherUtils.*(..))")
public void rancherBefore() {}
@Pointcut("execution(* com.lenovo.moss.devops.utils.K8sUtils.*(..))")
public void k8sBefore() {}
使用 execution 表达式匹配工具类的所有公开方法,实现"全量拦截、按需代理"。
环绕通知(以 Rancher 为例)
@SneakyThrows
@Around("rancherBefore()")
public Object rancherAround(ProceedingJoinPoint jp) {
MethodSignature signature = (MethodSignature) jp.getSignature();
Rancher rancher = (Rancher) jp.getArgs()[0];
if (rancher.getNeedProxy() && StrUtil.isNotEmpty(proxyUrl)) {
// ① 序列化所有参数
Map<String, List<String>> params = new HashMap<>();
params.put("params", Arrays.stream(jp.getArgs())
.map(JSON::toJSONString).toList());
params.put("paramsType", Arrays.stream(jp.getArgs())
.map(item -> item.getClass().getName()).toList());
// ② 转发到代理服务器
String body = HttpUtil.createPost(
proxyUrl + "/proxy/rancher/" + signature.getName())
.timeout(20000)
.header("traceId", ThreadContext.get("traceId"))
.body(JSON.toJSONString(params))
.execute().body();
// ③ 反序列化响应
return deserializeProxyResponse(body, signature);
} else {
// 无需代理,直接执行原方法
return jp.proceed();
}
}
三个关键设计点:
1. 双重条件判断
if (rancher.getNeedProxy() && StrUtil.isNotEmpty(proxyUrl))
只有当实体标记了 needProxy=true 且 配置了代理地址时才走代理。这意味着:
- 代理服务器自身不配置
proxyUrl,永远走直连 - 开发环境可以通过配置随时关闭代理
2. 参数与类型双序列化
params.put("params", Arrays.stream(jp.getArgs()).map(JSON::toJSONString).toList());
params.put("paramsType", Arrays.stream(jp.getArgs()).map(item -> item.getClass().getName()).toList());
不仅传递参数值,还传递参数的完整类名。这是因为工具方法参数类型多样(Rancher、String、Long 等),代理端需要知道如何反序列化。
3. TraceId 传递
.header("traceId", ThreadContext.get("traceId"))
将日志追踪 ID 透传到代理服务器,使得跨服务器的调用链可以在日志系统中串联起来。
响应反序列化
代理响应是一个 JSON 字符串,但原方法的返回值类型各异,需要智能反序列化:
private Object deserializeProxyResponse(String body, MethodSignature signature)
throws Exception {
Class returnType = signature.getReturnType();
Method method = signature.getMethod();
if (returnType == String.class) {
// 字符串类型直接返回(如 YAML 内容)
return body;
} else if (returnType == List.class) {
// List 类型需要解析泛型参数
Type genericReturnType = method.getGenericReturnType();
Type genericType = null;
if (genericReturnType instanceof ParameterizedType pt) {
Type[] typeArguments = pt.getActualTypeArguments();
for (Type typeArgument : typeArguments) {
genericType = typeArgument;
}
}
return genericType == null
? JSON.parseObject(body, returnType)
: JSON.parseArray(body, genericType);
} else if (body.startsWith("{")) {
// JSON 对象,检查是否为错误响应
JSONObject result = JSON.parseObject(body);
if (result.get("code") != null && result.get("code").equals(500)) {
throw new Exception(result.get("msg").toString());
}
return JSON.parseObject(body, returnType);
} else if (body.startsWith("<")) {
// HTML 错误页面,直接抛异常
throw new Exception(body);
} else {
return JSON.parseObject(body, returnType);
}
}
这里有一个容易踩的坑:List<T> 的泛型在运行时会被擦除。如果只用 JSON.parseObject(body, List.class),得到的是 List<JSONObject> 而非 List<Pod>。必须通过 Method.getGenericReturnType() 获取方法签名中声明的泛型参数,再用 JSON.parseArray(body, genericType) 正确反序列化。
3.3 ProxyController — 代理接收端
代理服务器上的控制器负责接收转发请求并执行真实调用。
启动时构建白名单
private static final Set<String> ALLOWED_METHODS =
Arrays.stream(RancherUtils.class.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(ProxyAllowed.class))
.map(Method::getName)
.collect(Collectors.toUnmodifiableSet());
static final— 类加载时初始化一次,不可变toUnmodifiableSet()— 防止运行时被篡改- 仅扫描
@ProxyAllowed标注的方法
请求处理
@PostMapping("/rancher/**")
public Object rancher(HttpServletRequest request,
@RequestBody ProxyEntity proxyEntity) throws Exception {
// ① 从 URL 中提取方法名
String methodName = request.getRequestURI()
.substring("/proxy/rancher/".length());
// ② 白名单校验
if (!ALLOWED_METHODS.contains(methodName)) {
log.warn("Proxy call rejected: method '{}' is not allowed", methodName);
throw new MossException("Proxy call rejected: method '"
+ methodName + "' is not allowed");
}
// ③ 反序列化参数
String[] params = proxyEntity.getParams().toArray(new String[0]);
Object[] newParams = new Object[params.length];
System.arraycopy(params, 0, newParams, 0, params.length);
Class[] paramsTypes = proxyEntity.getParamsType().stream()
.map(item -> {
try { return Class.forName(item); }
catch (ClassNotFoundException e) { return String.class; }
}).toArray(Class[]::new);
for (int i = 0; i < paramsTypes.length; i++) {
newParams[i] = JSON.parseObject(
String.valueOf(newParams[i]), paramsTypes[i]);
}
// ④ 关闭二次代理,防止循环
((Rancher) newParams[0]).setNeedProxy(false);
// ⑤ 反射调用
return ProxyUtils.invokeMethod(
RancherUtils.class, methodName, newParams, paramsTypes);
}
防循环转发是这里最关键的设计:
((Rancher) newParams[0]).setNeedProxy(false);
代理服务器上同样部署了 MyAspect 切面。如果不将 needProxy 设为 false,反射调用会再次被 AOP 拦截并转发回来,形成无限循环。
3.4 ProxyUtils — 反射调用封装
public class ProxyUtils {
public static Object invokeMethod(Class<?> clazz, String methodName,
Object[] args, Class<?>[] parameterTypes) {
try {
Method method = clazz.getDeclaredMethod(methodName, parameterTypes);
return method.invoke(
clazz.getDeclaredConstructor().newInstance(), args);
} catch (Exception e) {
log.error(e.getMessage(), e);
return null;
}
}
}
通过 getDeclaredMethod 精确匹配方法名和参数类型,避免方法重载时的歧义。
3.5 ProxyEntity — 传输协议
@Data
public class ProxyEntity {
List<String> params; // 每个参数的 JSON 字符串
List<String> paramsType; // 每个参数的完整类名
}
这是一个极简的传输协议:参数值和参数类型一一对应,代理端按索引配对后反序列化。
四、安全设计
方法级代理本质上涉及远程反射调用,如果不做限制,就是一个远程代码执行(RCE)漏洞。本方案采用了多层防护:
4.1 白名单注解
只有标注了 @ProxyAllowed 的方法才能被远程调用。攻击者即使构造了恶意请求(如 /proxy/rancher/exec),也会被白名单拦截:
if (!ALLOWED_METHODS.contains(methodName)) {
throw new MossException("Proxy call rejected: method '"
+ methodName + "' is not allowed");
}
4.2 不可变白名单集合
Collectors.toUnmodifiableSet()
白名单在类加载时构建,运行时无法修改。
4.3 认证豁免的最小化
ignore-urls:
- /proxy/**
代理接口跳过了用户认证(因为是服务间调用),但这要求代理接口只在内网可达。在生产环境中,应通过网络策略(如防火墙规则、K8s NetworkPolicy)限制只允许特定 IP 访问代理端口。
4.4 防循环转发
((Rancher) newParams[0]).setNeedProxy(false);
从设计上杜绝了代理链路的循环。
五、如何扩展新的代理方法
当需要新增一个工具方法并支持代理调用时,只需两步:
第一步:在工具类中定义方法并添加 @ProxyAllowed 注解
public class RancherUtils {
@ProxyAllowed
public List<Event> queryEvents(Rancher rancher, String namespace) {
// 你的实现...
}
}
第二步:无。
没有第二步。@ProxyAllowed 注解会被 ProxyController 在启动时自动扫描到白名单中,MyAspect 的切面表达式已经匹配了工具类的所有方法。新方法自动获得代理能力。
这就是"零侵入"的含义——业务代码不需要知道代理的存在,代理体系不需要知道具体的业务方法。
六、方案总结
优点
| 维度 | 说明 |
|---|---|
| 零侵入 | 业务代码无需修改,AOP 切面自动处理 |
| 易扩展 | 新方法只需加一个注解 |
| 安全可控 | 白名单注解 + 不可变集合,防止 RCE |
| 防循环 | needProxy=false 从设计上杜绝无限转发 |
| 可观测 | TraceId 透传,跨服务器日志可串联 |
| 配置灵活 | proxyUrl 为空即关闭代理,无需改代码 |
注意事项
- 返回值类型处理:要特别注意
List<T>泛型擦除问题,需通过Method.getGenericReturnType()获取实际泛型参数 - 异常传播:代理端的异常需要通过 HTTP 响应体传播回调用方,需要约定错误格式(如
{code: 500, msg: "..."}) - 超时控制:代理调用增加了一跳网络开销,需要合理设置超时(本方案使用 20 秒)
- 序列化兼容性:两端代码版本必须保持一致,否则可能出现反序列化失败
适用场景
- 跨网络环境调用:如本文的开发/生产网络隔离场景
- 多机房部署:不同机房间的服务互调
- 灰度发布:特定请求代理到灰度环境
- API 网关:作为轻量级方法级网关使用
技术栈
- Spring Boot 3.x
- Spring AOP(
@Aspect、@Around) - Java 反射(
Method.invoke) - Fastjson2(序列化/反序列化)
- Hutool HTTP(HTTP 请求)
七、完整调用链路时序图
sequenceDiagram
participant Caller as 调用方(123)
participant Aspect1 as MyAspect(123)
participant Proxy as ProxyController(44)
participant Aspect2 as MyAspect(44)
participant Utils as RancherUtils(44)
participant API as Rancher API
Caller->>Aspect1: queryPodList()
activate Aspect1
Aspect1->>Aspect1: needProxy=true?
Aspect1->>Aspect1: YES
Aspect1->>Proxy: POST /proxy/rancher/queryPodList
activate Proxy
Proxy->>Proxy: 白名单校验 ✓
Proxy->>Proxy: 反序列化参数
Proxy->>Proxy: needProxy=false
Proxy->>Aspect2: invokeMethod()
activate Aspect2
Aspect2->>Aspect2: needProxy=false
Aspect2->>Aspect2: 直接执行
Aspect2->>Utils: 反射调用
activate Utils
Utils->>API: HTTP Request
activate API
API-->>Utils: Response
deactivate API
Utils-->>Aspect2: 返回结果
deactivate Utils
Aspect2-->>Proxy: 返回结果
deactivate Aspect2
Proxy-->>Aspect1: HTTP Response
deactivate Proxy
Aspect1->>Aspect1: 反序列化响应
Aspect1-->>Caller: 结果
deactivate Aspect1
本方案在实际 DevOps 平台中稳定运行,代理了 Rancher 管理接口(24 个方法)和 K8s 直连接口(17 个方法),日均处理数千次代理调用。核心思路是"切面拦截 + 反射调用 + 白名单安全",可以推广到任何需要跨网络环境透明转发方法调用的场景。