记一次反射应用实践

267 阅读8分钟

公众号搜索“码路印记”,点关注不迷路!

背景

近期公司项目需要迁移改造,对项目架构、技术选型进行了重新设计,初衷是在保证前端调用不受影响的情况下,仅对后端服务进行改造,然后通过“中间件+灰度策略”实现前端流量从0-100的平滑迁移。所以,新应用保留了旧应用所有的接口及出入参数定义,以确保接口兼容迁移。

然而,在开发工作接近尾声时,突然识别到公司已有的接口代理服务中间件不支持跨应用级的灰度配置,测试、联调无法进行,凉凉~。考虑前端资源无法快速匹配,最后评估采用“灰度+旧应用接口转发”的方式实现。即,所有流量先进入老接口,然后根据灰度判断是走老接口还是新接口。简单图示如下: image.png 这种方式最终会使得旧应用变为一个流程转发的空壳,最终却无法下线,若要下线还需要前端进行适配,但是可以提前让新服务接受线上洗礼,及时发现代码中的问题,待前端适配时直接切换入口即可。

思考

从整个方案看灰度配置实现简单,新应用接口调用封装也比较容易,但问题是有几十个接口,实在太多了,如果一个个封装都是简单无脑的搬运,纯体力活;而且,这些代码很快就会废弃,所以我就考虑有没有简单的方法可以一次性解决所有的接口转发。

