Persistenz für den Feedreader

Heute möchte ich mich mit Persistenz beschäftigen. Als Ausgangspunkt dient mir der Feedreader, der im letzten Teil etwas dynamischer wurde. Ich werde JPA mit Hibernate einsetzen, um die Daten, die aus den Newsfeeds ausgelesen werden, in einer Datenbank zu speichern und von dort auch wieder auszulesen. Außerdem werden wir auch hier wieder mit Wicket in Berührung kommen, denn der Code kann und sollte etwas verbessert werden, wenn wir unsere Daten aus einer Datenbank holen.

Vorarbeit

Bevor wir loslegen, müssen wir erstmal die benötigten Bibliotheken herunterladen. Das machen wir natürlich nicht per Hand, sondern wie bisher auch mit Maven. In unserer pom.xml ergänzen wir folgende Abhängigkeiten:

		<dependency>
			<groupId>org.hibernate</groupId>
			<artifactId>hibernate-annotations</artifactId>
			<version>3.3.1.GA</version>
		</dependency>
		<dependency>
			<groupId>org.hibernate</groupId>
			<artifactId>hibernate-commons-annotations</artifactId>
			<version>3.3.0.ga</version>
		</dependency>
		<dependency>
			<groupId>org.hibernate</groupId>
			<artifactId>hibernate-entitymanager</artifactId>
			<version>3.3.2.GA</version>
		</dependency>
		<dependency>
			<groupId>org.hibernate</groupId>
			<artifactId>hibernate</artifactId>
			<version>3.2.6.ga</version>
			<exclusions>
				<exclusion>
					<groupId>javax.transaction</groupId>
					<artifactId>jta</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.apache.geronimo.specs</groupId>
			<artifactId>geronimo-jta_1.1_spec</artifactId>
			<version>1.1</version>
		</dependency>
		<dependency>
			<groupId>hsqldb</groupId>
			<artifactId>hsqldb</artifactId>
			<version>1.8.0.7</version>
		</dependency>

Die ersten beiden Abhängigkeiten brauchen wir für die (JPA-)Annotations, die wir verwenden wollen. Mit dem Entity Manager hatte ich meine Probleme, da ich erst nicht wusste, dass ich ihn brauch und dann gab es auch noch ein Problem mit der Version der Annotation-Library, denn mit der 3.3.0 will der Manager mit Version 3.3.2 offenbar nicht zusammenarbeiten. Dann folgt Hibernate selbst, aber eine der Abhängigkeiten von Hibernate klammern wir aus, da sie von Sun nicht freigegeben ist und Maven uns nur den Hinweis bringen würden, dass wir sie manuell runterladen müssten. Dazu sind wir aber zu faul (also zumindest ich bin es) und deswegen holen wir das Ding aus dem Geronimo-Projekt. In diesem stehen viele Enterprise APIs unter der Apache License zur Verfügung, was uns etwas die Arbeit erleichtert. Schließlich besorgen wir uns noch die HSQLDB.

$ mvn install
[...]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4 minutes 54 seconds
[INFO] Finished at: Fri Oct 03 12:04:18 CEST 2008
[INFO] Final Memory: 10M/18M
[INFO] ------------------------------------------------------------------------
$ mvn eclipse:eclipse
[...]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSFUL
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1 minute 42 seconds
[INFO] Finished at: Fri Oct 03 12:30:47 CEST 2008
[INFO] Final Memory: 6M/12M
[INFO] ------------------------------------------------------------------------

Nun wollen wir erstmal die Datenbank zum Laufen bekommen. HSQLDB ist eine kleine leichte Datenbank, die komplett in Java geschrieben wurde und die Daten (zumeist) komplett im Speicher behält. Für geringe Datenmengen oder Tests eignet sie sich damit sehr gut. Sie kann einerseits wie ein Datenbank-Server betrieben werden, der an einem Port lauscht, oder direkt in dem Programm angesprochen werden, von dem die Datenbank verwendet werden soll.

In meinem Projekt-Verzeichnis erstelle ich einen Unterordner für die HSQLDB und kopiere anschließend das Jar dort hinein. Der Pfad zum lokalen Maven-Repository wird bei euch sicher anders sein. Dann starte ich erstmals den Server:

