В первой части статьи о работе с HTTP в Spring Web MVC я писал о заголовках и особых методах запроса. В этой статье я продолжу тему HTTP и Spring Web MVC.
Кэширование
Кэширование является важной частью современного веба. Раньше с помощью кэширующих прокси пытались сэкономить дорогие каналы на стороне клиента, теперь, когда полгигабита в каждый дом является практически нормой, с помощью кэширования стараются сэкономить ресурсы сервера.
Идея кэширования очень проста: данные, пересланные от сервера к клиенту, сохраняются на клиенте и, в случае если они запрашиваются повторно, используется локальная копия. Единственная проблема с кэшем — если данные на сервере изменятся, клиент не будет об этом знать и будет использовать старые данные. В HTTP предусмотрено несколько механизмов для решения этой проблемы.
Первым механизм — заголовок Cache-Control. Он указывает клиенту, можно ли кэшировать данный ресурс и на какой срок. В Spring Web MVC можно установить этот заголовок, возвращая особый объект ответа: ResponseEntity. ResponseEntity это нечто среднее между HttpServletResponse и простым возвратом нужных данных: с одной стороны с помощью ResponseEntity можно довольно подробно настроить посылаемый клиенту ответ, хоть это будет и не так удобно, как просто возврат ваших данных, с другой стороны это более высокоуровневый и, следовательно, более ограниченный API, чем HttpServletResponse. В моём случае я буду использовать ResponseEntity для установки заголовков кэширования:
1
2
3
4
5
6
7
8
|
@GetMapping("/day")
ResponseEntity<String> getDayWithCache() {
CacheControl cache = CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic();
return ResponseEntity
.ok()
.cacheControl(cache)
.body(LocalDate.now().toString());
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
>curl -v http://localhost:8080/api/day
> GET /api/day HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
>
< HTTP/1.1 200
< Cache-Control: max-age=86400, public
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 10
< Date: Thu, 03 May 2018 09:53:46 GMT
<
2018-05-03
|
Объект CacheControl позволяет настроить параметры кэширования: ответ можно закэшировать на 1 день и его можно кэшировать в публичных прокси. Потом я добавляю этот объект к ответу и в заголовках ответа автоматически появится заголовок Cache-Control. Мой заголовок говорит клиенту, что ответ можно запомнить и хранить до примерно 10 утра по GMT 4го мая. Стоит отметить, что этот заголовок разрешает клиенту кэширование, но не обязывает его кэшировать.
Иной механизм, позволяющий клиенту явно проверить, не изменился ли объект, называется ETag. Сервер присваивает каждому ответу уникальный ETag, клиент отправляет запрос с известным ему значением ETag и если оно соответствует текущему, данные не передаются. Это позволяет сократить расходы на повторную передачу объекта и, при этом же, позволяет продолжать использовать закэшированный объект после истечения срока его кэширования.
В Spring Web MVC значение ETag отправляется так же с помощью ResponeEntity:
1
2
3
4
5
6
7
8
9
10
|
@GetMapping("/tomorrow")
ResponseEntity<String> getDayWithEtag() {
String tomorrow = LocalDate.now().plus(1, ChronoUnit.DAYS).toString();
CacheControl cache = CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic();
return ResponseEntity
.ok()
.cacheControl(cache)
.eTag(tomorrow)
.body(tomorrow);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
>curl -v http://localhost:8080/api/tomorrow --header 'If-Modified-Since: Thu, 03 May 2018 12:00:00 GMT'
* About to connect() to localhost port 8080 (#0)
* Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /api/tomorrow HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
>
< HTTP/1.1 200
< ETag: "2018-05-04"
< Cache-Control: max-age=86400, public
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 10
< Date: Thu, 03 May 2018 10:12:13 GMT
<
* Connection #0 to host localhost left intact
2018-05-04
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
>curl -v http://localhost:8080/api/tomorrow --header 'If-None-Match: "2018-05-04"'
* About to connect() to localhost port 8080 (#0)
* Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /api/tomorrow HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
> If-None-Match: "2018-05-04"
>
< HTTP/1.1 304
< ETag: "2018-05-04"
< Cache-Control: max-age=86400, public
< Date: Thu, 03 May 2018 10:12:51 GMT
<
|
При запросе без дополнительных заголовков Spring Web MVC просто вставит наш ETag в заголовки ответа. Однако, если мы отправим запрос с заголовком If-None-Match, значение которого будет совпадать с ETag в ResponseEntity, Spring Web MVC автоматически вернёт 304 Not modified и не будет передавать клиенту ответ. Недостатком такого подхода является тот факт, что со стороны сервера ответ всё таки подготавливается и ресурсы на него тратятся. Таким образом экономится только полоса и время на передачу, но не ресурсы сервера.
Однако, можно вручную прерывать обработку в случае совпадений ETag или достаточной свежести данных и таким образом экономить ресурсы сервера. Для этого используется объект WebRequest, являющийся более человечным аналогом низкоуровневого HttpServletRequest.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@GetMapping("/effectivetomorrow")
ResponseEntity<String> getDayEffectively(WebRequest request) {
Long lastModified = LocalDate.now().atTime(0,0,0).toInstant(ZoneOffset.UTC).toEpochMilli();
if (request.checkNotModified(lastModified)) { //Check modification timestamp
return null;
}
String tomorrow = LocalDate.now().plus(1, ChronoUnit.DAYS).toString();
if (request.checkNotModified(tomorrow)) { //Check etag
return null;
}
CacheControl cache = CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic();
return ResponseEntity
.ok()
.cacheControl(cache)
.eTag(tomorrow)
.body(tomorrow);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
|
>curl -v http://localhost:8080/api/effectivetomorrow --header 'If-Modified-Since: Thu, 03 May 2018 12:00:00 GMT'
> GET /api/effectivetomorrow HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
> If-Modified-Since: Thu, 03 May 2018 12:00:00 GMT
>
< HTTP/1.1 304
< Last-Modified: Thu, 03 May 2018 00:00:00 GMT
< Date: Thu, 03 May 2018 10:25:37 GMT
<
* Connection #0 to host localhost left intact
|
1
2
3
4
5
6
7
8
9
10
11
12
|
>curl -v http://localhost:8080/api/effectivetomorrow --header 'If-None-Match: "2018-05-04"'
> GET /api/effectivetomorrow HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
> If-None-Match: "2018-05-04"
>
< HTTP/1.1 304
< Last-Modified: Thu, 03 May 2018 00:00:00 GMT
< ETag: "2018-05-04"
< Date: Thu, 03 May 2018 10:26:22 GMT
<
|
Используя WebRequest я проверяю сразу два заголовка. Первый, If-Modified-Since, указывает мне, что я бы хотел получить новые данные, только если они изменились с указанного момента времени. Второй, If-None-Match, проверяет значение ETag. В случае выполнения одного из условий я возвращаю null, что является указанием вернуть 304 Not Modified. И только если условия не выполнены, я формирую полноценный ответ и отправляю его. Этот подход позволяет сэкономить как ресурсы сети на передачу ответа, так и ресурсы сервера на его генерацию.
Параметры формы
Раз уж речь зашла о кэшировании контента, самое время написать о том, что кэшировать не стоит — POST запросы с даными форм. HTML формы (обычно) отправляются на сервер в виде POST запроса с особым типом контента и передачей данных формы в теле запроса. Хорошая новость в том, что такие запросы обрабатываются с помощью аннотации @RequestParam:
1
2
3
4
|
@PostMapping("/form")
String postForm(@RequestParam("key") String key, @RequestParam("value") String value) {
return String.format("%s = %s", key, value);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
>curl -v -X POST -H "Content-Type: application/x-www-form-urlencoded" -d "key=somekey&value=somevalue" http://localhost:8080/api/form
> POST /api/form HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.52.1
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 27
>
* upload completely sent off: 27 out of 27 bytes
< HTTP/1.1 200
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 19
< Date: Sun, 06 May 2018 16:08:19 GMT
<
somekey = somevalue
|
Отправлять данные формы вручную не очень удобно: надо выставить правильный заголовок Content-Type и сформировать строку с параметрами.
Отправка файлов
А ещё из формы браузера можно отправить файл. А если запрос формируется не браузером, а скриптом или каким-то другим клиентом, то можно послать и несколько файлов и ещё добавить к ним какие-то дополнительные данные. Spring поддерживает загрузку такого контента с помощью аннотации @RequestPart:
1
2
3
4
5
6
7
8
|
@PostMapping("/file")
StringBuilder postFile(@RequestPart("metadata") String data, @RequestPart("filedata") MultipartFile file) throws IOException {
StringBuilder result = new StringBuilder();
result.append(String.format("File data: %s\n", data));
result.append("File content:\n");
result.append(new String(file.getBytes(), "UTF-8"));
return result;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
>curl -v -i -X POST -H "Content-Type: multipart/mixed" -F "metadata={\"name\":\"testfile.txt\"};type=application/json" -F "[email protected]" http://localhost:8080/api/file
> POST /api/file HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.52.1
> Accept: */*
> Content-Length: 477
> Expect: 100-continue
> Content-Type: multipart/mixed; boundary=------------------------82b19b678de6b498
>
< HTTP/1.1 100
< HTTP/1.1 200
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 176
< Date: Sun, 06 May 2018 16:53:44 GMT
<
File data: {"name":"testfile.txt"}
File content:
Visit the project page for details about these builds and the list of changes:
https://github.com/vszakats/curl-for-win
* Connection #0 to host localhost left intact
|
Командой выше я отправляю json объект и какой-то первый попавшийся файл и передаю его обратно. Само содержимое файла может быть либо прочитано в память, с помощью вызова getBytes(), либо получено в виде InputStream с помощью вызова getInputStream().
Код примера доступен на github