Статьи

Миграция Spring MVC RESTful веб-сервисов в Spring 4

1. Введение

Spring 4 приносит несколько улучшений для приложений MVC. В этой статье я остановлюсь на успокоительных веб-сервисах и попробую эти улучшения, взяв проект, реализованный в Spring 3.2, и обновив его до Spring 4. Следующие пункты суммируют содержание этого поста:

  • Миграция с весны 3.2 на весну 4.0
  • Изменения в @ResponseBody и включение @RestController
  • Синхронные и асинхронные вызовы

Исходный код следующих проектов можно найти на github:

2 Весна 3.2 ОТДЫХ

Начальный проект реализован с помощью Spring 3.2 ( pom.xml ). Он состоит из приложения Spring MVC, которое обращается к базе данных для получения данных о сериалах. Давайте посмотрим на его REST API, чтобы было понятнее:

блог-отдых-апи

Конфигурация пружины

1
2
3
4
5
6
7
<import resource="db-context.xml"/>
 
<!-- Detects annotations like @Component, @Service, @Controller, @Repository, @Configuration -->
<context:component-scan base-package="xpadro.spring.web.controller,xpadro.spring.web.service"/>
 
<!-- Detects MVC annotations like @RequestMapping -->
<mvc:annotation-driven/>

дб-context.xml

1
2
3
4
5
6
7
8
9
<!-- Registers a mongo instance -->
<bean id="mongo" class="org.springframework.data.mongodb.core.MongoFactoryBean">
    <property name="host" value="localhost" />
</bean>
 
<bean id="mongoTemplate" class="org.springframework.data.mongodb.core.MongoTemplate">
    <constructor-arg name="mongo" ref="mongo" />
    <constructor-arg name="databaseName" value="rest-db" />
</bean>

Внедрение сервиса

Этот класс отвечает за получение данных из базы данных mongoDB:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class SeriesServiceImpl implements SeriesService {
     
    @Autowired
    private MongoOperations mongoOps;
     
    @Override
    public Series[] getAllSeries() {
        List<Series> seriesList = mongoOps.findAll(Series.class);
        return seriesList.toArray(new Series[0]);
    }
     
    @Override
    public Series getSeries(long id) {
        return mongoOps.findById(id, Series.class);
    }
     
    @Override
    public void insertSeries(Series series) {
        mongoOps.insert(series);
    }
     
    @Override
    public void deleteSeries(long id) {
        Query query = new Query();
        Criteria criteria = new Criteria("_id").is(id);
        query.addCriteria(criteria);
         
        mongoOps.remove(query, Series.class);
    }
}

Реализация контроллера

Этот контроллер будет обрабатывать запросы и взаимодействовать со службой для получения данных серии:

01
02
03
04
05
06
07
08
09
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
@Controller
@RequestMapping(value="/series")
public class SeriesController {
     
    private SeriesService seriesService;
     
    @Autowired
    public SeriesController(SeriesService seriesService) {
        this.seriesService = seriesService;
    }
     
    @RequestMapping(method=RequestMethod.GET)
    @ResponseBody
    public Series[] getAllSeries() {
        return seriesService.getAllSeries();
    }
     
    @RequestMapping(value="/{seriesId}", method=RequestMethod.GET)
    public ResponseEntity<Series> getSeries(@PathVariable("seriesId") long id) {
        Series series = seriesService.getSeries(id);
         
        if (series == null) {
            return new ResponseEntity<Series>(HttpStatus.NOT_FOUND);
        }
         
        return new ResponseEntity<Series>(series, HttpStatus.OK);
    }
     
    @RequestMapping(method=RequestMethod.POST)
    @ResponseStatus(HttpStatus.CREATED)
    public void insertSeries(@RequestBody Series series, HttpServletRequest request, HttpServletResponse response) {
        seriesService.insertSeries(series);
        response.setHeader("Location", request.getRequestURL().append("/").append(series.getId()).toString());
    }
     
    @RequestMapping(value="/{seriesId}", method=RequestMethod.DELETE)
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteSeries(@PathVariable("seriesId") long id) {
        seriesService.deleteSeries(id);
    }
}

Интеграционное тестирование

Эти интеграционные тесты протестируют наш контроллер в фиктивной среде Spring MVC. Таким образом, мы сможем также проверить сопоставления наших методов-обработчиков. Для этой цели класс MockMvc становится очень полезным. Если вы хотите научиться писать тесты для контроллеров Spring MVC, я настоятельно рекомендую серию учебных пособий по тестированию Spring MVC Петри Кайнулайнена.

