Obsługa transakcji JTA w projekcie GWT + Hibernate

Czasami bywa tak, że mamy gotowy już produkt, bądź większą jego część, ale w pewnym momencie musimy do niego dorobić coś, do czego nasz projekt od początku nie był przystosowany. Zazwyczaj pojawiają się wtedy dwa wyjścia. Pierwsze z nich zakłada głębsze zmiany w kodzie i architekturze aplikacji, aby dostosować ją do nowych potrzeb. Niestety jest to proces pracochłonny, czasochłonny, a ponadto będzie wymagał dogłębnego przetestowania aplikacji w zakresie praktycznie całej jej funkcjonalności. Jeśli nie możesz sobie na to pozwolić, zostaje jak zwykle drugie wyjście, czyli dorobienie czegoś „na szybko”, „na sztywno”, itp. Nie jest to ładne, ale jeśli klient czeka na gotowy produkt, czasem trzeba tak zrobić.

Dzisiejszy artykuł po części dotyka tematu wspomnianego „drugiego wyjścia”, gdyż to, co chcę Tobie zaprezentować, nie często występuje w przyrodzie w takiej właśnie postaci. Świadczy o tym chociażby ilość trafnych odpowiedzi w wyszukiwarce Google’a na zapytanie „GWT + JTA”. Oczywiście jeśli znasz temat, zaproponujesz Spring’a, bądź EJB. I słusznie! O wiele łatwiej jest skorzystać z mechanizmu transakcji JTA, gdy posiadamy wspomniane frameworki. Jeśli jednak masz już gotową, monolityczną aplikację napisaną w samym GWT, a musisz obsługiwać w niej transakcje poprzez JTA, być może zaoszczędzę Ci trochę czasu na rozpoznawanie tematu :) Oczywiście nic nie stoi na przeszkodzie, aby używać JTA w projekcie GWT już od samego początku, dzięki czemu uzyskamy kilka zalet płynących właśnie z wykorzystania tego mechanizmu transakcji.

Czym jest więc JTA? Jest to standard JavaEE definiujący API pozwalające na używanie transakcji rozproszonych na wiele zasobów XA. Prościej mówiąc pozwala np.  na wspólną, pojedynczą transakcję, wewnątrz której będziemy odwoływali się do zasobów znajdujących się na różnych bazach danych. Implementacje obsługi transakcji JTA są dostarczane przez serwery aplikacji (np. WebSphere, JBoss, GlassFish), przez co nie da się z nich od razu skorzystać w kontenerach serwletów takich jak Jetty lub Tomcat. Istnieją jednak samodzielne implementacje, których można użyć gdziekolwiek. Co ważne, chcąc zmienić implementację transakcji JTA, wystarczy jedynie zmodyfikować pliki konfiguracyjne aplikacji, bez konieczności ponownej kompilacji. Po więcej informacji na temat JTA odsyłam do wikipedii.

Jako że w trybie deweloperskim GWT aplikacja osadzana jest w kontenerze Jetty, potrzebna jest osobna implementacja JTA. Wybrałem tutaj BTM (Bitronix Transaction Manager), gdyż stwarzał on najmniej problemów przy próbie uruchomienia go w projekcie GWT. Chodzi o to, że Jetty z trybu deweloperskiego ma ograniczone możliwości konfiguracji (chociażby brak możliwości konfigurowania pliku jetty.xml) w stosunku do normalnej instancji tego kontenera. Warstwę dostępu do danych zapewni nam Hibernate – popularne narzędzie służące do mapowania obiektowo-relacyjnego. Dane będą składowane w pamięciowej bazie danych – HipersonicSQL. Podobnie jak w poprzednim artykule, zakładam, że znasz już GWT w stopniu przynajmniej podstawowym, będę więc omijał mniej ważne elementy projektu. Oczywiście na koniec udostępnię pliki źródłowe z gotową aplikacją.

Zaczniemy od klasy, której obiekty będą zapisywane w bazie danych. Projekt jest bardzo prosty, będzie więc to jedyna taka klasa.

Car.java
public class Car implements Serializable {

