Введение


Недавно столкнулся с задачей реализовать контроллер, различным образом обрабатывающий ситуации, где есть параметры запроса и где их нет. Проблема усугублялась тем, что нужны были именно два разных метода в контроллере. Стандартные возможности Spring MVC не позволяли этого сделать. Пришлось копнуть чуть глубже. Кому интересно — добро пожаловать под кат.


Что мы хотим


Вот небольшой тест, который описывает сущность задачи. 


TestControllerTest.java
@SpringJUnitWebConfig(WebConfig.class)
class TestControllerTest {

    @Autowired
    WebApplicationContext webApplicationContext;

    @Test
    void testHandleRequestWithoutParams() throws Exception {
        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();

        mockMvc.perform(MockMvcRequestBuilders.get("/test"))
                .andExpect(status().isOk())
                .andExpect(result ->
                        assertEquals(TestController.HANDLE_REQUEST_WITHOUT_PARAMS, result.getResponse().getContentAsString()));
    }

    @Test
    void testHandleRequestWithParams() throws Exception {
        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();

        mockMvc.perform(MockMvcRequestBuilders
                .get("/test")
                .param("someparam", "somevalue"))
                .andExpect(status().isOk())
                .andExpect(result ->
                        assertEquals(TestController.HANDLE_REQUEST_WITH_PARAMS, result.getResponse().getContentAsString()));
    }
}

Надеемся, что все разрешится само-собой


TestController.java
@Controller
@RequestMapping("/test")
public class TestController {

    public static final String HANDLE_REQUEST_WITH_PARAMS = "handleRequestWithParams";
    public static final String HANDLE_REQUEST_WITHOUT_PARAMS = "handleRequestWithoutParams";

    @GetMapping
    @ResponseBody
    public String handleRequestWithParams(SearchQuery query) {
        return HANDLE_REQUEST_WITH_PARAMS;
    }

    @GetMapping
    @ResponseBody
    public String handleRequestWithoutParams() {
        return HANDLE_REQUEST_WITHOUT_PARAMS;
    }
}

Однако Spring был не так дружелюбен, как ожидалось, и на выходе получил:


java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'getTestController' method 
public java.lang.String ru.pchurzin.spring.customannotations.TestController.handleRequestWithoutParams()
to {GET /test}: There is already 'getTestController' bean method
public java.lang.String ru.pchurzin.spring.customannotations.TestController.handleRequestWithParams(ru.pchurzin.spring.customannotations.SearchQuery) mapped.`

Не сдаемся


Были предприняты попытки разделить маппинг этих методов через аннотацию @RequestMapping(params = "some condition"), но к сожалению в используемой версии Spring 5.1.8 нет возможность задать условие на то, чтобы запрос содержал какие-то параметры. Можно задать либо наличие параметров с конкретными именами, либо отсутствие параметров с конкретными именами. Ни то, ни другое не подходило, т.к. параметры могли быть в каждом запросе разными. Хотелось бы написать что-то в этом роде @RequestMapping(params = "*") для указания, что запрос должен содержать какие-то параметры или @RequestMapping(params = "!*")` — для указания, что в запросе не должно быть никаких параметров.


А что в документации?


Покурив документацию, был найден раздел CustomAnnotations, в котором видим:


Spring MVC also supports custom request-mapping attributes with custom request-matching logic. 

Было принято решение  сделать свою аннотацию, которая позволила указать нужные мне условия на наличие параметров запроса.
 


Цель


Я хочу добавить к методу аннотацию @NoRequestParams, указав таким образом, что этот метод обрабатывает запросы без параметров.


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRequestParams {
}

Контроллер с нашей аннотацией:


TestContoller.java
@Controller
@RequestMapping("/test")
public class TestController {

    public static final String HANDLE_REQUEST_WITH_PARAMS = "handleRequestWithParams";
    public static final String HANDLE_REQUEST_WITHOUT_PARAMS = "handleRequestWithoutParams";

    @GetMapping
    @ResponseBody
    public String handleRequestWithParams(SearchQuery query) {
        return HANDLE_REQUEST_WITH_PARAMS;
    }

    @GetMapping
    @ResponseBody
    @NoRequestParams //придуманная аннотация
    public String handleRequestWithoutParams() {
        return HANDLE_REQUEST_WITHOUT_PARAMS;
    }
}

Приступим 


Стандартная конфигурация Spring-MVC, активируемая аннотацией @EnableWebMvc описана в классе WebMvcConfigurationSupport. Она инстанцирует бин класса RequestMappingHandlerMapping, который реализует маппинг, используя для этого объекты класса RequestMappingInfo, которые в свою очередь инкапсулируют в себе информацию о маппинге запросов на методы. Эта информация представлена в виде условий — объектов классов, реализующих интерфейс RequestCondition. В Spring имеются 6 готовых реализаций:



В дополнение к этим реализациям мы можем определить свою. Для этого нам нужно реализовать интерфейс RequestCondition, и использовать эту реализацию для своих нужд. Можно реализовать интерфейс напрямую или же воспользоваться абстрактным классом
AbstractRequestCondition.


NoRequestParamsCondition.java
public class NoRequestParamsCondition extends AbstractRequestCondition<NoRequestParamsCondition> {

    public final static NoRequestParamsCondition NO_PARAMS_CONDITION = new NoRequestParamsCondition();

    @Override
    protected Collection<?> getContent() {
        return Collections.singleton("no params");
    }

    @Override
    protected String getToStringInfix() {
        return "";
    }

    @Override
    public NoRequestParamsCondition combine(NoRequestParamsCondition other) {
        return this;
    }

