Saturday, March 01, 2008

My first Custom Annotation

In a previous post, I noted that although I did not know much about how annotations worked, I seem to be using them more and more nowadays. I guess it was only a matter of time before I found a use-case where I would need to write my own annotation. Anyway, the opportunity presented itself when I was trying to write out a JPA @Entity bean described in my previous post out to a Lucene index. This post describes the annotation itself, and how I used it in my index building code.

My objective was to annotate some of the fields in my @Entity beans to tell the indexing process that these fields should be picked up. Additionally, I wanted to specify the Store and Index attributes that will govern how they will be stored and indexed.

To do this, I created the following Annotation class. The Storable and Indexable enums have elements that shadow the Field.Store and Field.Index enums in Lucene.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// IndexMeta.java
package com.mycompany.myapp.indexing;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * Property level annotation to indicate indexing meta-information.
 * Bean properties that should be indexed should be annotated with this.
 * Additional field level indexing metadata is supplied through the 
 * attributes Indexable and Storable. 
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface IndexMeta {

  public enum Indexable {NO, NO_NORMS, TOKENIZED, UN_TOKENIZED};
  public enum Storable {COMPRESS, NO, YES};
  
  Indexable indexable() default Indexable.NO;
  Storable storable() default Storable.NO;
}

To test this, we created two BookArticle objects in the database. Since BookArticle objects inherit from Article objects, we need to annotate properties in both classes. This is shown below:

 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
// Article.java
package com.mycompany.myapp.persistence;

import java.util.ArrayList;
import java.util.List;

import javax.persistence.DiscriminatorColumn;
import javax.persistence.DiscriminatorType;
import javax.persistence.Entity;
import javax.persistence.Inheritance;
import javax.persistence.InheritanceType;

import com.mycompany.myapp.indexing.IndexMeta;
import com.mycompany.myapp.indexing.IndexMeta.Indexable;
import com.mycompany.myapp.indexing.IndexMeta.Storable;

/**
 * The article superclass table contains all the common fields.
 */
@Entity
@Inheritance(strategy=InheritanceType.JOINED)
@DiscriminatorColumn(discriminatorType=DiscriminatorType.INTEGER, name="articleTypeId")
public abstract class Article extends ModelBase {

  @IndexMeta(indexable=Indexable.UN_TOKENIZED, storable=Storable.YES)
  private String articleId;
  @IndexMeta(indexable=Indexable.TOKENIZED, storable=Storable.YES)
  private String title;
  @IndexMeta(indexable=Indexable.TOKENIZED, storable=Storable.YES)
  private String summary;
  @IndexMeta(indexable=Indexable.NO, storable=Storable.YES)
  private String url;
  private String fileLocation;

  ... getters and setters removed ...  
}
 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
// BookArticle.java
package com.mycompany.myapp.persistence;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

import com.mycompany.myapp.indexing.IndexMeta;
import com.mycompany.myapp.indexing.IndexMeta.Indexable;
import com.mycompany.myapp.indexing.IndexMeta.Storable;

/**
 * Models a book article.
 */
@Entity
@DiscriminatorValue("2")
public class BookArticle extends Article {

  private static final long serialVersionUID = -2274023497279749079L;
  
  @IndexMeta(indexable=Indexable.TOKENIZED, storable=Storable.YES)
  private String authorName;
  private String publisherName;
  private String isbnNumber;
  
  ... getters and setters removed ...
}

Finally, we use Java 1.5's new reflection methods to find and apply the annotations during our indexing process. The JUnit test below shows how to get all the BookArticle beans from the database using JPA, and then passing the beans to a Lucene IndexWriter.

  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
// IndexingTest.java
package com.mycompany.myapp.indexing;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.Persistence;
import javax.persistence.Query;

import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.index.IndexWriter;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import com.mycompany.myapp.indexing.IndexMeta.Indexable;
import com.mycompany.myapp.indexing.IndexMeta.Storable;
import com.mycompany.myapp.persistence.BookArticle;

/**
 * Test case to demonstrate indexing using the IndexMeta annotation.
 */
public class IndexingTest {

  private final Log log = LogFactory.getLog(getClass());
  
  private final String INDEX_LOCATION = "/tmp/testindex";
  
  private EntityManagerFactory emf;
  private EntityManager em;
  private IndexWriter indexWriter;
  
  @Before
  public void setUp() throws Exception {
    emf = Persistence.createEntityManagerFactory("myapp");
    em = emf.createEntityManager();
    indexWriter = new IndexWriter(INDEX_LOCATION, new StandardAnalyzer(), true);
  }

  @After
  public void tearDown() throws Exception {
    em.close();
    emf.close();
    indexWriter.optimize();
    indexWriter.close();
  }

  /**
   * Builds a Lucene index using the List of beans returned by a Query.
   * @throws Exception if one is thrown.
   */
  @Test
  public void testBuildIndex() throws Exception {
    // get the indexing metadata
    Map<String,IndexMeta> indexingMetadata = 
      buildIndexingMetadata(BookArticle.class);
    // get the data
    Query q = em.createQuery("select ba from BookArticle ba");
    List<BookArticle> articles = 
      (List<BookArticle>) q.getResultList();
    for (BookArticle article : articles) {
      Document doc = new Document();
      for (String fieldName : indexingMetadata.keySet()) {
        String value = getFieldValue(article, fieldName);
        if (value == null) {
          continue;
        }
        IndexMeta indexMeta = indexingMetadata.get(fieldName);
        Store storeProperty = getStoreProperty(indexMeta.storable());
        Index indexProperty = getIndexProperty(indexMeta.indexable());
        org.apache.lucene.document.Field f = 
          new org.apache.lucene.document.Field(
          fieldName, value, storeProperty, indexProperty);
        doc.add(f);
      }
      indexWriter.addDocument(doc);
    }
  }

  /**
   * Reflectively gets the String value of the named bean property. If an 
   * exception occurs, the value returned is null.
   * @param obj the Article object.
   * @param fieldName the name of the bean property.
   * @return the String value of the bean property.
   */
  private String getFieldValue(Object obj, String fieldName) {
    try {
      return String.valueOf(BeanUtils.getProperty(obj, fieldName));
    } catch (Exception e) {
      return null;
    }
  }
  
  /**
   * Reflectively build a Map of bean property name to the annotated IndexMeta
   * properties. Climbs the object tree until the Object class is reached.
   * @param type the Article type to build the Index metadata for.
   * @return a Map of bean property name to the associated IndexMeta annotation.
   */
  private Map<String,IndexMeta> buildIndexingMetadata(Class<?> type) {
    Map<String,IndexMeta> indexingMetadata = 
      new HashMap<String,IndexMeta>();
    do {
      Field[] fields = type.getDeclaredFields();
      for (Field field : fields) {
        IndexMeta indexMeta = field.getAnnotation(IndexMeta.class);
        if (indexMeta == null) {
          // no annotation, no lucene field
          continue;
        }
        indexingMetadata.put(field.getName(), indexMeta);
      }
      type = type.getSuperclass();
    } while (type != Object.class);
    return indexingMetadata;
  }

  /**
   * Helper method to map the Field.Index value from IndexMeta.Indexable
   * @param indexable an instance of Indexable.
   * @return an instance of Field.Index.
   */
  private Index getIndexProperty(Indexable indexable) {
    switch(indexable) {
      case NO_NORMS:
        return Index.NO_NORMS;
      case TOKENIZED:
        return Index.TOKENIZED;
      case UN_TOKENIZED:
        return Index.UN_TOKENIZED;
      case NO:
      default:
        return Index.NO;
    }
  }

  /**
   * Helper method to return Field.Store value for IndexMeta.Storable.
   * @param storable an instance of Storable.
   * @return an instance of Field.Store.
   */
  private Store getStoreProperty(Storable storable) {
    switch(storable) {
      case COMPRESS:
        return Store.COMPRESS;
      case YES:
        return Store.YES;
      case NO:
      default:
        return Store.NO;
    }
  }
}

And of course, the obligatory screenshot of what this index looks like in Luke.

I hope this has been useful. I had read about annotations in the official annotation documentation and in the O'Reilly Java 1.5 Tiger - A Developer's Handbook by Brett McLaughlin and David Flanagan, but I could never come up with a use-case where I would need to build my own. This example may resonate a little better with Java developers who use ORMs and Lucene.

On a somewhat related note, check out Steve Yegge's blog post "Execution in the Kingdom of Nouns". Not only is it quite hilarious, it is also the clearest analogy I have seen that explains the differences between Object Oriented programming and functional programming. Why related? Well, we started with classes and interfaces. Java 1.5 gave us Enums and Annotations. Maybe it is time Java gave us Function objects as well, so we can use them directly when requirements dictate, rather than setting up anonymous holder classes to do the same thing.

4 comments:

  1. Hi Sujit,
    It is "out of box" thinking and I would really appreciate it. This articles means that If we have custom annotations we could easily split complex logic and have a clean, understandable and manageable(most important factor) code against a small overhead of learning it.
    If you could explain process of fetching annotation's data from bean to our data structure, would be of great help.

    ReplyDelete
  2. Hi Hemant, the annotations are extracted from the bean properties to the map in buildIndexingMetaData(), all I do is to start with the class passed in and climb the inheritance tree until I reach Object, and pull the annotations into a map keyed by field name, which I can later refer to when building the index.

    ReplyDelete
  3. Excellent article!

    I understand annotations but didn't find a need to create custom annotation in any of my projects. This article showed a very good usage of custom annotations -- I use both Lucene and ORM framework (JPA) so I was able to relate instantly.

    Another place where custom annotations can be helpful is in selecting certain methods to apply aspects (around advice for example): create custom annotation and define a point cut to select only those methods that have the custom annotation

    ReplyDelete
  4. Thanks Satish, and yes, custom annotations can be very useful for selectively applying aspects, as you pointed out. We have used a similar approach of annotating our controllers to "advise" our ad categorizer on "what kind" of page it is looking at.

    ReplyDelete

Comments are moderated to prevent spam.