01
02
03
04
05
06
07
08
09
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration(locations={
    "classpath:xpadro/spring/web/test/configuration/test-root-context.xml",
    "classpath:xpadro/spring/web/configuration/app-context.xml"})
public class SeriesIntegrationTest {
    private static final String BASE_URI = "/series";
     
    private MockMvc mockMvc;
     
    @Autowired
    private WebApplicationContext webApplicationContext;
     
    @Autowired
    private SeriesService seriesService;
     
    @Before
    public void setUp() {
        reset(seriesService);
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
         
        when(seriesService.getAllSeries()).thenReturn(new Series[]{
            new Series(1, "The walking dead", "USA", "Thriller"),
            new Series(2, "Homeland", "USA", "Drama")});
             
        when(seriesService.getSeries(1L)).thenReturn(new Series(1, "Fringe", "USA", "Thriller"));
    }
     
    @Test
    public void getAllSeries() throws Exception {
        mockMvc.perform(get(BASE_URI)
            .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json;charset=UTF-8"))
            .andExpect(jsonPath("$", hasSize(2)))
            .andExpect(jsonPath("$[0].id", is(1)))
            .andExpect(jsonPath("$[0].name", is("The walking dead")))
            .andExpect(jsonPath("$[0].country", is("USA")))
            .andExpect(jsonPath("$[0].genre", is("Thriller")))
            .andExpect(jsonPath("$[1].id", is(2)))
            .andExpect(jsonPath("$[1].name", is("Homeland")))
            .andExpect(jsonPath("$[1].country", is("USA")))
            .andExpect(jsonPath("$[1].genre", is("Drama")));
             
        verify(seriesService, times(1)).getAllSeries();
        verifyZeroInteractions(seriesService);
    }
     
    @Test
    public void getJsonSeries() throws Exception {
        mockMvc.perform(get(BASE_URI + "/{seriesId}", 1L)
            .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json;charset=UTF-8"))
            .andExpect(jsonPath("$.id", is(1)))
            .andExpect(jsonPath("$.name", is("Fringe")))
            .andExpect(jsonPath("$.country", is("USA")))
            .andExpect(jsonPath("$.genre", is("Thriller")));
         
        verify(seriesService, times(1)).getSeries(1L);
        verifyZeroInteractions(seriesService);
    }
     
    @Test
    public void getXmlSeries() throws Exception {
        mockMvc.perform(get(BASE_URI + "/{seriesId}", 1L)
            .accept(MediaType.APPLICATION_XML))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_XML))
            .andExpect(xpath("/series/id").string("1"))
            .andExpect(xpath("/series/name").string("Fringe"))
            .andExpect(xpath("/series/country").string("USA"))
            .andExpect(xpath("/series/genre").string("Thriller"));
             
        verify(seriesService, times(1)).getSeries(1L);
        verifyZeroInteractions(seriesService);
    }
}

Я показываю некоторые из реализованных тестов. Проверьте SeriesIntegrationTesting для полной реализации.

Функциональное тестирование

Приложение содержит некоторое функциональное тестирование с использованием класса RestTemplate . Вам нужно развернуть веб-приложение, чтобы проверить это.

01
02
03
04
05
06
07
08
09
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={
    "classpath:xpadro/spring/web/configuration/root-context.xml",
    "classpath:xpadro/spring/web/configuration/app-context.xml"})
public class SeriesFunctionalTesting {
    private static final String BASE_URI = "http://localhost:8080/spring-rest-api-v32/spring/series";
    private RestTemplate restTemplate = new RestTemplate();
     
    @Autowired
    private MongoOperations mongoOps;
     
    @Before
    public void setup() {
        List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>();
        converters.add(new StringHttpMessageConverter());
        converters.add(new Jaxb2RootElementHttpMessageConverter());
        converters.add(new MappingJacksonHttpMessageConverter());
        restTemplate.setMessageConverters(converters);
         
        initializeDatabase();
    }
     
    private void initializeDatabase() {
        try {
            mongoOps.dropCollection("series");
             
            mongoOps.insert(new Series(1, "The walking dead", "USA", "Thriller"));
            mongoOps.insert(new Series(2, "Homeland", "USA", "Drama"));
        } catch (DataAccessResourceFailureException e) {
            fail("MongoDB instance is not running");
        }
    }
     
    @Test
    public void getAllSeries() {
        Series[] series = restTemplate.getForObject(BASE_URI, Series[].class);
         
        assertNotNull(series);
        assertEquals(2, series.length);
        assertEquals(1L, series[0].getId());
        assertEquals("The walking dead", series[0].getName());
        assertEquals("USA", series[0].getCountry());
        assertEquals("Thriller", series[0].getGenre());
        assertEquals(2L, series[1].getId());
        assertEquals("Homeland", series[1].getName());
        assertEquals("USA", series[1].getCountry());
        assertEquals("Drama", series[1].getGenre());
    }
     
    @Test
    public void getJsonSeries() {
        List<HttpMessageConverter<?>> converters = new ArrayList<HttpMessageConverter<?>>();
        converters.add(new MappingJacksonHttpMessageConverter());
        restTemplate.setMessageConverters(converters);
         
        String uri = BASE_URI + "/{seriesId}";
        ResponseEntity<Series> seriesEntity = restTemplate.getForEntity(uri, Series.class, 1l);
        assertNotNull(seriesEntity.getBody());
        assertEquals(1l, seriesEntity.getBody().getId());
        assertEquals("The walking dead", seriesEntity.getBody().getName());
        assertEquals("USA", seriesEntity.getBody().getCountry());
        assertEquals("Thriller", seriesEntity.getBody().getGenre());
        assertEquals(MediaType.parseMediaType("application/json;charset=UTF-8"), seriesEntity.getHeaders().getContentType());
    }
     
    @Test
    public void getXmlSeries() {
        String uri = BASE_URI + "/{seriesId}";
        ResponseEntity<Series> seriesEntity = restTemplate.getForEntity(uri, Series.class, 1L);
        assertNotNull(seriesEntity.getBody());
        assertEquals(1l, seriesEntity.getBody().getId());
        assertEquals("The walking dead", seriesEntity.getBody().getName());
        assertEquals("USA", seriesEntity.getBody().getCountry());
        assertEquals("Thriller", seriesEntity.getBody().getGenre());
        assertEquals(MediaType.APPLICATION_XML, seriesEntity.getHeaders().getContentType());
    }
}

Вот и все, веб-приложение протестировано и работает. Теперь пришло время перейти на весну 4.

3 Переход на весну 4

Проверьте эту страницу, чтобы прочитать информацию о миграции с более ранних версий среды Spring.

3.1 Изменение зависимостей maven

В этом разделе объясняется, какие зависимости должны быть изменены. Вы можете посмотреть полный pom.xml здесь .

Первым шагом является изменение версии зависимостей Spring с 3.2.3.RELEASE на 4.0.0.RELEASE:

01
02
03
04
05
06
07
08
09
10
11
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-context</artifactId>
    <version>4.0.0.RELEASE</version>
</dependency>
 
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webmvc</artifactId>
    <version>4.0.0.RELEASE</version>
</dependency>

Следующим шагом является обновление до спецификации Servlet 3.0. Этот шаг важен, поскольку некоторые функции Spring основаны на Servlet 3.0 и не будут доступны. Фактически, попытка выполнить SeriesIntegrationTesting приведет к исключению ClassNotFoundException по этой причине, которая также объясняется здесь .

1
2
3
4
5
<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>3.1.0</version>
</dependency>

3.2 Обновление пространства имен Spring

Не забудьте изменить пространство имен ваших конфигурационных файлов Spring:

1
2
3
http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.0.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.0.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.0.xsd

Просмотрите информационную страницу, указанную в разделе 2, поскольку в пространство имен mvc внесены некоторые изменения.

3.3 Устаревание библиотек Джексона

Если вы проверите SeriesFunctionalTesting (метод установки) еще раз, вы заметите, что конвертер Джексона теперь устарел. Если вы попытаетесь запустить тест, он выдаст ошибку NoSuchMethodError из-за изменения метода в библиотеках Джексона:

1
java.lang.NoSuchMethodError: org.codehaus.jackson.map.ObjectMapper.getTypeFactory()Lorg/codehaus/jackson/map/type/TypeFactory

Весной 4 поддержка Jackson 1.x устарела в пользу Jackson v2. Давайте изменим старую зависимость:

1
2
3
4
5
<dependency>
    <groupId>org.codehaus.jackson</groupId>
    <artifactId>jackson-mapper-asl</artifactId>
    <version>1.4.2</version>
</dependency>

Для этих:

01
02
03
04
05
06
07
08
09
10
11
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.3.0</version>
</dependency>
 
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.3.0</version>
</dependency>

Наконец, если вы явно регистрируете конвертеры сообщений, вам нужно изменить устаревший класс для новой версии:

1
2
//converters.add(new MappingJacksonHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());