	private Long id;
	private String brand;
	private String model;
	private String color;

	public Car() {
	}

	public Long getId() {
		return id;
	}

	public void setId(Long id) {
		this.id = id;
	}

	public String getBrand() {
		return brand;
	}

	public void setBrand(String brand) {
		this.brand = brand;
	}

	public String getModel() {
		return model;
	}

	public void setModel(String model) {
		this.model = model;
	}

	public String getColor() {
		return color;
	}

	public void setColor(String color) {
		this.color = color;
	}
}

Klasa ta będzie wykorzystywana po stronie klienta w aplikacji GWT, nie można więc tu skorzystać z konfiguracji za pomocą adnotacji.

Car.hbm.xml
<?xml version="1.0"?>
<!DOCTYPE hibernate-mapping PUBLIC
    "-//Hibernate/Hibernate Mapping DTD 3.0//EN"
    "http://hibernate.org/dtd/hibernate-mapping-3.0.dtd">

<hibernate-mapping>
  <class name="pl.avd.samples.gwt.jta.shared.Car" table="T_CARS">
    <id name="id" column="ID">
      <generator class="increment"/>
    </id>

    <property name="brand" column="BRAND"/>
    <property name="model" column="MODEL"/>
    <property name="color" column="COLOR"/>
  </class>
</hibernate-mapping>

Mapowanie klasy Car na tabelę w bazie danych. Mapowanie zostanie użyte do automatycznego wygenerowania struktury bazy za pomocą Hibernate’a.

hibernate.cfg.xml
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
    "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
    "http://hibernate.org/dtd/hibernate-configuration-3.0.dtd">

<hibernate-configuration>
  <session-factory>

    <!-- ===== Database connection settings ===== -->
    <property name="connection.datasource">jdbc/db</property>
    <property name="connection.release_mode">after_statement</property>

    <!-- SQL dialect -->
    <property name="dialect">org.hibernate.dialect.HSQLDialect</property>

    <!-- Echo all executed SQL to stdout -->
    <property name="show_sql">true</property>

    <!-- Auto update database structure -->
    <property name="hibernate.hbm2ddl.auto">update</property>

    <!-- ===== Transaction settings ===== -->
    <property name="transaction.auto_close_session">true</property>

    <!-- Enable JTA session context management -->
    <property name="current_session_context_class">jta</property>

    <property name="transaction.factory_class">org.hibernate.transaction.JTATransactionFactory</property>

    <!-- JETTY/Tomcat - Bitronix Transaction Manager -->
    <property name="transaction.manager_lookup_class">org.hibernate.transaction.BTMTransactionManagerLookup</property>

    <!-- Resources mappings -->
    <mapping resource="Car.hbm.xml"/>

  </session-factory>
</hibernate-configuration>

Konfiguracja Hibernate’a. Do bazy danych odwołujemy się poprzez źródło danych zdefiniowane w linii nr 10 (jdbc/db). W linii nr 11 określamy sposób zwalniania połączeń przez Hibernate’a. Do obsługi transakcji JTA zaleca się użycia tutaj opcji „after_statement”, która powoduje, że połączenie z bazą danych może zostać zwrócone do puli połączeń po każdym wyrażeniu. Skorzystamy również z opcji automatycznego zamykania sesji po zakończeniu transakcji (linia nr 23). W linii nr 26 określamy sposób wiązania transakcji z aktualnym kontekstem (wykorzystujemy tutaj wbudowaną opcję „jta”). Linia nr 28 określa klasę fabryki transakcji – Hibernate dostarcza tutaj stosowną implementację. W linii nr 31 należy podać klasę, z której Hibernate będzie korzystał w celu pobierania nowych transakcji. Korzystamy tutaj również z dostarczonej implementacji dla użytego przez nas menadżera transakcji BTM. Właśnie tą właściwość należy zmodyfikować przy wgrywaniu aplikacji na serwer aplikacji dostarczający własną implementację JTA. Hibernate dostarcza tutaj szereg gotowych klas dla wielu serwerów aplikacyjnych.

