与旧项目共舞(二):告别 XML 配置的第一步
在上一篇文章中,我们聊到了为何要将项目配置最小化,同时回顾了 Spring 和 Spring Boot 的注解使用,以及 Spring Boot 在配置最小化方面的设计。本篇文章将结合项目实际中做法聊聊如何迈出告别 XML 配置的第一步。
制定重构计划
上一篇文章中也提到,配置文件数量巨大。所以比起撸起袖子开始做,制定一个切实可行的计划更为重要。综合分析项目特点,我认为启动重构时要做的事情是这样的:
- 优先重构冗余最多、对开发维护影响最大的 XML 配置。
- 由于重构是一个持续的过程,所以要能兼容新旧配置。
- 测试密集型的工作尽量集中在初期完成,尽可能简化后续重构、测试的过程。这样做的好处是,对次要部分的重构可以分散到业务需求的开发过程中。
确定了重构主体是 XML,接下来根据配置的功能再进行细分:
- Spring 配置,包括框架配置和业务 Bean 配置。
- Hibernate 配置,主要为 hbm 文件。
- web.xml。值得一提的是由于长期未更新,配置文件还是基于 Servlet 2.0 的,也计划在重构中一并升级到 3.x。
- 业务配置数据。按约定格式定义数据,并在应用中读取。数量较少,但和部分 Spring 配置一样,存在生产 / 测试环境的差异。
结合以上的分析,可以列出对 XML 配置的重构计划:
- Spring 框架核心配置迁移至 Java Config,支持注解和 XML 并存的配置。
- Hibernate 支持注解和 XML 并存。
- Spring 中环境敏感的配置迁移至 Java Config。
- web.xml 迁移至 XML(转为 Spring Boot 项目)。
- 整理业务配置,重构数据读取方式,将生产 / 测试配置定义在同一个文件中,敏感数据接入配置中心。
- 长期进行的重构,包括 BeanDefinition 注解化、数据库实体注解化。
接下来讲讲重构过程中的一些重点。
Spring 框架配置重构
由于框架核心配置较多,不适合集中在一个配置类中配置。因此,根据配置的用途简单的设计了配置类的组织结构,基本如下:
RootConfig - 根配置
┣ DataSourceConfig - 数据源配置
┣WebMvcConfig - Spring WebMvc 配置
┣ SsoConfig- SSO 相关配置(主要是把 XML 转到 @Bean
,后面不做展开)
┗ ScheduleConfig - 定时任务配置
RootConfig
根配置的唯一作用是聚合所有配置,以便在其他地方统一使用。
@Configuration
@ImportResource(("classpath:xxx.xml", ...})
@Import({DataSourceConfig.class, WebMvcConfig.class, SsoConfig.class, ScheduleConfig.class})
@ComponentScan("cn.basepackage")
@EnableAspectJAutoProxy
public class RootConfig{
}
WebMvcConfig
WebMVC 的配置相对来说比较简单。
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
// 静态资源处理
registry.addResourceHandler("/attachment/**").addResouceLocations("/attachment/");
}
@Override
protected void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@Override
protected void addInterceptors(InterceptorRegistry registry) {
// 拦截器配置
registry.addInterceptor(someInterceptor());
}
//启用 multipart request 解析
@Bean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
public MultipartResolver multipartResolver() {
return new CommonsMultipartResolver();
}
//其他配置
}
DataSourceConfig
由于项目的数据库信息并没有保存在本地,而是通过接口统一获取。由于这一点,重构时只是将原先的 XML 配置转换为 Java Config,而没有考虑使用类似 Spring Boot 的数据源配置方式
@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
//从第三方服务获取数据库凭证
}
@Bean
public LocalSessionFactoryBean sessionFactory() {
LocalSessionFactoryBean sfb = new LocalSessionFactoryBean();
sfb.setDataSource(dataSource());
// 使 Hibernate 同时支持注解和 xml 配置
sfb.setPackagesToScan("cn.basepackage");
sfb.setConfigLocation(new ClassPathResource("hibernate.cfg.xml"));
Properties properties = new Properties();
// 自定义属性
sfb.setHibernateProperties(properties);
return sfb;
}
@Bean
public HibernateTransactionManager transactionManager() {
return new HibernateTransactionManager(sessionFactory().getObject());
}
@EventListener({ContextStoppedEvent.class, ContextClosedEvent.class})
public void deregisterDriver() {
// 释放资源
Iterators.forEnumeration(DriverManager.getDrivers())
.forEachRemaining(DataSourceConfig::deregisterDriver);
}
private static void deregisterDriver(Driver driver) {
try {
DriverManager.deregisterDriver(driver);
} catch (SQLException e) {
log.error("deregister driver error", e);
}
}
}
在转换过程中发现,旧框架的事务控制是通过声明式的方式控制的。
<bean id="txProxyTemplate" lazy-init="true" abstract="true"
class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
<property name="transactionManager" ref="transactionManager"/>
<property name="transactionAttributes">
<props>
<prop key="save*">PROPAGATION_REQUIRED</prop>
<prop key="del*">PROPAGATION_REQUIRED</prop>
<prop key="update*">PROPAGATION_REQUIRED</prop>
<prop key="create*">PROPAGATION_REQUIRED</prop>
<prop key="add*">PROPAGATION_REQUIRED</prop>
<prop key="find*">PROPAGATION_NOT_SUPPORTED</prop>
<prop key="load*">PROPAGATION_NOT_SUPPORTED</prop>
<prop key="get*">PROPAGATION_NOT_SUPPORTED</prop>
<prop key="search*">PROPAGATION_NOT_SUPPORTED</prop>
<prop key="query*">PROPAGATION_NOT_SUPPORTED</prop>
<prop key="*">PROPAGATION_NOT_SUPPORTED</prop>
</props>
</property>
</bean>
<bean id="fooService" parent="txProxyTemplate">
<property name="target">
<bean class="FooServiceImpl"/>
</property>
</bean>
这种方式创建了一个 abstract bean,无法通过 Java Config 的方式构造。另一方面,这种方式控制事务的粒度过粗,不适用于当下复杂的业务场景和分布式事务,我希望能使用 @Transactional
注解代替这种形式。但由于大量的 Service Bean 都是通过这种方式来生成,要想一次把所有地方全部重构不太现实,所以计划逐步替换为注解式事务。
与此同时,我又希望在业务开发过程中可以快速把 Service Bean 替换为注解形式。所以不希望每次都逐一检查每个接口方法是否需要事务。这时候可以利用 Spring 的 AbstractAutoProxyCreator
类来实现这个需求。
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service
public @interface TransactionalService {
@AliasFor(annotation = Service.class)
String value() default "";
}
static class ServiceAutoProxyCreator extends AbstractAutoProxyCreator {
@Override
protected Object[] getAdvicesAndAdvisorsForBean(Class<?> beanClass, String beanName, TargetSource customTargetSource) throws BeansException {
// 仅检查 @TransactionalService 注解的 bean
TransactionalService service = AnnotatedElementUtils.findMergedAnnotation(beanClass, TransactionalService.class);
return service == null ? DO_NOT_PROXY : PROXY_WITHOUT_ADDITIONAL_INTERCEPTORS;
}
}
static class MethodPrefixTransactionAttributes extends Properties {
private static final String PROPAGATION_PREFIX = "PROPAGATION_";
private void add(Propagation propagation, String... methodPrefixes) {
String value = PROPAGATION_PREFIX + propagation.name();
for (String methodPrefix : methodPrefixes) {
put(methodPrefix + "*", value);
}
}
}
@Bean
public ServiceAutoProxyCreator autoProxyCreator() {
ServiceAutoProxyCreator autoProxyCreator = new ServiceAutoProxyCreator();
autoProxyCreator.setInterceptorNames(TRANSACTION_INTERCEPTOR_BEAN);
return autoProxyCreator;
}
@Bean(TRANSACTION_INTERCEPTOR_BEAN)
public TransactionInterceptor transactionInterceptor() {
TransactionInterceptor interceptor = new TransactionInterceptor();
interceptor.setTransactionManagerBeanName(TRANSACTION_MANAGER_BEAN);
MethodPrefixTransactionAttributes transactionAttributes = new MethodPrefixTransactionAttributes();
transactionAttributes.add(Propagation.REQUIRED, "add", "save", "del", "update");
transactionAttributes.add(Propagation.NOT_SUPPORTED, "get", "load", "find", "search", "query", Strings.EMPTY);
interceptor.setTransactionAttributes(transactionAttributes);
return interceptor;
}
这样的话,就可以简单的通过 @TransactionalService
注解代替 XML bean 配置,从而实现和 XML 一样声明式事务。
ScheduleConfig
理论上来说,Spring 默认提供了 @EnableSchedule
注解快速开启注解式定时任务。但由于我有一些定制化的需求,所以定义了自己的 ScheduleConfig。
首先项目在生产环境部署了 3 个实例,其中一个负责执行主要的定时任务,另两台处理用户请求。所以我希望这部分定时任务迁移到注解时仍然只在这一个实例运行。另外有一些任务通过创建线程循环执行的,我希望用定时任务的方式重构,这些任务需要在每一个实例上运行。
针对这个实例,我首先定义了一个注解 @Client
,用于标明任务应该在哪个实例上运行,接下来通过 ScheduleConfig 使规则生效。
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Bean(name = TaskManagementConfigUtils.SCHEDULED_ANNOTATION_PROCESSOR_BEAN_NAME)
public ScheduledAnnotationBeanPostProcessor scheduledAnnotationProcessor() {
return new CustomProcessor();
}
@Bean
@Primary
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(POOL_SIZE);
return taskScheduler;
}
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setTaskScheduler(taskScheduler());
}
static class CustomProcessor extends ScheduledAnnotationBeanPostProcessor {
@Override
protected void processScheduled(Scheduled scheduled, Method method, Object bean) {
Client client = AnnotatedElementUtils.findMergedAnnotation(method, Client.class);
if (canProcess(client)) {
super.processScheduled(scheduled, method, bean);
}
}
private boolean canProcess(Client client) {
// 未配置 Client 注解时仅在 main client 执行,降低转换成本
if (client == null) {
return Constants.isMainClient();
}
// 配置了 Client 时,如果未指定 client name,则全部实例执行,否则仅在指定实例上执行
return client.names().length == 0 || client.names().length == 1 && client.names()[0].equals(StringUtils.EMPTY)
|| ArrayUtils.contains(client.names(), Constants.CLIENT_NAME);
}
}
}
web.xml 配置重构
Spring 提供了 WebApplicationInitializer
接口,可以通过 Java 代码以 Servlet 3.0+ 标准实现 web.xml 配置。这个接口只有一个方法,暴露了 ServletContext 对象,所有的配置都利用这个对象完成。
public class MyInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
// 在这里配置
}
}
Servlet/Filter 配置
先回顾一下 xml 方式配置 Servlet
<servlet>
<servlet-name>springmvc</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>springmvc</servlet-name>
<url-pattern>/</url-pattern>
<servlet-mapping>
重构后有所简化,并且可以抽象为方法,达到重用的目的。
ServletRegistration.Dynamic servletRegistration = servletContext.addServlet("springmvc", DispatcherServlet.class);
servletRegistration.setLoadOnStartUp(1);
servletRegistration.addMapping("/")
Filter 的配置方式与之类似,不再赘述。
ContextParam / Listener / Session 配置
这几项配置比较简单,ServletContext 直接提供了方法进行配置。
servletContext.addListener(SomeListener.class);
// 或 servletContext.addListener(new SomeListener());
servletContext.setInitParameter("key", "value");
servletContext.setSessionTimeout(120);
值得注意的一点是,servletContext 还提供了一种方式配置 Session 过期时间
servletContext.getSessionCookieConfig().setMaxAge(120);
这种方式设置的时间单位是秒,前一种方式设置的时间单位是分钟,如果设置不当很容易导致出现登录状态丢失的问题。并且这个问题在测试过程中很难发现。
其实仔细分析可以看出两者配置的区别,第一种方式配置的是容器对 Session 的过期时间,第二种方式是客户端维护 Session ID 的 cookie 的过期时间。重构时需要注意保持和原 xml 配置的统一。
WelcomePage / ErrorPage 配置
这两种配置都无法通过 ServletContext 对象完成。参考 Spring Boot,它提供的欢迎页和错误页依赖于 Spring 的 Servlet。由于旧项目中存在多个 Servlet,当访问其他 Servlet 时出现错误时,Spring Boot 的 Servlet 无法处理。所以需要考虑其他解决方案。
由于生产环境容器是 Tomcat,几乎不可能替换。所以通过反射拿到容器相关的类进行配置,而不必太担心与容器的紧耦合。
Object applicationContext = FieldUtils.getFieldValue(servletContext, "context");
StandardContext standardContext = (StandardContext) FieldUtils.getFieldValue(applicationContext, "context");
其中 StandardContext 是 Tomcat 的运行上下文,通过 StandardContext 就可以进行欢迎页和错误页的配置。
先来看看欢迎页的配置方法。
// 移除已有的配置
String[] welcomeFiles = standardContext.findWelcomeFiles();
if (welcomeFiles != null) {
for (String welcomeFile : welcomeFiles) {
standardContext.removeWelcomeFile(welcomeFile);
}
}
standardContext.addWelcomeFile("index.jsp");
其中,移除已有配置的部分是必须的,因为通过这种方式配置会存在几个默认配置,干扰项目运行。
接下来是对错误页的配置。
ErrorPage errorPage = new ErrorPage();
errorPage.setLocation(location);
errorPage.setErrorCode(errorCode);
errorPage.setExceptionType(exceptionType != null ? exceptionType.getName() : null);
standardContext.addErrorPage(errorPage);
需要注意的是,如果直接用以上代码配置,ErrorPage 所在的包在不同 Tomcat 版本下会有所差异,如果需要升级 Tomcat 版本,不仅需要改代码,还需要修改项目的 Tomcat 包依赖。对以上配置稍加修改,就可以做到较好的兼容,切换 Tomcat 版本时不需要修改代码。
private Class<?> errorPageClass = getErrorPageClass();
private Method addErrorPageMethod = StandardContext.class.getMethod("addErrorPage", errorPageClass);
private Class<?> getErrorPageClass() {
try {
// try initializing with tomcat 8 or later
return getClassLoader().loadClass("org.apache.tomcat.util.descriptor.web.ErrorPage");
} catch (Exception e) {
try {
// failback to tomcat 7
return getClassLoader().loadClass("org.apache.catalina.deploy.ErrorPage");
} catch (ClassNotFoundException e1) {
throw new RuntimeException(e1);
}
}
}
@SneakyThrows
private void addErrorPage(String location, int errorCode, Class<? extends Exception> exceptionType) {
Object errorPage = errorPageClass.newInstance();
errorPageClass.getMethod("setLocation", String.class).invoke(errorPage, location);
errorPageClass.getMethod("setErrorCode", int.class).invoke(errorPage, errorCode);
errorPageClass.getMethod("setExceptionType", String.class).invoke(errorPage,
(String) (exceptionType != null ? exceptionType.getName() : null));
addErrorPageMethod.invoke(standardContext, errorPage);
}
接入 Spring Boot
通过上面的重构,接入 Spring Boot 就变得非常简单了。首先是创建程序入口,由于我们还不能依赖自动配置,所以定义程序入口时没有使用 @SpringBootApplication
。
@SpringBootConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class);
}
}
然后只要简单的修改 MyInitializer 即可
public class MyInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
// 禁用 Spring Boot 的错误页
setRegisterErrorPageFilter(false);
return builder
.bannerMode(Banner.Mode.OFF)
.sources(BiomartRootConfiguration.class);
}
}
另外,MyInitializer 中如果用到 WebApplicationContext,非 Spring Boot 项目初始化时直接用new AnnotationConfigWebApplicationContext()
,Spring Boot 则要调用createRootApplicationContext(servletContext)
。
至此,我们将整个项目的核心配置迁移到了Java Config。