返回

如何实现动态配置

前言

在一个项目中,经常会遇到这样一个需求,希望能够在程序运行过程中,直接动态变更某些属性配置。这些动态变更的配置可能包括降级和切量开关等等。

当然,如果是一个微服务项目,动态配置这一功能实现显然是可以通过注册中心(Nacos)来满足。但实际上,亦可以通过代码本身来实现这个功能。在这篇博客中就使用 Java反射+Redis发布/订阅 的方式来实现。

实现方法

在真正开始实现前,不妨可以想想技术路线。能够知道的是, Java反射机制的核心是在程序运行时动态加载类并获取类的详细信息,从而操作类或对象的属性和方法。 这个能力显然和需求十分匹配。那么如何能够在反射时知道要修改哪些字段以及修改的值是什么呢?前者可以通过自定义注解来实现,在希望进行动态配置的字段上添加相应的注解作为标记,当Spring扫描Bean对象时就可以管理这些类的属性。后者可以通过Redis的发布/订阅来实现(其实也就是消息队列),监听某一个topic获取相应要更改的值。

经过上面的描述,要自己实现动态配置大概可以分为以下几个步骤:

  1. 添加一个自定义注解,用于Spring扫描Bean对象的时候,可以直接管理这些配置了自定义注解的类的属性。
  2. 给服务类的属性添加自定义注解,这里的服务类也就是包含了动态配置属性的对象。
  3. 添加一个动态配置管理的工厂,用来自动完成属性信息的填充和动态变更操作
  4. 业务的使用,这里会调用步骤2中的属性服务。当配置有变动时,可以把配置信息直接刷新到内存属性上。
  5. 实现动态变更接口,当调用相应接口时,触发Redis的发布/订阅,以此来更新类上的属性。

自定义注解的实现和使用

自定义注解

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface DCCValue {
	String value() default "";
}

按照常规方式配置一个自定义注解,同时具有一个value属性值,默认值为空字符串即可。

自定义注解的使用

@Service
public class DCCService {

    // 是否降级
    @DCCValue("downgradeSwitch:0")
    private String downgradeSwitch;

    // 是否切量
    @DCCValue("cutRange:100")
    private String cutRange;

    public boolean isDowngradeSwitchOn() {
        return "1".equals(downgradeSwitch);
    }

    public boolean isCutRange(String userId) {
        int hashCode = Math.abs(userId.hashCode());
        // 获取最后两位
        int lastTwoDigit = hashCode % 100;
        if (lastTwoDigit <= Integer.parseInt(cutRange)) {
            return true;
        }
        return false;
    }
}

创建DCCService服务类,交给Spring管理,也是动态配置项的实际使用类,在这个类中定义了 downgradeSwitchcutRange两个具有动态配置能力的属性(也就是使用 @DCCValue 注解)。两个属性分别表示是否降级以及切量,降级简单的通过0和1来决定开启或关闭。切量通过userId的哈希值的最后两位进行判断。

动态变更属性值

Bean对象初始化后操作

在这部分中,就要开始真正实现动态变更操作。需要创建一个配置类实现 BeanPostProcessor 接口,同时重写 postProcessAfterInitialization(Object bean, String beanName) 方法。如下:

@Slf4j
@Configuration
public class DCCValueBeanFactory implements BeanPostProcessor {
    
    private final String BASE_CONFIG_PAHT = "group_buy_market_dcc_";

    private RedissonClient redissonClient;
    
    private final Map<String, Object> dccObjGroup = new HashMap<>();
    
    public DCCValueBeanFactory(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }
	
	@Override
	public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        Class<?> targetBeanClass = bean.getClass();
        Object targetBeanObject = bean;
        // 判断是否有代理
        if (AopUtils.isAopProxy(bean)) {
            targetBeanClass = AopUtils.getTargetClass(bean);
            targetBeanObject = AopProxyUtils.getSingletonTarget(bean);
        }

        // 获取对象的字段
        Field[] fields = targetBeanClass.getDeclaredFields();
        for (Field field : fields) {
            // 判断这个字段是否有DCCValue注解
            if (!field.isAnnotationPresent(DCCValue.class)) {
                continue;
            }
            // 获取字段的DCCValue注解
            DCCValue dccValue = field.getAnnotation(DCCValue.class);
            // 判断字段是否为空,为空抛出异常
            String value = dccValue.value();
            if (StringUtils.isBlank(value)) {
                throw new RuntimeException("...");
            }
            String[] split = value.split(":");
            String key = BASE_CONFIG_PAHT.concat(split[0]);
            String defaultValue = split.length == 2 ? split[1] : null;

            String setValue = defaultValue;
            try {
                // 判断是否设置了对应的值 设置错误抛出异常
                if (StringUtils.isBlank(defaultValue)) {
                    throw new RuntimeException("...");
                }
                // 从redis中获取设置值
                RBucket<String> bucket = redissonClient.getBucket(key);
                boolean exists = bucket.isExists();
                if (!exists) {
                    bucket.set(defaultValue);
                } else {
                    setValue = bucket.get();
                }

                // 填充值
                field.setAccessible(true); // 允许访问
                field.set(targetBeanObject, setValue);
                field.setAccessible(false);
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
            dccObjGroup.put(key, targetBeanObject);
        }
        return bean;
    }
}

在这个配置类中,首先是实现了 BeanPostProcessor接口,意味着它能够在Spring容器初始化Bean之后,对Bean进行额外的处理。重写 postProcessAfterInitialization 方法就是为了实现对Bean初始化之后的操作。 因为在DCCService类上加上了Service注解,让这个服务类被Spring管理,所以这个方法能够扫描到服务类对象。

