Customize Wicket StringResource Loading for Entity classes

Problemstellung

Apache Wicket besitzt einen ausgefeilten Mechanismus zum Laden für Stringresourcen zur Internationalisierung (i18n) einer Anwendung. Dieser ist Komponenten-orientiert und findet Resourcen im Teilast der Komponentenhierarchie, den wicket-package.properties, der Superklassenhierarchie, und an globalen Orten für die Anwendung.

In einer Anwendung ist es aber durchaus sinnvoll, die Resourcen den Datenbank-Klassen (Entities) zuzuordnen, eine Property-Datei für jede Klasse, in der zum Beispiel Label, Titel und Description für ein Datenbankfeld stehen. Diese Resource-Dateien bleiben im Loading-Mechanismus von Wicket aber außen vor.

Da Wicket aber für die Erweiterung des Loading Möglichkeiten vorgesehen hat, ist dies keine Einschränkung, man muss nur wissen wie. Hierzu im Folgenden meine Erkenntnisse zur Implementierung eines eigenen ResourceLoaders, angeregt durch eine Seite im Wiki.

Lösungsansatz

Schon in zwei früheren Posts habe ich eine Version gepostet, die jedoch das Caching der Resourcen durch Wicket nicht berücksichtigte und daher fehlerhaft war. Statt dieser zwei Postings hier also heute die verbesserte Version.

Einhängen des Loaders

Der Loader muss in der init() Methode der Application-Klasse der Anwendung eingehängt werden:

    @Override
    protected void init() {
        super.init();
        ...
        getResourceSettings().getStringResourceLoaders().add(0, new EntityResourceLoader(projectGlobals));
    }

Versorgung mit Entityklassen

Ein Loader, der nicht zuständig ist oder nichts findet soll null zurückliefern, deswegen klinke ich den neuen Loader an vorderster Stelle ein, da er, wenn nicht zuständig, schnell terminiert und dann der restliche Mechanismus greift, der nach meiner Annahme aufwendiger ist.

Der Parameter projectGlobals implementiert EntityInspector, das alle Entity-Klassen ermittelt. Dies ist mittels ServiceLoader implementiert.

public interface EntityInspector {

    /**
     * Return a set of all entity classes used in the application. This
     * functionality is needed, cause we cannot reflect all classes in a package
     * at runtime.
     *
     * @return Set of entity classes.
     */
    public Collection<Class<? extends Entity>> getEntityClasses();
}

Gleich im Constructor wird damit eine Map als Hilfsstruktur befüllt:

    public EntityResourceLoader(EntityInspector entityInspector) {
        for (Class<? extends Entity> entClass : entityInspector.getEntityClasses()) {
            entityClasses.put(entClass.getSimpleName(), entClass);
        }
    }

Struktur des Loaders

Ein Loader, der nicht zuständig ist oder nichts findet soll null zurückliefern, deswegen klinke ich den neuen Loader an vorderster Stelle ein, da er, wenn nicht zuständig, schnell terminiert und dann der restliche Mechanismus greift, der nach meiner Annahme aufwendiger ist.

Zuständig fühlt sich der EntityResourceLoader, der key des Aufrufs eine bestimmte Struktur hat, die die Entity-Klasse mit angibt. Ein Beispiel: Entity.Book.mykey muss der key lauten, wenn der Entity’s Klasse getSimpleName() Book liefert und der eigentliche key myKey heißt.

Einstieg und Header

Der Vollständigkeit halber habe ich das JavaDoc mal mit drinnen gelassen.

/**
 * Loader to load String resources from Entity Classes Resource files.
 * <p>The implementation caches a BundleStringResourceLoader for each entity class and
 * delegates to it, if the key is prefixed in a way, that is associated with an entity class.</p>
 * <h4>Example for prefix construction:</h4>
 * <p>The association is made by the form {@code Entity.Book.mykey} of the key if the
 * Entity's class getSimpleName() is {@code Book} and the name of the key is {@code myKey}.</p>
 * <p>The usage in the ResourceModel should build the Key with the static convenience functions
 * like {@link #createEntityClassKey(java.lang.Class, java.lang.String) } and {@link #createEntityClassKey(org.apache.wicket.model.IModel, java.lang.String) }
 * and so on, so that this convention is automatically taken in care.
 * If you do not use this functions take a look at the constants {@link #CLASSNAME_STARTER}
 * and {@link #CLASSNAME_KEY_SEPARATOR}.</p>
 *
 * Implemented according to <a href"https://cwiki.apache.org/WICKET/creating-a-custom-resource-locator.html">https://cwiki.apache.org/WICKET/creating-a-custom-resource-locator.html</a>.
 *
 * @author Dieter Tremel <tremel@tremel-computer.de>
 */
