ORIGINAL DRAFT

In a previous column, I wrote about a solution that allowed you to use XML documents as configuration files. This is a practice that’s become increasingly common place. This month, we’ll put something together that allows us to do console-based XML file editing, a fairly common requirement in server-based software configuration. This solution is generic enough to apply fairly widely. The only assumptions are that the XML file stores configuration values in the form of attributes rather than inline text. The XMLConsole class allows you to load and save XML configuration files, navigate the hierarchy and edit attributes in a menu-driven, scrolling dialog with the end user.

In the process of developing this project, a number of reusable classes came into play. Figure 1 shows the classes that are part of this solution. The ConsoleIO class is actually a collection of static variables and methods that act much like the Java System class, but allows us to use indirection that keeps us one level away from the JVM’s console-handling. As such, you can get a ConsoleWriter from ConsoleIO.out and ConsoleIO.err, and a ConsoleWriter from the ConsoleIO.in variables. I’ve followed the same conventions as the System IO print and input streams. By default, the writer and readers map onto the System.in, System.out and System.err.

Figure 1: Classes in the XMLConsole project.

Figure 1: Classes in the XMLConsole project.

The ConsoleReader provides some utility methods for reading text and integer inputs. It uses a utility class called ConstraintUtil, which holds a set of static methods for constraint management. When an expected constraint is not met, a ConstraintException gets thrown. The exception text is expected to explain the problem so that it can be presented to the end user and the input can be suitably correct. For example, when a number menu of items is presented for selection, if the user enters a value outside the range of acceptable choices, we’ll present the exception text so the user knows what went wrong and then redisplay the menu. This mechanism is easy to use and provides a unified way of handling input constraints.

There are three classes that provide user interface elements. The ConsoleMenu presents a numbered list of text entries and asks the user to enter a numerical value for their choice. The ConsoleChoice class provides a question associated with a shorter list of choices, such as a Yes/No questions, with an optional default value. The ConsoleValue class provides a mechanism for editing short text values, such as attributes. The ConsoleValue class expects new input but defaults to the old value if the string is empty, showing the default in square brackets.

XMLConsole manages the high-level interaction with the XML file and the end user. The user is free to navigate entries and make changes, optionally saving before quitting. XMLConsole provides a cookie crumb trail similar to the context found in web pages. For each menu or text input a user is faced with answering, the trail reflects the XML tags from top to the current position, along with the an index value indicating the tag position relative to it’s parent. This way, it’s easy for the end user to stay oriented while managing tag collections that are similar, such as a list of tags aggregating children of the same type.

Let’s look at a few of the key classes to clarify a few details. XMLConsole is the centerpiece, which controls the interaction flow. To put things in context, we’ll also look at ConsoleReader and the ConsoleMenu class, which will serve as a good example of building text-only component. Naturally, you can take the same ideas and apply them to other similar components.

The ConsoleReader class (Listing 1) extends BufferedReader primarily so that we can read line input and extend the behavior to handle more specific input. There are two constructors, one of which wraps an InputStreamReader around an InputStream object. The other just passes the Reader argument to the parent class. The three methods are fairly straight forward. The first is just an alias for readLine, which can be modified if new capabilities need to be added. This way, we can avoid changing the readLine behavior in the future. Because ConsoleReader extends BufferedReader, we have implicit access to all of the methods in BufferedReader in any case.

The two readInt method variants are there to filter line input which is intended to be an integer value. Other input types could be provided, but for our purposes this was sufficient. The first readInt method tries to parse the result of a readText call as an integer and catches the NumberFormatException. If we see this, a ConstraintException is thrown, with a suitable explanation of what happened. This text will be presented to the user when the data type that was entered is incorrect.

The second readInt method extends the behavior by checking for values within a specified range. Because all the basic Java data types have object wrappers that implement the Comparable interface, the static utility methods in ConstraintUtil expect Comparable arguments. This allows us to compare not only basic Number instances, but also String and other instances for classes that implement the Comparable interface, reusing the same methods. It’s worth noting that most Java runtime code expects the compared objects to be of the same type.

