我爱编程

与旧项目共舞(二):告别 XML 配置的第一步

2018-01-06  本文已影响0人  Boreasy

在上一篇文章中,我们聊到了为何要将项目配置最小化,同时回顾了 Spring 和 Spring Boot 的注解使用,以及 Spring Boot 在配置最小化方面的设计。本篇文章将结合项目实际中做法聊聊如何迈出告别 XML 配置的第一步。

制定重构计划

上一篇文章中也提到,配置文件数量巨大。所以比起撸起袖子开始做,制定一个切实可行的计划更为重要。综合分析项目特点,我认为启动重构时要做的事情是这样的:

确定了重构主体是 XML,接下来根据配置的功能再进行细分:

  1. Spring 配置,包括框架配置和业务 Bean 配置。
  2. Hibernate 配置,主要为 hbm 文件。
  3. web.xml。值得一提的是由于长期未更新,配置文件还是基于 Servlet 2.0 的,也计划在重构中一并升级到 3.x。
  4. 业务配置数据。按约定格式定义数据,并在应用中读取。数量较少,但和部分 Spring 配置一样,存在生产 / 测试环境的差异。

结合以上的分析,可以列出对 XML 配置的重构计划:

  1. Spring 框架核心配置迁移至 Java Config,支持注解和 XML 并存的配置。
  2. Hibernate 支持注解和 XML 并存。
  3. Spring 中环境敏感的配置迁移至 Java Config。
  4. web.xml 迁移至 XML(转为 Spring Boot 项目)。
  5. 整理业务配置,重构数据读取方式,将生产 / 测试配置定义在同一个文件中,敏感数据接入配置中心。
  6. 长期进行的重构,包括 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。

上一篇 下一篇

猜你喜欢

热点阅读