3.4 Миграция завершена

Миграция завершена. Теперь вы можете запустить приложение и выполнить его тесты. В следующем разделе будут рассмотрены некоторые улучшения, которые я упомянул в начале этого поста.

4 Spring 4 Веб-улучшения

4.1 @ResponseBody и @RestController

Если ваш REST API обслуживает контент в формате JSON или XML, то для некоторых методов API (помеченных @RequestMapping) тип возвращаемого значения помечается @ResponseBody. При наличии этой аннотации тип возвращаемого значения будет включен в тело ответа. В Spring 4 мы можем упростить это двумя способами:

Аннотируйте контроллер с помощью @ResponseBody

Эта аннотация теперь может быть добавлена ​​на уровне типа. Таким образом, аннотация наследуется, и мы не обязаны помещать эту аннотацию в каждый метод.

1
2
3
@Controller
@ResponseBody
public class SeriesController {

Аннотируйте контроллер с помощью @RestController

1
2
@RestController
public class SeriesController {

Эта аннотация еще больше упрощает контроллер. Если мы проверим эту аннотацию, то увидим, что она сама аннотирована @Controller и @ResponseBody:

1
2
3
4
5
6
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {

Включение этой аннотации не повлияет на методы, аннотированные @ResponseEntity. Адаптер обработчика просматривает список обработчиков возвращаемого значения, чтобы определить, кто способен обрабатывать ответ.
Обработчик, отвечающий за обработку типа возврата ResponseEntity, запрашивается перед типом ResponseBody, поэтому он будет использоваться, если в методе присутствует аннотация ResponseEntity.

4.2 Асинхронные вызовы

Использование служебного класса RestTemplate для вызова службы RESTful будет блокировать поток, пока он не получит ответ. Spring 4 включает AsyncRestTemplate для выполнения асинхронных вызовов. Теперь вы можете сделать вызов, продолжить выполнять другие вычисления и получить ответ позже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void getAllSeriesAsync() throws InterruptedException, ExecutionException {
    logger.info("Calling async /series");
    Future<ResponseEntity<Series[]>> futureEntity = asyncRestTemplate.getForEntity(BASE_URI, Series[].class);
    logger.info("Doing other async stuff...");
     
    logger.info("Blocking to receive response...");
    ResponseEntity<Series[]> entity = futureEntity.get();
    logger.info("Response received");
    Series[] series = entity.getBody();
     
    assertNotNull(series);
    assertEquals(2, series.length);
    assertEquals(1L, series[0].getId());
    assertEquals("The walking dead", series[0].getName());
    assertEquals("USA", series[0].getCountry());
    assertEquals("Thriller", series[0].getGenre());
    assertEquals(2L, series[1].getId());
    assertEquals("Homeland", series[1].getName());
    assertEquals("USA", series[1].getCountry());
    assertEquals("Drama", series[1].getGenre());
}

Асинхронные звонки с обратным вызовом

Хотя предыдущий пример выполняет асинхронный вызов, поток будет блокироваться, если мы попытаемся получить ответ с помощью futureEntity.get (), если ответ еще не был отправлен.

AsyncRestTemplate возвращает ListenableFuture , который расширяет Future и позволяет нам зарегистрировать обратный вызов. Следующий пример выполняет асинхронный вызов и продолжает выполнять свои собственные задачи. Когда служба возвращает ответ, он будет обработан обратным вызовом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void getAllSeriesAsyncCallable() throws InterruptedException, ExecutionException {
    logger.info("Calling async callable /series");
    ListenableFuture<ResponseEntity<Series[]>> futureEntity = asyncRestTemplate.getForEntity(BASE_URI, Series[].class);
    futureEntity.addCallback(new ListenableFutureCallback<ResponseEntity<Series[]>>() {
        @Override
        public void onSuccess(ResponseEntity<Series[]> entity) {
            logger.info("Response received (async callable)");
            Series[] series = entity.getBody();
            validateList(series);
        }
         
        @Override
        public void onFailure(Throwable t) {
            fail();
        }
    });
     
    logger.info("Doing other async callable stuff ...");
    Thread.sleep(6000); //waits for the service to send the response
}

5. Заключение

Мы взяли веб-приложение Spring 3.2.x и перенесли его в новую версию Spring 4.0.0. Мы также рассмотрели некоторые улучшения, которые можно применить к веб-приложению Spring 4.

Я публикую свои новые сообщения в Google Plus и Twitter. Следуйте за мной, если вы хотите быть в курсе нового контента.