最近做BI看板的时候遇到个需求,就是有时候需要切换看板所展示的数据(懂的都懂),但每次都手动改数据库视图后面再改回来比较麻烦,于是需要做一个快速切换公司内部看的真实数据模式和给客户看的演示模式的功能。
每个看板数据接口都加个参数侵入性太大肯定不行,通过配置来切换也比较麻烦,最后想到了大多数情况下都是一个接口对一个实现类的Service层。只需要根据配置的变动改变实现类,就能很轻松地实现数据来源切换。
有了想法之后,就和豆包讨论方案接着实施了,最后可以实现只需要加注解和新的Service实现类就能为看板添加新的数据来源的效果,还可以通过修改Nacos管理的配置来动态更新。
问题
Service层通常是编写一个Service接口和一个ServiceImpl(实现类),而现在除了返回真实数据的ServiceImpl之外,还需要一个返回演示用的数据的DemoServiceImpl,两个ServiceImpl使用不同的Bean名称来区分。
1 2 3 4 5 6 7
|
@Service("realSalesBoardService") public class RealSalesBoardServiceImpl implements SalesBoardService {
}
|
1 2 3 4 5 6 7
|
@Service("demoSalesBoardService") public class DemoSalesBoardServiceImpl implements SalesBoardService {
}
|
要实现动态切换,需要解决如下问题:
- Springboot注入Bean之后就固定了,怎么在修改配置之后让Controller层动态切换使用配置指定的ServiceImpl;
- 怎么避免每次添加接口都要写大量代码来实现这个功能。
动态切换
动态刷新配置可以使用@RefreshScope注解,这个注解的作用是:在配置变化时,标注了该注解的Bean实例的缓存会被清空,下次获取该Bean时,Spring会重新创建该Bean,并且注入最新的配置值。
可以创建一个工厂类,Controller不再注入业务层实现类对象,而是通过这个工厂类主动去获取。
而这个工厂类标注@RefreshScope,在每次配置更新时,根据新的配置来动态获取该Service的实现类。
不同看板的Service接口对应的配置键可以用自定义注解来标识,配置值对应的两种模式(真实模式real,演示模式demo)可以用枚举类来声明。
看一下Controller的代码就能很容易理解这个思路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @RestController @RequestMapping("/board") public class BoardController { @Autowired private BoardServiceFactory boardServiceFactory;
@GetMapping("/sales") public Result<SalesVO> querySales( @RequestParam LocalDate start, @RequestParam LocalDate end) { BoardService boardService = boardServiceFactory.getCurrentBoardService(); SalesVO salesVO = boardService.querySalesData(start, end); return Result.success(salesVO); }
@GetMapping("/user") public Result<UserVO> queryUser() { BoardService boardService = boardServiceFactory.getCurrentBoardService(); return Result.success(boardService.queryUserData()); } }
|
自定义注解@DynamicService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import java.lang.annotation.*;
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface DynamicService {
String configKey();
DynamicServiceType defaultType() default DynamicServiceType.REAL; }
|
枚举:
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
|
public enum DynamicServiceType { REAL("real", "真实业务数据(内部使用)"), DEMO("demo", "演示数据(客户/对外展示)");
private final String beanPrefix; private final String desc;
DynamicServiceType(String beanPrefix, String desc) { this.beanPrefix = beanPrefix; this.desc = desc; }
public static DynamicServiceType fromConfig(String configValue) { for (DynamicServiceType type : values()) { if (type.beanPrefix.equals(configValue)) { return type; } } return REAL; }
public String getBeanPrefix() { return beanPrefix; } public String getDesc() { return desc; } }
|
标注Service接口:
1 2 3 4 5 6 7 8 9 10
|
@DynamicService( configKey = "board.sales.service.type", // Nacos配置Key(独立配置) defaultType = DynamicServiceType.REAL ) public interface SalesBoardService {
}
|
作为核心的工厂类的实现如下:
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
| @Component @RefreshScope public class GenericDynamicServiceFactory implements InitializingBean { private static final Logger log = LoggerFactory.getLogger(GenericDynamicServiceFactory.class);
@Autowired private Environment environment;
private final Map<Class<?>, DynamicService> annotationCache = new ConcurrentHashMap<>(); private final Map<Class<?>, Object> serviceCache = new ConcurrentHashMap<>();
private static final String BASE_PACKAGE = "com.xxx.board.service";
@Override public void afterPropertiesSet() { ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false) { @Override protected boolean isCandidateComponent(BeanDefinition beanDefinition) { return beanDefinition.getMetadata().isInterface(); } }; scanner.addIncludeFilter(new AnnotationTypeFilter(DynamicService.class));
Set<BeanDefinition> definitions = scanner.findCandidateComponents(BASE_PACKAGE); for (BeanDefinition def : definitions) { try { Class<?> interfaceClazz = Class.forName(def.getBeanClassName()); DynamicService annotation = interfaceClazz.getAnnotation(DynamicService.class); annotationCache.put(interfaceClazz, annotation); refreshServiceInstance(interfaceClazz); } catch (ClassNotFoundException e) { log.error("扫描动态服务接口失败", e); } } }
private <T> void refreshServiceInstance(Class<T> interfaceClazz) { DynamicService annotation = annotationCache.get(interfaceClazz); if (annotation == null) { throw new IllegalArgumentException("接口" + interfaceClazz.getName() + "未标注@DynamicService"); }
String configValue = environment.getProperty(annotation.configKey(), annotation.defaultType().getBeanPrefix()); DynamicServiceType serviceType = DynamicServiceType.fromConfig(configValue);
String interfaceName = interfaceClazz.getSimpleName(); String beanName = serviceType.getBeanPrefix() + interfaceName.substring(0, 1).toLowerCase() + interfaceName.substring(1);
T serviceInstance = SpringUtils.getBean(beanName, interfaceClazz); serviceCache.put(interfaceClazz, serviceInstance);
log.info("动态服务切换:接口={}, 配置Key={}, 生效类型={}, Bean名={}", interfaceClazz.getSimpleName(), annotation.configKey(), serviceType.getDesc(), beanName); }
@SuppressWarnings("unchecked") public <T> T getService(Class<T> interfaceClazz) { if (!serviceCache.containsKey(interfaceClazz)) { refreshServiceInstance(interfaceClazz); } return (T) serviceCache.get(interfaceClazz); }
@EventListener(RefreshScopeRefreshedEvent.class) public void onConfigRefresh() { log.info("Nacos配置变更,批量刷新动态服务"); annotationCache.keySet().forEach(this::refreshServiceInstance); } }
|
以下是我提出的疑问和AI的解答:
- 配置的读取没有使用@Value,而是使用
org.springframework.core.env.Environment,因为@Value不支持动态的配置键,只做单个Service的切换还行,但不同Service的配置键不一样,就没法用@Value了;
- SpringUtils是我所做项目里面自带的一个工具类,可以用“实现ApplicationContextAware接口并获取ApplicationContext”来替代;
- 之所以不用getBeansWithAnnotation方法来收集标注了自定义注解的Bean,是因为它收集的是Bean实例,而我们需要获取的是标注了自定义注解的Service接口,所以采用初始化时扫描并缓存。
- @PostConstruct虽然可以在这里替代afterPropertiesSet来进行初始化的扫描逻辑,但afterPropertiesSet执行时机更晚(Bean的属性注入完成后),适合依赖其他Bean的场景,后续如果该工厂类要注入其他Bean,就不需要改动什么。
- 由于@RefreshScope是懒加载,切换配置后,等到getService方法被首次调用才刷新,因此监听刷新事件并主动刷新一下服务状态。这个是可选的,算是AI额外考虑的情况,我觉得放着也不碍事,就没删。
动态注入
核心功能以上方案已经实现了,只不过每个看板Controller的每个接口都多了一步手动获取Service实现类的语句。
一两个接口还好,但后面扩展起来要命。
好在有办法简化。效果如下,和以前一样注入Service,但是@Autowired改为自定义注解@DynamicAutowired
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import org.springframework.web.bind.annotation.*;
@RestController @RequestMapping("/board/sales") public class SalesBoardController { @DynamicAutowired private SalesBoardService salesBoardService;
@GetMapping public Result<SalesVO> querySales( @RequestParam LocalDate startDate, @RequestParam LocalDate endDate) { return Result.success(salesBoardService.querySalesData(startDate, endDate)); } }
|
首先定义一个新的自定义注解用于标记要动态注入的属性:
1 2 3 4 5 6 7 8
|
@Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @interface DynamicAutowired { }
|
接着定义一个处理器,实现 Spring Bean 后置处理器(BeanPostProcessor):
- 在初始化所有Bean的时候为标注了@DynamicAutowired 注解的Bean注入当前配置的动态Service实现类实例;
- 监听配置刷新,通过工厂动态获取最新的ServiceImpl并注入。
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
| import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock;
@Component public class DynamicAutowiredProcessor implements BeanPostProcessor { private static final Logger log = LoggerFactory.getLogger(DynamicAutowiredProcessor.class);
@Autowired private GenericDynamicServiceFactory dynamicServiceFactory;
private final List<FieldInjectInfo> injectFieldCache = new ArrayList<>(); private final ReentrantLock lock = new ReentrantLock();
private static class FieldInjectInfo { Object bean; Field field;
FieldInjectInfo(Object bean, Field field) { this.bean = bean; this.field = field; } }
@Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { ReflectionUtils.doWithFields(bean.getClass(), field -> { if (field.isAnnotationPresent(DynamicAutowired.class)) { lock.lock(); try { Class<?> fieldType = field.getType(); Object serviceInstance = dynamicServiceFactory.getService(fieldType); field.setAccessible(true); field.set(bean, serviceInstance); injectFieldCache.add(new FieldInjectInfo(bean, field)); log.info("动态注入字段:Bean={}, 字段={}, 注入实例={}", beanName, field.getName(), serviceInstance.getClass().getSimpleName()); } catch (IllegalAccessException e) { log.error("动态注入字段失败", e); } finally { lock.unlock(); } } }); return bean; }
@EventListener(RefreshScopeRefreshedEvent.class) public void refreshDynamicFields() { lock.lock(); try { log.info("配置刷新,重新注入所有动态服务字段"); for (FieldInjectInfo info : injectFieldCache) { Class<?> fieldType = info.field.getType(); Object newInstance = dynamicServiceFactory.getService(fieldType); info.field.setAccessible(true); info.field.set(info.bean, newInstance); } } catch (IllegalAccessException e) { log.error("重新注入动态字段失败", e); } finally { lock.unlock(); } } }
|
添加配置
最后,就是在nacos或者本地配置里面加上@DynamicService注解中标注的配置键。
最后整体的结构如下:
