Самое главное в любом web фреймворке — отображение: какой код обслуживает какой http endpoint((это ещё называют routing). В Spring Web MVC для этого используются аннотации, связывающие методы классов с веб запросами.
Основы web и HTTP
Перед тем, как начинать писать методы, вызываемые через web, стоит определиться с терминологией и понять, как это всё работает. Протокол HTTP (и HTTP/2 тоже) используют URL для идентификации ресурсов. URL выглядит следующим образом:
<схема>:[//[<логин>:<пароль>@]<хост>[:<порт>]][/<URL‐путь>][?<параметры>][#<якорь>]
Всё, что написано до /<URL-путь> несомненно очень важно, однако обрабатывается до того, как в дело вступает наше приложение. Когда HTTP клиент подключается к HTTP серверу, он посылает путь в запросе. Кроме пути каждый запрос характеризуется http методом, указывающего, что же надо сделать с ресурсом, определяемым путём. Таким образом, к одному пути может быть аж до девяти обработчиков, по одному для каждого метода. Метод и путь — не единственная часть запроса, с запросом передаётся набор заголовков, параметры запроса и тело запроса. К сожалению, заголовков даже в стандарте определено слишком много, чтобы их здесь перечислять, а уж параметры и тело могут быть вообще какие угодно. Использование всего богатства HTTP я рассмотрю в следующих статьях, а пока сосредоточимся на простом — пути и методах.
Контроллеры Spring Wev MVC
В качестве основы я взял пустое Spring boot приложение с поддержкой Spring Web MVC. В это пустое приложение добавим единственный контроллер и тест к нему:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
@RestController
@RequestMapping("/notes")
public final class NoteController {
private Map<String, String> notes = new TreeMap<>();
@GetMapping
public List<String> list() {
return notes
.entrySet()
.stream()
.map(entry -> String.format("%s - %s", entry.getKey(), entry.getValue()))
.collect(Collectors.toList());
}
@GetMapping("/{id}")
public String read(@PathVariable String id) {
if (!notes.containsKey(id)) {
throw new NoteNotFoundException();
}
return notes.get(id);
}
@PostMapping("")
@ResponseStatus(HttpStatus.CREATED)
public String create(@RequestParam(required = false) String id, @RequestBody String note) {
if (StringUtils.isEmpty(id)) {
id = note.split(" ")[0];
}
notes.put(id, note);
return id;
}
@PutMapping("/{id}")
public String update(@PathVariable String id, @RequestBody String note) {
if (!notes.containsKey(id)) {
throw new NoteNotFoundException();
}
notes.put(id, note);
return this.read(id);
}
@RequestMapping(value = "/{id}",method=RequestMethod.DELETE)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable String id) {
notes.remove(id);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
package ru.easyjava.spring.webmvc.helloboot;
import org.junit.Test;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.*;
public class NoteControllerTest {
private NoteController testedObject = new NoteController();
@Test
public void testPostAndGet() {
testedObject.create("TEST", "TEST");
assertThat(testedObject.read("TEST"), is("TEST"));
}
@Test
public void testPostGenerateIdAndGet() {
testedObject.create(null, "EMPTY TEST");
assertThat(testedObject.read("EMPTY"), is("EMPTY TEST"));
}
@Test(expected = NoteNotFoundException.class)
public void testNotFound() {
testedObject.read("INVALID");
}
@Test
public void testUpdate() {
testedObject.create("TOUPDATE", "OLDVALUE");
assertThat(testedObject.read("TOUPDATE"), is("OLDVALUE"));
assertThat(testedObject.update("TOUPDATE", "NEWVALUE"), is("NEWVALUE"));
assertThat(testedObject.read("TOUPDATE"), is("NEWVALUE"));
}
@Test
public void testDelete() {
testedObject.create("DELETEME", "DELETEME");
assertThat(testedObject.read("DELETEME"), is("DELETEME"));
testedObject.delete("DELETEME");
try {
testedObject.read("DELETEME");
fail();
} catch (NoteNotFoundException ex) { }
}
}
|
Всякий контроллер обязательно имеет аннотацию @Controller или @RestController, последняя расширяет @Controller и добавляет к нему @ResponseBody, тем самым говоря, что все методы класса возвращают именно те данные, которые надо переслать клиенту, игнорируя MVC часть (которую я тоже рассмотрю отдельно). Вторая аннотация в моём классе контроллера — @RequestMapping, в такой форме она задаёт общий путь для всех членов класса, каждый из которых может как обслуживать какие-то методы, вызываемые для общего пути класса, так и методы для путей под путём класса.
Собственно первый же метод, list(), использует этот путь по умолчанию. Аннотация @GetMapping говорит — метод list() должен быть вызван, когда кто-то вызывает метод GET на пути /notes. Имя пути, очевидно, берётся из параметра @RequestMapping на классе.
Второй метод, read(), показывает сразу две особенности маппинга. Во-первых @GetMapping содержит путь и этот путь будет добавлен к пути, который задан у всего класса. Во-вторых, этот путь содержит переменную {id}, что позволяет не задавать путь явно, если вы не знаете какую-либо часть пути или если он переменный. В случае метода read() он будет вызываться на все пути вида
- /notes/1
- /notes/test
- /notes/parmigiano reggiano
и так далее.
Разумеется, если есть метод задать переменную часть пути, есть метод и прочитать её. И именно этот метод показан в параметрах read() — @PathVariable String id, имя переменной в методе должно совпадать с именем переменной пути. Это позволяет иметь пути с несколькими переменными.
Наконец, именно в методе read() показано, как можно из одной функции вернуть два ответа. Собственно я следую соглашениям HTTP — если по запрошенному пути что-то есть, возвращаем данные, если нет — возвращаем ошибку 404. А для того, чтобы в java вернуть ошибку, используются исключения. А Spring Web MVC умеет перехватывать исключения и конвертировать их в HTTP статусы. Для этого достаточно завести собственное исключение и навесить на него аннотацию @ResponseStatus
1
2
|
@ResponseStatus(HttpStatus.NOT_FOUND)
public class NoteNotFoundException extends RuntimeException { }
|
Метод create() показывает, что @ResponseStatus можно навешивать и на любой метод, чтобы он начал отвечать на всё заданным кодом ответа. Аннотация @PostMapping без спецификации пути, говорит нам, что метод create() обслуживает POST запросы по пути /notes. Кроме того, в этом методе показано как работать с другими частями HTTP запроса: параметрами, передающимися в URL и телом запроса. @RequestParam связывает параметр запроса и переменную по имени. По умолчанию параметры обязательны и, если его не будет, ваш код даже не будет вызван и Spring Web MVC вернёт ошибку самостоятельно. Однако в моём случае метод можно вызывать без параметра. Параметров метода с @RequestParam может быть сколько угодно. А вот параметр с @RequestBody может быть только один и с ним будет передано тело запроса. Кстати, хотя это и редко используется, тело запроса можно передавать с любым типом запроса.
Наконец метод update() показывает, как обрабатывать PUT запросы. В принципе в нём используются уже описанные вещи, поэтому подробно я его рассматривать не буду. Строго говоря, я вписал этот метод только для полноты поддержки CRUD (Create-Read-Update-Delete).
Последний метод, delete(), показывает альтернативный метод маппинга, с помощью @RequestMapping — в параметрах аннотации можно передать не только путь, но и HTTP метод, на который следует реагировать. Строго говоря, все ранее перечисленные аннотации являются всего лишь сокращёнными версиями @RequestMapping
Работа приложения
Как несложно было понять из кода — приложение позволяет сохранять строки в памяти, просматривать их и удалять. Давайте проверим его в работе:
1
2
3
4
5
6
7
8
9
10
11
12
|
>curl -X POST -d "First note" -v http://localhost:8080/notes/
* Connected to localhost (::1) port 8080 (#0)
> POST /notes/ HTTP/1.1
> Host: localhost:8080
>
* upload completely sent off: 10 out of 10 bytes
< HTTP/1.1 201
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 11
< Date: Tue, 17 Apr 2018 21:21:09 GMT
<
First+note
|
1
2
3
4
5
6
7
8
9
10
11
|
>curl -X POST -d "Second note" -v http://localhost:8080/notes?id=mynote
* Connected to localhost (::1) port 8080 (#0)
> POST /notes?id=mynote HTTP/1.1
>
* upload completely sent off: 11 out of 11 bytes
< HTTP/1.1 201
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 6
< Date: Tue, 17 Apr 2018 21:23:18 GMT
<
mynote
|
Записи вроде бы создались, проверим их наличие:
1
2
3
4
5
6
7
8
9
10
|
>curl -v http://localhost:8080/notes/
* Connected to localhost (::1) port 8080 (#0)
> GET /notes/ HTTP/1.1
>
< HTTP/1.1 200
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Tue, 17 Apr 2018 21:24:24 GMT
<
["First+note= - First+note=","mynote - id=mynote&Second+note="]
|
Выглядит нормально. Попробуем заменить значение одной из записей и прочитать её:
1
2
3
4
5
6
7
8
9
10
11
|
>curl -X PUT -d "edited" -v http://localhost:8080/notes/mynote
* Connected to localhost (::1) port 8080 (#0)
> PUT /notes/mynote HTTP/1.1
>
* upload completely sent off: 6 out of 6 bytes
< HTTP/1.1 200
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 6
< Date: Tue, 17 Apr 2018 21:26:06 GMT
<
edited
|
1
2
3
4
5
6
7
8
9
10
|
>curl -v http://localhost:8080/notes/mynote
* Connected to localhost (::1) port 8080 (#0)
> GET /notes/mynote HTTP/1.1
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=ISO-8859-1
< Content-Length: 6
< Date: Tue, 17 Apr 2018 21:27:09 GMT
<
edited
|
Редактирование работает, самое время что-нибудь удалить:
1
2
3
4
5
6
|
>curl -X DELETE -v http://localhost:8080/notes/mynote
* Connected to localhost (::1) port 8080 (#0)
> DELETE /notes/mynote HTTP/1.1
>
< HTTP/1.1 204
< Date: Tue, 17 Apr 2018 21:28:16 GMT
|
1
2
3
4
5
6
7
8
9
10
|
>curl -v http://localhost:8080/notes/mynote
* Connected to localhost (::1) port 8080 (#0)
> GET /notes/mynote HTTP/1.1
>
< HTTP/1.1 404
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Tue, 17 Apr 2018 21:28:59 GMT
<
{"timestamp":1524000539562,"status":404,"error":"Not Found","message":"No message available","path":"/notes/mynote"}
|
Запись успешно удалена и попытка её прочитать возвращает ошибку 404. Обратите внимание, что Spring Web MVC любезно сформировал красивый JSON ответ с ошибкой для нас.
Код примера доступен на github.