本文的实践方案其实很久之前就已经实现了(其想法跟实践从主要点来看都非常的简洁),由于我一直想将此方案应用于企业应用中并形成一个比较基础的应用框架,因此关于本方案的文档化拖了很久都没有形成。
运行时加载组件其实是个老生常谈的话题了。比较早的windows桌面应用(最新的桌面应用实现此功能应该更不是事儿了)中,无论是功能组件还是窗口组件,在项目方案定义完接口后,主框架利用LoadLibrary、FreeLibrary等,在运行时(runtime),由用户从某位置选载到func/window.dll组件文件,然后加载到应用中,完成主框架、功能框架的对接以及功能的运行时添加。如下图。
由上面的思路,本文介绍的主题是:在spring web应用中,如何做到在启动运行时,根据用户设定的应用配置,让spring框架选择性的加载组件(或者创建bean)。
我把上面这段主题的“例子”单列出来:
比如,你有个web应用项目,是个小型oa管理系统,一般来说,你在idea中使用spring mvc创建一个工程后,为了实现信息持久化,你需要连接数据库;为了实现oa流程,你需要依赖activiti之类的bpm框架;为了实现某些场景的定时任务(比如每月一次的加班审报邮件确认),你需要依赖quartz之类的调度框架;等等。无论这些功能框架有没有类似springboot下面的starter,你都可以自己造一些与这些框架或者组件适配的***Configuration,然后使用@Configuration注解,在应用运行时,创建这些框架相关的bean。
上面这里的问题是,一旦你选择了让功能框架添加进来,那启动应用后,按照基本的默认情况,这些bean是必然是被创建的,启动之后数据库相关的bean、相对庞大的activiti、时不时出现的quartz日志(为了调试,你总是会输出一些东西吧?),将会通过控制台告诉你他们还活着。
然而,我们当前其实就是想做一个crud页面的前后端联试,看看页面是否正常,根本不需要那些数据库、流程、调度框架现身。
spring当然已经提供了@Conditional注解来告诉我们可以选择了,然而spring考虑的仍然是普适,在一向需要定制的企业应用中,如果不在已有的基础上进行增强,多半是满足不了业务需求的(对开发来说,我们这里是需要专一性):我们仍然是希望于通过一个简单的外部配置参数(不同profile)来修改应用的状态,而不是修改pom.xml中的依赖或者修改类的实现。
本文就是介绍如何在spring web中,通过增强spring以实现以上场景中的“专一”开发的(即若是你多人开发,一人前端,一人流程,一人调度,各自pull下来的都是同一份程序,仅本地配置文件的值不同)。由此引入的应用场景是:多人开发的便捷、配置位置更加松散……
严格来说(或者也是很明显的),由于当前解决的是“启动运行时”,因此本文介绍的方案属于半运行时状态,与一开始的windows桌面应用的运行时的即时加载还是有区别的(即时加载ms也有很多实现了,框架也提供了运行时创建bean的能力)。
另外,很多同学早已转战使用spring boot了,在spring boot里,使用对应的技术点,也可以实现本文的“启动运行时”选择性加载组件(我其实也已经实现了相关的功能,但关于相关文档的说明仍在拖拉中……)
结束冗长的背景回忆、适用场景以及简短的目标,我们来开始工程的实现相关。
1、环境
jdk:1.7
开发环境:idea 2016.3.4
spring版本:4.3.14.RELEASE
maven:3.3+
2、主要技术点
这是一个maven项目。
2.0 pom.xml的配置。分为两个部分:
一是关于<profiles>节,我们需要根据项目情况创建若干个<profile>,每个<profile>内,我们需要在<properties>节内定义一个属性(比如mvn.profiles.active),这个属性的值与当前<profile>的<id>一般是相同的(也可以不同),用来替换后续在工程src/main/resources/application-***.properties文件名中的“***”区域,这主要是为了在使用打包命令mvn package -P<profile.id>时,结合之后pom.xml中<resources>段中的设置,进行资源文件的精准化打包;
二是关于<build>/<resources>节,我们主要是实现资源的精准化打包。结合上面对<profile>的配置来完成,具体在后面的工程中可查看。
2.1 从org.springframework.web.context.ContextLoaderListener类派生一个CustomContextLoaderListenerod类,主要进行定制化实现:完成对某个profile约定的诸多properties文件中环境变量的先加载(重载customizeContext方法)。这里,先创建这些环境变量的原因是为后续在@Conditional注解需要的Conditional派生类中可以使用。
CustomContextLoaderListenerod类中加载的properties文件,必需有一个约定好名称的环境变量(比如本文示例工程中的PROP_ZBASE_MAIN_CONFIG_ROOT),来表示外部配置文件组的根路径。
*外部配置文件组:指对某个<profile>来说,根据业务需要,你总是要有一堆配置存在某个地方,这个文件组就是专门存这里配置的,对本项目来说,虽然是文件组,但它仍然可能是存在数据库或者远端某个服务器上。
2.2 web.xml中添加2.1中定制的<listener>;
2.3 application.properties文件。在resources目录下,创建一个默认的applications.properties属性文件,此文件其实跟本文的主题没什么关系,总之,你可以设置一些与<profile>无关的属性放在它里面;
2.4 application-**.properties文件。这些文件是必需的,且与<profile>应该是对应的。因为PROP_ZBASE_MAIN_CONFIG_ROOT代表的环境变量,应该在每个application-**.properties文件里都设置一个,用来表示外部配置文件组的根路径。
2.5 外部配置文件中的bean创建开关。其实是.properties文件中的一个键值对,正常来说,你需要在每个<profile>对的配置文件组里都有这么一个创建开关(当然值可以不同,键肯定是要相同的)
2.6 根据项目需要,实现一个或者若干个org.springframework.context.annotation.Condition接口(比如PropConfigurationCondition类实现了此接口)。接口的matches方法最简单的形式是判断上面2.5步中的键值对中的值,以返回true或者false;
2.7 在一个bean创建类(比如PropConfiguration类)上,使用@Configuration注解的同时,也使用@Conditional注解,后者的value设置为2.5步中的PropConfigurationCondition类;
至此,关于根据不同<profile>的配置,选择性的创建bean的步骤就介绍完了。
下面是验证步骤的要点:
2.8 创建一个Controller,在其中创建一个成员变量,使用@Autowired标识此成员变量由spring 进行注入,然而,此处你需要注意注解@Autowired的required需要设置为false。
3、maven工程的主要实现
这一节里,就是主要实现的各种贴贴贴,你完全可以绕过本节到最后的下载链接里,直接下载工程。
3.1 在idea中,新建一个maven工程:
我们这里把工程的groupid设为com.bn.zbase,artifactid设为zbase-test-web。
使用上面archetype生成的工程里,为我们生成了一个默认的老旧的工程结构(应该是还有别的archetype可以达到主动设置此效果了,然后我已经等不及了)。这里,我直接贴出改造后的目录结构:
3.2 pom.xml的修改
本文配套的项目仅是一个演示,因此仅包含了基本的依赖,以下给出与2.0节对应的配置,其余的可下载工程后查看
<profiles>
<profile>
<id>dev</id>
<properties>
<mvn.profiles.active>dev</mvn.profiles.active>
</properties>
</profile>
<profile>
<id>sit</id>
<properties>
<mvn.profiles.active>sit</mvn.profiles.active>
</properties>
</profile>
</profiles>
<build>
<finalName>zbase-test-web</finalName>
<plugins>
<plugin>
<artifactId>maven-war-plugin</artifactId>
<version>2.6</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.3</version>
<configuration>
<source>1.7</source>
<target>1.7</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<excludes>
<exclude>application*.properties</exclude>
</excludes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>application.properties</include>
<include>application-${mvn.profiles.active}.properties</include>
</includes>
</resource>
</resources>
</build>
3.3 CustomContextLoaderListener类的实现
主要是重载了ContextLoader类的customizeContext方法,以使用本地方法先加载必备的属性到context的环境变量对象中:
public class CustomContextLoaderListener extends ContextLoaderListener {
private static final String PROP_ZBASE_MAIN_CONFIG_ROOT = "zbase.main.config.root";
@Override
protected void customizeContext(ServletContext sc, ConfigurableWebApplicationContext wac) {
// 加载classpath下的配置
String strConfigApplication = "classpath:application*.properties";
loadCustomConfigFile(wac, strConfigApplication);
String strZbaseMainConfigRoot = wac.getEnvironment().getProperty(PROP_ZBASE_MAIN_CONFIG_ROOT);
if (strZbaseMainConfigRoot.endsWith("/")) {
strZbaseMainConfigRoot = strZbaseMainConfigRoot.substring(0, strZbaseMainConfigRoot.length() - 1);
}
// 以下分开读取的原因是:当使用“http://***/*.properties”来获取远程的配置文件时,你需要指定具的文件名,
// 而不能由*.properties来读取所有的文件
// 关于如何实现*.properties文件组,而不是使用下面的示例,其实也可以通过多加一个间接层来实现
String strConfig = strZbaseMainConfigRoot + "/app.properties";
loadCustomConfigFile(wac, strConfig);
strConfig = strZbaseMainConfigRoot + "/app2.properties";
loadCustomConfigFile(wac, strConfig);
strConfig = strZbaseMainConfigRoot + "/biz.properties";
loadCustomConfigFile(wac, strConfig);
super.customizeContext(sc, wac);
}
/**
* 加载配置文件
* @param wac
* @param strConfigFilePath
*/
private void loadCustomConfigFile(ConfigurableWebApplicationContext wac, String strConfigFilePath) {
List<Resource> listResources = new ArrayList<Resource>();
ResourcePatternResolver objResolver = new PathMatchingResourcePatternResolver();
try {
listResources.addAll(Arrays.asList(objResolver.getResources(strConfigFilePath)));
} catch (IOException e) {
e.printStackTrace();
}
Resource[] locations = new Resource[listResources.size()];
locations = listResources.toArray(locations);
for (Resource item : locations) {
ResourcePropertySource objRes = null;
try {
objRes = new ResourcePropertySource(item.getFilename(), item);
wac.getEnvironment().getPropertySources().addLast(objRes);
} catch (IOException e) {
e.printStackTrace();
}
}
}
3.4 web.xml中的配置
添加一个<listener>节:
<listener>
<listener-class>com.bn.zbase.listener.CustomContextLoaderListener</listener-class>
</listener>
3.5 resources目录下的属性文件添加
在resources目录下,为了演示,添加了三个资源文件:
与<profile>无关的application.properties:
# 当前使用的maven profile
# 当你在控制台使用命令:mvn clean package -DskipTests -P***
# ***代表此处的值(不太准确,这是因为此处的mvn.profiles.active与pom.xml中的<profiles>|<profile>的<id>设
# 为相同的值
zbase.profiles.active=${mvn.profiles.active}
<profile>为dev时的资源:application-dev.properties:
# 远程服务器上的配置文件根目录
# zbase.main.config.root=http://127.0.0.1:8070/dev
zbase.main.config.root=file:/${project.basedir}/conf/dev
<profile>为sit时的资源:application-sit.properties:
zbase.main.config.root=file:/e:/proj/java/zbase-test-web/conf/sit
需要注意的是:application-dev.properties与application-sit.properties文件中属性集应该是相同的。
3.6 自定义的外部配置文件组
如目录结构图中的conf文件夹,里面包含了dev跟sit两个子文件夹,这里以dev/biz.properties文件为例(bean创建开关的属性文件):
# PropConfigurationCondition类中使用本属性,用以判断是否创建bean
propconfig.enabled=true
3.7 实现Condition接口的条件类
/**
* Created by zcn on 2018/2/11.
* PropConfiguration类由spring生成bean的条件
*/
public class PropConfigurationCondition implements Condition {
public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata annotatedTypeMetadata) {
return conditionContext.getEnvironment().getProperty("propconfig.enabled").equalsIgnoreCase("true");
}
}
3.8 bean配置类
@Configuration
@Conditional(PropConfigurationCondition.class)
public class PropConfiguration {
@Value("${propconfig.enabled}")
private String _strConfigRoot;
@Value("${zbase.config.app.val01}")
private String _strAppVal01;
@Value("${zbase.profiles.active}")
private String _strProfilesActive;
@Bean("bizParasConfig")
public BizParasConfig appConfig() {
BizParasConfig objBean = new BizParasConfig();
objBean.setMainConfigRoot(_strConfigRoot);
return objBean;
}
}
至此,主要的代码段就结束,关于测试,就是如下的片段了:
@Controller
public class MainController {
private static final Logger logger = LoggerFactory.getLogger(MainController.class);
private static final String MAIN_CONFIG_ROOT = "mainConfigRoot";
@Autowired(required = false)
@Qualifier("bizParasConfig")
private BizParasConfig bizParasConfig;
@RequestMapping(value = {"/", "/index"})
public String index(Model model) {
String strVal = "nothing";
if (bizParasConfig != null) {
strVal = bizParasConfig.getMainConfigRoot();
}
model.addAttribute(MAIN_CONFIG_ROOT, strVal);
logger.info(strVal);
return "index";
}
}
注意上面的@Autowired的required属性为false。
最后,是本文示例的下载链接:
https://github.com/basenumber/zbase-test-web.git