That being said, we’re primarily interested in Integer objects, so we call the isAboveInclusive and isBelowInclusive methods after wrapping the int value in an Integer object. You’ll notice that this allows us to support null constraints. If the min or max values are null, the constraint is assumed to be unnecessary. Finally, if the tests all pass, we return an int value. Like the first version of readInt, the second, parameterized, version throws a ConstraintException if the entered values are inappropriate.

On top of the primitive input handling available in the ConsoleReader, we can build more complex components. ConsoleMenu represents a key element in out XMLConsole, and so deserves a closer look. Listing 2 shows the code for ConsoleMenu. The constructor expects a String list, which represents the list of items to be selected. This version of ConsoleMenu handles a single choice, but it would be easy enough to extend the behavior to handle a comma-separated list of choices.

Once you have a ConsoleMenu object defined, you can use it by calling either the select or ask method. The select method does most of the work and returns an integer value, verified to be within the right constraints. To simplify any higher-level code, ConsoleMenu takes responsibility for presenting the user with the choices recursively if a ConstraintException was thrown. This is done only after the user is told there was a problem. You’ll notice that error output goes to ConsoleIO.err, input is retrieved from ConsoleIO.in and normal output goes to ConsoleIO.out.

Presenting the user with a menu is a simple matter of prefixing the elements from the String list with an incrementing number and asking for a numerical value as input. We return the numerical value with select, but provide a pair of methods to relate the integer value to the original text. The asText method takes the index value and returns the String, but you can use the ask method directly and skip over the integer step if you know you want only the text value.

There are advantages and disadvantages to both approaches. If you use integer values, you can usually keep the text uncoupled and enable internationalization or text position movement within the menu list without disturbing the logic. There are times, however, where managing numerical values is troublesome, such as when you add text values with specific meanings or contexts. As you’ll see in the higher-level code, we use numerical values in XMLConsole but provide utility methods for making a distinction between selection contexts. We also use String values in cases where the selection context changes position dynamically. If you wanted to use indirection, such as when localizing the text, the localized text can just as easily be compared.

Listing 3 shows the XMLConsole class, which uses the underlying classes to read and save XML files and provide console-based text navigation and editing. The constructor calls the load method, which uses the JDOM DOMBuilder to construct a JDOM Document from the file. This is what we use to manage the XML content. The save method reverses the process to save the Document to a file. We save a reference to the loaded file object so that the interface can save the file without prompting the user for a file name. Notice that because we use DOM, this solution is limited to XML documents that fit in memory. If the file is used for configuration and doesn’t fit into memory, XML is probably not the right solution for your configuration problems.

There are a number of protected methods in this class. Rather than studying the code for each, I’ll just mention their function, so that the code is easy to follow. Most of the code is easy to understand and I’ll explain the details if they tend to be confusing.

The first method is getChildIndex, which takes a JDOM Element object and returns it’s own index position relative to it’s parent. The two getContextTrail methods construct a String indicating the current context. This is effectively a cookie crumb trail that shows the path from the root node to the current node with separators on a single line. I’ve implemented the ability to provide an attribute name as well. The attribute name is ignored if the String is null. The first version of the method defaults to the non-attribute behavior.

Because the user will need to select child tags as well as attributes and navigational choices in each of the main menus, I’ve divided these into categories. The first is detectable using the isChildIndex method. If the index value from the menu choice returns true when this method is called, we are dealing with a child tag selection. The isAttrsIndex returns true if we are choosing an attribute name. Because the process of elimination suggests that if it is not a tag or attribute it must be something else, we don’t need another method to make that distinction. We do, however, want a method that translates the menu offset to the attribute-relative index value, so we have a method called getAttrsIndex to do that.

