通过切换Service实现类来切换看板数据来源

最近做BI看板的时候遇到个需求,就是有时候需要切换看板所展示的数据(懂的都懂),但每次都手动改数据库视图后面再改回来比较麻烦,于是需要做一个快速切换公司内部看的真实数据模式和给客户看的演示模式的功能。

每个看板数据接口都加个参数侵入性太大肯定不行,通过配置来切换也比较麻烦,最后想到了大多数情况下都是一个接口对一个实现类的Service层。只需要根据配置的变动改变实现类,就能很轻松地实现数据来源切换。

有了想法之后,就和豆包讨论方案接着实施了,最后可以实现只需要加注解和新的Service实现类就能为看板添加新的数据来源的效果,还可以通过修改Nacos管理的配置来动态更新。

问题

Service层通常是编写一个Service接口和一个ServiceImpl(实现类),而现在除了返回真实数据的ServiceImpl之外,还需要一个返回演示用的数据的DemoServiceImpl,两个ServiceImpl使用不同的Bean名称来区分。

Java 复制代码
/**
 * 真实数据实现类:Bean名=realSalesBoardService(前缀+接口名首字母小写)
 */
@Service("realSalesBoardService")
public class RealSalesBoardServiceImpl implements SalesBoardService {

}
Java 复制代码
/**
 * 演示数据实现类:Bean名=demoSalesBoardService(前缀+接口名首字母小写)
 */
@Service("demoSalesBoardService")
public class DemoSalesBoardServiceImpl implements SalesBoardService {

}

要实现动态切换,需要解决如下问题:

  1. Springboot注入Bean之后就固定了,怎么在修改配置之后让Controller层动态切换使用配置指定的ServiceImpl;
  2. 怎么避免每次添加接口都要写大量代码来实现这个功能。

动态切换

动态刷新配置可以使用@RefreshScope注解,这个注解的作用是:在配置变化时,标注了该注解的Bean实例的缓存会被清空,下次获取该Bean时,Spring会重新创建该Bean,并且注入最新的配置值。

可以创建一个工厂类,Controller不再注入业务层实现类对象,而是通过这个工厂类主动去获取。

而这个工厂类标注@RefreshScope,在每次配置更新时,根据新的配置来动态获取该Service的实现类。

不同看板的Service接口对应的配置键可以用自定义注解来标识,配置值对应的两种模式(真实模式real,演示模式demo)可以用枚举类来声明。

看一下Controller的代码就能很容易理解这个思路:

Java 复制代码
@RestController
@RequestMapping("/board")
public class BoardController {
    @Autowired
    private BoardServiceFactory boardServiceFactory;

    @GetMapping("/sales")
    public Result<SalesVO> querySales(
            @RequestParam LocalDate start,
            @RequestParam LocalDate end) {
        // 核心:从工厂获取当前生效的Service,调用统一接口
        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:

Java 复制代码
import java.lang.annotation.*;

/**
 * 标注需要动态切换实现类的接口
 */
@Target(ElementType.TYPE) // 仅作用于接口
@Retention(RetentionPolicy.RUNTIME) // 运行时可反射获取
public @interface DynamicService {
    /**
     * Nacos配置项Key(如board.sales.service.type)
     */
    String configKey();

    /**
     * 默认数据类型(兜底)
     */
    DynamicServiceType defaultType() default DynamicServiceType.REAL;
}

枚举:

Java 复制代码
/**
 * 动态服务数据类型:Real(真实数据)/ Demo(演示数据)
 */
public enum DynamicServiceType {
    REAL("real", "真实业务数据(内部使用)"),
    DEMO("demo", "演示数据(客户/对外展示)");

    private final String beanPrefix; // 实现类Bean名前缀(如realBoardService)
    private final String desc;

    DynamicServiceType(String beanPrefix, String desc) {
        this.beanPrefix = beanPrefix;
        this.desc = desc;
    }

    // 从Nacos配置值获取枚举(兜底默认REAL)
    public static DynamicServiceType fromConfig(String configValue) {
        for (DynamicServiceType type : values()) {
            if (type.beanPrefix.equals(configValue)) {
                return type;
            }
        }
        return REAL;
    }

    // Getter
    public String getBeanPrefix() { return beanPrefix; }
    public String getDesc() { return desc; }
}

标注Service接口:

Java 复制代码
/**
 * 看板销售数据接口(统一调用入口)
 */
@DynamicService(
    configKey = "board.sales.service.type", // Nacos配置Key(独立配置)
    defaultType = DynamicServiceType.REAL
)
public interface SalesBoardService {

}

作为核心的工厂类的实现如下:

Java 复制代码
@Component
@RefreshScope // Nacos配置变更时,刷新工厂实例
public class GenericDynamicServiceFactory implements InitializingBean {
    private static final Logger log = LoggerFactory.getLogger(GenericDynamicServiceFactory.class);

    // 环境配置(读取Nacos配置)
    @Autowired
    private Environment environment;

    // 缓存:接口Class → 注解配置(避免重复反射)
    private final Map<Class<?>, DynamicService> annotationCache = new ConcurrentHashMap<>();
    // 缓存:接口Class → 当前生效的实现类实例(线程安全)
    private final Map<Class<?>, Object> serviceCache = new ConcurrentHashMap<>();

    // 接口所在包(需修改为实际包路径)
    private static final String BASE_PACKAGE = "com.xxx.board.service";

    /**
     * 初始化:扫描所有@DynamicService接口,加载默认实现
     */
    @Override
    public void afterPropertiesSet() {
        // 1. Spring原生扫描器:只筛选带注解的接口
        ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false) {
            @Override
            protected boolean isCandidateComponent(BeanDefinition beanDefinition) {
                return beanDefinition.getMetadata().isInterface(); // 仅处理接口
            }
        };
        scanner.addIncludeFilter(new AnnotationTypeFilter(DynamicService.class));

        // 2. 扫描并缓存注解、初始化实例
        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");
        }

        // 1. 从Nacos获取配置,转换为枚举类型
        String configValue = environment.getProperty(annotation.configKey(), annotation.defaultType().getBeanPrefix());
        DynamicServiceType serviceType = DynamicServiceType.fromConfig(configValue);

        // 2. 按规范拼接实现类Bean名(前缀+接口名首字母小写)
        String interfaceName = interfaceClazz.getSimpleName();
        String beanName = serviceType.getBeanPrefix() +
                          interfaceName.substring(0, 1).toLowerCase() +
                          interfaceName.substring(1);

        // 3. 用项目SpringUtils获取Bean,更新缓存
        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的解答:

  1. 配置的读取没有使用@Value,而是使用org.springframework.core.env.Environment,因为@Value不支持动态的配置键,只做单个Service的切换还行,但不同Service的配置键不一样,就没法用@Value了;
  2. SpringUtils是我所做项目里面自带的一个工具类,可以用"实现ApplicationContextAware接口并获取ApplicationContext"来替代;
  3. 之所以不用getBeansWithAnnotation方法来收集标注了自定义注解的Bean,是因为它收集的是Bean实例,而我们需要获取的是标注了自定义注解的Service接口,所以采用初始化时扫描并缓存。
  4. @PostConstruct虽然可以在这里替代afterPropertiesSet来进行初始化的扫描逻辑,但afterPropertiesSet执行时机更晚(Bean的属性注入完成后),适合依赖其他Bean的场景,后续如果该工厂类要注入其他Bean,就不需要改动什么。
  5. 由于@RefreshScope是懒加载,切换配置后,等到getService方法被首次调用才刷新,因此监听刷新事件并主动刷新一下服务状态。这个是可选的,算是AI额外考虑的情况,我觉得放着也不碍事,就没删。

动态注入

核心功能以上方案已经实现了,只不过每个看板Controller的每个接口都多了一步手动获取Service实现类的语句。

一两个接口还好,但后面扩展起来要命。

好在有办法简化。效果如下,和以前一样注入Service,但是@Autowired改为自定义注解@DynamicAutowired

Java 复制代码
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) {
        // 直接调用,和普通@Autowired注入无区别
        return Result.success(salesBoardService.querySalesData(startDate, endDate));
    }
}

