Рубрики
Без рубрики

Военные истории: Неуловимая служба

Когда небольшие различия оказывают огромное влияние, но на самом деле не должны. С тегами java, spring, spring boot, kotlin.

Если вам нравятся “военные истории”, истории, которые рассказывают разработчики, столкнувшиеся с действительно непонятными проблемами и почувствовавшие, что сходят с ума, прежде чем, наконец, найдут решение – читайте дальше, это одна из них.

Я писал новую конечную точку REST в приложении Spring Boot – ничего такого, чего бы я не делал сотни раз раньше. Что может пойти не так/| . Поскольку в последнее время мне очень нравится программировать на Kotlin, я решил попробовать. Вот как выглядел контроллер:

@RestController
open class MyRestController{

    @Autowired
    private lateinit var service: MyService

    @ResponseBody
    @RequestMapping(method = HttpMethod.GET, path = "/yadda-yadda")
    fun getSomeData(): MyDTO{
        val data = this.service.produceData()
        return MyDTO(data)
    }

}

Это простая конечная точка REST – она получает некоторые данные из службы, помещает их в DTO и отправляет в качестве ответа. Здесь даже не было задействовано транзакций с базой данных, это было так basic , как это возможно.

Как бы то ни было, я решил написать для него небольшой тест:

class MyTest {

   @Test
   fun canGetDataViaREST(){
       val mvc = ... // spring mock MVC setup, not important here
       val objectMapper = ... // Jackson setup, not important here
       val responseJson = mvc.perform(
           get("/yadda-yadda")
       ).andExpect(status().isOk)
        .andReturn().response.bodyAsString
       val dto = objectMapper.readValue(responseJson)
       assertNotNull(dto)
   }

}

Итак, тест довольно прост: он использует Spring MockMvc для имитации вызова REST для нашей новой конечной точки, извлекает результат в виде JSON и десериализует его с помощью Jackson object mapper. Там нет ничего особенного, как вообще . Это Spring REST 101 . И все же, конечно же, тест провалился .

Тест провалился. Не с ошибкой утверждения, как вы могли бы ожидать. Или какая-то проблема с сериализацией или десериализацией JSON, нет. Это было исключение NullPointerException . Как ни странно, поскольку эти вещи действительно трудно производить в Kotlin. Это строка, обозначенная исключением:

val data = this.service.produceData() // NullPointerException: lateinit var 'service' is not initialized!

Это было странно. Очень странно. Неспособность @Autowire что-то обычно указывало на отсутствующий компонент spring или что-то еще в этом роде. Однако, как знают пользователи Spring среди вас, если Spring не удается что-то настроить, он терпит неудачу, и он терпит неудачу громко , вы получаете около 7 километров трассировок стека. Нет, не в данном конкретном случае. Весна сказала, что все в порядке. Тем не менее, мой @Autowired не сработал.

Можете ли вы определить, где я ошибся? Я не. И я потратил изрядный нервотрепочный час на поиски причины.

Ошеломленный таким поведением, я начал расследование. Я проверил все аннотации ко всем связанным классам, я проверил конфигурацию, все казалось нормальным. У меня были десятки подобных сценариев в проекте, и все они работали безупречно. Однако этот проект потерпел неудачу по необъяснимым причинам.

Затем я начал подозревать Kotlin в качестве виновника, поскольку это было основным отличием между этим делом (которое провалилось) и всеми другими случаями (которые сработали). Я просмотрел сгенерированный байт-код, декомпилировал его в Java и немного очистил, что дало мне следующее:

@RestController
public class MyRestController{

    @Autowired
    private MyService service;

    @ResponseBody
    @RequestMapping(method = HttpMethod.GET, path = "/yadda-yadda")
    public final MyDTO getSomeData() {
        Data data = this.service.produceData();
        return new MyDTO(data);
    }

}

Это выглядело так же, как и другие тысячи конечных точек rest, которые я написал до сих пор. Тем не менее, даже в Java ошибка все еще существовала: NullPointerException при попытке получить доступ к @Autowired служебному полю. Срань господня.