$ cd /cygdrive/c/myprojects
$ mkdir hsqldb
$ cp /cygdrive/c/Users/ex-ratt/.m2/repository/hsqldb/hsqldb/1.8.0.7/hsqldb-1.8.0.7.jar hsqldb
$ cd hsqldb
$ java -cp hsqldb-1.8.0.7.jar org.hsqldb.Server
...

Nun läuft der Datenbank-Server. Da wir keine weiteren Parameter übergeben haben, wurde die Datenbank „test“ genannt, zu sehen an den drei Dateien, die im hsqldb-Verzeichnis erstellt wurden (test.lck, test.log und test.properties). Im Log finden wir eine SQL-Anweisung, die einen User mit Namen „sa“ und ohne Passwort anlegt und ihm Administrator-Rechte gibt.

Konfiguration

Damit Hibernate weiß, wie es auf die Datenbank zugreifen soll, müssen wir noch eine Konfiguration erstellen. Wir halten uns dabei an den JPA-Standard. Eine Datei namens persistence.xml beinhaltet diese Konfiguration und muss im META-INF-Verzeichnis liegen, das wiederum im Classpath zu finden sein muss. Damit hatte ich meine lieben Probleme. Damit das Starten aus Eclipse heraus funktioniert, legt man im Verzeichnis WEB-INF ein Verzeichnis classes an und da hinein kommt META-INF mitsamt der persistence.xml. In meinem Fall sähe der komplette Pfad so aus: C:/myprojects/wicket/src/main/webapp/WEB-INF/classes/META-INF/persistence.xml. Will man Jetty über Maven starten, so kann man META-INF unter target/classes ablegen.

<persistence xmlns="http://java.sun.com/xml/ns/persistence"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_1_0.xsd"
	version="1.0">
	<persistence-unit name="feed">
		<provider>org.hibernate.ejb.HibernatePersistence</provider>
		<class>net.rattlab.model.Game</class>
		<class>net.rattlab.model.News</class>
		<properties>
			<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />
			<property name="hibernate.archive.autodetection" value="class" />
			<property name="hibernate.hbm2ddl.auto" value="create-drop" />
			
			<property name="hibernate.connection.driver_class" value="org.hsqldb.jdbcDriver" />
			<property name="hibernate.connection.url" value="jdbc:hsqldb:hsql://localhost" />
			<property name="hibernate.connection.username" value="sa" />
			<property name="hibernate.connection.passwords" value="" />
			
			<property name="hibernate.cache.provider_class" value="org.hibernate.cache.EhCacheProvider" />
		</properties>
	</persistence-unit>
</persistence>

Die erste wichtige Information ist der Provider. Normalerweise findet man JPA im Application-Server-Umfeld und dieser stellt eine Implementierung bereit. Wir aber haben keinen solchen Server, sondern nutzen direkt Hibernate und müssen das angeben. Dann folgen die nötigen Parameter wie z.B. Nutzername, Passwort und URL. Eigentlich sollte man die persistenten Klassen nicht extra angeben müssen, aber das funktionierte bei mir leider nicht wie gewünscht.

Die Klassen selber werden mit Annotationen versehen, die beschreiben, wie die Variablen der Objekte in der Datenbank abgebildet werden sollen. Ursprünglich hat man das bei Hibernate mit XML-Dateien gemacht, JPA bietet nun aber die Annotationen an (die Hibernate aber auch selber anbietet bzw. erweitert). Werfen wir also einen Blick auf unsere beiden persistenten Klassen:

package net.rattlab.model;

import java.io.Serializable;
import java.util.Date;

import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Lob;
import javax.persistence.ManyToOne;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;

@Entity
@NamedQueries({
	@NamedQuery(name = "findAllNews", query = "from News order by date desc"),
	@NamedQuery(name = "countNews", query = "select count(*) from News")
})
public class News implements Serializable {

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private long id;
	
	@ManyToOne(cascade = CascadeType.ALL)
	private Game game;
	
	private String page;
	
	private String url;
	
	private String title;
	
	@Lob
	private String description;
	
	@Temporal(value = TemporalType.TIMESTAMP)
	private Date date;
	
	public News() {}
	
	public News(Game game, String page, String url, String title, String description, Date date) {
		this.game = game;
		this.page = page;
		this.url = url;
		this.title = title;
		this.description = description;
		this.date = date;
	}
	
	public long getId() {
		return id;
	}
	
	public Game getGame() {
		return game;
	}

	public String getPage() {
		return page;
	}
	
	public String getUrl() {
		return url;
	}
	
	public String getTitle() {
		return title;
	}
	
	public String getDescription() {
		return description;
	}
	
	public Date getDate() {
		return date;
	}
	
	public void setId(long id) {
		this.id = id;
	}

	public void setGame(Game game) {
		this.game = game;
	}
	
	public void setPage(String page) {
		this.page = page;
	}
	
	public void setUrl(String url) {
		this.url = url;
	}
	
	public void setTitle(String title) {
		this.title = title;
	}
	
	public void setDescription(String description) {
		this.description = description;
	}
	
	public void setDate(Date date) {
		this.date = date;
	}
}

Mit @Entity wird die Klasse markiert, damit ihre Objekte persistent gemacht werden können. Standardmäßig geht die JPA-Implementierung davon aus, dass jedes Attribut in die Datenbank abgebildet werden soll. Da außerdem je nach Java-Datentyp bestimmte Datenbank-Datentypen automatisch zugeordnet werden, können wir uns an vielen Variablen die Annotationen sparen. Damit ein Datenbankeintrag eindeutig referenziert werden kann, braucht jede Zeile eine ID – diese müssen wir mit in unsere Klasse einbauen und mit @Id markieren, @GeneratedValue(strategy = GenerationType.AUTO) zeigt dabei an, dass der Wert generiert werden soll. Zu Game gibt es eine Beziehung, so kann jedes Spiel mehrere News haben, aber jede News ist genau einem Spiel zugeordnet. Eine Fremdschlüsselspalte wird automatisch angelegt.

package net.rattlab.model;

import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.NamedQuery;

import org.hibernate.annotations.CollectionOfElements;
import org.hibernate.annotations.MapKey;

@Entity
@NamedQuery(name = "findAllGames", query = "from Game")
public class Game implements Serializable {
	
	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private long id;
	
	private String name;
	
	@CollectionOfElements
	@JoinTable(name = "feed", joinColumns = @JoinColumn(name = "game"))
	@Column(name = "url")
	@MapKey(columns = @Column(name = "site"))
	private Map<String, String> feeds;
	
	public Game() {}
	
	public Game(String name) {
		setName(name);
		feeds = new HashMap<String, String>();
	}
	
	public void addFeed(String page, String url) {
		feeds.put(page, url);
	}
	
	public Map<String, String> getFeeds() {
		return feeds;
	}
	
	public long getId() {
		return id;
	}
	
	public String getName() {
		return name;
	}
	
	public void setId(long id) {
		this.id = id;
	}

	public void setName(String name) {
		this.name = name;
	}
}

Hier benötigt die Map, die die Feed-URLs mit ihren Namen speichert, besondere Aufmerksamkeit. Mit JPA allein ist es nicht möglich, primitive Datentypen oder Strings in einer Collection oder Map zu halten, weshalb wir hier direkt auf Hibernate-Annotations zurückgreifen müssen. Für die Map muss eine eigene Tabelle angelegt werden, deren Spaltennamen wir vorgeben, sonst würden diese KEY und VALUE heißen.

Da wir für Tabellen- und Spaltennamen kaum Vorgaben gemacht haben, werden sie einfach aus den Namen der Attribute abgeleitet. Die Tabellen heißen also Game und News, die Spalten name, page usw. Bei Galileo Computing findet ihr eine gute Übersicht über alle Entity-Annotationen.

Lesen und Schreiben auf der Datenbank

Jetzt ersetzen wir erstmal ganz plump die beiden Listen in der WicketApplication und fügen Datenbankzugriffe hinzu. Hier werden NamedQuerys erstellt, die wir vorher mit @NamedQuery in den Entities angegeben haben. Auf die manuelle Sortierung der News können wir jetzt verzichten, da diese beim Datenbankzugriff schon sortiert ausgelesen werden (siehe entsprechenden benannten Query).

package net.rattlab.wicket;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map;

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

import net.rattlab.model.Game;
import net.rattlab.model.News;

import org.apache.wicket.Application;
import org.apache.wicket.protocol.http.WebApplication;

import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.io.FeedException;
import com.sun.syndication.io.SyndFeedInput;
import com.sun.syndication.io.XmlReader;

/**
 * Application object for your web application. If you want to run this application without deploying, run the Start class.
 * 
 * @see net.rattlab.wicket.Start#main(String[])
 */
public class WicketApplication extends WebApplication {
	
	private EntityManagerFactory entityManagerFactory;
	
	/**
	 * Constructor
	 */
	public WicketApplication() {
		entityManagerFactory = Persistence.createEntityManagerFactory("feed");
		
		EntityManager entityManager = entityManagerFactory.createEntityManager();
		createGames(entityManager);
		createNews(entityManager);
		entityManager.close();
	}
	
	/**
	 * Creates the games and news feeds.
	 */
	protected void createGames(EntityManager entityManager) {
		EntityTransaction transaction = entityManager.getTransaction();
		transaction.begin();
		
		Game starcraft2 = new Game("StarCraft 2");
		Game diablo3 = new Game("Diablo 3");
		
		starcraft2.addFeed("inStarCraft", "http://starcraft2.ingame.de/feed.php?type=RSS1.0§ion=345");
		starcraft2.addFeed("StarCraft 2 Mecca", "http://forum.gamersunity.de/external.php?type=rss2&forumids=195");
		diablo3.addFeed("inDiablo", "http://diablo3.ingame.de/feed.php?type=RSS1.0");
		diablo3.addFeed("Diablo 3 Source", "http://diablo3.4players.de/rss_feed.xml");
		
		entityManager.persist(starcraft2);
		entityManager.persist(diablo3);
		
		transaction.commit();
	}
	
	/**
	 * Creates the news from the news feeds.
	 */
	protected void createNews(EntityManager entityManager) {
		EntityTransaction transaction = entityManager.getTransaction();
		transaction.begin();
		SyndFeedInput input = new SyndFeedInput();
		for (Game game : getGames(entityManager)) {
			Map<String, String> feeds = game.getFeeds();
			for (String page : feeds.keySet()) {
				String feedUrl = feeds.get(page);
				try {
					URL url = new URL(feedUrl);
					SyndFeed feed = input.build(new XmlReader(url));
					for (Object o : feed.getEntries()) {
						// rome 0.9 does not support generics
						SyndEntry entry = (SyndEntry) o;
						entityManager.persist(new News(game, page, entry.getLink(), entry.getTitle(),
								entry.getDescription().getValue(), entry.getPublishedDate()));
					}
				}
				catch (MalformedURLException exc) {}
				catch (IllegalArgumentException e) {}
				catch (FeedException e) {}
				catch (IOException e) {}
			}
		}
		transaction.commit();
	}
	
	@Override
	protected void init() {
		super.init();
		getMarkupSettings().setStripWicketTags(true);
	}
	
	public static WicketApplication get() {
		return (WicketApplication) Application.get();
	}
	
	/**
	 * @see org.apache.wicket.Application#getHomePage()
	 */
	public Class<HomePage> getHomePage() {
		return HomePage.class;
	}
	
	@SuppressWarnings("unchecked")
	public List<Game> getGames() {
		EntityManager entityManager = entityManagerFactory.createEntityManager();
		List<Game> games = entityManager.createNamedQuery("findAllGames").getResultList();
		entityManager.close();
		return games;
	}
	
	@SuppressWarnings("unchecked")
	public List<Game> getGames(EntityManager entityManager) {
		return entityManager.createNamedQuery("findAllGames").getResultList();
	}
	
	@SuppressWarnings("unchecked")
	public List<News> getNews() {
		EntityManager entityManager = entityManagerFactory.createEntityManager();
		List<News> news = entityManager.createNamedQuery("findAllNews").getResultList();
		entityManager.close();
		return news;
	}
}

