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.
Is there a git repo where you can inspect the entire implementation. I am struggling to get the custom return value handler to work. I cant even get the handler to be registered and be called on a normal get request.
Greetings Lucca
I am sorry but there is no puplic repository where you have access to the source code.
Registering the custom HandlerMethodReturnValueHandler was/is tricky.
As far as I remember, you have to use the RequestMappingHandlerAdapter and get the List of all HandlerMethodReturnValueHandler and inject the custom one at a appropiate place.
But I will look throw my private repositories for further information.
I have found some code about the initialization of the
HandlerMethodReturnValueHandler
.It uses a custom
InitializingBean
an theEtagHeaderResultReturnValueHandler
wraps twoHandlerMethodReturnValueHandler
from theRequestMappingHandlerAdapter
.private HandlerMethodReturnValueHandler wrap(HandlerMethodReturnValueHandler handler) {
if (handler instanceof RequestResponseBodyMethodProcessor
|| handler instanceof RepresentationModelProcessorHandlerMethodReturnValueHandler) {
return new EtagHeaderResultReturnValueHandler(handler);
}
return handler;
}
Hope this helps.