public class EntityResourceLoader implements IStringResourceLoader {

    private static final Logger logger = Logger.getLogger(EntityResourceLoader.class.getName());
    /**
     * Common Prefix for all entity class Strings. Used in conjunction with {@link #CLASSNAME_KEY_SEPARATOR},
     * and therefore use {@link #CLASSNAME_STARTER}.
     */
    public static final String CLASSNAME_PREFIX = "Entity";
    /**
     * The keys for Entityclasses are EntityClassName + CLASSNAME_KEY_SEPARATOR + key
     * @see #createEntityClassKey(java.lang.Class, java.lang.String)
     */
    public static final String CLASSNAME_KEY_SEPARATOR = ".";
    /**
     * Common Prefix for all entity class Strings.
     */
    public static final String CLASSNAME_STARTER = "Entity" + CLASSNAME_KEY_SEPARATOR;

Caching von BundleStringResourceLoadern

Der Loader schlägt die Resourcen nicht selber nach, sondern delegiert an einen für die jeweilige Entity-Klasse zuständigen BundleStringResourceLoader, der diese Arbeit prima macht. Die BundleStringResourceLoader stehen in einem mit einer Map realisierten Cache.

    // Entity class helper structures
    private Map<String, Class<? extends Entity>> entityClasses = new HashMap<>();
    private Map<Class<? extends Entity>, BundleStringResourceLoader> entityLoaderMap = new HashMap<>();

    public EntityResourceLoader(EntityInspector entityInspector) {
        for (Class<? extends Entity> entClass : entityInspector.getEntityClasses()) {
            entityClasses.put(entClass.getSimpleName(), entClass);
        }
    }

    private BundleStringResourceLoader getEntityLoader(Class<? extends Entity> entityClass) {
        BundleStringResourceLoader entityLoader = entityLoaderMap.get(entityClass);
        if (entityLoader == null) {
            entityLoader = new BundleStringResourceLoader(ResourceHelper.classBundleBaseName(entityClass));
            entityLoaderMap.put(entityClass, entityLoader);
        }
        return entityLoader;
    }

Extrahieren der Klasse und Nachschlagen

Implementiert ist nur die Loader-Methode, die eine Komponenten-Instanz beinhaltet. Nach dem derzeitigen Stand macht das nichts, da wir bei unserer Anwendung immer eine Komponenten-Instanz haben und das Model bei Bedarf mit wrapOnAssignment() einpacken können.

Wenn das einleitende Präfix nicht gefunden wird, steigt der Loader sofort aus. Ansonsten extrahiert er die Klasse und delegiert an den zuständigen BundleStringResourceLoader. Nachrichten in den Logs informieren über Erfolg oder Fehlerfälle.

    @Override
    public String loadStringResource(Class<?> clazz, String key, Locale locale, String style, String variation) {
        return null;
    }