Das ist jetzt zugegebenermaßen nicht die schönste Lösung, aber so können wir erstmal testen, ob der Kram überhaupt funktioniert. Die nötigen Tabellen in der Datenbank werden übrigens automatisch erzeugt. In der persistence.xml gibt es eine Zeile <property name="hibernate.hbm2ddl.auto" value="create-drop" />, die beim Starten unserer Anwendung aus den bestehenden Annotationen ein Datenbankschema erstellt und beim Beenden alles wieder löscht. Ideal ist sowas für automatisierte Tests, da so immer wieder auf einer frischen Datenbank gearbeitet wird, womit die Ergebnisse reproduzierbar bleiben.

Das scheinbar willkürliche Erstellen von EntityManager und Starten von EntityTransaction wollen wir nun, nachdem wir wissen, dass unsere Applikation funktioniert, etwas schicker gestalten. Optimal wäre es, wenn wir uns nicht darum kümmern müssten, wann ein neuer Manager benötigt oder eine neue Transaktion gestartet werden muss. Die Hibernate-Dokumentation beschreibt ein Open Session in View Pattern, bei dem für jeden Request ein neuer Manager samt Transaktion gestartet wird, die am Ende des Requests wieder geschlossen werden. Das bietet sich für Webanwendungen natürlich an.

Allerdings können wir das Beispiel, wie es in der Dokumentation angegeben ist, nicht eins zu eins übernehmen, da wir mit JPA arbeiten und nicht direkt mit Session oder SessionFactory von Hibernate. Außerdem wollen wir auch keinen ServletFilter nutzen, sondern direkt in dem von uns benutzten Web-Framework ansetzen. Bei Wicket ist die ideale Stelle dafür der WebRequestCycle. Für jede Anfrage wird ein neues solches Objekt erzeugt und es hat Methoden, die angesprochen werden, wenn die Anfrage beginnt (onBeginRequest) und wenn sie aufhört (onEndRequest).

package net.rattlab.wicket;

import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;

import org.apache.wicket.Page;
import org.apache.wicket.Response;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.protocol.http.WebRequest;
import org.apache.wicket.protocol.http.WebRequestCycle;

import persistence.JpaUtil;

public class JpaRequestCycle extends WebRequestCycle {

	public JpaRequestCycle(WebApplication application, WebRequest request, Response response) {
		super(application, request, response);
	}

	@Override
	protected void onBeginRequest() {
		super.onBeginRequest();
		EntityManager manager = JpaUtil.getEntityManagerFactory().createEntityManager();
		EntityTransaction transaction = manager.getTransaction();
		transaction.begin();
		JpaUtil.setCurrentEntityManager(manager);
	}
	
	@Override
	protected void onEndRequest() {
		super.onEndRequest();
		EntityManager manager = JpaUtil.getCurrentEntityManager();
		EntityTransaction transaction = manager.getTransaction();
		if (transaction.isActive()) {
			transaction.commit();
		}
		if (manager.isOpen()) {
			manager.close();
		}
	}
	
	@Override
	public Page onRuntimeException(Page page, RuntimeException e) {
		EntityManager manager = JpaUtil.getCurrentEntityManager();
		EntityTransaction transaction = manager.getTransaction();
		if (transaction.isActive()) {
			transaction.rollback();
		}
		if (manager.isOpen()) {
			manager.close();
		}
		return super.onRuntimeException(page, e);
	}
}

Wir nutzen hierbei eine von uns geschriebene Klasse JpaUtil, über die wir später auf den EntityManager zugreifen. Dieser muss für die Dauer des Requests irgendwo gespeichert werden und dabei müssen wir noch eines beachten: Parallelität. EntityManager ist nicht threadsicher, darf also nur von einem Thread zur gleichen Zeit benutzt werden, was auch überhaupt der Grund ist, wieso wir das Open Session in View Pattern einsetzen wollen. Dafür nutzen wir im JpaUtil eine Variable, die lokal für jeden Thread engelegt wird, ThreadLocal. Darin speichern wir unseren Manager und nun hat jeder Thread seinen eigenen und somit auch jeder Request, denn ein Request wird von genau einem Thread ausgeführt (und jeder Thread hat zu einem Zeitpunkt maximal einen Request).

package net.rattlab.persistence;

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

