Optimistic Locking mit dem ETag Header

Nach fast zwei Jahren gibt es nun einen Folgebeitrag zu Catch 304 – der faule Trick. Der Beitrag behandelte die Möglichkeit weniger Daten zum REST Client zu übertragen. Der Mechanismus ist dabei recht simpel aufgebaut und nutzt eine eindeutige ID für jede Version einer Resource.

Beim ersten Zugriff erhält der Client die ID für die aktuelle Version über die Header ETag mitgeteilt. Bei weiteren Aufrufen sendet der Client diese ID im Header If-Modified-Since mit, damit der Server überprüfen kann, ob sich die Version mittlerweile geändert hat. Hast sich die Version nicht geändert, dann schickt der Server eine leere Antwort mit dem Status Code Not Modified (304), im anderen Fall die erwartete Antwort mit einem aktualisierten ETag Header.

Spring Boot liefert eine einfache aber doch sehr elegante Implementierung dieses Mechanismus in Form des ShallowEtagHeaderFilter. Dieser Filter benutzt als ETag Header einen Hashwert über den Inhalt der Antwort. Für jede Antwort wird also vor dem Versand ein Hashwert berechnet und dieser mit dem angefragten If-Modified-Since Wert verglichen. Sind beide Werte gleich, dann hat sich die Resource nicht verändert und eine leere Antwort mit dem Status Code Not Modified (304) kann versendet werden. Der Mechanismus verringert nicht die Arbeit des Servers, aber die Übertragung über das Netzwerk wird reduziert und der Client erspart sich unnötige Aktualisierungen.

Wie kann dieser Mechanismus verbessert oder wenigstens besser genutzt werden? Die Zauberformel Optimistic Locking gab dabei einen kleinen Anstoß in eine neue Richtung.

Wenn mehr als ein Beteiligter beim Speichern von Daten involviert ist, kann es zu ungewollten Überschreibungen kommen. Um solche Probleme zu umgehen, existieren drei Vorgehensweisen. Beim Pessimistic Locking blockiert der Erste und alle anderen müssen warten, bis dieser mit seiner Arbeit fertig ist. Optimistic Locking blockiert nicht, aber bei gleichzeitigen Änderung erfährt einer der Kontrahenten eine Ablehnung. Bei der dritten Variante werden solche Probleme beim Aktualisieren einfach ignoriert.

Hat man sich für Optimistic Locking in der eigenen Spring Boot Anwendung entschieden, sind die anfänglichen Umbauarbeiten nicht sehr groß. Benötigt wird bei allen betroffenen Entitäten ein zusätzliches Versionsattribut.

@Entity
@Data
public class AncestorTree {
    @Id
    @GeneratedValue(strategy=GenerationType.AUTO)
    private Long id;
  
    @Version
    private Long version;  

    @Column(length = 50, nullable = false)
    private String name;

    @Column(length = 30, nullable = false)
    private String file;
}

Durch die Annotation @Version ist dieses Attribut für das Optimistic Locking reserviert und wird bei jedem Schreibzugriff auf den erwarteten Wert geprüft. Etwas verwirrend ist es, dass dieses Attribut nicht gesetzt werden darf. Es dient nur der JPA Infrastruktur zur Realisierung des Optimistic Locking.

Werden nun zwei parallele Schreibzugriffe festgestellt, dann erhält einer der beiden eine ObjectOptimisticLockingFailureException. Mit einem entsprechenden @ExceptionHandler kann der RestController mit dem Status Code Conflict (409) antworten.

  @ExceptionHandler(value = ObjectOptimisticLockingFailureException.class)
  @ResponseStatus(HttpStatus.CONFLICT)
  protected CustomErrorResponse noResourceFound(ObjectOptimisticLockingFailureException e, WebRequest request) {
    logger.warn("optimistic locking failure: {}", e.getMessage());
    return new CustomErrorResponse(HttpStatus.CONFLICT, e.getMessage());
  }

Es bietet sich an, das Versionsattribut als ETag Header zu versenden und wenn der Client die Resource ändern möchte, die ihm bekannte Version mitzuschicken. Im Server wird dann diese Version mit der aktuellen verglichen und bei Bedarf eine Exception geworfen.

AncestorTree tree = repository.findById(id).orElseThrow();
if (!tree.getVersion().equals(version)) {
  throw new ObjectOptimisticLockingFailureException(AncestorTree.class.getName(), id);
}

Es gibt zwei Positionen im Request an denen der Client die Version kodieren kann. Entweder in der zu ändernden Resource im Request Body oder oder in einem HTTP Header. Es bietet sich der HTTP Header an, weil es zum restlichen Mechanismus mit ETag und If-Modified-Since Header passt und die passende Header If-Match schon existiert.

@PutMapping("/trees/{id}")
public EntityModel<TreeDto> update(@PathVariable @Min(1) Long id,
    @RequestBody TreeDto tree, @RequestHeader(HttpHeaders.IF_MATCH) Long version) {
  return createEntityModel(service.update(id, tree, version));
}

Die update Methode wird mit dem zusätzlichen version Parameter aus dem Request versorgt und reicht diesen an die update Methode des TreeService weiter.

So bleiben nur noch zwei Mankos des aktuellen Ansatzes bestehen. Zum einen arbeitet dieser Ansatz nicht mit dem ShallowEtagHeaderFilter Ansatz von Spring Boot zusammen und die ETag Header muss explizit in die ResponseEntity eingefügt werden.

Um den ShallowEtagHeaderFilter mit der hier entworfene Lösung zu verbinden, darf der ShallowEtagHeaderFilter nur dann ETags erzeugen, wenn sie nicht schon im Response vorhanden sind. Dazu benötigen wir einen eigenen Filter, der nur bei fehlenden ETags an den ShallowEtagHeaderFilter delegiert.

public class EtagVersionHeaderResultFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    filterChain.doFilter(request, response);
    if (!HttpStatus.valueOf(response.getStatus()).is2xxSuccessful()) {
      return;
    }
    String ifNoneMatchHeader = request.getHeader(HttpHeaders.IF_NONE_MATCH);
    String eTagAttribute = (String) request.getAttribute(HttpHeaders.ETAG);
    if (eTagAttribute == null) {
      return;
    }
    response.setHeader(HttpHeaders.ETAG, eTagAttribute);
    if (ifNoneMatchHeader != null && Objects.equals(ifNoneMatchHeader, eTagAttribute)) {
      response.setStatus(List.of("GET", "HEAD", "PUT").contains(request.getMethod()) ?
          HttpStatus.NOT_MODIFIED.value() : HttpStatus.PRECONDITION_FAILED.value());
    }
  }
}

Dazu holt der Filter das zuvor in ein Request-Attribute gespeicherte ETag und den If-None-Match Header aus dem Request und vergleicht sie. Existiert ein ETag in einem Request Attribute, dann wird dieses in den Header geschrieben und bei einem Match mit dem If-None-Match Header, der entsprechende Response-Status gesetzt.

Die bisherige Lösung benötigt mehr, als die oben angegebene update Methode, weil bislang kein ETag Header in der Methode gesetzt wird. Auch müssen zusätzlich einige GET und POST Endpoints angepasst werden, um das Versionsattribut auch dort im ETag Header zurück zu liefern.

@PutMapping("/trees/{id}")
public ResponseEntity<EntityModel<TreeDto>> update(@PathVariable @Min(1) Long id,
    @RequestBody TreeDto tree, @RequestHeader(HttpHeaders.IF_MATCH) Long version) {
  TreeDto result = service.update(id, tree, version);
  return ResponseEntity.ok().header(HttpHeaders.ETAG, result.getVersion()).body(createEntityModel(result));
}

Damit die elegantere Variante genutzt werden kann, muss unter der Haube das Versionsattribut aus dem Resultat extrahiert werden und in die Response Header geschrieben werden.

Implementierungen vom Typ HandlerMethodReturnValueHandler kümmern sich in Spring Boot um Bearbeitung der Rückgabewerte. Eine eigene Implementierung, die an eine bestehende Implementierung delegiert, kann sich um das Setzen des ETag Header kümmert.

@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
    ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
  HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
  if (response == null || HttpStatus.valueOf(response.getStatus()).is2xxSuccessful()) {
    delegate.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
    return;
  }
  String currentETag = response.getHeader(ETAG);
  if (currentETag != null) {
    delegate.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
    return;
  }
  HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
  extractETagValue(returnValue, returnType).ifPresent(v -> request.setAttribute(ETAG, v));
  delegate.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
}

Wenn es kein HttpServletResponse gibt oder das ETag Header schon gesetzt wurde, dann wird die Verarbeitung sofort delegiert. Im anderen Fall wird aus dem Parameter returnValue das gewünschte Attribute extrahiert und das ETag Attribute im HttpServletRequest gesetzt. Das ETag kann nicht direkt im Header gesetzt werden, weil sonst die Verarbeitung der Filter frühzeitig beendet wird.

Damit die Implementierung an dieser Stelle variabel bleibt, muss noch eine weitere Ergänzung eingebaut werden.

@PutMapping("/trees/{id}")
@ETag("result.content.version") 
public EntityModel<TreeDto> update(@PathVariable @Min(1) Long id,
    @RequestBody TreeDto tree, @RequestHeader(HttpHeaders.IF_MATCH) Long version) {
  return createEntityModel(service.update(id, tree, version));
}

Die @ETag Annotation ist eine selbst erstellte Annotation die das Versionsattribut im Resultat adressiert. Dadurch weiß der Mechanismus genau, wo der Inhalt für den ETag Header zu finden ist.

private Optional<String> extractETagValue(Object returnValue, MethodParameter returnType) {
  ETag eTag = returnType.getMethodAnnotation(ETag.class);
  if (eTag == null) {
    return Optional.empty();
  }
  Expression exp = new SpelExpressionParser().parseExpression(eTag.value());
  EvaluationContext context = new StandardEvaluationContext(new Result(returnValue));
  return Optional.ofNullable(exp.getValue(context)).map(v -> "\"" + v + "\"");
}

Auf die Annotation kann über den Parameter returnType zugegriffen werden und dann mit Hilfe der Spring Expression Language (SpEL), der Wert extrahiert und in einen String umgewandelt werden.

Noch immer hat der Server die ganze Arbeit, aber neben den Vorteilen der ursprünglichen ShallowEtagHeaderFilter Lösung, kann nun Optimistic Locking in der REST Schnittstelle genutzt werden.

Schreibe einen Kommentar