Finally, we get to the two key methods in this class. The createMenu method constructs a menu from a given XML Element. For each child tag, we create an entry of the form “Configure <tag name>”. For each attribute at the current level, we add an entry with the form “Edit <attribute name>” and, based on whether we are at the root or not, we either add the “Save Config” and “Exit Program” choices or the “Previous Menu” choice. Equipped with menus of this form, we can now navigate the XML document hierarchy with relative ease.

The navigate method does most of the work, so we’ll cover it in slightly more detail. We start with the root and set an exit flag to false before dropping into a loop that exits only when the flag is set to true. For each iteration, we show the user the context trail an call createMenu with the current context Element. Then we present the menu for user selection. You’ll notice we store both the numerical and text value for the menu choice in local variables. Now all we have to do is trigger suitable behavior for each menu choice.

If isChildIndex returns true, we are dealing with a child tag, so all we need to do is set the context variable to the child Element and the next loop will take care of everything for us. If the isAttrsIndex method returns true, we are dealing with an attribute and want to enable users to edit the value. If the Attribute value is not null, we use the ConsoleValue component to offer a prompt with a default value option. The ConsoleValue class gets single-line text input from the user and puts the default value in square brackets. If the user enters nothing (pressing enter with an empty string), the old value is retained. The returned value is either the old value or a new one, which we use to set the Attribute value.

There are three other possible menu selections during navigation, which we deal with by matching the text rather than the integer value. If the choice is “Previous Menu”, we just need to retrieve the current context parent and set the context variable so that it will be used on the next loop. If the “Save Config” option is chosen, we provide some user feedback and save the file that’s currently being edited using the save method. If the “Exit Program” option is chosen, we just set the exit flag to true and the loop exists, ending the main program call.

Listing 4 shows the trace for a a quick session that edits the Example.xml file provided with the code. The trace shows navigation from the root node to Port value in the second Server entry, which itself is under a Servers category. The Port value is changed from 80 to 8080 and the navigation choices take us back to the root node, at which point we save, answering yes to the prompt and quit, confirming that we want to quit before exiting the program. When you run the XMLConsole’s main method, you’ll be able to navigate and change values as you see fit.

While this implementation is fairly basic, it clearly offers a straight forward mechanism for editing XML configuration files in environments where non-console interfaces are not an option. This is especially true in server environments. Using the same mechanism, you can also provide remote configuration by using Socket streams. The framework is extensible enough, so you can add new text components that provide improved data type-checking or offer different selection options. Console interfaces have been around longer than GUIs and still provide a preferred configuration option in many server application environments. I hope that these ideas can serve you well.

Listing 1

import java.io.*;

public class ConsoleReader extends BufferedReader
{
  public ConsoleReader(InputStream stream)
  {
    super(new InputStreamReader(stream));
  }
  
  public ConsoleReader(Reader reader)
  {
    super(reader);
  }
  
  public String readText()
    throws IOException
  {
    return readLine();
  }

  public int readInt()
    throws IOException
  {
    String text = readText();
    try
    {
      return Integer.parseInt(text);
    }
    catch (NumberFormatException e)
    {
      throw new ConstraintException(
        "This value must be an integer.");
    }
  }
  
  public int readInt(Integer min, Integer max)
    throws IOException
  {
    Integer value = new Integer(readInt());
    if (min != null)
    {
      ConstraintUtil.isAboveInclusive(value, min);
    }
    if (max != null)
    {
      ConstraintUtil.isBelowInclusive(value, max);
    }
    return value.intValue();
  }
}

Listing 2

import java.io.*;

public class ConsoleMenu
{
  protected String[] list;
  
  public ConsoleMenu(String[] list)
  {
    this.list = list;
  }
  
  public String ask()
    throws IOException
  {
    return asText(select());
  }
  
  public String asText(int index)
  {
    return list[index];
  }
  
