Migration einer Zwei-Schicht-Anwendung auf drei Schichten

Zwei-Schicht-Architektur

Michael Buchholz

© iStockphoto.com / ArtyFree

Wie kann man aus einer vorhandenen Zwei-Schicht-Anwendung eine Drei-Schicht-Anwendung machen, ohne den Code komplett neu zu schreiben? Der nachfolgende Artikel gibt die Erfahrungen bei der Migration einer RCP-Applikation wieder. Das vorgestellte Konzept ist aber auch für neue Applikationen interessant.

Bei einem Kunden existiert ein über mehrere Jahre entwickeltes System. Es handelt sich um einen auf der Eclipse Rich Client Platform (RCP) basierenden Rich-Client für die Anwender (nachfolgend GUI-Client genannt) und einen RCP-basierten Batch-Client. Der Batch-Client dient u. a. zur Verarbeitung von Schnittstellendateien. Beide Clients sind als Zwei-Schicht-Anwendung implementiert, verwenden eine gemeinsame Codebasis und greifen per JPA auf dieselbe Datenbank zu. Als JPA-Implementierung wird Hibernate verwendet. Zum Editieren von Daten im GUI-Client werden diese in einem RCP-Editor geöffnet, bearbeitet und anschließend durch einen Klick auf den Speichern-Button gespeichert. Hierbei verwenden wir das EntityManager-per-Conversation-Pattern (Kasten: „Gültigkeitsbereich des EntityManagers“), wobei eine Konversation vom Öffnen bis zum Schließen des Editors dauert. Beim Öffnen des Editors wird eine Transaktion gestartet und beim Speichern wird ein Commit ausgeführt, wobei alle Änderungen an den Entities automatisch gespeichert werden. Da der EntityManager während der gesamten Lebensdauer des Editors geöffnet bleibt, können Daten bei Bedarf per Lazy Loading nachgeladen werden.

Aus Sicherheitsgründen (u. a. Datenbankverbindungsdaten im GUI-Client abgelegt) und durch Änderungen an der Infrastruktur (Verschieben der Datenbank in eine Sicherheitszone, sodass diese vom GUI-Client nicht mehr direkt erreichbar ist) ist der Betrieb des zweischichtigen GUI-Clients zukünftig nicht mehr möglich. Daher soll die Anwendung (GUI-Client) auf eine Drei-Schicht-Architektur migriert werden, wobei einige Vorgaben einzuhalten sind:

  • Die Anwender sollen weiterhin mit dem vertrauten GUI-Client arbeiten
  • Aus Kostengründen soll möglichst viel vom vorhandenen Code übernommen werden
  • GUI- und Batch-Client verwenden weiterhin eine gemeinsame Codebasis
  • Der Batch-Client soll möglichst weiterhin als Zwei-Schicht-Applikation direkt auf die Datenbank zugreifen

Nachfolgend wird zunächst ein Konzept vorgestellt, das das Betreiben einer Applikation im Zwei- oder Drei-Schicht-Modus ermöglicht. Anschließend wird auf die konkrete Migration der Anwendung eingegangen.

Gültigkeitsbereich des EntityManagersEntityManager per Conversation

  • EntityManager wird nach einer Transaktion nicht geschlossen, sondern über einen längeren Zeitraum offen gehalten
  • Entities werden aktiv verwaltet, d. h. beim Commit werden alle Änderungen automatisch gespeichert
  • Lazy Loading ist möglich
    EntityManager per Request (with detached Entities)
  • EntityManager wird am Ende jeder Transaktion geschlossen
  • Beim Schließen des EntityManagers wechseln die Entities in den Zustand „Detached“
  • Kein Lazy Loading für detached Entities

Command Pattern