jndi.properties
java.naming.factory.initial=org.mortbay.naming.InitialContextFactory

Plik ten zawiera konfigurację serwera JNDI. W przypadku kontenera Jetty potrzebny jest wpis określający klasę fabryki kontekstu nazw, w którym będzie przechowywana nazwa źródła danych oraz transakcji JTA. Serwery aplikacyjne często nie wymagają dodatkowej konfiguracji JNDI.

jetty-web.xml
<?xml version="1.0"?>
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://jetty.mortbay.org/configure.dtd">
<Configure class="org.mortbay.jetty.webapp.WebAppContext">
  <Call name="getConfiguration" class="bitronix.tm.TransactionManagerServices">
    <Set name="serverId">jetty-btm-node0</Set>
    <Set name="logPart1Filename"><SystemProperty name="jetty.home" default="." />/target/btm1.tlog</Set>
    <Set name="logPart2Filename"><SystemProperty name="jetty.home" default="." />/target/btm2.tlog</Set>
  </Call>

  <Call name="getTransactionManager" class="bitronix.tm.TransactionManagerServices">
    <Set name="transactionTimeout">180</Set>
  </Call>

  <New class="org.mortbay.jetty.plus.naming.Resource">
    <Arg>java:comp/UserTransaction</Arg>
    <Arg><Call name="getTransactionManager" class="bitronix.tm.TransactionManagerServices"/></Arg>
  </New>

  <New id="db" class="org.mortbay.jetty.plus.naming.Resource">
    <Arg>jdbc/db</Arg>
      <Arg>
        <New class="bitronix.tm.resource.jdbc.PoolingDataSource">
          <Set name="className">org.hsqldb.jdbc.pool.JDBCXADataSource</Set>
          <Set name="uniqueName">db</Set>
          <Set name="minPoolSize">0</Set>
          <Set name="maxPoolSize">3</Set>
          <Set name="allowLocalTransactions">false</Set>
          <Get name="driverProperties">
            <Put name="url">jdbc:hsqldb:file:test.db;shutdown=true</Put>
            <Put name="user">test</Put>
            <Put name="password">test</Put>
          </Get>
        <Call name="init" />
      </New>
    </Arg>
  </New>
</Configure>

W pliku konfiguracyjnym Jetty określamy lokalizację plików logów BTM’a, czas nieaktywności transakcji, po którym zostanie ona wycofana oraz wiążemy implementację JTA z nazwą JNDI „java:comp/UserTransaction”. Tutaj też skonfigurowane zostało źródło danych, do którego odwołaliśmy się w pliku konfiguracyjnym Hibernate’a. Źródło danych zawiera konfigurację puli połączeń, z której będą przydzielane połączenia z bazą. Dla innej bazy danych należy podać klasę źródła danych XA oraz odpowiednio skonfigurować właściwości sterownika takie jak adres URL bazy, użytkownik oraz hasło. Dokładny opis dostępnych parametrów konfiguracyjnych można znaleźć na stronie BTM’a.

To wszystko, jeśli chodzi o konfigurację dostępu do bazy danych oraz konfigurację mechanizmu transakcji JTA. Został jeszcze jeden plik konfiguracyjny – deskryptor wdrożenia aplikacji webowej web.xml. Na jego podstawie omówię kilka kolejnych elementów aplikacji.

web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

  <display-name>SampleGwtJta</display-name>
  <description>Sample GWT: JTA</description>

  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
  </welcome-file-list>

  <servlet>
    <servlet-name>data</servlet-name>
    <servlet-class>pl.avd.samples.gwt.jta.server.DataServiceImpl</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>data</servlet-name>
    <url-pattern>/sample/data</url-pattern>
  </servlet-mapping>

  <filter>
    <filter-name>TransactionFilter</filter-name>
    <filter-class>pl.avd.samples.gwt.jta.server.TransactionFilter</filter-class>
  </filter>

  <filter-mapping>
    <filter-name>TransactionFilter</filter-name>
    <url-pattern>/sample/data</url-pattern>
  </filter-mapping>
</web-app>