public class JpaUtil {
	
	private static final EntityManagerFactory entityManagerFactory = Persistence.createEntityManagerFactory("feed");
	private static final ThreadLocal<EntityManager> entityManager = new ThreadLocal<EntityManager>();
	
	public static EntityManagerFactory getEntityManagerFactory() {
		return entityManagerFactory;
	}
	
	public static EntityManager getCurrentEntityManager() {
		return entityManager.get();
	}
	
	public static void setCurrentEntityManager(EntityManager manager) {
		entityManager.set(manager);
	}
}

Schließlich müssen wir noch unsere WicketApplication dahingehend umbauen, dass unser neuer RequestCycle auch tatsächlich benutzt wird und dass wir uns den EntityManager vom JpaUtil holen. Außerdem müssen wir uns einmal außerhalb des Anfragezyklus einen Manager samt Transaktion besorgen, da das Füllen der Daten aus den Newsfeeds nicht während eines Requests passiert, sondern beim Starten des Servers.

package net.rattlab.wicket;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import java.util.Map;

import javax.persistence.EntityManager;
import javax.persistence.EntityTransaction;

import net.rattlab.model.Game;
import net.rattlab.model.News;

import org.apache.wicket.Application;
import org.apache.wicket.Request;
import org.apache.wicket.RequestCycle;
import org.apache.wicket.Response;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.protocol.http.WebRequest;

import persistence.JpaUtil;

import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.io.FeedException;
import com.sun.syndication.io.SyndFeedInput;
import com.sun.syndication.io.XmlReader;

/**
 * Application object for your web application. If you want to run this application without deploying, run the Start class.
 * 
 * @see net.rattlab.wicket.Start#main(String[])
 */
public class WicketApplication extends WebApplication {
	
	/**
	 * Constructor
	 */
	public WicketApplication() {
		try {
			EntityManager manager = JpaUtil.getEntityManagerFactory().createEntityManager();
			EntityTransaction transaction = manager.getTransaction();
			transaction.begin();
			JpaUtil.setCurrentEntityManager(manager);
			
			createGames();
			createNews();
			
			transaction.commit();
		} catch (RuntimeException exc) {
			JpaUtil.getCurrentEntityManager().getTransaction().rollback();
			throw exc;
		} finally {
			JpaUtil.getCurrentEntityManager().close();
		}
	}
	
	/**
	 * Creates the games and news feeds.
	 */
	protected void createGames() {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		
		Game starcraft2 = new Game("StarCraft 2");
		Game diablo3 = new Game("Diablo 3");
		
		starcraft2.addFeed("inStarCraft", "http://starcraft2.ingame.de/feed.php?type=RSS1.0§ion=345");
		starcraft2.addFeed("StarCraft 2 Mecca", "http://forum.gamersunity.de/external.php?type=rss2&forumids=195");
		diablo3.addFeed("inDiablo", "http://diablo3.ingame.de/feed.php?type=RSS1.0");
		diablo3.addFeed("Diablo 3 Source", "http://diablo3.4players.de/rss_feed.xml");
		
		entityManager.persist(starcraft2);
		entityManager.persist(diablo3);
	}
	
	/**
	 * Creates the news from the news feeds.
	 */
	protected void createNews() {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		SyndFeedInput input = new SyndFeedInput();
		for (Game game : getGames()) {
			Map<String, String> feeds = game.getFeeds();
			for (String page : feeds.keySet()) {
				String feedUrl = feeds.get(page);
				try {
					URL url = new URL(feedUrl);
					SyndFeed feed = input.build(new XmlReader(url));
					for (Object o : feed.getEntries()) {
						// rome 0.9 does not support generics
						SyndEntry entry = (SyndEntry) o;
						entityManager.persist(new News(game, page, entry.getLink(), entry.getTitle(),
								entry.getDescription().getValue(), entry.getPublishedDate()));
					}
				}
				catch (MalformedURLException exc) {}
				catch (IllegalArgumentException e) {}
				catch (FeedException e) {}
				catch (IOException e) {}
			}
		}
	}
	
	@Override
	protected void init() {
		super.init();
		getMarkupSettings().setStripWicketTags(true);
	}
	