Das Konzept zum wahlweisen Betrieb einer Applikation im Zwei- oder Drei-Schicht-Modus basiert auf einem modifizierten Command-Pattern. Ein Command ist in diesem Fall eine Aktion, die der Client innerhalb einer Transaktion ausführen kann. Normalerweise wird ein Command remote auf einem Applikationsserver ausgeführt. Es ist aber auch möglich, Commands lokal in der Client-JVM auszuführen. Ein Command kann Parameter und einen Rückgabewert haben. Per Konvention hat ein Command in unserem Fall genau einen Konstruktor, der die für die Ausführung erforderlichen Daten als Parameter enthält und mittels der @Command-Annotation gekennzeichnet wird. Die Ausführung eines Commands erfolgt durch Aufruf der execute()-Methode. Falls das Command kein Ergebnis liefert, wird als Rückgabewert java.lang.Void angegeben, und die execute()-Methode liefert in diesem Fall null zurück. Alle Commands leiten von der Klasse ACommand ab und erhalten so Zugriff auf den EntityManager (Listing 1). Ein Beispiel für ein einfaches Command ist das Laden aller Personen mit einem bestimmten Namen (Listing 2). Commands können aber auch beliebig komplexe Anwendungslogik beinhalten.

public abstract class ACommand<R> {
  private EntityManager m_entityManager;
  public final EntityManager getEntityManager() {
    return m_entityManager;
  }
  public final void setEntityManager(EntityManager entityManager) {
    m_entityManager = entityManager;
  }
  /**
   * Execute the command
   * @return the result
   * @throws CommandException if command execution fails
   */
  public abstract R execute() throws CommandException;
}
@Command
public class FindPersonsByNameCommand extends ACommand<List<Person>> {
  private String m_name;
  public FindPersonsByNameCommand(String name) {
    m_name = name;
  }
  @Override
  public List<Person> execute() throws CommandException {
    return new PersonDAO(getEntityManager()).findByName(m_name);
  }
}

Die Schnittstelle für den Client zum Ausführen von Commands ist die Methode public <R> R execute(ICommandDesc<R> cmd) throws CommandException; aus dem Interface ICommandExecutor. Als Parameter wird ein Command Descriptor übergeben. Dieser dient zur eindeutigen Identifikation des auszuführenden Commands und beinhaltet die zur Ausführung erforderlichen Parameter. Die Aufspaltung in Commands und Command Descriptoren ermöglicht die Trennung des Codes zwischen Client und Server. Die Command Descriptoren werden mittels Codegenerierung erzeugt (Kasten: „Codegenerierung“). Für das ICommandExecutor-Interface existieren zwei Implementierungen:

  1. CommandExecutorRemote zur entfernten Ausführung der Commands (Listing 3)
  2. CommandExecutorLocal zur lokalen Ausführung der Commands (Listing 4)

Die Implementierungen befinden sich jeweils in einem eigenen Fragment zum Target-Plug-in cmd.client (Abb. 1). So wird in der Run-Konfiguration des Clients durch die Auswahl genau eines der beiden Fragmente gesteuert, ob der Client im Zwei- oder Drei-Schicht-Modus gestartet wird. Die Instanziierung der konkreten Implementierungsklasse kann mithilfe von java.util.ServiceLoader realisiert werden oder über einen eigenen Extension Point.

Hinweis: Der Client kennt nur die ICommandExecutor-Schnittstelle und weiß nicht, ob er zwei- oder dreischichtig ausgeführt wird!

Die Kommunikation zwischen Client und Server erfolgt in unserem Projekt über Hessian. Serverseitig existiert ein von com.caucho.hessian.server.HessianServlet abgeleitetes Servlet, das das Interface ICommandService mit der Methode public <R> R executeCommand(ICommandDesc<R> cmd); implementiert. Es empfängt die Aufrufe vom Client und delegiert sie an eine zustandslose Enterprise Java Bean (Stateless EJB). Diese erzeugt mittels der CommandFactory (Listing 5) das Command, setzt den EntityManager, führt das Command aus und liefert das Ergebnis zurück.
Ein EntityManager wird genau für einen Serveraufruf (Ausführung eines Commands) verwendet und anschließend geschlossen, sodass das Command-Pattern auf dem EntityManager-per-Request-Pattern basiert (Kasten: „Gültigkeitsbereich des EntityManagers“) und damit eine gravierende Abweichung zum bisherigen Konzept darstellt.