Я, наконец, запустил отладчик и создал @PostConstruct блок в контроллере REST:

@PostConstruct
private void init(){
    System.out.println("Test!");
}

Я поставил точку останова в операторе println . Конечно же, это было достигнуто при выполнении теста. Я проверил объект this , и, о чудо, поле service было заполнено значением! Служба была назначена, как я и предполагал. Все казалось прекрасным. Я продолжил экзекуцию. Тест не прошел . Определение безумия – делать одно и то же снова и снова, надеясь на другой результат…

Я вставил дополнительную точку останова в строку, которая вызвала исключение. this.service был правильно заполнен в методе init… но поле было null в getSomeData()

Когда я вернулся и выполнил еще один прогон, я кое-что заметил: во второй точке останова this ссылался на другой идентификатор объекта, чем в первом. Но что еще более важно: он также ссылался на другой класс : MyRestController$$_EnhancerByCGLIB_$$ .

В качестве быстрого обхода: Spring не только широко использует динамические прокси JDK для выполнения своей работы, но также использует CGLib в подкласс ваши классы во время выполнения, генерируя байт-код и вставляя дополнительные строки кода на лету. Я говорил об этом с коллегой, когда первопричина проблемы ударила мне прямо в лицо.

Посмотрите внимательно, что сгенерировал Kotlin:

public final MyDTO getSomeData() { ... }

Посмотри еще раз. Видишь это? финал . Kotlin, по замыслу, объявляет все как final неявно, если только вы не объявите его как open . Это в точности противоположно тому, что делает Java, где все переопределяется, если не объявлено как final . Работая раньше с такими фреймворками, как Hibernate, я знал, что финал и CGLib плохо сочетаются друг с другом – вообще .

Поэтому я удалил модификатор final .

Тест сработал.

Spring сгенерировал подкласс My RestController . Этот подкласс был тем, который действительно использовался. По умолчанию Spring переопределяет каждый метод конечной точки REST. За исключением того, что в данном случае он не смог этого сделать из-за модификатора final . Результатом было то, что get Some Data() был вызван в базовом классе (тот, который я написал вручную), но значение поля @Autowired было доступно только в sub классе (да, эти поля являются реплицируется также Spring/CGLib). Вот почему метод (не- final ) init мог видеть значение поля, а get Some Data() не мог.

Из этого есть несколько выводов:

1) Будьте очень осторожны при использовании Kotlin с Spring. Объявите все и его содержимое как open . 2) Если у вас есть сомнения при работе с Kotlin, посмотрите на сгенерированный Java-код. 3) В то время как Spring улавливает множество проблем, некоторые непреднамеренные неприятности могут проскользнуть без предупреждения. 4) Это может быть настолько просто, насколько это возможно, если оно не протестировано , это собирается сломаться в какой-то момент. Никаких исключений. 5) Всегда будьте начеку, никогда не будьте слишком самоуверенны при кодировании, даже во время рутинных задач. То, что вы сделали правильно 100 раз, может пойти не так со 101-й попытки. Или 103-й. Но в конце концов это произойдет.

В связи с этим я все еще в корне не согласен с дизайнерским решением open ключевое слово в Kotlin. Это просто еще один пример того, почему это была плохая идея с самого начала.

Если разработчик Spring случайно прочитает это: пожалуйста, выведите большое жирное предупреждение, если метод по какой-либо причине не может быть переопределен CGLib (в данном случае причиной является ключевое слово final ).

Я надеюсь, что это поможет другим людям не повторять мою ошибку. Я не собираюсь обижаться на Spring, Kotlin или любую другую упомянутую технологию – мне нравится работать с ними. Просто иногда они не совсем согласны. А потом случаются такие вещи, как это.

Я надеюсь, что вам понравилось читать вместе с нами!

Оригинал: “https://dev.to/martinhaeusler/war-stories-the-elusive-service-5605”