首先定义一个新的自定义注解用于标记要动态注入的属性:

Java 复制代码
/**
 * 动态服务注入注解:替代手动调用factory.getService(),自动注入当前生效实例
 */
@Target(ElementType.FIELD) // 仅作用于类字段
@Retention(RetentionPolicy.RUNTIME)
@interface DynamicAutowired {
    // 仅作为标记,无需额外属性
}

接着定义一个处理器,实现 Spring Bean 后置处理器(BeanPostProcessor)

  1. 在初始化所有Bean的时候为标注了@DynamicAutowired 注解的Bean注入当前配置的动态Service实现类实例;
  2. 监听配置刷新,通过工厂动态获取最新的ServiceImpl并注入。
Java 复制代码
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; // 所属Bean(如Controller实例)
        Field field; // 要注入的字段(如SalesBoardService)

        FieldInjectInfo(Object bean, Field field) {
            this.bean = bean;
            this.field = field;
        }
    }

    /**
     * Spring初始化Bean时执行:扫描@DynamicAutowired字段并注入实例
     */
    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        // 扫描当前Bean的所有字段
        ReflectionUtils.doWithFields(bean.getClass(), field -> {
            // 找到带@DynamicAutowired的字段
            if (field.isAnnotationPresent(DynamicAutowired.class)) {
                lock.lock();
                try {
                    // 1. 获取字段类型(如SalesBoardService接口)
                    Class<?> fieldType = field.getType();
                    // 2. 通过通用工厂获取当前生效的实例
                    Object serviceInstance = dynamicServiceFactory.getService(fieldType);
                    // 3. 反射注入字段(突破private访问限制)
                    field.setAccessible(true);
                    field.set(bean, serviceInstance);
                    // 4. 缓存字段信息(配置刷新时重新注入)
                    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注解中标注的配置键。

最后整体的结构如下:

相关推荐
橙序员小站2 小时前
Springboot3.0并不能拯救你的屎山
java·后端·架构
YJlio2 小时前
Active Directory 工具学习笔记(10.13):AdRestore——把误删“拉回现场”的最快姿势
java·笔记·学习
小黄编程快乐屋2 小时前
Python 期末复习知识点汇总
java·服务器·python
千寻技术帮2 小时前
10400_基于Springboot的职业教育管理系统
java·spring boot·后端·毕设·文档·职业教育
Non importa2 小时前
用滑动窗口代替暴力枚举:算法新手的第二道砍
java·数据结构·c++·学习·算法·leetcode·哈希算法
涛声依旧Cjt2 小时前
Spring AOP实战--优雅的对出参进行脱敏
java·spring·springaop 实战·aop 优雅脱敏·spring 脱敏
灰乌鸦乌卡2 小时前
练手项目0 介绍
java
laocooon5238578862 小时前
C语言枚举知识详解与示例
java·c语言·数据库
月明长歌2 小时前
【码道初阶】【LeetCode 160】相交链表:让跑者“起跑线对齐”的智慧
java·算法·leetcode·链表