Blog

Persistenz, Transaktionsgrenzen und Optimistische Locking-Strategie

Die erste Regel der verteilten Systeme lautet: verteile nicht die Systeme. In der Tat kann die Architektur eines monolithischen Systems auf wesentliche komplexe Fragestellungen verzichten, die in der Architektur des verteilten Systems eine entscheidende Rolle spielen. Manche funktionale Anforderungen lassen sich jedoch nur sehr schwer in einem monolithischen System realisieren, so dass eine verteilte mehrschichtige Anwendung mit einem Server und einem (Rich-) Client entsteht. Eine mögliche, nicht seltene Variante ist die Bereitstellung von Funktionalität über eine Serviceschicht, die vom Client aus benutzt wird. Der Entwurf von funktionalen Service-Schnittstellen ist ein wichtiger Schritt in der Entwicklung der Mehrschichtanwendung. Auch für den Aufbau der serviceorientierten Architektur ist die Frage des Schneidens der Services von zentraler Bedeutung. Es ist ein Balanceakt zwischen fein- und grobgranular, zustandslos und zustandbehaftet, synchron und asynchron, transaktional und fire-and-forget.

Architektur

Ein nicht-untypisches Design einer Mehrschichtapplikation ist mit einem Boundary-Control-Entity Architekturpattern beschrieben. In Enterprise Java kann dieses mit Spring oder mit EJB implementiert werden – der Unterschied ist marginal. Die Persistenz-Schicht (Entity) wird üblicherweise mit Hilfe der Java Persistence API (JPA) implementiert, die  jedoch hinter der Datenzugriffschicht (Data Access Objects, DAOs) versteckt wird. Dies vereinfacht das Testen und ermöglicht es, die Details der Persistenz-Abbildung von der Businesslogik fernzuhalten. Die Businesslogik wird in zustandslosen Beans implementiert (SpringBeans oder Stateless Session Beans) und manipuliert die Persistent Entities über DAOs. Damit die Geschäftslogik von einem entfernten Client aufgerufen werden kann, muss diese über eine Schicht von externen Services zur Verfügung gestellt werden. Diese Grenzschicht (Boundary) ist für die Bereitstellung einer Remoting-Technologie zuständig, übernimmt die Validierung der Aufrufparameter und konvertiert zwischen den Datentransferobjekten (DTOs) und Persistent Entities und orchestriert den Zugriff auf interne Beans. Am folgenden Beispiel, das EJB 3 verwendet, kann die Gesamtarchitektur studiert werden:

Der Client führt eine Methode auf der Facade aus, die DTOs als Parameter und Rückgabetyp verwendet.  Die Facade delegiert den Aufruf an die internen Service Beans, die über spezielle Konverter DTOs zu Persistent Entities konvertieren und ggf. über DAOs auf die Persistent Entities zugreifen. Der Rückgabewerte von Service Beans (Persistent Entites) müssen vor der Übermittlung an den Client in die DTOs umgewandelt werden. Diese Aufgabe wird erneut von Konverter erledigt. Am folgenden Codebespiel kann dieser Zusammenhang demonstriert werden:

Ein Kundenservice stellt eine Methode zum Auslesen der gespeicherten Kunden aus:

/**
 * Customer Service Facade
 */
public interface CustomerService {
	List<CustomerDto> getCustomers();
}

Der Kundenservice ist als eine Stateless Session Bean implementiert, die die fachliche Schnittstelle als Remote Interface verwendet:

/**
 * Facade implementation.
 */
@Stateless
@Remote(CustomerService.class)
public class CustomerServiceBean implements CustomerService {
	@EJB
	private InternalCustomerService customerService;

	@EJB
	private Converter converter;

	public List<CustomerDto> getCustomers() {
		return converter.toCustomerDtos(customerService.getAllValidCustomers());
	}
}

Die Kunden-Klassen als Datentransferobjekt und persistente Entität:

public class CustomerDto extends BaseDto implements Serializable {
	private String name;
        // constructor and getter, setter
        ....
}
public class BaseDto implements Serializable {
	private Long id;
	private Integer version;

        // constructor and getter, setter
        ....
}