在重写的 postProcessAfterInitialization 方法中

  • 这个方法会对所有Bean对象进行扫描,通过反射获取字节码文件以及Object对象。 这里需要注意的是,要判断拿到的对象是否是代理对象,如果是,需要借助AopUtils重新获取相应的字节码文件和Object对象。
  • 利用Class文件获取对象声明的属性,并在判断属性是否加上了 @DCCValue 注解,如果有,就判解析出注解上的配置项和默认值。同时,由于将配置值存放在Redis中,也需要去判断Redis中是否存在,存在就使用Redis中的值。
  • 将反射获取到的对象字段设置为可访问,并更新其相应的配置值。这里可以利用一个Map来存储反射获取的对象,避免之后再次通过反射去寻找。

Redis订阅/发布

为了实现能够多次更改配置项的值,还需要使用Redis中的订阅/发布功能。还是在这个类中,创建一个Redis主题的监听方法,并将这个主题作为Bean对象返回(交给Spring管理,在发布消息时能够使用)。

public RTopic dccRedisTopicListener(RedissonClient redissonClient) {
    // 创建Topic
    RTopic topic = redissonClient.getTopic("group_buy_market_dcc");
    // 为这个topic添加监听器
    topic.addListener(String.class, new MessageListener<String>() {
        // 第一个参数代表哪个主题(也就是topic),第二个参数就是实际的消息内容
        @Override
        public void onMessage(CharSequence charSequence, String s) {
            String[] split = s.split(Constants.SPLIT);

            String attribute = split[0];
            String key = BASE_CONFIG_PAHT + attribute;
            String value = split[1];

            RBucket<Object> bucket = redissonClient.getBucket(key);
            boolean exists = bucket.isExists();
            if (!exists) return;

            bucket.set(value);
            // 通过之前存储的map获取一下对象
            Object objBean = dccObjGroup.get(key);
            if (null == objBean) return;

            // 获取class
            Class<?> objBeanClass = objBean.getClass();
            if (AopUtils.isAopProxy(objBeanClass)) {
                objBeanClass = AopUtils.getTargetClass(objBean);
            }

            try {
                Field field = objBeanClass.getDeclaredField(attribute);
                field.setAccessible(true);
                field.set(objBean, value);
                field.setAccessible(false);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }

        }
    });
    return topic;
}

这个方法实现思路同上面的方法异曲同工,也是通过反射来修改对象的配置值。只不过经过 postProcessAfterInitialization 的执行之后,可以通过之前建立的Map来直接获取对象。当然,依旧需要判断获取到的对象是否是代理对象。

postProcessAfterInitialization 方法不同的是,在设置配置值时要从Redis中获取,如果Redis中不存在相应的配置项,那么就可以直接返回了。

外部接口的实现

到了这一步就是简单的接口实现了,只需要在调用接口时,往Redis中的相应key中发送一条消息即可。

public interface IDCCService {

    Response<Boolean> updateConfig(String key, String value);
}

@Slf4j
@RestController()
@CrossOrigin("*")
@RequestMapping("/api/v1/gbm/dcc/")
public class DCCController implements IDCCService {

    @Resource
    private RTopic dccTopic;

    @RequestMapping(value = "update_config", method = RequestMethod.GET)
    @Override
    public Response<Boolean> updateConfig(@RequestParam String key, @RequestParam String value) {
        try {
            // 往topic发送消息
            dccTopic.publish(key + "," + value);

            return Response.<Boolean>builder()
                    .code(ResponseCode.SUCCESS.getCode())
                    .info(ResponseCode.SUCCESS.getInfo())
                    .build();
        } catch (Exception e) {
            return Response.<Boolean>builder()
                    .code(ResponseCode.UN_ERROR.getCode())
                    .info(ResponseCode.UN_ERROR.getInfo())
                    .build();
        }
    }
}

为什么要检测对象是否是代理对象

在博客的最后,回过头去想一想,为什么在 DCCValueBeanFactory 中有这样一段代码

if (AopUtils.isAopProxy(bean)) {
    targetBeanClass = AopUtils.getTargetClass(bean);
    targetBeanObject = AopProxyUtils.getSingletonTarget(bean);
}

加入这段代码的核心原因是为了正确处理被Spring AOP代理过的Bean对象,确保反射能够作用于原始的目标对象,而不是代理对象。 下面就解释Spring AOP与代理对象

AOP核心概念

  • AOP (Aspect-Oriented Programming) : 面向切面编程,用于将 横切关注点 (如日志、事务、权限)从业务逻辑中分离出来,形成可重用的 切面 (Aspect)
  • 目标对象 (Target Object) :包含核心业务逻辑的原始对象。
  • AOP代理 (AOP Proxy) :AOP框架创建的一个对象,它包裹了目标对象。应用程序中实际引用的是这个代理对象。

代理对象(Proxy)的工作原理

代理对象就像一个“中介”。当客户端调用一个Bean的方法时,调用首先被代理对象拦截。其调用过程如下:

  1. 客户端调用 proxy.doSomething()
  2. 代理对象 拦截调用,执行 前置通知(如打印日志)。
  3. 代理对象将调用 委托 给被它包裹的 原始目标对象doSomething() 方法。
  4. 目标对象 执行真正的业务逻辑。
  5. 业务逻辑执行完毕后,返回给 代理对象
  6. 代理对象 获得控制权,执行 后置通知(如记录方法耗时)。
  7. 最后,代理对象 将最终结果返回给客户端。

因此,如果不检测反射获取的对象是否是代理对象,就会发现所作的修改是作用在了无用的代理对象上,而真正执行业务的目标对象内部实际上是没有改变的,业务最后也会继续按照旧的配置进行。

你要相信流星划过会带给我们幸运,就像现实告诉你我要心存感激