前言
在一个项目中,经常会遇到这样一个需求,希望能够在程序运行过程中,直接动态变更某些属性配置。这些动态变更的配置可能包括降级和切量开关等等。
当然,如果是一个微服务项目,动态配置这一功能实现显然是可以通过注册中心(Nacos)来满足。但实际上,亦可以通过代码本身来实现这个功能。在这篇博客中就使用 Java反射+Redis发布/订阅 的方式来实现。
实现方法
在真正开始实现前,不妨可以想想技术路线。能够知道的是, Java反射机制的核心是在程序运行时动态加载类并获取类的详细信息,从而操作类或对象的属性和方法。 这个能力显然和需求十分匹配。那么如何能够在反射时知道要修改哪些字段以及修改的值是什么呢?前者可以通过自定义注解来实现,在希望进行动态配置的字段上添加相应的注解作为标记,当Spring扫描Bean对象时就可以管理这些类的属性。后者可以通过Redis的发布/订阅来实现(其实也就是消息队列),监听某一个topic获取相应要更改的值。
经过上面的描述,要自己实现动态配置大概可以分为以下几个步骤:
- 添加一个自定义注解,用于Spring扫描Bean对象的时候,可以直接管理这些配置了自定义注解的类的属性。
- 给服务类的属性添加自定义注解,这里的服务类也就是包含了动态配置属性的对象。
- 添加一个动态配置管理的工厂,用来自动完成属性信息的填充和动态变更操作
- 业务的使用,这里会调用步骤2中的属性服务。当配置有变动时,可以把配置信息直接刷新到内存属性上。
- 实现动态变更接口,当调用相应接口时,触发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管理,也是动态配置项的实际使用类,在这个类中定义了 downgradeSwitch,cutRange两个具有动态配置能力的属性(也就是使用 @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的方法时,调用首先被代理对象拦截。其调用过程如下:
- 客户端调用
proxy.doSomething()。 - 代理对象 拦截调用,执行 前置通知(如打印日志)。
- 代理对象将调用 委托 给被它包裹的 原始目标对象 的
doSomething()方法。 - 目标对象 执行真正的业务逻辑。
- 业务逻辑执行完毕后,返回给 代理对象。
- 代理对象 获得控制权,执行 后置通知(如记录方法耗时)。
- 最后,代理对象 将最终结果返回给客户端。
因此,如果不检测反射获取的对象是否是代理对象,就会发现所作的修改是作用在了无用的代理对象上,而真正执行业务的目标对象内部实际上是没有改变的,业务最后也会继续按照旧的配置进行。