    @Override
    public NoRequestParamsCondition getMatchingCondition(HttpServletRequest request) {
        if (request.getParameterMap().isEmpty()) {
            return this;
        }
        return null;
    }

    @Override
    public int compareTo(NoRequestParamsCondition other, HttpServletRequest request) {
        return 0;
    }
}

Первые два метода используются для строкового представления нашего условия.


Метод T combine(T other) нужен для комбинирования условий, например, при наличии аннотации на методе и классе. В таком случае для комбинирования используется метод combine. Наша аннотация не предполагает комбинирования — поэтому мы просто возвращаем наш текущий экземпляр условия.


Метод int compareTo(T other, HttpServletRequest request) служит для сравнения условий в контексте некоего запроса. Т.е. при наличии нескольких однотипных условий для запроса, выясняет какое из них наиболее специфично. Но опять же наше условие единственно возможное поэтому мы просто возвращаем 0, т.е. все наши условия равны между собой.


Основная логика работы содержится в методе T getMatchingCondition(HttpServletRequest request). В этом методе мы должны решить для запроса, применяется ли к нему наше условие или нет. Если да, то возвращаем объект условия. Если нет — возвращаем null. В нашем случае мы возвращаем объект условия, если запрос не содержит в себе никаких параметров.


Теперь нам нужно подключить наше условие в процесс маппинга. Чтобы это сделать мы унаследуемся от стандартной реализации RequestMappingHandlerMapping
и переопределим метод getCustomMethodCondition(Method method), который как раз и создан для того, чтобы добавлять свои кастомизированые условия. Причем этот метод используется при определении условий для методов контроллера. Есть еще метод getCustomTypeCondition(Class<?> handlerType), который можно использовать для определения условий на основе информации о классе контроллера. В нашем случае он нам не нужен.


В итоге имеем следующую реализацию:


CustomRequestMappingHandlerMapping.java
public class CustomRequestMappingHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        return method.isAnnotationPresent(NoRequestParams.class) ? NO_PARAMS_CONDITION : null;
    }
}

Логика не сложная — проверяем наличие нашей аннотации и, если она присутствует, возвращаем объект нашего условия.


Чтобы подключить нашу реализацию маппинга, расширим стандартую Spring MVC конфигурацию:


WebConfig.java
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    
    @Override
    protected RequestMappingHandlerMapping createRequestMappingHandlerMapping() {
        return new CustomRequestMappingHandlerMapping();
    }

    @Bean
    public TestController getTestController() {
        return new TestController();
    }
}

Добавляем нашу аннотацию в контроллер:


TestController.java
@Controller
@RequestMapping("/test")
public class TestController {

    public static final String HANDLE_REQUEST_WITH_PARAMS = "handleRequestWithParams";
    public static final String HANDLE_REQUEST_WITHOUT_PARAMS = "handleRequestWithoutParams";

    @GetMapping
    @ResponseBody
    public String handleRequestWithParams(SearchQuery query) {
        return HANDLE_REQUEST_WITH_PARAMS;
    }

    @GetMapping
    @ResponseBody
    @NoRequestParams
    public String handleRequestWithoutParams() {
        return HANDLE_REQUEST_WITHOUT_PARAMS;
    }
}

и проверяем результат:


Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.851 s - in ru.pchurzin.spring.customannotations.TestControllerTest

Results:

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Таким образом мы можем изменять логику маппинга в соответсвие с нашими нуждами и придумывать свои условия. Например, условия на IP адрес или же на используемый User-agent. Возможно, есть и более простые решения, но в любом случае собирать свой велосипед тоже иногда полезно.


Спасибо за внимание.


Код примера на гитхабе

Комментарии (5)


  1. kdmitrii
    04.12.2019 20:36
    +1

    Что-то я не понял что мешало сделать как-то так:
    if(anyParams(req)) return handler1();
    return handler2();


    1. lavilav
      04.12.2019 23:49

      Просто потому что смог :)
      Обычно когда 100500 раз одно раз и тоже проверяешь, будь то в контроллере или сервисе — имеет смысл вывести куда то выше.
      Я бы наверное сделал обычный resolver где был бы валидатор. Пару минут времени.


      1. tuxi
        05.12.2019 00:00

        «Выше» — в сервлет спецификации есть например фильтры. Не совсем может быть подходит под этот кейс, но сделать префильтр на них иногда проще и логичнее. Нам же не надо читать параметры реквеста? Нам же нужно только понять есть ли они там или нет, верно?


        1. pchurzin Автор
          07.12.2019 08:05

          Такой вариант тоже рассматривался, как я написал ниже.


    1. pchurzin Автор
      07.12.2019 08:02

      Опишу чуть подробнее ситуацию.


      Был интерфейс контроллера


      @RequestMapping("/useful")
      @RestController
      public interface UsefulDataHandler {
          SomeData getSomeData();
      }

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


      public UsefulDataHandlerImpl implements UsefulDataHandler {
          SomeData getSomeData() {
              //do http request and return response
          }
      }

      Теперь представим, что мы добавляем еще один метод:


      @RequestMapping("/useful")
      @RestController
      public interface UsefulDataHandler {
          SomeData getSomeData();
          AnotherData getAnotherData(@RequestParam Params params);
      }

      И здесь мы получаем ситуацию о которой я и писал. Мы должны по разному обрабатывать запросы.


      На самом деле рассматривались и другие варианты решения, например, просто назначение методов на разные URL или через фильтры или через заголовки. Самое смешное, что пока никак не сделали.


      К тому же статья не претендует на руководство как надо делать. Я столкнулся с копанием во внутренностях Spring, и этот опыт я посчитал интересным чтобы им поделиться.