	public static WicketApplication get() {
		return (WicketApplication) Application.get();
	}
	
	/**
	 * @see org.apache.wicket.Application#getHomePage()
	 */
	public Class<HomePage> getHomePage() {
		return HomePage.class;
	}
	
	@Override
	public RequestCycle newRequestCycle(Request request, Response response) {
		return new JpaRequestCycle(this, (WebRequest) request, response);
	}
	
	@SuppressWarnings("unchecked")
	public List<Game> getGames() {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		return entityManager.createNamedQuery("findAllGames").getResultList();
	}
	
	@SuppressWarnings("unchecked")
	public List<News> getNews() {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		return entityManager.createNamedQuery("findAllNews").getResultList();
	}
}

Speicher schonen

Noch sind wir aber nicht an unserem Ziel angelangt. Aktuell werden nämlich alle News zusammen mit der (letzten Version der) Seite in der Session gespeichert – das hängt damit zusammen, dass Wicket, da unsere Applikation zustandsorientiert ist, den aktuellen Status speichert. Das wollen wir aber nicht, da das in diesem Fall einen recht sinnfreien Ressourcenverbrauch darstellt. Dafür kann man ein LoadableDetachableModel nutzen, das am Ende eines Requests sein Objekt abkoppelt und es bei der nächsten Anfrage wieder lädt, so dass die Session nicht damit belastet wird.

Die simple Nutzung eines LoadableDetachableModels allein würde uns bei einem anderen Problem aber nicht helfen. Es würden nämlich alle News auf einmal geladen, obwohl nur eine bestimmte Anzahl pro Seite angezeigt werden – sinnvoller wäre es, wenn nur genau die News aus der Datenbank geholt würden, die auch angezeigt werden sollen. Dafür gibt es eine Komponente DataView, der ein IDataProvider statt eines Models übergeben wird. Dieser holt bestimmte Datensätze und verpackt sie in Models. Da die Daten dann bereits im Speicher vorhanden sind, bringt uns auch ein LoadableDetachableModel nur bedingt etwas, denn das würde das Objekt anhand der ID nochmal laden, weshalb wir uns ein eigenes Model bauen.

Werfen wir zuerst einen Blick auf den NewsDataProvider. An der Methode iterator(int first, int count) ist zu erkennen, dass tatsächlich nur eine bestimmte Anzahl von News angefordert werden. Sie gibt einen Iterator zurück, den die DataView nutzt, um die einzelnen News zu holen. Damit wird dann die Methode model(News news) aufgerufen, die jede News in ein eigenes Model steckt.

public class NewsDataProvider implements IDataProvider<News> {

	@Override
	@SuppressWarnings("unchecked")
	public Iterator<? extends News> iterator(int first, int count) {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		return entityManager.createNamedQuery("findAllNews")
				.setFirstResult(first)
				.setMaxResults(count)
				.getResultList()
				.iterator();
	}

	@Override
	public IModel model(News news) {
		return new NewsModel(news);
	}

	@Override
	public int size() {
		EntityManager entityManager = JpaUtil.getCurrentEntityManager();
		return ((Long) entityManager.createNamedQuery("countNews").getSingleResult()).intValue();
	}

	@Override
	public void detach() {}
}

Das NewsModel ist nicht von LoadableDetachableModel abgeleitet, da wir keinen schreibenden Zugriff auf das zu speichernde Objekt hätten, sondern dieses immer über load() geladen werden müsste. Dennoch ist die Model-Klasse recht einfach und übersichtlich. Es speichert die ID und die News selbst, beim Speichern in der Session wird die News aber nicht serialisiert. Werden die Daten wieder aus der Session geholt (z.B. durch ein Neu Laden der Seite), so wird die News anhand der ID erneut aus der Datenbank geladen. Eine allgemein gehaltenere Version eines solchen Models beschreibt Igor Vaynberg, einer der Wicket-Mitentwickler, im Blogeintrag „Building a smart EntityModel“.

public class NewsModel implements IModel<News> {

	private Long id;
	private transient News news;
	
	public NewsModel(News news) {
		this.id = news.getId();
		this.news = news;
	}

