Tuesday, December 8, 2009

Configuring the Spring Dynamic Modules Tomcat Server

Spring Dynamic Modules&tm; has a nice Tomcat server implementation that can be started in the OSGi container. This is very handy, but what is a pain is that it can only be configured by fragment bundles. Basically, you create a new server.xml file (as found in the tomcat conf/ directory) and put it in a fragment bundle with the fragment host being org.springframework.osgi.catalina.start.osgi.

That works very well, but it is very painful to have to create a fragment bundle for every configuration. Especially when you have more than one OSGi container running on a single host. In this case you may only want to change the port number(s) of the listeners so that multiple tomcat instances do not clash. It would be nice if we could pull some of the configuration information from the OSGi Configuration Admin service. Here is how to do that

First, grab a copy of Spring's tomcat starter bundle source code. The bundle is the tomcat.start.osgi bundle available from Spring's S3 Maven repo at http://maven.springframework.org/osgi. The source is available as well.

The class that does the work is org.springframework.osgi.web.tomcat.internal.Activator. This class starts tomcat using a separate thread. Looking at the source we can see that the bundle classpath is searched for a resource named conf/server.xml. This file is copied to a temporary location and tomcat is instructed to load using it as its configuration. A default version of this file is provided in the bundle. It looks like:

<Server port="8005" shutdown="SHUTDOWN">

  <Service name="Catalina">
    <Connector port="8080" />

    <Engine name="Catalina" defaultHost="localhost">
      <Host name="localhost" unpackWARs="false" autoDeploy="false"
       liveDeploy="false" deployOnStartup="false"
       xmlValidation="false" xmlNamespaceAware="false"/>
    </Engine>
    
  </Service>
</Server>

A convenient way to introduce the configuration into this file is to do property placeholder substitution while the file is being copied to the temporary location. To do that we only have to change the copyURLToFile method. The following is the replacement method:

 private void copyURLToFile(InputStream inStream, FileOutputStream outStream) {
  PropertyPlaceholderSupport pph = new PropertyPlaceholderSupport(bundleContext);
  byte[] newline = System.getProperty("line.separator").getBytes();
  
  BufferedReader reader = new BufferedReader(new InputStreamReader(inStream));
  String line;
  try {
   while ((line = reader.readLine()) != null) {
    outStream.write(pph.process(line).getBytes());
    outStream.write(newline);
   }
  }
  catch (IOException ex) {
   throw (RuntimeException) new IllegalStateException("Cannot copy URL to file").initCause(ex);
  }
  finally {
   pph.close();
   try {
    inStream.close();
   }
   catch (IOException ignore) {
   }
   try {
    outStream.close();
   }
   catch (IOException ignore) {
   }
  }
 }

This version of the method reads the server xml one line at a time, calling a property placeholder class to substitute variables as it goes. All that is required now is a class that does the property placeholder work. I did not use the Spring property placeholder configurer because I wanted to load properties from Configuration Admin PIDs. Therefore a different property specification is needed. I chose to use a URI to specify the property PID, the property name and the default value. The URI looks like:

pid://thePid/theProperty[?default]

For example: pid://infor.catalina/port?8080

The scheme of the URI is "pid", the authority is used as the PID, the path is used as the property name, and the query string as a default value. You could use any other way of encoding a property reference if this is not to your liking.

This is the PropertyPlaceholderSupport class used:

//**
 * ---Begin Copyright Notice---20090105T1353
 *
 * NOTICE
 *
 * THIS SOFTWARE IS THE PROPERTY OF AND CONTAINS CONFIDENTIAL INFORMATION OF
 * INFOR AND/OR ITS AFFILIATES OR SUBSIDIARIES AND SHALL NOT BE DISCLOSED
 * WITHOUT PRIOR WRITTEN PERMISSION. LICENSED CUSTOMERS MAY COPY AND ADAPT
 * THIS SOFTWARE FOR THEIR OWN USE IN ACCORDANCE WITH THE TERMS OF THEIR
 * SOFTWARE LICENSE AGREEMENT. ALL OTHER RIGHTS RESERVED.
 *
 * (c) COPYRIGHT 2009 INFOR. ALL RIGHTS RESERVED. THE WORD AND DESIGN MARKS
 * SET FORTH HEREIN ARE TRADEMARKS AND/OR REGISTERED TRADEMARKS OF INFOR
 * AND/OR ITS AFFILIATES AND SUBSIDIARIES. ALL RIGHTS RESERVED. ALL OTHER
 * TRADEMARKS LISTED HEREIN ARE THE PROPERTY OF THEIR RESPECTIVE OWNERS.
 *
 * ---End Copyright Notice---
 */
