Thursday, September 6, 2012

Spring Provisional/Conditional Bean Loading (Part 1)

In Spring 3.1 (2011), profiles were introduced.  These Bean Definition Profiles allow Spring developers to set up different profiles for loading different beans in different environments.  I know several developers that are moving to 3.1 just for that feature.

However, what does one do if one needs to load different beans for different environments, but he/she cannot immediately upgrade to Spring 3.1?  In this blog entry I will cover that scenario with two solutions from long ago that still work today in Spring 3.0.x.

Solution #1:

Far and away the easiest solution is to use config file properties, the Spring PropertyPlaceholderConfigurer, and Spring Expression Language (SpEL).  Below is an XML snippet from a bean definition file.  The code uses SpEL to get at the Spring.ENV property found in the config.properties, and referenced as the Spring container starts to load.
…<bean id="icfi.propertyConfigurer"
  class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
  <property name="locations">
   <list>
    <value>classpath:config.properties</value>
   </list>
  </property>  
  <property name="ignoreUnresolvablePlaceholders" value="false" />
 </bean>
…
 <import resource="beans.xml" />
 <import resource="beans2.xml" />
 <import resource="${Spring.ENV}-beans.xml" />…
As long as the property is located before the Spring Container starts to load bean definitions, this will work to load files whose names match the resource arguments.  This solution has issues, not the least of which are that properties must be used and they must be coordinated to match file names related to bean definition files.

System properties can also be used in lieu of the PropertyPlaceholderConfigurer.

Solution #2:

This next solution is way more involved, requiring custom extensions to the Spring Container API, but is configuration properties file driven instead of system property driven.  To understand this approach, we start with what we would like to see in the bean definition file.  Below is the <profile:if> custom tag that will test whether certain beans are to be loaded.

<profile:if test="${Spring.ENV=='DEV'}" src="config.properties">
 <bean id="devBean" class="com.icfi.spring.init.beans.DevBean" name="ibean">
  <property name="valueOne" value="This is DEV." />
 </bean>
</profile:if>
In this example, the <profile:if> tag tests to see if the Spring.ENV property has the required value of "DEV" to load this bean.  The Spring.ENV property should be found in the config.properties resource bundle that is declared in the src attribute of the <profile:if> tag.  If the test passes (Spring.ENV == 'DEV') then the devBean will be loaded.

The plumbing for this solution extends the Spring Container API via the Extensible XML Authoring API with Java and XML Schema (XSD), along with two additional Spring configuration files.  It is rather complex, but I will try to simplify it here.

The Extensible XML Authoring API (EXAA for short) has been around since Spring 2.0.  However, it never really got the visibility that other Spring features enjoyed.  With EXAA, there are four steps to extending the API:

  1. Authoring XML schemas
  2. Coding Namespace handlers
  3. Coding BeanDefinitionParser extensions
  4. Registering the XSD and code artifacts with the Spring Container


I will start will authoring the schema.  Below is the XSD that defines the new custom tags we want to use. As a point of reference, I built this solution in Eclipse Helios SR2 using Maven.  I placed the new profile.xsd into directory in the image below.


The contents of the schema are seen below.  In this XSD I defined the target namespace (http://icfi.com/springbeans/profile) and the custom tag (if) with required attributes, test and src.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>

<xsd:schema xmlns="http://icfi.com/springbeans/profile"
 xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:beans="http://www.springframework.org/schema/beans"
 targetNamespace="http://icfi.com/springbeans/profile"
 elementFormDefault="qualified" attributeFormDefault="unqualified">

 <xsd:element name="if">
  <xsd:complexType>
   <xsd:sequence>
    <xsd:any minOccurs="0" />
   </xsd:sequence>
   <xsd:attribute name="test" type="xsd:string" use="required" />
   <xsd:attribute name="src" type="xsd:string" use="required" />
  </xsd:complexType>
 </xsd:element>

</xsd:schema>
Once the XSD was done, I needed to register the schema using the appropriate properties file, spring.schemas, in the META-INF directory.
http\://icfi.com/springbeans/profile/profile.xsd=com/icfi/springbeans/profile/profile.xsd
Next, I needed to code the handler and parser. The handler, a simple class as it turns out, is used by the Spring Container to register the new custom Bean Definition Parser. My handler is below.
package com.icfi.springbeans.profile;

import org.springframework.beans.factory.xml.NamespaceHandlerSupport;

public class ProfileBeanNamespaceHandler extends NamespaceHandlerSupport {
 public void init() {
  super.registerBeanDefinitionParser("if",
    new ProfileBeanDefinitionParser());
 }
}
Next I needed to register the handler. For this I created the appropriate properties file, spring.handlers, in the META-INF directory.
http\://icfi.com/springbeans/profile=com.icfi.springbeans.profile.ProfileBeanNamespaceHandler
Below is the location in my Maven layout for both registration properties files mentioned so far.


The ProfileBeanDefinitionParser is referenced in the handler.  This parser does all the heavy lifting of parsing the custom tag, verifying the attributes, loading the defined resource bundle (properties file), testing the condition for bean load, parsing the bean definition, and then registering the bean with Spring.  Below is the custom Bean Definition Parser that I wrote.
package com.icfi.springbeans.profile;

import java.util.HashMap;
import java.util.Map;
import java.util.ResourceBundle;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanDefinitionHolder;
import org.springframework.beans.factory.support.BeanDefinitionReaderUtils;
import org.springframework.beans.factory.xml.BeanDefinitionParser;
import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate;
import org.springframework.beans.factory.xml.ParserContext;
import org.springframework.util.xml.DomUtils;
import org.w3c.dom.Element;

public class ProfileBeanDefinitionParser implements BeanDefinitionParser {
 private static Logger log = LoggerFactory
   .getLogger(ProfileBeanDefinitionParser.class);

 private ResourceBundle bundle;

 /** SpEL prefix: "${" */
 public static final String PREFIX = "${";

 /** SpEL suffix: "}" */
 public static final String SUFFIX = "}";

 /**
  * Parse the "if" element, check for the required "test" and "src" attributes.
  */
 public BeanDefinition parse(Element element, ParserContext parserContext) {
  try {
   if (DomUtils.nodeNameEquals(element, "if")) {
    String test = element.getAttribute("test");
    String src = element.getAttribute("src");

    if (StringUtils.isNotEmpty(src)) {
     if (src.indexOf(".") > 0) {
      src = src.substring(0, src.indexOf("."));
     }

     bundle = ResourceBundle.getBundle(src);
    } else {
     throw new IllegalArgumentException(
       "src attribute not found.");
    }

    if (StringUtils.isNotEmpty(test)) {
     Map<String, String> map = this.getExpressionMap(test);
  
     String left = this.bundle.getString(map.get("left"));
     String right = map.get("right");

     if (left != null && right != null && left.equals(right)) {
      Element beanElement = DomUtils
        .getChildElementByTagName(element, "bean");
      return parseRegisterBean(beanElement, parserContext);
     }
    } else {
     throw new IllegalArgumentException(
       "test attribute not found.");
    }
   }
  } catch (Exception e) {
   log.error(e.getMessage());
  }

  return null;
 }

 private Map<String, String> getExpressionMap(String value) {
  Map<String, String> map = new HashMap<String, String>();

  if (StringUtils.isEmpty(value)) {
   return null;
  }

  String entire = value.substring(PREFIX.length(),
    value.length() - SUFFIX.length());

  String left = entire.substring(0, entire.indexOf("=="));

  String right = entire.substring(entire.indexOf('\'') + 1,
    entire.lastIndexOf('\''));

  map.put("left", left);
  map.put("right", right);

  return map;
 }

 private BeanDefinition parseRegisterBean(Element element,
   ParserContext parserContext) {
  BeanDefinitionParserDelegate delegate = parserContext.getDelegate();
  BeanDefinitionHolder holder = delegate
    .parseBeanDefinitionElement(element);
  BeanDefinitionReaderUtils.registerBeanDefinition(holder,
    parserContext.getRegistry());

  return holder.getBeanDefinition();
 }
}
The config.properties file contains the Spring.ENV property that will be used to test the condition defined in the tag, test attribute. This file is found at the root of the src/main/resources Maven layout.

Below is a test class, SpringInitMotivator, that I used to exercise this solution,  Also seen below is the main.xml used to configure Spring in my application.  The SpringInitMotivator uses a helper class, SpringBeanFactory, to get at beans.  For this demo, I use the IBeans interface, implemented by the DevBean and ProdBean classes.  I load the beans via the name attribute and not the id, as multiple beans can not have the same ID, and I wanted to use a common name for both bean load conditions.
package com.icfi.spring;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;

import com.icfi.spring.init.beans.IBeans;

public class SpringInitMotivator {
 private static Logger log = LoggerFactory
   .getLogger(SpringInitMotivator.class);

 public static void main(String[] args) {
  ApplicationContext ctx = new GenericXmlApplicationContext(
    "context/main.xml"); // Spring 3.0

  IBeans ibean = (IBeans) SpringBeanFactory.getBean("ibean");
  log.info(ibean.getValueOne());
 }
}

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
 xmlns:aop="http://www.springframework.org/schema/aop" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
 xmlns:oxm="http://www.springframework.org/schema/oxm" xmlns:lang="http://www.springframework.org/schema/lang"
 xmlns:context="http://www.springframework.org/schema/context"
 xmlns:profile="http://icfi.com/springbeans/profile"
 xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
      http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop-3.0.xsd
         http://www.springframework.org/schema/lang http://www.springframework.org/schema/lang/spring-lang-3.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd
         http://icfi.com/springbeans/profile http://icfi.com/springbeans/profile/profile.xsd">
 <import resource="beans.xml" />
 <import resource="beans2.xml" />
 <profile:if test="${Spring.ENV=='DEV'}" src="config.properties">
  <!-- <import resource="development-beans.xml" /> -->
  <bean id="devBean" class="com.icfi.spring.init.beans.DevBean" name="ibean">
   <property name="valueOne" value="This is DEV." />
  </bean>
 </profile:if>

 <profile:if test="${Spring.ENV=='PROD'}" src="config">
  <!-- <import resource="development-beans.xml" /> -->
  <bean id="prodBean" class="com.icfi.spring.init.beans.ProdBean" name="ibean">
   <property name="valueOne" value="This is PROD." />
  </bean>
 </profile:if>
</beans>

No comments:

Post a Comment