buchholz_2-schicht-architektur_1

Abb. 1: Klassendiagramm

/** Remote command execution (3-tier) */
public class CommandExecutorRemote implements ICommandExecutor {
  private ICommandService m_service;
  @Override
  public <R> R execute(ICommandDesc<R> cmd) throws CommandException {
    try {
      return getCommandService().executeCommand(cmd);
    } catch (EJBException e) {
      // unwrap EJBException
      Exception cause = e.getCause() != null ? e.getCause() : e;
      throw new CommandException("Failed to execute command", cause);
    }
  }
  private ICommandService getCommandService() {
    if (m_service == null) {
      try {
        HessianProxyFactory factory = new HessianProxyFactory(
          getClass().getClassLoader());
        m_service = (ICommandService)factory.create(
          ICommandService.class, "https://...");
      } catch (Exception e) {
        throw new RuntimeException("Server not available", e); 
      }
    }
    return m_service;
  }
}
/** Local command execution (2-tier) */
public class CommandExecutorLocal implements ICommandExecutor {
  private EntityManagerFactory m_emFactory = 
    Persistence.createEntityManagerFactory("...");
  private CommandFactory m_commandFactory = new CommandFactory();
  @Override
  public <R> R execute(ICommandDesc<R> cmd) throws CommandException {
    try {
      EntityManager em = m_emFactory.createEntityManager();
      R result = null;
      boolean success = false;
      EntityTransaction tx = null;
      try {
        tx = em.getTransaction();
        tx.begin();
        ACommand<R> command = m_commandFactory.createCommand(cmd);
        command.setEntityManager(em);
        result = command.execute();
        tx.commit();
        success = true;
      } finally {
        if (!success && tx != null && tx.isActive()) {
          tx.rollback();
        }
        em.close(); // close the entity manager (entities are detached)
      }
      return result;
    } catch (Exception e) {
      throw new CommandException("Failed to execute command", e);
    }
  }
}
public class CommandFactory {
  public <R> ACommand<R> createCommand(ICommandDesc<R> cmd) {
    try {
      Class<?> c = Class.forName(cmd.getCommandClass());
      return (ACommand<R>)c.getConstructors()[0].newInstance(
      cmd.getConstructorArgs());
    } catch (Exception e) {
      throw new RuntimeException("Failed to create command", e);
    }
  }
}

Codegenerierung

Die Commands werden mit einer eigenen Annotation @Command gekennzeichnet. Mithilfe eines Annotationsprozessors, der von javax.annotation.processing.AbstractProcessor ableitet, wird zu jeder Command-Klasse eine zugehörige Command-Descriptor-Klasse generiert. Diese enthält den qualifizierten Klassennamen des zugehörigen Commands sowie die Parameter für den Konstruktor und ist mit dem Rückgabetyp parametrisiert (Listing 6). Somit kann aus einer Command Description per Reflection das zugehörige Command erzeugt werden (Listing 5).

Der Klassenname des Annotationsprozessors wird dem Java-Compiler mithilfe des Parameters processor übergeben und über die Callback-Methode process(Set<? extends TypeElement>, RoundEnvironment) wird der Prozessor beim Kompilieren der annotierten Klassen aufgerufen (siehe hier und hier).

/* GENERATED FILE - DO NOT EDIT */
public class FindPersonsByNameCmdDesc 
  implements ICommandDesc<java.util.List<persistence.model.Person>> {
  private final Object[] m_args;
  public FindPersonsByNameCmdDesc(java.lang.String name) {
    m_args = new Object[] { name };
  }
  @Override
  public String getCommandClass() {
    return "command.persistence.FindPersonsByNameCommand";
  }
  @Override
  public Object[] getConstructorArgs() {
    return m_args;
  }
}

Migration der Applikation

