Some time ago, I needed to write a client to query a Lucene index running Solr. Most people have heard of Lucene, a popular open source search index building and querying toolkit, which can be used to build search functionality into your sites. Fewer people are likely to have heard of Solr, which is an HTTP wrapper over a Lucene index, which allows populating the index by HTTP POSTing XML documents to it, and retrieval by HTTP GETs. The HTTP GET syntax is a request URL with a set of query arguments specifying number of rows needed, start position, an enumeration of the fields needed, and the query itself as a Lucene query. The GET returns an XML document which needs to be parsed by your client. In effect, it allows you to set up and integrate a Lucene index with very little knowledge of the Lucene API itself.
Knowing nothing about Solr at that time, (and being too lazy and pressed for time to RTFM), I assumed that I would have to look up a Java API to figure out how to write a query client, so I talked to one of the Solr developers who told me to just use XPath.
Since Solr returns the search results as a XML document (with a predefined format), my query client would need to send the HTTP GET request to the Solr server, then parse the returned XML. I could either write a custom XML parser or use XPath calls. I think of XPath as the 4GL of the XML world, which allows you to specify nodes in an XML document, much like you would specify the full path name of a file on a Unix-like operating system. I choose the XPath approach, because it saved me from having to write yet another XML parser. You would think that performance would suffer as a result of using XPath instead of a custom XML parser, but I did not notice any significant degradation.
The XPath client I wrote was not terribly generic, however. The client would compose and send the appropriate HTTP GET request to the Solr server. The Solr HTTP query syntax is minimalistic but very powerful, so you can specify exactly what you want and how much. Since I was retrieving collections of nodes, I needed to iterate over them, something I could not do without hardcoding this in the client code. I mentioned my newfound knowledge of XPath to a colleague and described the client I wrote, and he casually asked if this was something generic that maybe he could use too, just by changing configuration. I replied in the negative, and it was no big deal, and the conversation moved on to other things. But it got me thinking, and this article is a result of that thought process.
Extended XPath Expression | Comments |
meta/numberOfResults/text() | Returns the number of results returned in the XML as a scalar string. |
data/array/row/col[@name="reviewer"]/text():[0] | Returns a 1 element List of String, containing the first reviewer in the XML result. |
data/array/row/col[@name="id"]/text():[] | Returns a List of String, containing all the ids in the XML result. |
data/array/row/col[@name="id"]/text():[0-1] | Returns a 2 element List String, containing the ids found at the first two rows in the XML result. |
data/array/row/col[@name="id"]/text():[1-] | Returns a List of String, with all ids starting from the 1st row in the XML result. |
data/array/row/col[@name="id"]/text():[-3] | Returns a List of String, with all ids starting from the 3rd row to the last row in the XML result. |
data/array/row[@id="1"]:{} | Returns a Map of String key and String value, with all the columns where the row id attribute is "1". |
data/array/row[@id="1"]:{"name"} | Returns a Map of String key and String value, containing the value of the name column where the row id attribute is "1". |
Notice that only the first query is a pure XPath query. All the others have little qualifiers in the query after the colon(:). The extension syntax is quite simple, with [] representing a List, and {} representing a Map. This syntax is similar to that used by JSON or PLists. We can also specify a slice of the List or a set of keys in the Map by using the Python like array slice notation [m-n] and the {"key"} notation respectively. Having the extra qualifying notation allows us to specify generic XPath like patterns which can be set up in the configuration. So while XPath simplifies the job of writing an XML parser (compared to using the DOM, SAX or JDOM APIs), using the XPathClient can eliminate it altogether.
Here is how you can call the XPathClient from Java code.
1 2 3 4 5 6 7 8 9 10 11 | // set up the xpath client
XPathClient client = new XPathClient();
client.setServiceUrl("http://path.to.webservice/service.html");
client.setRootElement("result"); // in my example
Map<String,String> xpathExpressions = new HashMap<String,String>();
xpathExpressions.put("key", "valid/extended/XPath/Expression");
client.setXPathExpressions(xpathExpressions);
// run the xpath client
Map<String,Object> results = client.runQuery();
// do something with the results
System.out.println(results.get("key").toString());
|
Alternatively, the XpathClient can also be configured using Spring like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <!-- beans.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "spring-beans.dtd" >
<beans>
<!-- Configuration for the XpathClient -->
<bean id="xpathClient" class="com.mycompany.xpath.XpathClient">
<property name="serviceUrl" value="http://localhost/test.xml" />
<property name="rootElement" value="result" />
<property name="xpathExpressions">
<props>
<prop key="numberOfResults">meta/numberOfResults/text()</prop>
<prop key="reviewer[all].id">data/array/row/col[@name="id"]/text():[]</prop>
<prop key="reviewer[0-1].id">data/array/row/col[@name="id"]/text():[0-1]</prop>
<prop key="reviewer[1-].id">data/array/row/col[@name="id"]/text():[1-]</prop>
<prop key="reviewer[-3].id">data/array/row/col[@name="id"]/text():[-3]</prop>
<prop
key="reviewer[0].reviewer">data/array/row/col[@name="reviewer"]/text():[0]</prop>
<prop key="review[0]">data/array/row[@id="1"]:{}</prop>
<prop key="review[0].name">data/array/row[@id="1"]:{"name"}</prop>
</props>
</property>
</bean>
</beans>
|
And the resulting client call is even simpler. The code below configures the XPathClient for a standalone application, which would typically be the case for a web service client. Similar code can be written for clients which are embedded into web applications. In that case you will get a handle to the ApplicationContext and get the bean from it.
1 2 3 4 5 6 7 8 | // you would do this part anyway for the other beans
Resource res = new ClassPathResource("beans.xml");
XmlBeanFactory factory = new XmlBeanFactory(res);
// this is where you get the injected reference from the configuration
client = (XpathClient) factory.getBean("xpathClient");
Map<String,Object> results = client.runQuery();
// do something with the results
|
Here is the code for the XpathClient class. It has a default constructor and uses setter injection to set it up. The runQuery() method makes a HTTP GET call to the configured service URL, and gets a handle to the root element Node object. It then runs all the extended XPath expressions on the root element Node. This strategy ensures that we make only a single network call to the web service per call to the runQuery() method. Pure XPath expression is trivial, it simply delegates to the XPath object's evaluate() method. However, if it finds a colon sign in the XPath expression, it concludes that the expression is an extended XPath expression, and based on what the contents of the expression after the colon sign, it will do extra processing to either return a List or Map of information.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | // $Id$
// $Source$
package com.mycompany.xpath;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* A simple configuration driven XPath client to read data off an XML stream.
* @author Sujit Pal
* @version $Revision$
*/
public class XpathClient {
protected final static Log log = LogFactory.getLog(XpathClient.class);
private String serviceUrl;
private String rootElement;
private Map<String,String> xpathExpressions;
public XpathClient() {
super();
}
public void setRootElement(String rootElement) {
this.rootElement = rootElement;
}
public void setServiceUrl(String serviceUrl) {
this.serviceUrl = serviceUrl;
}
public void setXpathExpressions(Map<String, String> xpathExpressions) {
this.xpathExpressions = xpathExpressions;
}
/**
* Runs the set of queries specified in the XPath expressions map and
* returns results in a Map with the results posted against identical
* keys. Results for exact matches are returned as String values. Results
* with multiple matches are returned as a List<String>.
* @return a result Map<String,Object>
* @throws Exception if one is thrown.
*/
public Map<String,Object> runQuery() throws Exception {
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
Document doc = builder.parse(serviceUrl);
XPath xpath = XPathFactory.newInstance().newXPath();
// get the rootNode as our reference point
Node rootNode = (Node) xpath.evaluate("/" + this.rootElement, doc, XPathConstants.NODE);
// Build our new output map
Map<String,Object> resultMap = new HashMap<String,Object>(); for (String key : xpathExpressions.keySet()) {
String xpathExpr = xpathExpressions.get(key);
if (StringUtils.isEmpty(xpathExpr)) {
log.warn("No XPath expression defined for " + key);
continue;
}
if (xpathExpr.indexOf(':') > -1) {
// Collection retrieval mode
String[] parts = xpathExpr.split(":");
if (parts[1].startsWith("[")) {
// should return a List of results
NodeList nodeList = (NodeList) xpath.evaluate(rewriteXpathExpr(parts[0], false), rootNode, XPathConstants.NODESET);
int numNodesInSet = nodeList.getLength();
int[] sliceCoordinates = getSliceCoordinates(parts[1], numNodesInSet);
List<String> resultList = new ArrayList<String>();
for (int i = sliceCoordinates[0]; i <= sliceCoordinates[1]; i++) {
String value = nodeList.item(i).getTextContent();
resultList.add(value);
}
resultMap.put(key, resultList);
} else if (parts[1].startsWith("{")) {
// should retrieve a flat Map of name value pairs
if (parts[0].endsWith("/*")) {
parts[0] = parts[0].substring(0, parts[0].length() - 2); }
Node parentNode = (Node) xpath.evaluate(parts[0], rootNode, XPathConstants.NODE);
NodeList nodeList = parentNode.getChildNodes();
String[] keys = getRequestedAttributes(parts[1]);
Map<String,String> objectMap = new HashMap<String,String>();
int numChildren = nodeList.getLength();
for (int i = 0; i < numChildren; i++) {
Node node = nodeList.item(i);
if (node.getAttributes() == null || node.getAttributes().getLength() == 0) {
continue;
}
String firstAttribute = node.getAttributes().item(0).getNodeValue();
if (isRequestedAttribute(firstAttribute, keys)) {
objectMap.put(firstAttribute, node.getTextContent());
}
}
resultMap.put(key, objectMap);
}
} else {
// should return a single result
String result = (String) xpath.evaluate(rewriteXpathExpr(xpathExpr, true), rootNode, XPathConstants.STRING);
resultMap.put(key, result);
}
}
return resultMap;
}
/**
* Prepends the rootElement to the XPath expression and appends a /text()
* call if not present.
* @param xpathExpr the XPath expression.
* @param appendTextCall true if single record, else false.
* @return the modified XPath expression.
*/
private String rewriteXpathExpr(String xpathExpr, boolean appendTextCall) {
StringBuilder sb = new StringBuilder("/").
append(rootElement).append("/").append(xpathExpr);
if (appendTextCall && (!(xpathExpr.endsWith("/text()")))) {
sb.append("/text()");
}
return sb.toString();
}
/**
* Returns the start and end coordinates of the slice to be returned
* from a NodeSet object. This expression is appended to the end of a
* valid XPath expression that can return more than one value. The rules
* are as follows:
* [] - return all elements of the set.
* [m-n] - return elements[m] to elements[n] both inclusive.
* [-n] - return elements[0] to elements[n] both inclusive.
* [m-] - return elements[m] till end of list.
* [m] - return only element[m].
* @param slicePart the slice coordinate subexpression.
* @param nodeSetLength the total length of the nodeset.
* @return the start and end coordinates.
*/
private int[] getSliceCoordinates(String slicePart, int nodeSetLength) {
int[] coords = new int[] {0, 0};
String temp = slicePart.replaceAll("\\[", "").replaceAll("\\]", "");
if (StringUtils.isEmpty(temp)) {
// []: return all elements
coords = new int[] {0, nodeSetLength - 1};
} else if (temp.startsWith("-")) {
// [-n]: return 0-th to n-th elements
coords = new int[] {0, Integer.parseInt(temp.substring(1)) - 1};
} else if (temp.endsWith("-")) {
// [m-]: return m-th to last elements
coords = new int[] {Integer.parseInt(temp.substring(0, temp.length() - 1)), nodeSetLength - 1};
} else if (StringUtils.isNumeric(temp)) {
// [n]: return the n-th element
coords = new int[] {Integer.parseInt(temp), Integer.parseInt(temp)}; } else {
// [m-n]: return the m-th to the n-th element
String[] tempParts = temp.split("-");
coords = new int[] {Integer.parseInt(tempParts[0]), Integer.parseInt(tempParts[1]) - 1};
}
return coords;
}
/**
* Returns an array of requested attributes. The parsing is done by removing * the leading and trailing {} characters, then splitting on comma.
* @param string the requested attribute string.
* @return an array of requested attributes.
*/
private String[] getRequestedAttributes(String attrString) {
String temp = attrString.replaceAll("\\{", "").replaceAll("\\}", "");
if (StringUtils.isEmpty(temp)) {
return new String[0];
}
return temp.split("\\s*,\\s*");
}
/**
* Scans the specified array for a match and returns true if found, false
* if not.
* @param attrib the attribute to look for.
* @param keys an array of requested attributes.
* @return true if attrib found in keys, false if not.
*/
private boolean isRequestedAttribute(String attrib, String[] keys) {
if (keys == null || keys.length == 0) {
// accept all attributes
return true;
}
for (int i = 0; i < keys.length; i++) {
if (keys[i].equals("\"" + attrib + "\"")) {
return true;
}
}
return false;
}
}
|
One thing to realize is that your XML must be parseable using XPath, ie, you should be able to uniquely specify the node within the XML document. This is not as hard as it sounds, all that is needed is to specify (on the server side) an id attribute for each node, which must be unique across the document for that node.
If, like me when I first started playing with XPath, you are not too familiar with the XPath syntax, and would need help validating them against your document, there is XPath Explorer (xpe), a free XPath browser from Purpletech which helped me a lot when trying to build my XPath expressions.
A possible improvement may be a cleaner and more maintainable parsing logic for my extended XPath expression syntax, but the best solution for that would have been to write a full blown parser, which I felt is a little overkill for something as simple as this.
The XPathClient has been unit tested with JUnit using the extended XPath expressions in the Spring configuration file shown above.
Thank you, dontcare, I will.
ReplyDelete