	@Override
	public News getObject() {
		if (null == news && null != id) {
			EntityManager manager = JpaUtil.getCurrentEntityManager();
			news = manager.find(News.class, id);
		}
		return news;
	}

	@Override
	public void setObject(News object) {
		throw new UnsupportedOperationException("Model " + getClass() +
				" does not support setObject(Object)");
	}

	@Override
	public void detach() {
		news = null;
	}
}

Im Konstruktor der NewsList-Komponente stehen auch noch einige Veränderungen aus. Zum einen soll eine DataView verwendet werden, zum anderen aber sind dort noch zwei Stolpersteine aus dem Weg zu räumen, die dafür sorgen, dass die News dennoch in der Session serialisiert abgespeichert werden. Das Problem war, dass die News als final deklariert war und von zwei Models referenziert wurde – das umgehen wir, indem wir über das Model auf die News zugreifen.

	public NewsList(String id) {
		super(id);
		
		DataView<News> newsList = new DataView<News>("news", new NewsDataProvider(), 15) {
			@Override
			protected void populateItem(final Item<News> item) {
				News news = item.getModelObject();
				item.add(new AttributeModifier("class", true, new AbstractReadOnlyModel<String>() {
					@Override
					public String getObject() {
						return item.getModelObject().getGame().getName().toLowerCase().replace(' ', '-');
					}
				}));
				item.add(new ExternalLink("link", news.getUrl(), news.getTitle()).setEscapeModelStrings(false));
				item.add(new Label("page", news.getPage()).setRenderBodyOnly(true));
				
				MultiLineLabel description = new MultiLineLabel("description", news.getDescription());
				description.setEscapeModelStrings(false);
				item.add(description);
				item.add(new ChangeVisibilityLink("expand", description));
				
				item.add(new DateTimeLabel("date", new PropertyModel<Date>(item.getModel(), "date")));
			}
		};
		newsList.setItemReuseStrategy(new ReuseIfModelsEqualStrategy());
		newsList.add(new ToggleTextBehavior("ul.newslist > li a.expand", "[+]", "[-]"));
		add(newsList);
		add(new PagingNavigator("pagenavigation", newsList));
	}

Wir ersetzen außerdem den Aufruf von newsList.setReuseItems(true);, da es diese Methode bei DataView nicht gibt, durch newsList.setItemReuseStrategy(new ReuseIfModelsEqualStrategy());, was uns im Grunde das selbe Verhalten gibt. Damit das funktioniert, musste unser NewsModel die equals-Methode überschreiben, damit ein sinnvoller Vergleich zustande kommt. Ist ein neues Model gleich dem bestehenden alten, so wird das alte Item wiederverwendet, was für die Funktionalität des Aufklappens der Beschreibung ohne JavaScript notwendig ist. Da wir direkt mit der ID vergleichen, muss die News des alten Models (die ja losgelöst wurde) auch nicht extra aus der Datenbank geladen werden.

Zu guter Letzt muss nun nur noch das Erstellen der NewsList in HomePage angepasst werden, denn dort ist es nicht mehr nötig, die Liste mit News zu übergeben. Nun wird auch eigentlich getNews() in WicketApplication nicht mehr benötigt. Wie üblich könnt ihr euch den kompletten Sourcecode herunterladen.

Fazit

Mit einer Menge Aufwand wurde die kleine Anwendung nun auf eine Datenbank aufgesetzt. Das hat mich weit mehr Zeit gekostet, als die vorangegangenen Tutorials, da ich mich recht frisch in die Thematik eingearbeitet habe und einige Stolpersteine aus dem Weg räumen musste. Dafür bin ich jetzt um einige Erfahrungen reicher. Perfekt ist das Projekt aber noch immer nicht, denn es wäre z.B. auch schön, wenn die News dauerhaft in der Datenbank bleiben und sie regelmäßig aktualisiert werden würden, z.B. mittels Timer und TimerTask. Das darf sich aber jeder selber überlegen, sonst kommt dieser Beitrag nie zu einem Ende.

Tags: , ,

1 Pingback

  1. Dynamik für den Feedreader - rattlab.net 28. Oktober 2008 um 22:05

Keine Antworten

Die Kommentfunktion ist nicht aktiviert.