package com.infor.container.catalina.start.internal;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Dictionary;

import org.osgi.framework.BundleContext;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
import org.osgi.util.tracker.ServiceTracker;

import com.infor.osgi.util.BundleUtil;


/**
 * Support property place holders in catalina configuration. This class processes
 * place holders with a URI like: <code>pid://<i>thePid</i>/<i>theProperty</i>[?<i>default</i>]</code>.
 * The CM configuration will be consulted first, falling back to a configuration or
 * system property named thePid.theProperty.
 * <p>For example:</p>
 * <code>    pid://infor.catalina/port?8080</code>
 * <p>Will look in CM PID <code>infor.container</code> for a property named <code>port</code>
 * and use that value if it is defined. If there is no property named port in infor.catalina,
 * or infor.catalina does not exist, then search for a context property named 
 * <code>infor.catalina.port</code>. If that does not exist look for a system property named
 * <code>infor.catalina.port</code>. If that also does not exist, then use the default value 8080.</p>
 * 
 * @author dlaidlaw
 *
 */
public class PropertyPlaceholderSupport {
 /** The CM service tracker. */
 private ServiceTracker cmTracker;
 /** The amount of time to wait for CM to become available. */
 private long cmTimeout = 15 * 60 * 1000;
 /** The default PID for the component. */
 private String defaultPid;
 /** The bundle context. */
 private BundleContext context;

 /** The start of a property. */
 public static final String PROPERTY_PREFIX = "${";
 /** The end of a property. */
 public static final String PROPERTY_SUFFIX = "}";
 
 /**
  * Construct a new instance for the specified bundle context.
  * @param context the OSGi bundle context.
  */
 public PropertyPlaceholderSupport(final BundleContext context) {
  super();
  this.context = context;
  
  cmTracker = new ServiceTracker(context, ConfigurationAdmin.class.getName(), null);
  cmTracker.open();
  
  defaultPid = BundleUtil.getInforComponentType(context.getBundle());
 }
 
 /**
  * Close. Releases the service tracker created in the constructor.
  */
 public void close() {
  if (cmTracker != null) {
   cmTracker.close();
   cmTracker = null;
  }
  context = null;
 }
 
 /**
  * Process a string which may contain one or more properties to
  * be substituted.
  * @param str the string to process.
  * @return the same string if there are no property definitions, otherwise,
  *   a new string with the properties substituted.
  * @throws IOException in case there is a ConfigurationAdmin access error.
  */
 public String process(final String str) throws IOException {
  if (str == null || str.length() < 4) {
   return str;
  }
  
  ConfigurationAdmin cm = null;
  try {
   cm = (ConfigurationAdmin) cmTracker.waitForService(cmTimeout);
   if (cm == null) {
    throw new IOException("Timeout waiting for the ConfigurationAdmin service.");
   }
  } catch (InterruptedException e) {
   throw new IOException("Interrupted while waiting for the ConfigurationAdmin Service.", e);
  }
  
  String result = str;
  int start = 0;
  while ((start = result.indexOf(PROPERTY_PREFIX, start)) >= 0) {
   int end = result.indexOf(PROPERTY_SUFFIX, start + 2);
   if (end < 0) {
    break;
   } else if (end - start - 2 <= 0) {
    // empty spec, check for more after it
    start += 3;
    continue;
   }
   
   // A good start and end defined.
   String prefix = (start == 0) ? "" : result.substring(0, start);
   String suffix = (end == result.length() - 1) ? "" : result.substring(end + 1);
   String propDef = result.substring(start + 2, end);
   
   result = prefix.concat(substitute(cm, propDef)).concat(suffix);
  }
  
  return result;
 }
 
