SpringBoot踩坑记录:玩转@Value注解-自定义PropertySourcesPlaceHolderConfigurer

想法

开发环境:SpringBoot + IDEA

比如说,我们在代码中,有一些环境相关的变量配置到了 properties 配置文件中,使用时用 @Value 注解获取。
现在由于某种奇葩原因,有部分配置项不能直接配在配置文件properties中,需要以其他方式获取。

我们有以下几种方式可以来实现,先讲方案,后续再讲下原理。

方案一 自定义Value注解

自定义一个用法类似 @Value 的注解,取值逻辑自定义(实际原理与 @Value 并不相同),步骤如下:

1. 自定义一个取值注解 @CustomValue

比如我这里例子,定义一个名称为 CustomValue 的注解,注解只支持传入一个值 value,类似 @Value, 传入"${xxx}" 格式时表示取 key 为 xxx 的配置项。

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface CustomValue {
    String value();
}

2. 自定义 BeanPostProcessor 处理注解

BeanPostProcessor 会在SpringApplication启动过程尾声被逐个调用,我们可以自定义一个 BeanPostProcessor 并用 @Component 注解交给 Spring 框架管理。在这个处理类中我们检查每一个 Spring 创建的对象,判断其中如果有被 CustomValue 注解的字段,则进行解析赋值。

@Component
public class CustomValueBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if(bean == null){
            return bean;
        }
        //过滤出自定义注解@CustomValue标记的字段
        Field[] declaredFields = bean.getClass().getDeclaredFields();
        for(Field declaredField : declaredFields){
            CustomValue annotation = declaredField.getAnnotation(CustomValue.class);
            if(annotation == null){
                continue;
            }
            ReflectionUtils.makeAccessible(declaredField);
            try{
                //自定义解析注解字段获得值
                String value = getValue(annotation);
                //将值注入到目标字段中
                declaredField.set(bean, value);
            } catch (Exception e){
                e.printStackTrace();;
            }
        }
        return bean;
    }

    /** 用自定义方式获取值 */
    private String getValue(CustomValue annotation) {
		return "";
    }
}

3. 应用自定义的注解并测试

在Service层应用自定义的注解注入变量:

@Service
public class TestServiceImpl implements TestService{
    //使用官方注解
    @Value("${qc.test.value}")
    private String something;
    //使用自定义注解
    @CustomValue("${Levi}")
    private String name;
    //使用自定义注解
    @CustomValue(": How are you?")
    private String words;

    @Override
    public String saySomething() {
        return something + " " + name + " " + words;
    }
}

在控制层返回获取到的值:

@RestController
@RequestMapping("/test")
public class TestController {
    @Autowired
    private TestService testService;
	
    @RequestMapping(path = "/say", method = RequestMethod.GET)
    public String say(){
        return testService.saySomething();
    }
}

4. 配置取值

到这里整个代码基本框架已经完成,但是显然我们现在还没能获取到值,一是@Value中配置的变量还没配置到 properties 中,二是 CustomValueBeanPostProcessor 的 getValue() 方法还没有实现。

考虑到实际情况下我们会有开发、测试、生产环境的区别,故我们需要在配置文件目录下建两个配置文:
一个是 application.properties 文件,其中指定当前环境是dev开发环境:

spring.profiles.active=dev

再建一个 application-dev.properties 文件,其中配置代码用到的配置项:

qc.test.value=hello!

然后再来完善我们的自定义注解取值逻辑。
我们这里举例用比较简单的方式,从枚举中获取值,自定义一个枚举,支持传入 key 和 环境标识 来获取 value。

public enum CustomValueEnum {

    ELEN("Elen","dev-Elen","stg-Elen","prd-Elen"),
    LEVI("Levi","dev-Levi","stg-Levi","prd-Levi"),
    MIKASA("Mikasa","dev-Mikasa","stg-Mikasa","prd-Mikasa"),
    ZEKE("Zeke","dev-Zeke","stg-Zeke","prd-Zeke");

    /** 键 */
    private String key;
    /** dev环境的值 */
    private String dev;
    /** stg环境的值 */
    private String stg;
    /** prd环境的值 */
    private String prd;

    CustomValueEnum(String key, String dev, String stg, String prd){
        this.key = key;
        this.dev = dev;
        this.stg = stg;
        this.prd = prd;
    }

    private static HashMap<String, CustomValueEnum> map;
    static{
        map = new HashMap<>();
        Arrays.stream(values()).forEach(item->map.put(item.key, item));
    }
    /** 根据key获取枚举 */
    public static CustomValueEnum getEnumByKey(String key){
        return map.get(key);
    }

    /**根据 key 和 环境标识 获取值**/
    public static String getValByKeyEnv(String key, String env){
        if(key==null || key =="" || env == null || env ==""){
            return null;
        }
        CustomValueEnum valEnum = CustomValueEnum.getEnumByKey(key);
        if(valEnum==null ){
            return null;
        }
        switch (env){
            case "dev":
                return valEnum.dev;
            case "stg":
                return valEnum.stg;
            case "prd":
                return valEnum.prd;
            default:
                return null;
        }
    }
}

在 CustomValueBeanPostProcessor 的 getValue() 方法中通过枚举获取值:

@Component
public class CustomValueBeanPostProcessor implements BeanPostProcessor {

    @Value("${spring.profiles.active}")
    private String env;
	//匹配 ${xxx}格式
    private static final String patternStr = "^\\$\\{\\w+\\}$";

    private static final Pattern pattern = Pattern.compile(patternStr);

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        if(bean == null){
            return bean;
        }
        //过滤出自定义注解@CustomValue标记的字段
        Field[] declaredFields = bean.getClass().getDeclaredFields();
        for(Field declaredField : declaredFields){
            CustomValue annotation = declaredField.getAnnotation(CustomValue.class);
            if(annotation == null){
                continue;
            }
            ReflectionUtils.makeAccessible(declaredField);
            try{
                //自定义解析注解字段获得值
                String value = getValue(annotation);
                //将值注入到目标字段中
                declaredField.set(bean, value);
            } catch (Exception e){
                e.printStackTrace();;
            }
        }
        return bean;
    }

    /** 用自定义方式获取值 */
    private String getValue(CustomValue annotation) {
        String key = annotation.value();
        if(pattern.matcher(key).find()){
            key = key.substring(2,key.length()-1);
            return CustomValueEnum.getValByKeyEnv(key, env);
        }else{
            return key;
        }
    }
}

然后编写启动类,启动应用,访问接口地址,可以看到成功取到了值。
在这里插入图片描述

方案二 扩展 @Value 解析范围

除了自定义一个注解,我们还可以直接让 @Value 加载自己的值,不过可能出现一个问题,如果我们的key命名和其他组件的配置key冲突了,应该取谁的?这样可能会导致我们系统出现奇奇怪怪预想不到的问题。
所以要采用这种方案的话,建议通过生成一个随机串当自定义key前缀之类的方案,来避免key冲突。

1. 编写自定义的PropetySourcesPlaceHolderConfigurer

复用方案一的代码,再编写一个自定义的 PropetySourcesPlaceHolderConfigurer 并用 @Component 交给 Spring 管理,重写 mergeProperties() 方法,将自己的配置项和系统的配置项合并。
当 Spring 解析 @Value 的时候就会从合并后的结果中取到我们的自定义配置项。

@Component
public class CustomPropertySourcesPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer {
    /** 当前环境标识 */
    private static String env;

    @Override
    public void setEnvironment(Environment environment){
        super.setEnvironment(environment);
        //这里可以做一些自定义取值的初始化操作
        env = environment.getProperty("spring.profiles.active");
    }

    @Override
    protected Properties mergeProperties() throws IOException {
        Properties props = super.mergeProperties();
        if(props == null){
            props = new Properties();
        }
        //将枚举中的变量配置合并到系统环境变量 props 中,这样 @Value 就能在 props 中取到值了。
        //实际情况下建议自定义配置的key加一个随机串当前缀,避免和其他系统变量冲突导致奇奇怪怪的问题。
        for(CustomValueEnum item: CustomValueEnum.values()){
            if(!props.containsKey(item.getKey())){
                props.setProperty(item.getKey(), CustomValueEnum.getValByKeyEnv(item.getKey(), env));
            }
        }
        return props;
    }
}

2. 用@Value加载自定义配置项

改写Service,用 @Value 加载枚举类中的值,像下面示例代码中增加的extValue变量。

@Service
public class TestServiceImpl implements TestService{

    //使用官方注解,注入properties的值
    @Value("${qc.test.value}")
    private String something;
    //使用官方注解,注入自定义取值
    @Value("${Zeke}")
    private String extValue;
    //使用自定义注解
    @CustomValue("${Levi}")
    private String name;
    //使用自定义注解
    @CustomValue(": How are you?")
    private String words;

    @Override
    public String saySomething() {
        return something + " " + name + " " + words + " -- " + extValue;
    }
}

启动应用,访问接口,可以看到 extValue 的值被成功读取到了。
在这里插入图片描述
示例代码将附加到文末

原理探究

示例代码

示例代码


版权声明:本文为B_Hopkins原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。