  protected int select()
    throws IOException
  {
    try
    {
      ConsoleIO.out.println(
        "Please select from the following menu:\n");
      for (int i = 0; i < list.length; i++)
      {
        String number = "" + (i + 1) + ") ";
        ConsoleIO.out.println(number + list[i]);
      }
      ConsoleIO.out.printPrompt(
        "Enter a number to select a menu item");
      Integer min = new Integer(1);
      Integer max = new Integer(list.length);
      return ConsoleIO.in.readInt(min, max) - 1;
    }
    catch (ConstraintException e)
    {
      ConsoleIO.err.printError(e);
      return select();
    }
  }
}

Listing 3

import java.io.*;
import java.util.*;
import org.jdom.*;
import org.jdom.input.*;
import org.jdom.output.*;

public class XMLConsole
{
  protected File file;
  protected Document doc;
  
  public XMLConsole(File file)
    throws JDOMException
  {
    load(file);
  }
  
  protected int getChildIndex(Element element)
  {
    Element parent = element.getParent();
    if (parent == null) return 1;
    List children = parent.getChildren();
    for (int i = 0; i < children.size(); i++)
    {
      if (element == children.get(i))
      {
        return i + 1;
      }
    }
    return -1;
  }
  
  protected String getContextTrail(Element context)
  {
    return getContextTrail(context, null);
  }
  
  protected String getContextTrail(Element context, String attr)
  {
    Element parent = null;
    ArrayList trail = new ArrayList();
    if (context != null)
    {
      String name = context.getName() +
        '[' + getChildIndex(context) + ']';
      trail.add(name);
    }
    while ((parent = context.getParent()) != null)
    {
      String name = parent.getName() +
        '[' + getChildIndex(parent) + ']';
      trail.add(0, name);
      context = parent;
    }
    StringBuffer buffer = new StringBuffer("\n");
    for (int i = 0; i < trail.size(); i++)
    {
      if (i > 0) buffer.append(" / ");
      buffer.append((String)trail.get(i));
    }
    if (attr != null)
    {
      buffer.append(" / ");
      buffer.append(attr);
    }
    int count = buffer.length();
    for (int i = 0; i < count; i++)
    {
      if (i == 0) buffer.append('\n');
      else buffer.append('-');
    }
    return buffer.toString();
  }
  
  protected boolean isChildIndex(Element element, int index)
  {
    int childCount = element.getChildren().size();
    return index >= 0 && index < childCount;
  }
  
  protected boolean isAttrsIndex(Element element, int index)
  {
    int childCount = element.getChildren().size();
    int attrsCount = element.getAttributes().size();
    int count = childCount + attrsCount;
    return index >= childCount && index < count;
  }
  
  protected int getAttrsIndex(Element element, int index)
  {
    int childCount = element.getChildren().size();
    return index - childCount;
  }

  protected ConsoleMenu createMenu(Element element)
  {
    String[] extras = {"Previous Menu"};
    if (element.isRootElement())
    {
      extras = new String[] {"Save Config", "Exit Program"};
    }
    List children = element.getChildren();
    List attributes = element.getAttributes();
    int childCount = children.size();
    int attrsCount = attributes.size();
    int extraCount = extras.length;
    String[] options = new String[childCount + attrsCount + extraCount];
    for (int i = 0; i < childCount; i++)
    {
      Element child = (Element)children.get(i);
      options[i] = "Configure " + child.getName();
    }
    for (int i = 0; i < attrsCount; i++)
    {
      Attribute attr = (Attribute)attributes.get(i);
      options[childCount + i] = "Edit " + attr.getName() +
        " [" + '"' + attr.getValue() + '"' + "]";
    }
    for (int i = 0; i < extraCount; i++)
    {
      options[childCount + attrsCount + i] = extras[i];
    }
    return new ConsoleMenu(options);
  }
  