一起来分析下:

  • 新旧接口所属接口类不同,但是方法名称完全一致,而且不存在方法重载:我们可以通过简单的配置实现一对一的映射;
  • 新旧接口方法入参、返回值结构完全一致:这就为序列化/反序列化(或者通过反射为字段赋值,也可以使用BeanUtils#copyProperties等)提供了机会,结构转换没有问题;

摆在我们面前的问题就是:把对服务的一个请求转发到另外一个服务,而且目标服务几乎有当前服务相同的签名信息。当我们在旧服务处理这个请求时,我们可以通过多种方式拿到请求所在的类名、方法名、参数类型及参数等信息;如果我们能够依据这些信息找到一个目标服务实例及方法即可完成请求转发。

这看起来就像是RPC框架中,通过注册中心进行服务发现,拦截请求查找执行对象,然后进行接口调用的过程。只不过服务发现的过程是“全匹配”,而现在我们只需要在已知接口名的情况下找到对应的方法即可(无重载是前提);而RPC框架中的接口调用使用了反射技术。

我们是不是也可以参考RPC这套思路实现呢?注册中心、服务发现、请求拦截、反射调用,只要能解决这几个点我们的问题好像旧迎刃而解了。分别梳理一下初步的想法:

  • 注册中心:新旧接口所属接口类不同,我们可以通过Map做接口类的映射。我们需要让程序知道,当请求到接口A1#method时,能够把请求转发到A2#method。由于方法名称相同,我们可以缺省配置,在运行时动态设置,映射中只需完成A1到A2的映射即可。
  • 服务发现:在RPC中服务发现是需要有服务提供者实例注册到注册中心,然后服务消费者在本地创建一个服务引用对象,通过这个引用对象我们就可以实现对服务方法的远程调用。我们的服务本来就是基于RPC框架的,只要我们配置好引用,本地就可以生成这个消费者对象。我们需要解决的就是拿到这个对象,并且与目标服务接口类关联起来就好了。在Spring中它就是一个Bean,搞定它易如反掌。
  • 请求拦截:根据请求入口采用的方式,我们可以选择不同的拦截器或者Filter,也可以通过aop对需要转发请求的类或者方法进行切面,而且使用注解还可以实现对转发目标的精准控制。通过切面对象获取方法的元数据信息,如类名、方法名、参数列表及类型、返回值类型等。
  • 反射调用:前面两步我们可以得到目标服务接口类和目标服务接口类的本地引用对象,为反射提供了目标对象。剩下的就是使用反射技术查找这个目标对象的方法列表,然后进行参数转换,执行调用了。

通过上述分析、梳理,这个思路的可行性基本没有问题了,而且针对每个需要解决的问题提出了应对方案,相信你也对实现的过程有了大体的了解了。剩下的就是实现与论证了,说干就干~

实践与论证

公司的项目代码没有办法拿出来分享,我这里使用一个Demo重新实现,技术框架就选择Dubbo+Nacos,示例项目使用了一些我个人封装的公共包,大家可以从github下载。为了简单起见,示例仅使用一个方法。

转变问题为:旧版服务有一个GreetingController#sayHello方法,现在需要将其请求转发到新的Dubbo服务实现GreetingService#sayHello中。旧版服务代码如下所示:

@RestController
@RequestMapping(value = "/greeting")
@Slf4j
public class GreetingController {

    @RequestMapping(value = "hello", method = RequestMethod.POST)
    public SingleResponse<HelloResponseCO> sayHello(@RequestBody HelloRequestCmd helloRequestCmd) {
        log.info("sayHello params={}", helloRequestCmd);
        HelloResponseCO responseCO = new HelloResponseCO();
        responseCO.setName("abcd");
        responseCO.setGreeting("Hello," + helloRequestCmd.getName() + ". --from old service.");
        return SingleResponse.of(responseCO);
    }
}

也就是说:当前端请求/greeting/hello时执行拦截,获取参数信息并通过反射机制调用GreetingService#sayHello方法,再将结果转换并返回前端。

模拟注册中心及服务发现

通过RegistryUtils模拟注册中心,实现GreetingController与GreetingService之间的映射,用它来实现一个简单的路由查找功能。为了能够直接关联到GreetingService的消费者实例对象,通过Spring获取到Bean后直接保存在映射结构中,Spring容器初始化完成后会自动执行匹配及初始化逻辑。代码如下:


/**
 * 模拟注册中心及服务对象存储
 *
 * @author raysonxin
 * @since 2021/1/19
 */
@Slf4j
@Component
public class RegistryUtils implements ApplicationContextAware {

    /**
     * 映射
     */
    private static Map<String, ServiceDesc> serviceMap = new HashMap<>(1);


    static {
        // 初始化
        serviceMap.put(GreetingController.class.getName(), new ServiceDesc(GreetingService.class, null));
    }

    /**
     * 根据请求源类名获取目标服务的Bean对象
     *
     * @param sourceClass 源类名
     * @return bean
     */
    public static Object getBeanObject(String sourceClass) {
        if (serviceMap.containsKey(sourceClass)) {
            return serviceMap.get(sourceClass).getServiceBeanObject();
        }
        return null;
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        for (Map.Entry<String, ServiceDesc> entry : serviceMap.entrySet()) {
            try {
                // 按照类型获取bean对象,并设置到注册中心。
                Object bean = applicationContext.getBean(entry.getValue().getServiceType());
                entry.getValue().setServiceBeanObject(bean);
            } catch (Exception ex) {
                log.error("RegistryUtils getBean for {}->{} failed.", entry.getKey(), entry.getValue().getServiceType().getName());
            }
        }
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class ServiceDesc {

        /**
         * 服务类型
         */
        private Class<?> serviceType;

        /**
         * 服务对象实例
         */
        private Object serviceBeanObject;

    }
}

请求拦截及反射调用

定义注解EnableTransfer,用来修饰需要转发的接口或者方法,便于AOP进行织入。定义切面类TransferAspect,设置切点及环绕织入的执行逻辑。

若要完成接口转发,我们需要通过反射获取当前接口的类名、方法名、参数、返回值类型等内容,然后通过RegistryUtils找到当前类名对应的目标服务Bean实例。有了Bean实例就可以通过反射进行处理了,大家直接看代码就可以了。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EnableTransfer {
}


/**
 * 切面实现
 *
 * @author raysonxin
 * @since 2021/1/19
 */
@Component
@Slf4j
@Aspect
public class TransferAspect {

    @Pointcut("@within(com.rsxtech.demo.consumer.transfer.EnableTransfer)||@annotation(com.rsxtech.demo.consumer.transfer.EnableTransfer)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        // 请求类名
        String className = joinPoint.getTarget().getClass().getName();
        // 请求方法名
        String methodName = joinPoint.getSignature().getName();
        // 返回值类型
        Class<?> returnType = ((MethodSignature) joinPoint.getSignature()).getMethod().getReturnType();
        // 方法入参
        Object[] args = joinPoint.getArgs();

        try {
            // 判断是否需要转发,灰度逻辑
            if (this.needTransfer(args)) {
                // 执行调用
                return this.invokeProxy(className, methodName, args, returnType);
            }
        } catch (Exception ex) {
            log.error("TransferAspect execute {}#{} transfer failed",className,methodName,ex);
        }
        // 如果无灰度或者遇到异常了,还是使用旧的处理方式,新接口不行,还得保证系统可用。
        return joinPoint.proceed();
    }

    /**
     * 模拟灰度逻辑
     */
    private boolean needTransfer(Object[] args) {
        int rand = new Random().nextInt(100) % 2;
        return rand == 0;
    }

    private Object invokeProxy(String className, String methodName, Object[] args, Class<?> returnType) throws Exception {
        // 获取目标服务实例对象
        Object bean = RegistryUtils.getBeanObject(className);
        if (null == bean) {
            throw new NoSuchMethodException("no bean found");
        }

        // 方法匹配
        Method targetMethod = null;
        Method[] methods = bean.getClass().getDeclaredMethods();
        for (Method method : methods) {
            if (method.getName().equals(methodName)) {
                targetMethod = method;
                break;
            }
        }
        if (null == targetMethod) {
            throw new NoSuchMethodException("no matched method found");
        }
        
        // 参数类型判断及转换
        Object result = null;
        Class<?>[] targetParamTypes = targetMethod.getParameterTypes();
        if (targetParamTypes.length == 0) {
            result = targetMethod.invoke(bean);
        } else if (targetParamTypes.length == args.length) {
            Object[] targetParams = new Object[args.length];
            for (int i = 0; i < args.length; i++) {
                // 在这里进行参数转换
                targetParams[i] = ObjectConverter.convert(args[i],targetParamTypes[i]);
            }

            result = targetMethod.invoke(bean, targetParams);
        } else {
            throw new NoSuchMethodException("no matched method found");
        }


        // 返回前,需要根据returnType进行类型转换
        return ObjectConverter.convert(result,returnType);
    }
}

在参数及返回值的处理中涉及到了对象类型转换,我使用了ObjectConverter并使用Jackson增加了对复杂类型的支持。ObjectConverter是从github找到的Balusc的代码,链接附后。

论证

好了,一顿操作后,终于到论证的时候了。剩下的工作很简单,只需要为GreetingController类增加注解@EnableTransfer即可。当我们请求/greeting/hello时,会发现执行到TransferAspect#around中,执行效果大家可以自行体验。新服务的代码可以直接到github地址获取。

/**
 * 旧服务实现
 *
 * @author raysonxin
 * @since 2021/1/17
 */
@RestController
@RequestMapping(value = "/greeting")
@Slf4j
@EnableTransfer
public class GreetingController {

    @RequestMapping(value = "hello", method = RequestMethod.POST)
    public SingleResponse<HelloResponseCO> sayHello(@RequestBody HelloRequestCmd helloRequestCmd) {
        log.info("sayHello params={}", helloRequestCmd);
        HelloResponseCO responseCO = new HelloResponseCO();
        responseCO.setName("abcd");
        responseCO.setGreeting("Hello," + helloRequestCmd.getName() + ". --from old service.");
        return SingleResponse.of(responseCO);
    }
}

总结

文章到这里就结束了,还是简单总结一下。

  • 本文描述的问题,是我项目中遇到的真实案例,从开始做到完成大概花了半天时间。其实真正写代码的时间也就一个小时左右。当我着手处理这个问题时,我就不想一个一个接口去写,做那些重复无用的劳动,就考虑了代理、反射,想到了RPC框架,想法一点点成熟就付诸行动了。中间类型转换有点小曲折,最终还是解决了。
  • 文章开头说到的,在即将开发完成才识别到这么大的风险,我觉得确实算是方案设计的缺陷了。虽然,参与这个项目初期就向负责人确认了可以保证顺利灰度切换,可还是凉凉。所以,实际开发中我们一定要仔细评估并论证方案的可行性,否则就要自食其果了。

公众号搜索“码路印记”,点关注不迷路!