Bei der Erweiterung einer Zwei-Schicht-Anwendung auf drei Schichten stellt sich die Frage, wo die Grenze zwischen den Schichten verläuft. Naheliegend ist es, zunächst die Datenbankzugriffe genauer zu betrachten. In der alten Applikation existiert in unserem Fall ein Persistenz-Plug-in, das die JPA-Entities und die zugehörigen Data Access Objects (DAO) enthält. Die JPA-Entities werden in der GUI direkt verwendet; es existieren keine separaten Data Transfer Objects (DTO). Da die DAOs zum Server gehören, die JPA-Entities aber sowohl im Client als auch im Server benötigt werden, ist eine Aufteilung des bisherigen Persistenz-Plug-ins erforderlich.

Zuerst wird ein neues Plug-in für die Data Access Objects erstellt, und die zugehörigen Klassen werden verschoben. Die clientseitig verwendeten Plug-ins erhalten nur Zugriff auf das Plug-in mit den JPA-Entities. Dieses Vorgehen führt zu vielen Compilerfehlern, da die Importanweisungen für die DAO-Klassen im Client nicht mehr aufgelöst werden können. Anschließend werden die Compilerfehler der Reihe nach behoben. Die Zugriffe auf die DAO-Klassen müssen in der Regel durch die Ausführung entsprechender Commands ersetzt werden. Dabei ist zu prüfen, ob bestimmte Aktionen zu einem komplexeren Command zusammengefasst werden können, um die Anzahl der Serverzugriffe zu optimieren. Außerdem muss auf die Einhaltung der Datenkonsistenz geachtet werden, da jedes Command in einer eigenen Transaktion ausgeführt wird. Der Vorteil dieses Verfahrens liegt darin, dass man durch die Compilerfehler gezwungen wird, einen Blick auf alle von der Umstellung betroffenen Codestellen zu werfen. Das ist zwingend erforderlich, da sich das verwendete Pattern für den EntityManager geändert hat.

Eine weitere Baustelle bei der Migration der Applikation ist das Exception Handling im Client. Nicht alle Exceptions können weiterhin direkt im Client gefangen werden, da sie in einer Server- bzw. Command Exception gekapselt sind. Besondere Aufmerksamkeit ist hier auf die „erwarteten“ Exceptions zu richten, für die im Client eine gezielte Fehlerbehandlung existiert. In unserer alten Anwendung gab es beispielsweise eine spezielle Behandlung bei Auftreten einer OptimisticLockException. Nach der Migration wird der zugehörige catch-Block im Client aber nicht mehr aufgerufen, da die OptimisticLockException in einer EJBException bzw. CommandException verpackt ist.

Umstellung auf EntityManager-per-Request

Bisher (EM-per-Conversation) wurden die Entities aktiv vom EntityManager verwaltet, und alle Änderungen an Entities wurden automatisch beim Commit gespeichert. Nach der Umstellung (EM-per-Request) müssen die geänderten Entities durch die Ausführung eines entsprechenden Save Commands selbst gespeichert werden, d. h., die geänderte Entity muss vom Client zum Server gesendet werden, und es muss die merge()-Methode des EntityManagers aufgerufen werden, um die Entity vom Zustand „Detached“ nach „Persistent“ zu überführen. Eine Transaktion aus Anwendersicht (Öffnen des Editors/Laden der Daten, Bearbeiten/Ändern der Daten, Speichern) besteht jetzt (EM-per-Request) aus mehreren DB-Transaktionen, wobei alle bis auf die letzte nur lesend auf die Daten zugreifen. Die Konsistenz der Daten bei paralleler Bearbeitung durch mehrere Anwender wird durch optimistisches Locking (Versionierung) sichergestellt.

Nach dem Schließen des EntityManagers ist ein dynamisches Nachladen von Daten (Lazy Loading) nicht mehr möglich und führt zu einer LazyInitializationException. Es muss also sichergestellt werden, dass beim Schließen des EntityManagers alle Entities vollständig initialisiert sind. Abhängig vom Datenmodell kann es erforderlich sein, manche Assoziationen nicht in JPA zu mappen, um beim Initialisieren einer Entity nicht zu viele Daten laden zu müssen. Gegebenenfalls kann auch an einigen Stellen der Einsatz leichtgewichtiger Data Transfer Objects (DTO) sinnvoll sein, die nur die für den Anwendungsfall tatsächlich relevanten Daten enthalten.