  protected void navigate()
    throws IOException
  {
    Element context = doc.getRootElement();
    boolean timeToExit = false;
    while (!timeToExit)
    {
      ConsoleIO.out.printText(getContextTrail(context));
      ConsoleMenu menu = createMenu(context);
      int index = menu.select();
      String choice = menu.asText(index);
      
      if (isChildIndex(context, index))
      {
        List children = context.getChildren();
        context = (Element)children.get(index);
      }
      
      else if (isAttrsIndex(context, index))
      {
        List attributes = context.getAttributes();
        int offset = index - context.getChildren().size();
        Attribute attr = (Attribute)attributes.get(offset);
        if (attr != null)
        {
          String name = attr.getName();
          ConsoleIO.out.printText(
            getContextTrail(context, name));
          ConsoleValue editor = new ConsoleValue(
            "What is the new value for '" + name + "'",
            attr.getValue());
          attr.setValue(editor.ask());
        }
      }
      
      else if (menu.asText(index).equals("Previous Menu"))
      {
        context = context.getParent();
      }
      
      else if (menu.asText(index).equals("Save Config"))
      {
        ConsoleChoice confirm = new ConsoleChoice(
          "Save changes to your configuration",
          ConsoleChoice.YES_OR_NO, 1);
        if (confirm.ask().equalsIgnoreCase("yes"))
        {  
          ConsoleIO.out.printText("\nSaving...");
          save(file);
          ConsoleIO.out.printText("Done\n");
        }
      }
      
      else if (menu.asText(index).equals("Exit Program"))
      {
        ConsoleChoice confirm = new ConsoleChoice(
          "Are you sure you want to exit",
          ConsoleChoice.YES_OR_NO, 1);
        if (confirm.ask().equalsIgnoreCase("yes"))
        {  
          ConsoleIO.out.printText("\nExit\n");
          timeToExit = true;
        }
      }
    }
  }
  
  public void load(File file)
    throws JDOMException
  {
    this.file = file;
    DOMBuilder builder = new DOMBuilder();
    doc = builder.build(file);
  }
  
  public void save(File file)
    throws IOException
  {
    XMLOutputter output = new XMLOutputter();
    FileWriter writer = new FileWriter(file);
    output.output(doc, writer);
    writer.close();
  }
  
  public static void main(String[] args)
    throws Exception
  {
    File file = new File("Example.xml");
    XMLConsole console = new XMLConsole(file);
    console.navigate();
  }
}

Listing 4

Config[1]
---------
Please select from the following menu:

1) Configure Servers
2) Save Config
3) Exit Program

Enter a number to select a menu item: 1

Config[1] / Servers[1]
----------------------
Please select from the following menu:

1) Configure Server
2) Configure Server
3) Previous Menu

Enter a number to select a menu item: 1

Config[1] / Servers[1] / Server[1]
----------------------------------
Please select from the following menu:

1) Edit Host ["claude"]
2) Edit Port ["80"]
3) Previous Menu

Enter a number to select a menu item: 2

Config[1] / Servers[1] / Server[1] / Port
-----------------------------------------
What is the new value for 'Port'? [80]: 8080

Config[1] / Servers[1] / Server[1]
----------------------------------
Please select from the following menu:

1) Edit Host ["claude"]
2) Edit Port ["8080"]
3) Previous Menu

Enter a number to select a menu item: 3

Config[1] / Servers[1]
----------------------
Please select from the following menu:

1) Configure Server
2) Configure Server
3) Previous Menu

Enter a number to select a menu item: 3

Config[1]
---------
Please select from the following menu:

1) Configure Servers
2) Save Config
3) Exit Program

Enter a number to select a menu item: 2
Save changes to your configuration {Yes, No}? [No]: yes

Saving...
Done


Config[1]
---------
Please select from the following menu:

1) Configure Servers
2) Save Config
3) Exit Program

Enter a number to select a menu item: 3
Are you sure you want to exit {Yes, No}? [No]: yes

Exit