Plik index.html zawiera odwołanie do jedynego modułu GWT aplikacji. Moduł ma standardową konfigurację, nie będę więc tutaj jej przytaczał. Aplikacja posiada jeden serwis RPC (DataService), który widoczny będzie pod adresem /sample/data. Serwis zawiera dwie metody, jedną służącą do zapisu nowego obiektu do bazy danych, drugą listującą obecnie zapisane dane. Implementacja serwisu będzie zawierała operacje związane jedynie z dostępem do danych, nie będzie natomiast uwzględniała całej otoczki obsługi transakcji. Najprostszym sposobem na rozdzielenie obsługi transakcji od rzeczywistej logiki biznesowej jest zastosowanie filtru serwletów. Takowy więc tworzymy, precyzując, że dotyczy on naszego serwisu RPC.

TransactionFilter.java
public class TransactionFilter implements Filter {

	private Logger LOG = LoggerFactory.getLogger(TransactionFilter.class);

	public void destroy() {
	}

	public void init(FilterConfig filterConfig) throws ServletException {
	}

	public void doFilter(ServletRequest req, ServletResponse resp,
			FilterChain chain) throws IOException, ServletException {
		if (req instanceof HttpServletRequest) {
			UserTransaction tx = null;

			try {
				tx = (UserTransaction) new InitialContext().lookup("java:comp/UserTransaction");
				tx.begin();

				chain.doFilter(req, resp);

				// Commit and cleanup
				LOG.trace("Committing the database transaction");

				tx.commit();
			} catch (StaleObjectStateException staleEx) {
				LOG.error("This interceptor does not implement optimistic concurrency control!");
				LOG.error("Your application will not work until you add compensation actions!");
				// Rollback, close everything, possibly compensate for any permanent changes
				// during the conversation, and finally restart business conversation. Maybe
				// give the user of the application a chance to merge some of his work with
				// fresh data... what you do here depends on your applications design.
				throw staleEx;
			} catch (Throwable ex) {
				// Rollback only
				LOG.error("Exception thrown during transaction: " + ex);

				try {
					LOG.info("Trying to rollback database transaction after exception");
					tx.rollback();
				} catch (Throwable rbEx) {
					LOG.error("Could not rollback transaction after exception!" + rbEx.getMessage());
				}

				// Let others handle it... maybe another interceptor for exceptions?
				throw new ServletException(ex);
			} finally {
			}

		} else {
			chain.doFilter(req, resp);
		}
	}
}

Jak działa filtr serwletów? W momencie jakiegokolwiek odwołania do serwletu, dla którego zostały zdefiniowane filtry, kontener przekazuje sterowanie do metody doFilter pierwszego napotkanego filtru. Ten z kolei wewnątrz tej metody wywołuje kolejne filtry, a na końcu łańcucha wywoływany jest sam serwlet. Wewnątrz metody doFilter można sprecyzować, co będzie działo się przed oraz po wywołaniu serwletu. Właśnie dzięki temu obsługę transakcji można przenieść poza metody serwisów RPC, mając tym samym pewność, że przed wejściem do danej metody transakcja zostanie rozpoczęta, a po wyjściu z metody, transakcja zostanie zatwierdzona (lub cofnięta). Przyznam, że podany filtr częściowo skądś skopiowałem, przez co zostało w nim kilka wartych uwagi komentarzy. Najważniejsze są jednak same operacje związane z transakcjami.

Potrzebujemy jeszcze klasy pomocniczej, która będzie zwracała nam obiekty sesji Hibernate’a.

HibernateUtil.java
public class HibernateUtil {

	private static HibernateUtil instance = new HibernateUtil();

	private SessionFactory factory;

	public static HibernateUtil get() {
		return instance;
	}

	private HibernateUtil() {
		Configuration cfg = new Configuration();
		cfg.configure("hibernate.cfg.xml");

		factory = cfg.buildSessionFactory();
	}

	public static Session getCurrentSession() {
		return get().factory.getCurrentSession();
	}

	public static void closeCurrentSession() {
		get().factory.close();
	}
}

Tworzona jest tutaj instancja konfiguracji Hibernate’a oraz fabryka sesji. Metoda getCurrentSession() wywoływana na tej fabryce powoduje zwrócenie sesji Hibernate’a związanej z aktualnym kontekstem. Zgodnie z przeprowadzoną wcześniej konfiguracją, jeśli znajdziemy się w kontekście aktywnej transakcji JTA, zwracana będzie zawsze ta sama sesja (lub tworzona nowa, jeśli nie istnieje). Jeśli znajdziemy się w kontekście innej aktywnej transakcji JTA, również zostanie zwrócony inny obiekt sesji.

DataServiceImpl.java
public class DataServiceImpl extends RemoteServiceServlet implements DataService {

	public void saveCar(Car car) {
		HibernateUtil.getCurrentSession().save(car);
	}

	@SuppressWarnings("unchecked")
	public List listCars() {
		return HibernateUtil.getCurrentSession().createCriteria(Car.class).list();
	}
}

Implementacja serwisu RPC jest bardzo prosta. Jak widać, oddzielenie obsługi transakcji pozwala na pisanie krótkiego i czytelnego kodu. W pierwszej metodzie za pomocą Hibernate’a zapisywany jest nowy obiekt do bazy danych, zaś druga metoda listuje wszystkie dostępne w bazie rekordy.

Na koniec zostawiłem plik modułu GWT zawierający interfejs użytkownika.

SampleModule.java
public class SampleModule implements EntryPoint {

	private Logger LOG = Logger.getLogger("SAMPLE");

	private DataServiceAsync data = GWT.create(DataService.class);

	private ListDataProvider provider;

	private FlowPanel layout;

	public void onModuleLoad() {
		GWT.setUncaughtExceptionHandler(new GWT.UncaughtExceptionHandler() {

			public void onUncaughtException(Throwable caught) {
				LOG.log(Level.SEVERE, "uncaught exception", caught);
			}
		});

		Scheduler.get().scheduleDeferred(new Scheduler.ScheduledCommand() {

			public void execute() {
				init();
			}
		});
	}

	private void init() {
		layout = new FlowPanel();
		provider = new ListDataProvider();

		initForm();
		initTable();

		RootPanel.get().add(layout);

		callListCarsService();
	}

	private void initForm() {
		FlexTable form = new FlexTable();

		final TextBox brandField = new TextBox();
		final TextBox modelField = new TextBox();
		final TextBox colorField = new TextBox();

		form.setHTML(0, 0, "Marka:");
		form.setWidget(0, 1, brandField);
		form.setHTML(1, 0, "Model:");
		form.setWidget(1, 1, modelField);
		form.setHTML(2, 0, "Kolor:");
		form.setWidget(2, 1, colorField);

		layout.add(form);

		Button saveBtn = new Button("Zapisz");
		saveBtn.addClickHandler(new ClickHandler() {

			public void onClick(ClickEvent event) {
				Car car = new Car();
				car.setBrand(brandField.getValue());
				car.setModel(modelField.getValue());
				car.setColor(colorField.getValue());

				brandField.setValue(null);
				modelField.setValue(null);
				colorField.setValue(null);
				callSaveCarService(car);
			}
		});
		layout.add(saveBtn);
	}

	private void initTable() {
		CellTable table = new CellTable();
		table.setWidth("500px");

		provider.addDataDisplay(table);

		Column idCol = new Column(new NumberCell()) {

			@Override
			public Number getValue(Car object) {
				return object.getId();
			}
		};
		table.addColumn(idCol, "ID");

		Column brandCol = new Column(new TextCell()) {

			@Override
			public String getValue(Car object) {
				return object.getBrand();
			}
		};
		table.addColumn(brandCol, "Marka");

		Column modelCol = new Column(new TextCell()) {

			@Override
			public String getValue(Car object) {
				return object.getModel();
			}
		};
		table.addColumn(modelCol, "Model");

		Column colorCol = new Column(new TextCell()) {

			@Override
			public String getValue(Car object) {
				return object.getColor();
			}
		};
		table.addColumn(colorCol, "Kolor");

		layout.add(table);

		Button refreshBtn = new Button("Odśwież");
		refreshBtn.addClickHandler(new ClickHandler() {

			public void onClick(ClickEvent event) {
				callListCarsService();
			}
		});
		layout.add(refreshBtn);
	}