@Entity
@NamedNativeQuery(name = Customer.ALL, query = "select o from Customer o where o.validUntil is not null")
public class Customer {
	public static final String ALL = "Customer.ALL";

	@Id
	private Long id;
	private String name;
	private Date validUntil;
	@Version
	private Integer version;

        // constructor and getter, setter
        ....
}

In der Implementierung wird JPA mit optimistischen Locking Verwendet (beachte das @Version-Attribut am Customer).
Der Konverter ist als Stateless Session Bean implementiert:

/**
 * Converter for entity -> DTO and visa versa.
 */
public interface Converter {
	List<CustomerDto> toCustomerDtos(List<Customer> entities);
	CustomerDto toDto(Customer entity);
}
@Stateless
@Local(Converter.class)
public class ConverterBean implements Converter {
	public List<CustomerDto> toCustomerDtos(List<Customer> entities) {
		List<CustomerDto> dtos = new ArrayList<CustomerDto>();
		for (Customer c : entities) {
			dtos.add(toDto(c));
		}
		return dtos;
	}
	public CustomerDto toDto(Customer entity) {
		if (entity == null)
			return null;
		CustomerDto dto = new CustomerDto();
		dto.setId(entity.getId());
		dto.setVersion(entity.getVersion());
		dto.setName(entity.getName());
		return dto;
	}

}

Und schließlich die eigentliche Geschäftslogik, die in einer internen Session Bean und einem DAO (auch als Stateless Session Bean) liegt:

/**
 * Internal service.
 */
public interface InternalCustomerService {
	List<Customer> getAllValidCustomers();
}
/**
 * Internal service implementation.
 */
@Stateless
@Local(InternalCustomerService.class)
public class InternalCustomerServiceBean implements InternalCustomerService {
	@EJB
	private CustomerDao customerDao;

	public List<Customer> getAllValidCustomers() {
		Date currentDate = new Date();
		List<Customer> allCustomers = customerDao.getAllCustomers();
		List<Customer> result = new ArrayList<Customer>();
		for (Customer c : allCustomers) {
			if (c.getValidUntil().after(currentDate)) {
				result.add(c);
			}
		}
		return result;
	}
}

/**
 * Customer DAO
 */
public interface CustomerDao {
	List<Customer> getAllCustomers();
}
/**
 * DAO Implementation
 */
@Stateless
@Local(CustomerDao.class)
public class CustomerDaoBean implements CustomerDao {
	@PersistenceContext(name = "customer")
	private EntityManager em;

	public List<Customer> getAllCustomers() {
		return em.createNamedQuery(Customer.ALL).getResultList();
	}
}

Zusätzliche Veränderungen

Im obigen Beispiel greifen wir nur lesend auf die Datenbank zu, daher ist die Transaktionalität nur von bedingter Wichtigkeit. Das ändert sich jedoch wenn wir nun eine weitere Methode z.B. zum Aktualisieren eines Kunden hinzufügen möchten. Dank der optimistischen Locking-Strategie kann der konkurrierende Zugriff durch JPA-Provider geregelt werden. Dazu benutzt dieser das mit @Version-markierte Feld und schreibt dort eine Zahl hinein. Bei einer Aktualisierung des Datensatzes wird diese Zahl mit der in der Datenbank verglichen und danach um eins erhöht. Stimmt die Zahl mit der aus der Datenbank nicht überein (weil z.B. in der Zwischenzeit ein anderer Client den Datensatz geändert hat, womit die version erhöht wurde), wirft der JPA Provider eine Exception und rollt die Transaktion zurück. Nach dem Update ist die Version des Datensatzes erhöht (nur im Falle, wenn in der Tat eine Veränderung durchgeführt wurde) und diese Information muss an den Client transportiert werden. Üblicherweise liefert die update-Methode das aktualisierte Objekt (mit der neuen Version) zurück, aber auch aus fachlicher Sicht kann das Objekt mehr beinhalten.

Beginnend mit dem DAO, ermöglichen wir nun eine Kundendatenbearbeitung:

public interface CustomerDao {
	List<Customer> getAllCustomers();
	Customer updateCustomer(Customer customer);
}
...
public class CustomerDaoBean implements CustomerDao {
	...
	public Customer updateCustomer(Customer customer) {
		return em.merge(customer);
	}
}

Ein DAO kann eine abgetrennte (detached) Entität aus der Serviceschicht wieder speichern und dabei die Attribute verändern. Als nächstes ergänzen wir den Konverter um eine Methode, die aus einem DTO eine Entität gewinnt:

public interface Converter {
	...
	Customer toEntity(CustomerDto dto);
}

public class ConverterBean implements Converter {
	...
	public Customer toEntity(CustomerDto dto) {
		if (dto == null)
			return null;
		Customer entity = new Customer();
		entity.setId(dto.getId());
		entity.setVersion(dto.getVersion());
		entity.setName(dto.getName());
		return entity;
	}
}

Bei der Merge-Operation schaut der JPA-Provider auf das Id- und Version-Attribut und findet darüber den entsprechenden Datensatz.

Die interne Implementierung ist recht simpel, denn es delegiert eins-zu-eins an das DAO.

public interface InternalCustomerService {
	...
	Customer updateCustomer(Customer customer);
}

public class InternalCustomerServiceBean implements InternalCustomerService {
	...
	public Customer updateCustomer(Customer customer) {
		return customerDao.updateCustomer(customer);
	}
}

Nun sollte die Update-Methode dem Client zur Verfügung gestellt werden. Dazu ergänzen wir die Facade-Schnittstelle:

public interface CustomerService {
	...
	CustomerDto updateCustomer(CustomerDto dto);
}

Die angebotene Methode nimmt ein Kundentransferobjekt entgegen, konvertiert dieses zu einer losgelösten Entität, ruft dann die interne Implementierung auf, welche eine aktualisierte Version der Kundenentität zurückgibt, die zurück zum Datentransferobjekt konvertiert und an den Client zurückgegeben wird.
Aber Vorsicht, die Implementierung der Schreibmethode unterscheidet sich grundsätzlich in ihrem Transaktionsverhalten von der einer Lesemethode. D.h. der folgende Quelltext wird nicht funktionieren:

public class WrongCustomerServiceBean implements CustomerService {
        // will not work
	public CustomerDto updateCustomer(CustomerDto dto) {
		return converter.toDto(customerService.updateCustomer(converter.toEntity(dto)));
	}

Warum ist es so? Die Implementierung von JTA durch EJB und Spring bietet zwei Transaktionssteuerungsmechanismen an: einen Bean- und einen Container-gesteuerten. Die standardmäßige Container-Transaktionssteuerung legt die Transaktionsgrenzen so, dass eine Transaktion direkt vor dem Methodenaufruf beginnt und direkt nach dem (erfolgreichen) Ende des Methodenaufrufs abgeschlossen (commited) wird. Diese Transaktionsgrenzen können bei zustandlosen Beans auch nicht verschoben werden – lediglich können die Methoden mittels spezieller Annotationen als transaktions-erzeugend(required new), -anfordernd(required), -unterstützend (supports), -ablehnend (never) und ignorierend(not supported) gekennzeichnet werden. Die Voreinstellung dabei ist transaktionsanfordernd.

Angewandt auf unseren Fall bedeutet es, dass folgende Operationen in der Reihenfolge nacheinander ausgeführt werden:

  1. Transaktion startet
  2. updateCustomer(dto) wird mit dem Parameter aus 1. aufgerufen
  3. customerService.updateCustomer(dto) wird mit dem Ergebnis von 2. als Parameter aufgerufen. Dort ist kein Transaktionsattribut annotiert, also wird required-Voreinstellung genommen und die bereits geöffnete Transaktion verwendet.
  4. converter.toDto(entity) wird mit dem Ergebnis von 3. aufgerufen. Dabei kopiert der Konverter die Werte von Id, Version und Name ins DTO. Zu diesem Zeitpunkt ist die Entität Customer nicht losgelöst – JPA hat also die Transaktion noch nicht beendet und die Attribute sind wegen der Atomicity/Isolation-Eigenschaft noch nicht persistent. Vor allem beinhaltet das Version-Attribut die Version VOR der Aktualisierung.
  5. Das Ergebnis DTO wird als Rückgabe der Methode an den Client zurückgegeben, es beinhaltet eine falsche Version und würde bei der nächten Verwendung ein Fehler verursachen.
  6. Die Transaktion wird abgeschlossen. Dabei wird auch die Datenbanktransaktion abgeschlossen und die neue um eins inkrementierte Version in der Datenbank gespeichert.

Die Lösung

Es gibt mehrere Ansätze, wie das Problem der Transaktionsgrenzen beim optimistischen Locking gelöst werden kann. Eine Möglichkeit ist es, die Entität explizit beim Verlassen der DAOs oder der internen Service-Schicht loszulösen. Leider kann dies nur dadurch geschehen, dass der Entity Manager jeglichen Zustand sofort speichert (EntityManager#flush()). Dies ist besonders problematisch, wenn mehrere DAO-Zugriffe aus der internen Schicht durchgeführt werden.

Die von mir favorisierte Lösung basiert darauf, dass die Service-Facade keine ACID-Garantien beim Aufruf des Clients gibt. In der Tat widerspricht es auch dem serviceorientiertem Ansatz, dass interne Implementierungsdetails eines Services an einen Client weitergegeben werden. Insbesondere sind ACID-Garantien ein Zeichen für eine sehr enge Kopplung, die gerade durch die Einführung der DTOs als Abstraktion von den Entitäten vermieden werden sollte. Trotztdem sollte natürlich die interne Verarbeitung transaktional passieren.

Die Grundidee dabei ist, den Konverter-Aufruf außerhalb der Transaktion zu starten. Dies geschieht, indem man die Ausführung in einen transaktionalen und einen nichttransaktionalen Teil aufsplittet. Statt also die Voreinstellung zu verwenden, wird die nach außen sichtbare Methode als transaktions-ablehnend deklariert und darin ein Aufruf einer transaktionalen Methode in der gleichen Bean (über den Container) durchgeführt:

@Stateless
@Remote(CustomerService.class)
@Local(CustomerServiceLocal.class)
public class CustomerServiceBean implements CustomerService, CustomerServiceLocal {

	....

	@Resource
	private SessionContext ctx;

	@Override
	@TransactionAttribute(TransactionAttributeType.NEVER)
	public CustomerDto updateCustomer(CustomerDto dto) {
		return converter.toDto(getThis().updateCustomer0(dto));
	}

	@Override
	@TransactionAttribute(TransactionAttributeType.REQUIRED)
	public Customer updateCustomer0(CustomerDto dto) {
		return customerService.updateCustomer(converter.toEntity(dto));
	}

	private CustomerServiceLocal getThis() {
		return ctx.getBusinessObject(CustomerServiceLocal.class);
	}
}

public interface CustomerServiceLocal {
	Customer updateCustomer0(CustomerDto dto);
}

Es ist dabei wichtig, die updateCustomer0-Methode nicht direkt auf der aktuellen Instanz aufzurufen, sondern den Container nach einer anderen Instanz zu fragen (getThis()). Damit jedoch dieser Zugriff funktioniert, müssen die Methoden in einem lokalen Interface deklariert sein. Alternativ dazu kann die update0-Methode auch gleich in die interne Service-Schicht verschoben werden, so dass die Facade-Schicht nur die Konvertierung der Ergebnisse übernimmt und die Service-Schicht dahinter sich um die Orchestrierung der Aufrufe kümmert – aber das ist Geschmacksache.

Wichtig ist nur, dass die Facade-Schicht kein Transaktionsversprechen an den Client gibt.

Über den Autor

Simon Zambrovski

Simon Zambrovski ist als Senior Berater der Holisticon AG in Hamburg, BPM-Craftsman, Arhictekt und Entwickler in IT-Projekten unterwegs. Er entwickelte maßgeblich eine Methodologie zur Durchführung von agilen BPM Projekten. Sein aktuelles Interesse gilt der Automatisierung von Geschäftsprozessen, den Event-Driven Microservices und dem Enterprise Architektur Management.

Antwort hinterlassen