    @Override
    public String loadStringResource(Component component, String key, Locale locale, String style, String variation) {
        if (!key.startsWith(CLASSNAME_STARTER)) {
            // this IStringResourceLoader is only responsible if the key is prefixed the right way.
            // If not, let the others do the work immediately.
            return null;
        } else {
            logger.log(Level.FINER, "{0} search resource {1} {2}", new Object[]{this.getClass().getSimpleName(), component, key});
            // strip the starter from the key
            key = key.substring(CLASSNAME_STARTER.length());
        }
        Class<? extends Entity> entityClass = null;
        String entityName;
        String subkey = null;
        int separatPos = key.indexOf(CLASSNAME_KEY_SEPARATOR);
        if (separatPos == -1) {
            logger.log(Level.WARNING, "{0}: found Prefix \"{1}\" but no separated class name in following key {2}.", new Object[]{this.getClass().getSimpleName(), CLASSNAME_STARTER, key});
            return null;
        } else {
            entityName = key.substring(0, separatPos);
            subkey = key.substring(separatPos + 1);
            logger.log(Level.FINEST, "{0} found Entity Class key {1}.{2}", new Object[]{this.getClass().getSimpleName(), entityName, subkey});
            entityClass = entityClasses.get(entityName);
            if (entityClass == null) {
                logger.log(Level.WARNING, "{0} could not derive Entity Class from key {1}.{2}.", new Object[]{this.getClass().getSimpleName(), entityName, subkey});
                return null;
            } else {
                String loadedResource = getEntityLoader(entityClass).loadStringResource(component, subkey, locale, style, variation);
                logger.log(Level.FINE, "{0} found {2}({1}) {3}={4}", new Object[]{this.getClass().getSimpleName(), entityClass.getSimpleName(), component, key, loadedResource});
                return loadedResource;
            }
        }
    }

Convenience-Funktionen zum Aufbau von Keys und ResourceModel

Der Mechnismus funktioniert nur, wenn die Keys in den ResourceModeln auch richtig aufgebaut sind. Deswegen gibt es ein paar Convenience-Funktionen, die Schlüssel oder schon fertige Models erzeugen.

    /**
     * Construct a special key for a entity class resource.
     * The result will be a concatenation of the simple name of the entity class
     * and {@link #CLASSNAME_KEY_SEPARATOR} and the given key.
     *
     * @param entityClassModel Model with entity class.
     * @param key Key String without prefix.
     * @return Concatenated entity class key.
     */
    public static String createEntityClassKey(IModel<Class<? extends Entity>> entityClassModel, String key) {
        return createEntityClassKey(entityClassModel.getObject(), key);
    }

    /**
     * Construct a special key for a entity class resource.
     * The result will be a concatenation of the simple name of the entity class
     * and {@link #CLASSNAME_KEY_SEPARATOR} and the given key.
     *
     * @param entityClass Entity class.
     * @param key Key String without prefix.
     * @return Concatenated entity class key.
     */
    public static String createEntityClassKey(Class<? extends Entity> entityClass, String key) {
        return CLASSNAME_STARTER + entityClass.getSimpleName() + CLASSNAME_KEY_SEPARATOR + key;
    }

    /**
     * Convenience Function to create a ResourceModel for a label for an entity class.
     *
     * @param entityClass Entity class.
     * @param key Key String without prefix.
     * @return Resource Model for entity class labeling part identified by key.
     */
    public static ResourceModel createEntityResourceModel(Class<? extends Entity> entityClass, String key) {
        return new ResourceModel(createEntityClassKey(entityClass, key));
    }

    /**
     * Convenience Function to create a ResourceModel for a label for an entity class.
     *
     * @param entityClassModel Model with entity class.
     * @return Resource Model for entity class label.
     */
    public static ResourceModel createEntityLabelResourceModel(IModel<Class<? extends Entity>> entityClassModel) {
        return new ResourceModel(createEntityClassKey(entityClassModel, UIResourceSelector.LABEL.getResourceKey()));
    }

    /**
     * Convenience Function to create a ResourceModel for a title for an entity class.
     *
     * @param entityClassModel Model with entity class.
     * @return Resource Model for entity class title.
     */
    public static ResourceModel createEntityTitleResourceModel(IModel<Class<? extends Entity>> entityClassModel) {
        return new ResourceModel(createEntityClassKey(entityClassModel, UIResourceSelector.TITLE.getResourceKey()));
    }

    /**
     * Convenience Function to create a label for a entity attribute.
     *
     * @param <T> Type of entity.
     * @param epdModel Model with epd of attribute.
     * @return Resource Model for label for an entity attribute.
     */
    public static <T extends Entity> ResourceModel createEpdLabelResourceModel(IModel<EntityPropertyDescriptor<T>> epdModel) {
        EntityPropertyDescriptor<T> epd = epdModel.getObject();
        return new ResourceModel(createEntityClassKey(epd.getEntityClass(), epd.getUIResourceKey(UIResourceSelector.LABEL)));
    }

Verwendung des Loaders

Das ist der angenehme und elegante Teil. Zur Verwendung muss nur ein ResourceModel mit dem richtigen Key erzeugt werden. Hier helfen wieder die Convenience-Funktionen:

link.add(new Label("caption", EntityResourceLoader.createEntityLabelResourceModel(entityClassModel)));