	private void callSaveCarService(Car car) {
		AsyncCallback callback = new AsyncCallback() {

			public void onFailure(Throwable caught) {
				LOG.log(Level.SEVERE, "save car exception", caught);
			}

			public void onSuccess(Void result) {
			}
		};

		data.saveCar(car, callback);
	}

	private void callListCarsService() {
		AsyncCallback&gt; callback = new AsyncCallback&gt;() {

			public void onFailure(Throwable caught) {
				LOG.log(Level.SEVERE, "list cars exception", caught);
			}

			public void onSuccess(List result) {
				provider.getList().clear();

				for (Car car : result) {
					provider.getList().add(car);
				}
			}
		};

		data.listCars(callback);
	}
}

Nie będę szczegółowo omawiał całego pliku, gdyż jego kod wydaje się na tyle czytelny, abyś zrozumiał go sam. Tworzony jest w nim formularz, za pomocą którego dodamy nowe dane, oraz tabela, która będzie te dane prezentowała. Po każdym dodaniu nowego rekordu należy kliknąć przycisk „Odśwież”, aby ponownie załadować zawartość tabeli.

A oto rezultat działania programu:
Sample GWT JTA #1

Zanim jeszcze udostępnię Ci źródła, muszę wspomnieć o jednej rzeczy. Jeśli używasz maven’a do zarządzania zależnościami projektu, możesz mieć problem z pokrywającymi się zależnościami specyfikacji JTA. Jetty, Hibernate oraz BTM odwołują się do innych artefaktów, których jednoczesne istnieje w zależnościach będzie powodowało problemy z samym BTM’em. Przytoczę więc tutaj fragment definicji zależności z pliku pom.xml stworzonego projektu.

pom.xml
<dependency>
  <groupId>org.mortbay.jetty</groupId>
  <artifactId>jetty-plus</artifactId>
  <version>6.1.25</version>
  <scope>provided</scope>
  <exclusions>
    <exclusion>
      <artifactId>geronimo-spec-jta</artifactId>
      <groupId>geronimo-spec</groupId>
    </exclusion>
  </exclusions>
</dependency>

<dependency>
  <groupId>org.apache.geronimo.specs</groupId>
  <artifactId>geronimo-jta_1.1_spec</artifactId>
  <version>1.1.1</version>
  <scope>provided</scope>
</dependency>

<dependency>
  <groupId>org.codehaus.btm</groupId>
  <artifactId>btm</artifactId>
  <version>2.1.0</version>
  <exclusions>
    <exclusion>
      <artifactId>jta</artifactId>
      <groupId>javax.transaction</groupId>
    </exclusion>
  </exclusions>
  <scope>provided</scope>
</dependency>

<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-core</artifactId>
  <version>3.6.0.Final</version>
  <exclusions>
    <exclusion>
      <artifactId>jta</artifactId>
      <groupId>javax.transaction</groupId>
    </exclusion>
  </exclusions>
</dependency>

Do artykułu załączam też obiecane źródła. Projekt można uruchomić poleceniem „mvn gwt:run” wywołanym w jego katalogu.

Skomentuj

3 Komentarze.

  1. Za ten artykuł BARDZO/ dziękuję!
    Normalnie w takich sytuacjach klikam „DONATE” bo to jest to czego dokładnie potrzebowałem!

  2. Może kiedyś dorobię się takiego przycisku :) Sam temat JTA+GWT jest dość niszowy i sam spędziłem niezły kawał czasu, zanim zaczęło coś działać. Cieszę się, że mogłem pomóc.

  3. Choć potrzebowałem tego do Jersey i GlassFisha, to mimo wszystko Twój artykuł bardzo mi pomógł w uruchomieniu JTA z Hibernate na tej konfiguracji. Wielkie dzięki!

Skomentuj


UWAGA - Możesz używać HTML tags and attributes:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>

CommentLuv badge