Hibernate und Hessian

Da die JPA-Entities im Client verwendet werden, sind zum Deserialisieren mittels Hessian im Client die zugehörigen Hibernate-Klassen erforderlich (z. B. org.hibernate.collection.internal.PersistentBag). Das führt beim Deserialisieren allerdings zu einer LazyInitializationException. Als Workaround kann man die Hibernate-Klassen aus dem Klassenpfad des Clients entfernen. In diesem Fall ersetzt Hessian automatisch die Hibernate-Collections durch entsprechende Collections aus dem java.util-Paket ‑ allerdings enthält das Logging entsprechende Meldungen. Daher sollte man im Server eigene Serializer verwenden, die die Ersetzung der Hibernate-Collections übernehmen. Hierzu wird im Konstruktor des CommandServiceServlet durch Aufruf der Methode setSerializerFactory() die eigene Factory-Implementierung (Listing 7) gesetzt.

import java.io.IOException;
import java.util.*;
import com.caucho.hessian.io.*;

public class CustomSerializerFactory extends SerializerFactory {
  private static class SimpleCollectionSerializer implements Serializer {
    private CollectionSerializer m_delegate = new CollectionSerializer();
    @Override
    public void writeObject(Object obj, AbstractHessianOutput out) 
    throws IOException {
      m_delegate.writeObject(new ArrayList((Collection)obj), out);
    }
  }
  private static class SimpleSetSerializer implements Serializer {
    // analog SimpleCollectionSerializer...
  }
  private static class SimpleMapSerializer implements Serializer {
    // analog SimpleCollectionSerializer...
  }

  private Serializer m_collSerializer = new SimpleCollectionSerializer();
  private Serializer m_setSerializer = new SimpleSetSerializer();
  private Serializer m_mapSerializer = new SimpleMapSerializer();
  
  @Override
  public Serializer getSerializer(Class cl) throws HessianProtocolException {
    if (cl.getName().startsWith("org.hibernate.collection")) {
      if (Map.class.isAssignableFrom(cl)) {
        return m_mapSerializer;
      }
      if (Set.class.isAssignableFrom(cl)) {
        return m_setSerializer;
      }
      return m_collSerializer;
    }
    return super.getSerializer(cl);
  }
}

Fazit

Mit den hier vorgestellten Konzepten ist es uns unter Einhaltung der Vorgaben gelungen, den vorhandenen GUI-Client auf ein Drei-Schicht-Modell zu migrieren und den Batch-Client weiterhin im Zwei-Schicht-Modus zu betreiben.

Ein positiver Nebeneffekt des eingesetzten Command-Patterns ist, dass zur Entwicklung einer Drei-Schicht-Anwendung nicht zwingend ein Applikationsserver erforderlich ist. Während der Entwicklung kann die Anwendung auch im Zwei-Schicht-Modus gestartet werden, da es für den Client keinen Unterschied macht, ob er zwei- oder dreischichtig ausgeführt wird.

Ein weiterer entscheidender Vorteil des Command-Patterns ist die Trennung und Abkapselung der Kommunikationsschicht von der restlichen Geschäftslogik. Wenn beispielsweise die Client-Server-Kommunikation über einen Web Service anstatt über Hessian erfolgen soll, müssen im Prinzip nur zwei Klassen angepasst werden: Im Client ist eine neue Implementierung für das ICommandExecutor-Interface erforderlich und im Server die Web-Service-Schnittstelle für die Ausführung von Commands.

Die hier vorgestellte Architektur mit Client-Server-Kommunikation via Commands kann natürlich auch bei Neuentwicklungen erfolgreich eingesetzt werden.

Verwandte Themen:

Geschrieben von
Michael Buchholz
Michael Buchholz
Michael Buchholz arbeitet als Projektleiter und Softwareentwickler bei der BREDEX GmbH in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: