Mechanizm Conditional requests czyli warunkowych zapytań służy do optymalizacji żądań HTTP. Bardzo często odpytujemy o dany zasób po mimo iż jego stan się nie zmienił. W odpowiedzi dostajemy cały zasób i musimy zaimplementować logikę porównywania czy pojawiła się jakaś zmiana w obiekcie. Jednakże standard HTTP dostarcza nam mechanizmy do realizacji tego zagadnienia, są to słabe oraz silne nagłówki cachowania.
Mechanizm ten powinien być wykonywany dla operacji odczytu (ang. read). W momencie wysłania warunkowego żądania sprawdzany jest żądany zasób, czy zmienił się w stosunku do ostatniego wywołania. Nie jest to sprawdzanie bajt po bajcie tylko odbywa się to przy wykorzystaniu validatorów. Rozróżniamy dwa typy validatorów:
- data ostatniej modyfikacji zawarta w nagłówku Last-Modified
- wartość typu String przechowująca numer wersji nazywana Entity Tag lub ETag
Samo porównywanie wartości może odbywać się na dwa sposoby:
- słaba walidacja kiedy mogą wystąpić “drobne” różnice
- silna walidacja kiedy każdy bajt musi się zgadzać
HTTP domyślnie używa silnej walidacji. Natomiast do wykonywanie warunkowych zapytań wykorzystujemy warunkowe nagłówki:
- If-None-Match dla ETagów
- If-Modified-Since dla Last-Modified
Przykładowe flow znajduje się poniżej. Pierwsze wykonanie to wysłanie żądania GET o dowolny zasób. W odpowiedzi dostajemy ten zasób wraz z dwoma nagłówkami ETag oraz Last-Modified. Jeden z nich jest nadmiarowy, do realizacji warunkowych żądań wystarczy jeden mechanizm, ja po prostu chciałem zaprezentować dwa od razu.
Teraz jeśli chcemy sprawdzić czy zasób się zmienił nie musimy ponownie odpytywać o cały zasób. Wystarczy wykorzystać odpowiednie nagłówki. Dla ETag należy dodać If-None-Match z wartością otrzymaną za pierwszym razem natomiast dla Last-Modified należy użyć If-Modified-Since z datą otrzymaną wcześniej. Jeśli zasób się nie zmienił powinniśmy otrzymać kod 304 Not Modified. W przypadku gdy zasób zmienił się od tego czasu otrzymamy kod 200 wraz z tym zasobem oraz nową wersją ETagu i z zaktualizowaną datą Last-Modified.
W Springu możemy zrealizować ETagi na dwa sposoby. Pierwszy z nich to wykorzystanie adnotacji @Version
z pakietu javax.persistence,
która umieszczona na polu przechowującym wersję będzie ją automatycznie aktualizować:
@Data @Entity @RequiredArgsConstructor public class Todo { @Id @GeneratedValue private Long id; @Version private Long version; private String content; public Todo(String content) { this.content = content; }
Natomiast drugim sposobem jest dodanie beany ShallowEtagHeaderFilter
:
@Bean public Filter shallowEtagHeaderFilter() { return new ShallowEtagHeaderFilter(); }
Jedyną różnicą pomiędzy tymi rozwiązaniami jest rodzaj generowanego ETagu. W przypadku @Version
tag generowany jest od zera. W drugim rozwiązaniu tag wygląda na losowy ciąg na przykład: 03f91e84687eb66da6e5479c563dd6c9d. Aby sprawdzić poprawność działania można wykorzystać POSTMan’a lub jak najbardziej testy!
@Test public void shouldReturnETag() throws Exception { todoRepository.save(new Todo("content")); MockHttpServletResponse response = mvc.perform(get("/todos/1")) .andExpect(header().string(ETAG, is(notNullValue()))) .andReturn().getResponse(); mvc.perform(get("/todos/1").header(IF_NONE_MATCH, response.getHeader(ETAG))) .andExpect(status().isNotModified()) .andExpect(header().string(ETAG, is(notNullValue()))) .andReturn().getResponse(); }
Jeśli natomiast chcemy wykorzystać mechanizm If-Modified-Since musimy wykorzystać audyty JPA. Mechanizm audytów będzie automatycznie aktualizował pole które przechowuje datę ostatnie modyfikacji:
@Data @Entity @RequiredArgsConstructor @EntityListeners(AuditingEntityListener.class) public class Todo { @Id @GeneratedValue private Long id; @JsonIgnore @LastModifiedDate private LocalDateTime lastModifiedDate; private String content; public Todo(String content) { this.content = content; } }
Należy pamiętać także o dodaniu adnotacji @EnableJpaAuditing
na naszej aplikacji. Test do sprawdzenia działania:
@Test public void shouldReturnLastModified() throws Exception { todoRepository.save(new Todo("content")); MockHttpServletResponse response = mvc.perform(get("/todos/1")) .andExpect(header().string(LAST_MODIFIED, is(notNullValue()))) .andReturn().getResponse(); mvc.perform(get("/todos/1").header(IF_MODIFIED_SINCE, response.getHeader(LAST_MODIFIED))) .andExpect(status().isNotModified()) .andExpect(header().string(LAST_MODIFIED, is(notNullValue()))) .andReturn().getResponse(); }