问题引入
在写单元测试,特别是测试中间功能层的一些逻辑代码时候,我们可能会通过 @SpringBootTest 和 @MockBean 注解来 Mock 待测试类的注入依赖。但当单元测试类的数量上去以后,这些使用 @MockBean 的单元测试的测试类上下文是会重新加载的,这就会导致整个项目单元测试耗时长。其实有很多的单元测试是完全 Mock 的,它们可以不依赖 Spring 上下文,那我们一般就会用 Mockito 并通过 @ExtendWith(MockitoExtension.class) (Junit5), @InjectMocks , @Mock , @Spy 注解或编码的方式 Mock,这样就不需要启动 Spring 上下文,可以非常快的完成单元测试,举个例子如下:
待测试类
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserDAO userDAO;
@Autowired
private ServerProps serverProps;
public ServerProps getServerProps() {
return serverProps;
}
}
注入依赖的一个配置类
@Data
@NoArgsConstructor
@Component
@ConfigurationProperties("custom.service.server")
public class ServerProps {
private String normalTest;
private String easyPropertyPlaceHolderTest;
private String easyPropertyPlaceHolderDefaultTest;
private String combinedPropertyPlaceHolderTest;
private Color enumTest;
private List<String> listTest;
private Map<String, String> mapTest;
private Nested nested;
@Data
public static class Nested {
private String stringData;
}
public ServerProps(String normalTest) {
this.normalTest = normalTest;
}
}
启动配置文件(包含两个文件,这里粘贴在一起)
spring:
profiles:
include: jenkins
custom:
service:
server:
normal-test: normalMessage
easy-property-place-holder-test: ${EASY_PROPERTY_PLACE_HOLDER_TEST:defaultValue}
easy-property-place-holder-default-test: ${EASY_PROPERTY_PLACE_HOLDER_DEFAULT_TEST:defaultValue}
combined-property-place-holder-test: prefix-${COMBINED_PROPERTY_PLACE_HOLDER_TEST}
enum-test: RED
list-test:
- listValue1
- listValue2
- listValue3
map-test:
key1: value1
key2: value2
nested:
string-data: hello world!
---
EASY_PROPERTY_PLACE_HOLDER_TEST: easyPlaceHolderValue
COMBINED_PROPERTY_PLACE_HOLDER_TEST: combinedPlaceHolderValue
对应单元测测试
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@InjectMocks
private UserServiceImpl userService;
@Mock
private UserDAO userDAO;
@Spy
private ServerProps serverProps = new ServerProps("someValue");
@Test
void testGetServerProps() {
ServerProps props = userService.getServerProps();
assertEquals("someValue", props.getNormalTest());
}
}
可以看到上面的单元测试代码,由于没有启动 Spring 上下文,ServerProps 需要我们手动构造。但如果是一个复杂嵌套的配置类而且我们需要保持这个 Mock 和我们写的配置文件一致的情况下,就成了一个难题。本文就这个问题进行探讨和解决。
实现方法
笔者 SpringBoot 版本:2.3.12.RELEASE
我们知道使用 @ConfigurationProperties 这个注解,Spring 会通过反射自动将读取的 properties 使用每个 field 的 setter 方法一个一个注入到对应类中。一开始我期待直接使用 snakeyaml 完成这个功能,但是很可惜 snakeyaml 只能完成一个文件到一个类的映射,不能很简单地实现一个文件各种复杂路径下对多个类的注入,而且通常我们的配置文件还有占位符 ${} 的需求,snakeyaml 是做不到的。
那 Spring 是怎么做的呢?我们可以参考 Spring @ConfigurationProperties 以及 ConfigurationPropertiesBindingPostProcessor 的实现原理,使用其中已经写好的类实现这个功能岂不美哉?研究一番后,废话不多说,直接上代码。
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySourcesPropertyResolver;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StringUtils;
public class PropertyUtils {
private static final MutablePropertySources propertySources = new MutablePropertySources();
private static final PropertySourcesPropertyResolver resolver =
new PropertySourcesPropertyResolver(propertySources);
static {
init();
}
private static void init() {
loadProperties(new ClassPathResource("application.yaml"));
loadProperties(new ClassPathResource("application-jenkins.yaml"));
}
public static void loadProperties(Resource resource) {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
try {
loader.load(resource.getFilename(), resource).forEach(propertySources::addLast);
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
public static String getProperty(String key) {
return resolver.getProperty(key);
}
public static <T> T getProperty(String key, Class<T> targetValueType) {
return resolver.getProperty(key, targetValueType);
}
public static <T> T bindOrCreate(String name, Class<T> bean) {
Binder binder = new Binder(ConfigurationPropertySources.from(propertySources),
new PropertySourcesPlaceholdersResolver(propertySources));
return binder.bindOrCreate(name, bean);
}
public static <T> T bindOrCreate(Class<T> configurationPropertiesBean) {
String propertyPrefix = getConfigurationPropertiesPrefix(configurationPropertiesBean);
return bindOrCreate(propertyPrefix, configurationPropertiesBean);
}
public static String getConfigurationPropertiesPrefix(Class cl) {
ConfigurationProperties annotation = getConfigurationPropertiesAnnotation(cl);
String propertyPrefix = annotation.value();
if (StringUtils.isEmpty(propertyPrefix)) {
propertyPrefix = annotation.prefix();
}
return propertyPrefix;
}
public static ConfigurationProperties getConfigurationPropertiesAnnotation(Class cl) {
ConfigurationProperties annotation = (ConfigurationProperties) cl.getAnnotation(ConfigurationProperties.class);
if (null == annotation) {
throw new IllegalArgumentException("Class does not have @ConfigurationProperties annotation: " + cl.getSimpleName());
}
return annotation;
}
private PropertyUtils() {
throw new IllegalStateException("UtilityClass: " + this.getClass().getName());
}
}
使用方法
class PropertyUtilsTest {
@Test
void TestConfig() throws Exception {
ServerProps serverProps = PropertyUtils.bindOrCreate(ServerProps.class);
System.out.println(serverProps);
boolean showSql = PropertyUtils.getProperty("spring.jpa.show-sql", Boolean.class);
assertTrue(showSql);
String propertyPrefix = PropertyUtils.getConfigurationPropertiesPrefix(ServerProps.class);
String property = PropertyUtils.getProperty(propertyPrefix + ".easy-property-place-holder-test");
System.out.println(property);
}
}
单元测试执行结果
ServerProps(normalTest=normalMessage, easyPropertyPlaceHolderTest=easyPlaceHolderValue, easyPropertyPlaceHolderDefaultTest=defaultValue, combinedPropertyPlaceHolderTest=prefix-combinedPlaceHolderValue, enumTest=RED, listTest=[listValue1, listValue2, listValue3], mapTest={key1=value1, key2=value2}, nested=ServerProps.Nested(stringData=hello world!))
easyPlaceHolderValue
完美!占位符以及占位符默认值都正确注入了,嵌套类,List,Map 都有正确映射
原理简述
那这是怎么做到的呢,我们一个一个来看。首先这个 PropertyUtils 有两个静态成员
private static final MutablePropertySources propertySources = new MutablePropertySources();
private static final PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(propertySources);
static {
init();
}
private static void init() {
loadProperties(new ClassPathResource("application.yaml"));
loadProperties(new ClassPathResource("application-jenkins.yaml"));
}
public static void loadProperties(Resource resource) {
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
try {
loader.load(resource.getFilename(), resource).forEach(propertySources::addLast);
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
public static String getProperty(String key) {
return resolver.getProperty(key);
}
public static <T> T getProperty(String key, Class<T> targetValueType) {
return resolver.getProperty(key, targetValueType);
}
其中 propertySources 用于存储读取到的配置,resolver 用于提供单个的 getProperty 功能该 resolver 能正确解析存在占位符的情况。而实现 Bean 注入使用的是 Binder ,Bean 注入的实现如下:
public static <T> T bindOrCreate(String name, Class<T> bean) {
Binder binder = new Binder(ConfigurationPropertySources.from(propertySources),
new PropertySourcesPlaceholdersResolver(propertySources));
return binder.bindOrCreate(name, bean);
}
public static <T> T bindOrCreate(Class<T> configurationPropertiesBean) {
String propertyPrefix = getConfigurationPropertiesPrefix(configurationPropertiesBean);
return bindOrCreate(propertyPrefix, configurationPropertiesBean);
}
提供了两个重载方法,一个是自定义输入 config property 前缀,并指定类型完成值注入,另一个是获取类 ConfigurationProperties 注解的值拿到 config property 前缀再调用前面的方法完成注入。
关于 @ConfigurationProperties 的原理可以参考这篇文章:https://blog.csdn.net/qq_36789243/article/details/119429594;下面我们简单看下 Spring 提供的这个 Binder 类最终调用的构造函数,可以看到一共有 6 个参数
public Binder(Iterable<ConfigurationPropertySource> sources, PlaceholdersResolver placeholdersResolver,
ConversionService conversionService, Consumer<PropertyEditorRegistry> propertyEditorInitializer,
BindHandler defaultBindHandler, BindConstructorProvider constructorProvider) {
Assert.notNull(sources, "Sources must not be null");
this.sources = sources;
this.placeholdersResolver = (placeholdersResolver != null) ? placeholdersResolver : PlaceholdersResolver.NONE;
this.conversionService = (conversionService != null) ? conversionService
: ApplicationConversionService.getSharedInstance();
this.propertyEditorInitializer = propertyEditorInitializer;
this.defaultBindHandler = (defaultBindHandler != null) ? defaultBindHandler : BindHandler.DEFAULT;
if (constructorProvider == null) {
constructorProvider = BindConstructorProvider.DEFAULT;
}
ValueObjectBinder valueObjectBinder = new ValueObjectBinder(constructorProvider);
JavaBeanBinder javaBeanBinder = JavaBeanBinder.INSTANCE;
this.dataObjectBinders = Collections.unmodifiableList(Arrays.asList(valueObjectBinder, javaBeanBinder));
}
其中
- ConfigurationPropertySources: 外部配置文件的属性源
- PropertySourcesPlaceholdersResolver: 解析属性源中的占位符
${} ; - ConversionService: 对属性类型进行转换
- PropertyEditorInitializer: 用来配置 property editors,通常可以用来转换值
- BindHandler: 接口定义了onStart, onSuccess, onFailure 和 onFinish 方法,这四个方法分别会在执行外部属性绑定时的不同时机会被调用,在属性绑定时用来添加一些额外的处理逻辑,比如在 onSuccess 方法改变最终绑定的属性值或对属性值进行校验,在 onFailure 方法 catch 住相关异常或者返回一个替代的绑定的属性值
- BindConstructorProvider: 用于构造 ValueObjectBinder,提供不同的策略
我的只提供了最基本的两个 ConfigurationPropertySources,和 PropertySourcesPlaceholdersResolver 目前能满足我们的需求
可以进一步实现的地方
我们都知道,使用 @ConfigurationPropertiesBinding 和实现 org.springframework.core.convert.converter.Converter<S, T> 可以做到让 Spring 将一个值映射到我们自定义的类型,那目前 PropertyUtils 应该是做不到的。但根据传入 Binder 的其他构造参数是可以实现的,可以进一步探究。
另外就是这个代码大家都知道是依赖于 SpringBoot 框架的,无法在没有 Spring 框架下使用。
参考
- SpringBoot的配置属性值是如何绑定的? SpringBoot源码(五):https://blog.csdn.net/qq_36789243/article/details/119429594
|