Converting a Castor Class to JAXB

From OpenNMS
Jump to navigation Jump to search

I will, in this example, use snmp-config.xml to show how I have converted a Castor class to use JAXB.

Stop Generating The Code

The first thing you need to do is move the XSD and files that you are wanting to convert out of the build. To do so, you'll want to do one last build:

 cd opennms-config-model
 ../compile.pl install

...and then take the XSD and the generated classes, and move them into their new locations:

 git mv src/main/castor/snmp-config.xsd src/main/resources/xsds/snmp-config.xsd
 mkdir -p src/main/java/org/opennms/netmgt/config/snmp
 mv target/generated-sources/castor/org/opennms/netmgt/config/snmp/* \
   src/main/java/org/opennms/netmgt/config/snmp/

Now you have the exact same thing that we had before, except castor is no longer used to generate the classes. They have essentially been "frozen" in their current incarnation. You should also remove the line from src/main/castor/castorbuilder.properties that maps the XML namespace of the XSD to the Java package name, ie:

 http://xmlns.opennms.org/xsd/config/snmp=org.opennms.netmgt.config.snmp,\

Create a Package Info File

The first thing you'll need is a file that tells JAXB about your code. To do so, you need to create a "package-info.java" file that describes the namespace of the code in that java package. In the case of the SNMP config, src/main/castor/castorbuilder.properties had this line:

 http://xmlns.opennms.org/xsd/config/snmp=org.opennms.netmgt.config.snmp,

...which means that the namespace for this code is "http://xmlns.opennms.org/xsd/config/snmp", and it expects the related code to be in the org.opennms.netmgt.config.snmp java package.

Our package-info.java file, then, will look like this:

 @XmlSchema(
   namespace = "http://xmlns.opennms.org/xsd/config/snmp",
   elementFormDefault = javax.xml.bind.annotation.XmlNsForm.QUALIFIED
 )
 package org.opennms.netmgt.config.snmp;
 import javax.xml.bind.annotation.XmlSchema;

Annotating Classes

Now that JAXB knows how to map namespaces to packages, we need to annotate the actual code with JAXB instructions.

Class-Level Annotations

First we annotate the class itself with high-level annotations, saying what element the class represents if you were to serialize it to XML (XmlRootElement). Also, we add the annotation @XmlAccessorType to tell JAXB that we will be adding annotations to the fields inside the object, rather than to the methods.

 /**
  * Top-level element for the snmp-config.xml configuration
  *  file.
  * 
  * @version $Revision$ $Date$
  */
 
 @XmlRootElement(name="snmp-config")
 @XmlAccessorType(XmlAccessType.FIELD)
 @ValidateUsing("snmp-config.xsd")
 @SuppressWarnings("all") public class SnmpConfig
   extends org.opennms.netmgt.config.snmp.Configuration 
 implements java.io.Serializable
 {

Field Annotations

The next step is to add an annotation to each field to describe how that field gets turned into XML. The two most common are:

@XmlElement 
this field will be turned into a sub-element of the class (ie, a nested tag)
@XmlAttribute 
this field will be turned into an attribute of the class's tag

In the case of our SnmpConfig class, there is only one field, and it will be a list of <definition> tags inside the snmp-config.

   //--------------------------/
  //- Class/Member Variables -/
 //--------------------------/
 
 /**
  * Maps IP addresses to specific SNMP parmeters
  *  (retries, timeouts...)
  */
 @XmlElement(name="definition")
 private java.util.List<org.opennms.netmgt.config.snmp.Definition> _definitionList;

Here is an example of a class with what should be an attribute, rather than an element:

 @XmlRootElement(name="configuration")
 @XmlAccessorType(XmlAccessType.FIELD)
 @ValidateUsing("snmp-config.xsd")
 public class Configuration implements Serializable {
 	private static final long serialVersionUID = -6800972339377512259L;
 
 	/**
 	 * If set, overrides UDP port 161 as the port where SNMP GET/GETNEXT/GETBULK
 	 * requests are sent.
 	 */
 	@XmlAttribute(name="port")
 	private int _port;
 
 	/**
 	 * keeps track of state for field: _port
 	 */
 	@XmlTransient
 	private boolean _has_port;

Note that it also introduces a new annotation: @XmlTransient. This means that this particular field should *not* be turned into an XML tag (or vice-versa).

In this particular case, though, we do not want to use @XmlTransient. Because of the way Castor generates code, they always use primitive values (int, long, etc.) rather than objects (Integer, Long, etc.) for fields. To be able to tell if a field has actually been set or not, it has an additional field and method for primitive "real" fields, which returns a boolean true false to determine whether it's been defined.

This is generally not a good pattern for Java code, and the next step will be to refactor this code to use proper objects. To do so, we should get rid of the transient "has" field, and convert the port attribute to be an Integer instead of an int. The "hasPort()" method will left in place for compatibility, but needs to be modified to take this into account.

We will want to be sure that the behavior is exactly the same as before, though, to maintain compatibility, so we will never return a null value in getPort(), we will instead return "0" just like an uninitialized integer would have. We can, however, change the getter and setter to use Integer, since the auto-boxing feature of Java 1.5 will deal with Integer/int conversions for us in existing code calling these methods.

Here is the before:

 	@XmlAttribute(name="port")
 	private int _port;
 
 	@XmlTransient
 	private boolean _has_port;
 
 	public int getPort() {
 		return this._port;
 	}
 
 	public void setPort(final int port) {
 		this._port = port;
 		this._has_port = true;
 	}
 
 	public boolean hasPort() {
 		return this._has_port;
 	}
 
 	public void deletePort() {
 		this._has_port = false;
 	}

...and here is the after:

 	@XmlAttribute(name="port")
 	private Integer _port;
 
 	public Integer getPort() {
 		return _port == null? 0 : _port;
 	}
 
 	public void setPort(final Integer port) {
 		_port = port;
 	}
 
 	public boolean hasPort() {
 		return _port != null;
 	}
 
 	public void deletePort() {
 		_port = null;
 	}

Dealing with Defaults

One thing that does not work the same between JAXB and Castor is how default values are handled. JAXB will serialize any field that is set, whereas Castor will only do so if the hasFoo() method returns true. The easiest way to deal with this is to slightly modify the previous example where we changed the primitives to real options and make the getter method return the default value if the field is null. I'll show you with another example.

In the Configuration class, we had a field called "max-repetitions" that defaults to 2. The original code looked like this:

 	private int _maxRepetitions = 2;
 	private boolean _has_maxRepetitions;
 
 	public int getMaxRepetitions() {
 		return this._maxRepetitions;
 	}
 
 	public void setMaxRepetitions(final int maxRepetitions) {
 		this._maxRepetitions = maxRepetitions;
 		this._has_maxRepetitions = true;
 	}

In the modified version, I will not set the default value in the field initialization, I will instead return it in the getter when _maxRepetitions is null:

 	@XmlAttribute(name="max-repetitions")
 	private Integer _maxRepetitions;
 
 	public Integer getMaxRepetitions() {
 		return _maxRepetitions == null? 2 : _maxRepetitions;
 	}
 
 	public void setMaxRepetitions(final Integer maxRepetitions) {
 		_maxRepetitions = maxRepetitions;
 	}

Testing

A test infrastructure has been created (in core/test-api/xml) which will make it easy to test your JAXB annotations and make sure that the behavior between your new implementation and the old Castor implementation are equivalent. Just extend the XmlTest class, passing in your Castor/JAXB object, and give it parameter data to test with. I'll give you an example, and describe what it does.

Sample Test

 01  package org.opennms.netmgt.xml.eventconf;
 02  
 03  import java.text.ParseException;
 04  import java.util.Arrays;
 05  import java.util.Collection;
 06  
 07  import org.junit.runners.Parameterized.Parameters;
 08  import org.opennms.core.test.xml.XmlTest;
 09  
 10  public class ForwardTest extends XmlTest<Forward> {
 11  
 12  	public ForwardTest(final Forward sampleObject, final String sampleXml,
 13  			final String schemaFile) {
 14  		super(sampleObject, sampleXml, schemaFile);
 15  	}
 16  
 17  	@Parameters
 18  	public static Collection<Object[]> data() throws ParseException {
 19  		Forward forward0 = new Forward();
 20  		Forward forward1 = new Forward();
 21  		forward1.setMechanism("snmpudp");
 22  		forward1.setState("on");
 23  		return Arrays.asList(new Object[][] {
 24  				{forward0,
 25  				"<forward/>",
 26  				"target/classes/xsds/eventconf.xsd" },
 27  				{forward1,
 28  				"<forward state=\"on\" mechanism=\"snmpudp\"/>",
 29  				"target/classes/xsds/eventconf.xsd" } 
 30  		});
 31  	}
 32  
 33  }

How It Works

The meat of the test is that data() method, using JUnit's "parameter" support. The real tests are inside the XmlTest class, and it does things like:

  • marshal using Castor and compare to the passed-in XML using XMLUnit
  • marshal using JAXB and compare to the passed-in XML using XMLUnit
  • unmarshal the passed-in XML using Castor and compare the resulting java object to the passed-in object

...and so on.

Implementing Your Own data() Method

First, you have to implement the constructor (as in lines 12 and 13) which takes the object type, the XML, and the path to the XSD schema file that is associated with it. These map to the inner array returned by the data() method.

Then, create your data() method. It needs to return one thing - an array of arrays. The outside array lets you give more than one set of test data, and each test will be run multiple times, once for each inner array. The inner array contains a single set of the data you saw in the constructor, an object, that object's expected XML, and the XSD schema file path.

As you see in the example, we created multiple test objects for this particular test; one with an empty object, and one with the "state" and "mechanism" attributes set. The outer array contains two entries, corresponding with those objects (starting at lines 24 and 27).

Updating the existing integration tests

Locate the integrate test for the XML file in:

integration-tests/src/test/java/org/opennms/netmgt/config/WillItUnmarshalTest.java

and update it to use unmarshalJaxb() instead of unmarshal().