 /**
  * Find the substitution value for a property definition.
  * @param cm the {@link ConfigurationAdmin} service.
  * @param propertyDef the property definition.
  * @return the substitute value.
  * @throws IOException in case ConfigurationAdmin cannot be accessed.
  */
 @SuppressWarnings("unchecked")
 protected String substitute(final ConfigurationAdmin cm, final String propertyDef) throws IOException {
  String pid = defaultPid;
  String property = propertyDef;
  String defaultValue = null;
  
  if (propertyDef.startsWith("pid://")) {
   try {
    URI uri = new URI(propertyDef);
    pid = uri.getAuthority();
    property = uri.getPath();
    while (property.startsWith("/")) {
     property = property.substring(1);
    }
    defaultValue = uri.getQuery();
   } catch (URISyntaxException e) {
    throw new IllegalArgumentException("Property definition is not a valid URI: " + propertyDef, e);
   }
  }
  
  Configuration config = cm.getConfiguration(pid);
  if (config == null) {
   return findProperty(pid, property, defaultValue);
  }
  Dictionary<String, ?> dict = config.getProperties();
  if (dict == null) {
   return findProperty(pid, property, defaultValue);
  }
  Object value = dict.get(property);
  if (value == null) {
   return findProperty(pid, property, defaultValue);
  }
  
  return value.toString();
 }
 
 /**
  * Lookup the property in the configuration or system properties, in that order.
  * @param pid the first part of the property name.
  * @param property the second part.
  * @param defaultValue the default value to return if the property does not exist.
  * @return the property if it exists, or the default;
  */
 protected String findProperty(final String pid, final String property, final String defaultValue) {
  String lookup = pid.concat(".").concat(property);
  Object value = context.getProperty(lookup);
  if (value == null) {
   return defaultValue;
  }
  return value.toString();
 }
}

That is all you need to customize the server.xml file for tomcat before it is loaded. Note that this implementation does not listen to configuration changes and so you must stop this bundle and start it again to make any changes take effect. This will bring down the tomcat server and start it up again with the new values.

3 comments:

  1. This is great! I have actually just been running into the problem of needing a more robust way of configuring tomcat with spring dm this week. This post will save me countless hours.

    One question: I'm pretty new with maven, and am having trouble figuring out how to get the source. I would me much obliged if you could share a minimal POM file for me to use to download the source of the tomcat starter bundle. If this is not possible, can you point me in the right direction? Thanks so much!

    ReplyDelete
  2. Sure. Use the following pom. You will find the sources in the target/sources folder.

    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.infor.container.tomcat.start</groupId>
    <artifactId>com.infor.container.tomcat.start.spring</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>Tomcat Startup in OSGi</name>

    <dependencies>
    <dependency>
    <groupId>org.springframework.osgi</groupId>
    <artifactId>catalina.start.osgi</artifactId>
    <version>1.0.0</version>
    </dependency>
    </dependencies>

    <build>
    <plugins>
    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
    <execution>
    <id>src-dependencies</id>
    <phase>package</phase>
    <goals>
    <!--
    use copy-dependencies instead if you don't want to explode the
    sources
    -->
    <goal>unpack-dependencies</goal>
    </goals>
    <configuration>
    <classifier>sources</classifier>
    <failOnMissingClassifierArtifact>false</failOnMissingClassifierArtifact>
    <outputDirectory>${project.build.directory}/sources</outputDirectory>
    </configuration>
    </execution>
    </executions>
    </plugin>
    </plugins>
    </build>

    <repositories>
    <repository>
    <id>spring-osgified-artifacts</id>
    <snapshots>
    <enabled>true</enabled>
    </snapshots>
    <name>Springframework Maven OSGified Artifacts Repository</name>
    <url>http://maven.springframework.org/osgi</url>
    </repository>
    </repositories>
    </project>

    ReplyDelete
  3. Sorry about the format, comments do not allow embedding XML very nicely.

    Another note about the above: you could replace the conf/default-server.xml file in the bundle provided by spring with one with the property placeholders, but it would probably be better to just create a new fragment bundle. Don't forget to set the fragment host to your new bundle that starts tomcat, instead of the catalina.start.osgi bundle from Spring. Also, remember to remove the catalina.start.osgi bundle from the container, or at least not start it. There can't be two catalina starters!

    ReplyDelete