Стажировка Topjava
1. HW6
Краткое содержание
InMemory-тесты перестали работать, т.к ранее мы перенесли сканирование каталога web из spring-app.xml в конфигурацию spring-mvc.xml, которой нет в тестах. В результате контроллеры перестали
попадать в спринг-контекст тестов. Для восстановления добавим сканирование каталога web в конфигурацию inmemory.xml. Теперь в классах, которые работают с InMemory-реализацией, для создания
контекста можно оставить импорт только конфигурации
spring/inmemory.xml.
JDBC-тесты перестали работать, т.к в конфигурации spring-db.xml мы объявили бин JpaUtil только для профилей jpa и dataJpa, для других профилей (jdbc) этот бин создаваться не будет.
JDBC-тесты мы запускаем с профилем jdbc, но в абстрактном классе AbstractUserServiceTest (общем для всех тестов сервисного слоя User) для всех профилей мы указали необходимость создания переменной
типа JpaUtil. Соответственно, для профиля jdbc в контексте спринга будет отсутствовать этот бин, и спринг не сможет запустить приложение из-за неразрешенной зависимости.
Чтобы спринг смог поднять контекст в профиле JDBC, нужно указать над переменной jpaUtil
аннотацию @Autowired(required = false) - мы указываем спрингу, что эта зависимость не является обязательной и можно ее проигнорировать.
В новой версии заменил аннотацию на ленивую инициализацию
@Lazy
И в @Before методе тестов используем этот бин только для JPA реализаций.
Для этого создадим утильный метод isJpaBased(), который будет проверять, относится ли текущая реализация к jpa. Чтобы проверить, с какими профилями запущен Spring, нам придется внедрить
в AbstractServiceTest
бин класса Environment. Это класс спринга, который позволит получить доступ к информации о том, с какими параметрами он был запущен, с помощью
env.acceptsProfiles(org.springframework.core.env.Profiles.of(Profiles.JPA, Profiles.DATAJPA))
С помощью этого же утильного метода теперь мы можем проверить, что для MealServiceTest тесты на валидацию validateRootCause() будут выполняться только для jpa/dataJpa профилей (если этот тест
запустить для профиля jdbc, то он упадет, т.к. пока в JDBC у нас нет валидации).
- В файлы интернационализации
app.propertiesдобавляем дополнительные пары ключ-значение для русского и английского языка. В JSP страницах вместо текста, по аналогии со страницами для User, указываем ключи, вместо которых спринг должен подставить локализованные сообщения. - Для каждой JSP страницы для включения фрагментов указываем теги:
<jsp:include page="fragments/headTag.jsp"/> - в нем определены title страницы, ссылка на статические ресурсы и базовая ссылка на корень приложения.
<jsp:include page="fragments/bodyHeader.jsp"/> - верхняя часть страниц, в ней определены ссылки для навигации по приложению.
И в самом низу страниц:
<jsp:include page="fragments/footer.jsp"/>
Так как мы локализуем приложение с помощью Spring, на страницах нужно удалить тег:
<fmt:setBundle basename="messages.app"/> - с ним работает только jstl.
-
Для того, чтобы на страницах получить доступ к корню приложения, используется
"${pageRequest.request.contextPath}"- эту ссылку на root удобнее вынести вheadTagв виде<base href>элемента, чтобы она вместе с этим фрагментом добавлялась к каждой странице, и не требовалось бы ее везде дублировать. -
Чтобы видеть, к каким URL были привязаны контроллеры во время работы приложения, в
logback.xmlнастроим уровень логирования для Spring web:
<logger name="org.springframework.web" level="DEBUG"/>
Чтобы не дублировать одну и ту же функциональность для REST- и JSP-контроллеров, создадим абстрактный
AbstractMealController (от него будут наследоваться остальные Meal-контроллеры), куда перенесем все методы из
MealRestController. JSP-контроллер будет работать с jsp-страницами. Каждый метод этого контроллера будет делегировать основную функциональность в родительский абстрактный контроллер.
Внимание!. Не делайте без нужды абстрактных контроллеров в своих выпускных проектах!
Так как каждый метод этого контроллера должен отвечать за единственное действие, разнесем функциональность по разным методам, а доступ к самим методам разделим с помощью
аннотации @RequestMapping (@GetMapping / @PostMapping), в их параметрах укажем путь к endpoint, по которому можно обратиться к методу.
При этом для всего контроллера также зададим @RequestMapping("/meals") (value= - параметр по умолчанию, можно не указывать). Это префикс запроса для всех методов контроллера.
Один из признаков "хорошего" контроллера, где не смешивается разная функциональность, - этот общий url. Для каждой функциональности в выпускных создавайте свой собственный контроллер!
Для доступа к определенному методу контроллера нужно будет указать уникальный для нашего приложения "путь + http-метод", который складывается из маппинга к контроллеру, маппинга к нужному методу и
http-метода, например:
GET {корень приложения} + "/meals" + "/delete"
GET {корень приложения} + "/meals"
POST {корень приложения} + "/meals"
Для mealList.jsp теперь не нужно с запросом дополнительно передавать тип действия, которое мы хотим совершить с едой, мы можем просто обратиться к нужному методу по его уникальному пути (endpoint,
url).
Если на этом шаге запустить приложение, то мы столкнемся с проблемой: при выполнении манипуляций и переходе по ссылкам путь портится.
- путь к ресурсу по этой ссылке строится не от корня приложения (application context - topjava), а от текущего контекста сервлета (servlet context), например:
localhost:8080/topjava/meals'+'/mealsТакже перестали работать стили, так как путь к статическим ресурсам тоже определяется неверно (посмотрите вкладку Network браузера).
Чтобы это исправить, добавим базовый URL вheadTag:
base href = "${pageContext.request.contextPath}/". Теперь это станет url, от которой будут строиться все относительные ссылки на страницах.
Также некоторые методы контроллера в результате работы должны не просто вернуть название view, который Spring MVC должен отобразить, а совершить redirect. Для этого при возврате имени view
дополнительно укажем ключевое слово redirect:, например, redirect: /meals.
Последняя проблема — некорректное отображение текста в кодировке UTF-8. Spring предоставляет для ее решения стандартный фильтр, который будет перехватывать все запросы и ответы сервера и устанавливать
им нужную кодировку: в web.xml подключим encodingFilter.
Инжекцию в
AbstractUserServiceTest.jpaUtilсделал@Lazy: не иннициализировать бин до первого использования.
сделал фильтрацию еды через
get: операция идемпотентная, можно делать в браузере обновление по F5
При переходе на AJAX JspMealController удалим за ненадобностью, возвращение всей еды meals() останется в RootController.
2. HW6 Optional
Краткое содержание
-
В файле популирования базы данных
populateDB.sqlдобавим для admin дополнительную рольROLE_USER. -
В тестовых данных для него также добавим аналогичную роль.
После этого тесты, которые связаны с методом
getAll(), перестали работать, потому что для получения списка всех пользователей с их ролями в именованном запросе мы использовали LEFT JOIN FETCH.
Происходит объединение таблиц, в результирующей таблице вместо одной записи для админа появляются дублирующие записи для одного и того же пользователя.
- простой способ решения - исключить из запроса LEFT JOIN FETCH. Роли все равно будут загружены, так как они FetchType.EAGER.
- также можно добавить в запрос ключевое слово DISTINCT(u) - теперь в результирующей таблице будут содержаться только уникальные записи.
Чтобы аннотация @Transactional стала работать во всех профилях Spring - в файле spring-db.xml вынесем из профиля jpa, dataJpa в общую конфигурацию для всех профилей тег:
<tx:annotation-driven/>
Для профиля jdbc настроим DatasourceTransactionManager, который будет управлять транзакциями:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource" /> </bean>
После этого в JDBC-репозитории мы можем расставить аннотации @Transactional аналогично jpa репозиториям, и действия станут выполняться транзакционно (
напомню: <logger name="org.springframework.orm.jpa" level="debug"/> для логирования информации по транзакциям)
У пользователя добавим сеттер для его ролей. Для JDBC-репозитория создадим вспомогательные методы для записи ролей в базу и их считывания из базы и установления пользователю. Запись ролей в базу будем
производить методом
JdbcTemplate#batchUpdate, в таком случае не будет обращения в базу для записи каждой конкретной роли, команды для записи ролей будут накоплены в один пакет и выполнятся за одно обращение к БД. Для
удобства работы с batch Spring предоставляет нам интерфейс BatchPreparedStatementSetter, с помощью которого мы определяем как будут устанавливаться параметры для запроса и количество запросов в
одном пакете. Также создадим метод deleteRoles, в котором будем удалять роли пользователя из базы (для обновления ролей в базе мы делаем просто: сначала удалим старые из базы и запишем туда новые).
PS: в JPA с
@ElementCollectionи с параметром cascade в@OneToManyслияние (merge) изменений в связанных коллекциях происходит автоматически.
Если мы будем получать всех пользователей вместе с их ролями из базы с помощью JOIN, мы столкнемся с проблемой Декартова произведения: для каждого уникального пользователя количество записей в
результирующей таблице будет повторяться столько раз, сколько у него было ролей. Чтобы этого избежать, отдельным запросом получим из базы все роли, и сгруппируем их в Map по userId, где ключом
будет являться userId, а значением — набор ролей пользователя. После чего пройдемся по всем пользователям, загруженным из базы, и установим каждому его роли.
- Для доставания ролей у нас дублируется
fetch = EAGERиLEFT JOIN FETCH u.roles(можно делать что-то одно). Запросы выполняются по-разному: проверьте.
- Отключил
JdbcUserServiceTest- роли не работают. Будем чинить в7_06_HW6_jdbc_transaction_roles.patch DataJpaUserServiceTest.getWithMealsне работает для admin (у админа 2 роли, и еда при JOIN дублируется). Чиним в следующем патче.
@EntityGraphвDataJpaUserServiceTest.getWithMeals()в последнем Hibernate работает только сattributePaths = {"meals"}иtype = EntityGraph.EntityGraphType.LOAD- В
JpaUserRepositoryImpl.getByEmailиCrudUserRepository.getByEmailDISTINCT попадает в запрос, хотя он там не нужен. Это просто указание Hibernate не дублировать данные. Для оптимизации можно указать Hibernate делать запрос без distinct: 15.16.2. Using DISTINCT with entity queries - Бага HINT_PASS_DISTINCT_THROUGH does not work if 'hibernate.use_sql_comments=true'. При
hibernate.use_sql_comments=falseвсе работает - в SELECT нет DISTINCT.
Еще один вариант решения - в User сделать Set<Meals>. Интересно, что в ее реализации PersistentSetпорядок соблюдается и @OrderBy работает.
- в
JdbcUserRepositoryImpl.getAll()собираю роли изResultSetнапрямую вmap- в
insertRolesпоменял методbatchUpdateи сделал проверку на empty- в
setRolesдостаю роли черезqueryForList
Еще интересные JDBC реализации:
- в
getAll()/ get()/ getByEmail()делать запросы сLEFT JOINи сделать реализациюResultSetExtractor - подключить зависимость
spring-data-jdbc-core. Там есть готовыйOneToManyResultSetExtractor. Можно посмотреть, как он реализован. - реализация, зависимая от БД: доставать агрегированные роли и делать им
split(","). В этой реализации есть ограничение - одно поле из зависимой таблицы.
SELECT u.*, string_agg(r.role, ',') AS roles
FROM users u
JOIN user_roles r ON u.id=r.user_id
GROUP BY u.id
На данный момент у нас реализована валидация сущностей только для jpa- и dataJpa-репозиториев. При работе через JDBC-репозиторий может произойти попытка записи в БД некорректных данных, что приведет
к SQLException из-за нарушения ограничений, наложенных на столбцы базы данных. Для того, чтобы перехватить невалидные данные еще до обращения в базу, воспользуемся API javax.validation (ее
реализация hibernate-validator используется для проверки данных в Hibernate и будет использоваться в Spring Validation, которую подключим позже). В ValidationUtil создадим один потокобезопасный
валидатор, который можно переиспользовать (см. javadoc).
С его помощью в методах сохранения и обновления сущности в jdbc-репозиториях мы можем производить валидацию этой сущности: ValidationUtil.validate(object);
Чтобы проверка не падала, @NotNull Meal.user пришлось пока закомментировать. Починим в 10-м занятии через @JsonView.
Вместо наших приседаний с JpaUtil и проверкой профилей мы можем отключить Spring-кэш в тестах, подменив spring-cache.xml на тестовый (положив его в ресурсы тестов).
Отключить кэширование можно через пустую реализацию NoOpCacheManager или, как сейчас, не включая cache:annotation-driven, который подключает обработку @Cache-аннотаций.
Кэш Hibernate второго уровня отключаем через переопределение свойства entityManagerFactory.jpaPropertyMap: hibernate.cache.use_second_level_cache=false (кроме стандартного использования файла
пропертей, можно задать их прямо в конфигурации, через автодополнение в xml можно смотреть все варианты). Подкладываем новый spring-cache.xml в ресурсы тестов, он перекроет настройки кэша в
приложении. Остается удалить наши уже ненужные JpaUtil и AbstractServiceTest.isJpaBased()
Краткое содержание
Для более удобного сравнения объектов в тестах мы будем использовать библиотеку Harmcrest с Matcher'ами, которая позволяет делать сложные проверки. С Junit по умолчанию подтягивается Harmcrest
core, но нам потребуется расширенная версия:
в pom.xml из зависимости Junit исключим дочернюю hamcrest-core и добавим hamcrest-all.
Для тестирования web создадим вспомогательный класс AbstractControllerTest, от которого будут наследоваться все тесты контроллеров. Его особенностью будет наличие MockMvc - эмуляции Spring MVC для
тестирования web-компонентов. Инициализируем ее в методе, отмеченном @PostConstruct:
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).addFilter(CHARACTER_ENCODING_FILTER).build();
Для того, чтобы в тестах контроллеров не популировать базу перед каждым тестом, пометим этот базовый тестовый класс аннотацией @Transactional. Теперь каждый тестовый метод будет выполняться в
транзакции, которая будет откатываться после окончания метода и возвращать базу данных в исходное состояние. Однако теперь в работе тестов могут возникнуть нюансы, связанные с пропагацией транзакций:
все транзакции репозиториев станут вложенными во внешнюю транзакцию теста. При этом, например, кэш первого уровня станет работать не так, как ожидается. Т.е при таком подходе нужно быть готовыми к
ошибкам: мы их увидим и поборем в тестах на обработку ошибок на последних занятиях TopJava.
Создадим тестовый класс для контроллера юзеров, он должен наследоваться от AbstractControllerTest. В MockMvc
используется паттерн проектирования Builder.
mockMvc.perform(get("/users")) // выполнить HTTP метод GET к "/users"
.andDo(print()) // распечатать содержимое ответа
.andExpect(status().isOk()) // от контроллера ожидается ответ со статусом HTTP 200(ok)
.andExpect(view().name("users")) // контроллер должен вернуть view с именем "users"
.andExpect(forwardedUrl("/WEB-INF/jsp/users.jsp")) // ожидается, что клиент должен быть перенаправлен на "/WEB-INF/jsp/users.jsp"
.andExpect(model().attribute("users", hasSize(2))) // в модели должен быть атрибут "users" размером = 2
.andExpect(model().attribute("users", hasItem( // внутри которого есть элемент ...
allOf(
hasProperty("id", is(START_SEQ)), // ... с аттрибутом id = START_SEQ
hasProperty("name", is(USER.getName())) //... и name = user
)
)));
}
В параметры метода andExpect() передается реализация ResultMatcher, в которой мы определяем как должен быть обработан ответ контроллера.
- в
MockMvcдобавилсяCharacterEncodingFilter- поменял реализацию
ActiveDbProfileResolver: в профили аттрибута@ActiveProfiles(profiles=..)он добавляетProfiles.getActiveDbProfile()- сделал вспомогательный метод
AbstractControllerTest.perform()
Краткое содержание
Для миграции на 5-ю версию JUnit в файле pom.xml поменяем зависимость junit
на junit-jupiter-engine (No need junit-platform-surefire-provider dependency in maven-surefire-plugin). Актуальную
версию всегда можно посмотреть в центральном maven репозитории, берем только релизы (..-Mx означают предварительные milestone версии)
Изменять конфигурацию плагина maven-sureface-plugin в новых версиях JUnit уже не требуется. Junit5 не содержит в себе зависимости от Harmcrest (которую нам приходилось вручную отключать для JUnit4
в предыдущих шагах), поэтому исключение hamcrest-core просто удаляем. В итоге у нас останутся зависимости JUnit5 и расширенный Harmcrest.
Теперь мы можем применить все нововведения пятой версии в наших тестах:
- Для всех тестов теперь мы можем удалить
public. - Аннотацию
@Beforeисправим на@BeforeEach- теперь метод, который будет выполняться перед каждым тестом, помечается именно так. - В Junit5 работа с исключениями похожа на Junit4 версии 4.13: вместо ожидаемых исключений в параметрах аннотации
@Test(expected = Exception.class)используется методassertThrows(), в который первым аргументом мы передаем ожидаемое исключение, а вторым аргументом — реализацию функционального интерфейсаExecutable(кода теста, в котором ожидается возникновение исключения). - Метод
assertThrows()возвращает исключение, которое было выброшено в переданном ему коде. Теперь мы можем получить это исключение, извлечь из него сообщение с помощьюe.getMessage()и сравнить с ожидаемым. - Для теста на валидацию при проверке предусловия, только при выполнении которого будет выполняться следующий участок кода (например, в нашем случае тесты на валидацию выполнялись только в jpa
профиле), - теперь нужно пользоваться утильным методом
Assumptions(нам уже не требуется). - Проверку Root Cause - причины, из-за которой было выброшено пойманное исключение, мы будем делать позднее, при тестах на ошибки.
- Из JUnit5 исключена функциональность
@Rule, вместо них теперь нужно использоватьExtensions, которые могут встраиваться в любую фазу тестов. Чтобы добавить их в тесты, пометим базовый тестовый класс аннотацией@ExtendWith.
JUnit предоставляет нам набор коллбэков — интерфейсов, которые будут исполняться в определенный момент тестирования. Создадим класс TimingExtension, который будет засекать время выполнения тестовых
методов.
Этот класс будет имплементировать маркерные интерфейсы — коллбэки JUnit:
BeforeTestExecutionCallback- коллбэк, который будет вызывать методы этого интерфейса перед каждым тестовым методом.AfterTestExecutionCallback- методы этого интерфейса будут вызываться после каждого тестового метода;BeforeAllCallback- методы перед выполнением тестового класса;AfterAllCallback- методы после выполнения тестового класса;
Осталось реализовать соответствующие методы, которые описываются в каждом из этих интерфейсов, они и будут вызываться JUnit в нужный момент:
- в методе
beforeAll(который будет вызван перед запуском тестового класса) создадим спринговый утильный секундомерStopWatchдля текущего тестового класса; - в методе
beforeTestExecution(будет вызван перед тестовым методом) - запустим секундомер; - в методе
afterTestExecution(будет вызван после тестового метода) - остановим секундомер. - в методе
afterAll(который будет вызван по окончанию работы тестового класса) - выведем результат работы этого секундомера в консоль;
- Аннотации
@ContextConfigurationи@ExtendWith(SpringExtension.class)(замена@RunWith) мы можем заменить одной@SpringJUnitConfiguration(в старых версиях IDEA ее не понимает)
- No need
junit-platform-surefire-providerdependency inmaven-surefire-plugin - Наконец пофиксили баг с
@SpringJUnitConfig - Проверил без
junit-platform-launcherв pom для запуска JUnit 5 тестов из IDEA. В новой версии IDEA работает без него, проверьте у себя. - JUnit 5 homepage
- Overview
- Миграция с JUnit4 на JUnit5: важные отличия и преимущества
- 10 интересных нововведений
- Дополнительно:
Краткое содержание
REST - архитектурный стиль проектирования распределенных систем (типа клиент-сервер).
Чаще всего в REST сервер и клиент общаются посредством обмена JSON-объектами через HTTP-методы GET/POST/PUT/DELETE/PATCH.
Особенностью REST является отсутствие состояния (контекста) взаимодействий клиента и сервера.
В нашем приложении есть контроллеры для Admin и для User. Чтобы сделать их REST-контроллерами, заменим аннотацию @Controller на @RestController
Не поленитесь зайти чз Ctrl+Click в
@RestController: к аннотации@Controllerдобавлена@ResponseBody. Т.е. ответ от нашего приложения будет не имя View, а данные в теле ответа.
В @RequestMapping, кроме пути для методов контроллера (value) добавляем параметр produces = MediaType.APPLICATION_JSON_VALUE. Это означает, что в заголовки ответа будет добавлен
тип ContentType="application/json" - в ответе от контроллера будет приходить JSON-объект.
Чтобы было удобно использовать путь к этому контроллеру в приложении и в тестах, выделим путь к нему в константу REST_URL, к которой можно будет обращаться из других классов
-
Метод
AdminRestController.getAllпометим аннотацией@GetMapping- маршрутизация к методу по HTTP GET. -
Метод
AdminRestController.getпометим аннотацией@GetMapping("/{id}").
В скобках аннотации указано, что к основному URL контроллера будет добавлятьсяidпользователя - переменная, которая передается в запросе непосредственно в URL.
Соответствующий параметр метода нужно пометить аннотацией@PathVariable(если имя в URL и имя аргумента метода не совпадают, в параметрах аннотации дополнительно нужно будет уточнить имя в URL. Если они совпадают, этого не требуется. -
Метод создания пользователя
createотметим аннотацией@PostMapping- маршрутизация к методу по HTTP POST. В метод мы передаем объектUserв теле запроса (аннотация@RequestBody) и в формате JSON (consumes = MediaType.APPLICATION_JSON_VALUE). При создании нового ресурса правила хорошего тона - вернуть в заголовке ответа URL созданного ресурса. Для этого возвращем неUser, аResponseEntity<User>, который мы можем с помощью билдераServletUriComponentsBuilderдополнить заголовком ответаLocationи вернуть статусCREATED(201)(если пойти в кодResponseEntity.createdможно докопаться до сути, очень рекомендую смотреть в исходники кода). -
Метод
deleteпомечаем@DeleteMapping("/{id}")- HTTP DELETE. Он ничего не возвращает, поэтому помечаем его аннотацией@ResponseStatus(HttpStatus.NO_CONTENT). Статус ответа будет HTTP.204; -
Над методом обновления ставим
@PutMapping(HTTP PUT). В аргументах метод принимает@RequestBody User userи@PathVariable int id. -
Метод поиска по
emailтакже помечаем@GetMapping, и, чтобы не было конфликта маршрутизации с методомget(), указываем в URL добавку "/by". В этот методemailпередается как параметр запроса, аннотация@RequestParam.
Все это СТАНДАРТ архитектурного стиля REST. НЕ придумывайте ничего своего в своих выпускных проектах! Это очень большая ошибка - не придерживаться стандартов API.
ProfileRestControllerвыполняем аналогичным способом с учетом того, что пользователь имеет доступ только к своим данным.
Если на данном этапе попытаться запустить приложение и обратиться к какому-либо методу контроллера, сервер ответит нам ошибкой со статусом 406, так как Spring не знает, как преобразовать объект User в JSON...
- Переделал URL поиска по email на
/by-email
- Понимание REST
- JSON (JavaScript Object Notation)
- 15 тривиальных фактов о правильной работе с протоколом HTTP
- 10 Best Practices for Better RESTful
- Best practices for rest nested resources
- Request mapping
- Лучшие практики разработки REST API: правила 1-7,15-17
- Дополнительно:
Краткое содержание
Для работы с JSON добавляем в pom.xml зависимость jackson-databind.
Актуальную версию библиотеки можно посмотреть в центральном maven-репозитории.
Теперь спринг будет автоматически использовать эту библиотеку для сериализации/десериализации объектов в JSON (найдя ее в classpath).
Если сейчас запустить приложение и обратиться к методам REST-контроллера, то оно выбросит LazyInitializationException. Оно возникает из-за того, что у наших сущностей есть лениво загружаемые поля,
отмеченные FetchType.LAZY - при загрузке сущности из базы, вместо этого поля подставится Proxy, который и должен вернуть реальный экземпляр этого поля при первом же обращении. Jackson при
сериализации в JSON использует все поля сущности, и при обращении к Lazy полям возникает исключение, так как сессия работы с БД в этот момент уже закрыта, и нужный объект не может быть
инициализирован. Чтобы Jackson игнорировал эти поля, пометим их аннотацией @JsonIgnore.
Теперь при запуске приложения REST-контроллер будет работать. Но при получении JSON объектов мы можем увидеть, что Jackson сериализовал объект через геттеры (например в ответе есть поле new от
метода Persistable.isNew()). Чтобы учитывались только поля объектов, добавим над AbstractBaseEntity:
@JsonAutoDetect(fieldVisibility = ANY, // jackson видит все поля
getterVisibility = NONE, // ... но не видит геттеров
isGetterVisibility = NONE, //... не видит геттеров boolean полей
setterVisibility = NONE) // ... не видит сеттеровТеперь все сущности, унаследованные от базового класса, будут сериализоваться/десериализоваться через поля.
Краткое содержание
Сейчас, чтобы не сериализовать Lazy поля, мы должны пройтись по каждой сущности и вручную пометить их аннотацией @JsonIgnore. Это неудобно, засоряет код и допускает возможные ошибки. К тому же,
при некоторых условиях, нам иногда нужно загрузить и в ответе передать эти Lazy поля.
Чтобы запретить сериализацию Lazy полей для всего проекта, подключим в pom.xml библиотеку jackson-datatype-hibernate.
Также изменим сериализацию/десериализацию полей объектов в JSON: не через аннотацию @JsonAutoDetect, а в классе JacksonObjectMapper, который унаследуем от ObjectMapper (стандартный Mapper,
который использует Jackson) и сделаем в нем другие настройки. В конструкторе:
- регистрируем
Hibernate5Module- модульjackson-datatype-hibernate, который не делает сериализацию ленивых полей. - модуль для корректной сериализации
LocalDateTimeв поля JSON -JavaTimeModuleмодуль библиотекиjackson-datatype-jsr310 - запрещаем доступ ко всем полям и методам класса и потом разрешаем доступ только к полям
- не сериализуем null-поля (
setSerializationInclusion(JsonInclude.Include.NON_NULL))
Чтобы подключить наш кастомный JacksonObjectMapper в проект, в конфигурации spring-mvc.xml к настройке <mvc:annotation-driven> добавим MappingJackson2HttpMessageConverter, который будет
использовать наш маппер.
- Сериализация hibernate lazy-loading с помощью jackson-datatype-hibernate
- Handle Java 8 dates with Jackson
- Дополнительно:
Краткое содержание
Сейчас в тестах REST-контроллера мы проводим проверку только на статус ответа и тип возвращаемого контента. Добавим проверку содержимого ответа.
Чтобы сравнивать содержимое ответа контроллера в виде JSON и сущность, воспользуемся библиотекой
jsonassert, которую подключим в pom.xml со scope test.
Эта библиотека при сравнении в тестах в качестве ожидаемого значения ожидает от нас объект в виде JSON-строки. Чтобы вручную не преобразовывать объекты в JSON и не хардкодить их в виде строк в наши
тесты, воспользуемся Jackson.
Для преобразования объектов в JSON и обратно создадим утильный класс JsonUtil, в котором с помощью нашего JacksonObjectMapper и будет конвертировать объекты.
И мы сталкиваемся с проблемой: JsonUtil - утильный класс и не является бином спринга, а для его работы требуется наш кастомный маппер, который находится под управлением спринга и расположен в
контейнере зависимостей. Поэтому, чтобы была возможность получить наш маппер из других классов - сделаем его синглтоном и сделаем в нем статический метод, который будет возвращать его экземпляр.
Теперь JsonUtil сможет его получить.
И нам нужно указать спрингу, чтобы он не создавал второй экземпляр этого объекта, а клал в свой контекст существующий. Для этого в конфигурации spring-mvc.xml определим factory-метод, с помощью
которого спринг должен получить экземпляр (instance) этого класса:
<bean class="ru.javawebinar.topjava.web.json.JacksonObjectMapper" id="objectMapper" factory-method="getMapper"/>а в конфигурации message-converter вместо создания бина просто сошлемся на сконфигурированный objectMapper.
Метод ContentResultMatchers.json() из spring-test использует библиотеку jsonassert для сравнения 2-х JSON строк: одну из ответа контроллера и вторую - JSON-сериализация admin без
поля registered (это поле инициализируется в момент создания и отличается). В методе JsonUtil.writeIgnoreProps мы преобразуем объект admin в мапу, удаляем из нее игнорируемые поля и снова
сериализуем в JSON.
Также сделаем тесты для утильного класса JsonUtil. В тестах мы записываем объект в JSON-строку, затем конвертируем эту строку обратно в объект и сравниваем с исходным. И то же самое делаем со
списком объектов.
RootControllerTest
Сделаем рефакторинг RootControllerTest. Ранее мы в тесте получали модель, доставали из нее сущности и с помощью hamcrest-all
производили по одному параметру их сравнение с ожидаемыми значениями. Метод ResultActions.andExpect() позволяет передавать реализацию интерфейса Matcher, в котором можно делать любые сравнения.
Функциональность сравнения списка юзеров по ВСЕМ полям у нас уже есть - мы просто делегируем сравнение объектов в UserTestData.MATCHER. При этом нам больше не нужен harmcrest-all, нам достаточно
только harmcrest-core.
MatcherFactory
Теперь вместо jsonassert и сравнения JSON-строк в тестах контроллеров сделаем сравнения JSON-объектов через MatcherFactory. Преобразуем ответ контроллера из JSON в объект и сравним с эталоном
через уже имеющийся у нас матчер.
Вместо сравнения JSON-строк в метод andExpect() мы будем передавать реализации интерфейса ResultMatcher из MATCHER.contentJson(..).
MATCHER.contentJson(..) принимают ожидаемый объект и возвращают для него ResultMatcher с реализацией единственного метода match(MvcResult result), в котором делегируем сравнение уже существующим
у нас матчерам. Мы берем JSON-тело ответа (MatcherFactory.getContent), десериализуем его в объект (JsonUtil.readValue/readValues) и сравниваем через имеющийся MATCHER.assertMatch
десериализованный из тела контроллера объект и ожидаемое значение.
Методы из класса
TestUtilперенес вMatcherFactory, лишние удалил.
AdminRestControllerTest
getByEmail()- сделан по аналогии с тестомget(). Дополнительно нужно дополнить строку URL параметрами запроса.delete()- выполняем HTTP.DELETE. Проверяем статус ответа 204. Проверяем, что пользователь удален.
Раньше я получал всех users из базы и проверял, что среди них нет удаленного. При этом тесты становятся чувствительными ко всем users в базе и ломаются при добавлении-удалении новых тестовых данных.
update()- выполняем HTTP.PUT. В тело запроса подаем сериализованныйJsonUtil.writeValue(updated). После выполнения проверяем, что объект в базе обновился.create()- выполняем HTTP.POST аналогичноupdate(). Но сравнить результат мы сразу не можем, т.к. при создании объекта ему присваиваетсяid.
Поэтому мы извлекаем созданного пользователя из ответа (MATCHER.readFromJson(action)), получаем егоid, и уже с этимidэталонный объект мы можем сравнить с объектом в ответе контроллера и со значением в базе.getAll()- аналогично get(). Список пользователей из ответа в формате JSON сравниваем с эталонным списком (MATCHER.contentJson(admin, user)).
Тесты для ProfileRestController выполнены аналогично.
- В
JsonUtil.writeIgnorePropsвместо цикла по мапе сделалmap.keySet().removeAll
- Сделал внутренний класс
MatcherFactory.Matcher, который возвращается из фабрики матчеров.- Методы из класса
TestUtilперенес вMatcherFactory, лишние удалил.- Раньше в тестах я для проверок получал всех users из базы и сравнивал с эталонным списком. При этом тесты становятся чувствительными ко всем users в базе и ломаются при добавлении-удалении новых тестовых данных.
Краткое содержание
SOAP UI - это один из инструментов для тестирования API приложений, которые работают по REST и по SOAP.
Он позволяет нам по HTTP протоколу дернуть методы нашего API и увидеть ответ контроллеров.
Если в контроллер мы добавим метод, который в теле ответа будет возвращать текст на кириллице, то увидим, что кодировка теряется. Для сохранения кодировки используем StringHttpMessageConverter,
который конфигурируем в spring-mvc.xml. При этом мы должны явно указать, что конвертор будет работать только с текстом в кодировке UTF-8.
- Инструменты тестирования REST:
- SoapUi
- Что такое Curl? Как работает эта команда?
- Написание HTTP-запросов с помощью Curl.
Для Windows 7 можно использовать Git Bash, с Windows 10 v1803 можно прямо из консоли. Возможны проблемы с UTF-8: - IDEA: Tools->HTTP Client->...
- Postman
- Insomnia REST client
Импортировать проект в SoapUi из config\Topjava-soapui-project.xml. Response смотреть в формате JSON.
Проверка UTF-8: http://localhost:8080/topjava/rest/profile/text
Зачем у нас и UIController'ы, и RestController'ы? То есть в общем случае backend-разработчику недостаточно предоставить REST-api и RestController?
Часто используются и те и другие. REST обычно используют для отдельного UI например на React или Angular или для интеграции / мобильного приложения.
У нас REST контроллеры используются только для тестирования. UI мы используем для нашего приложения на JSP шаблонах.
Таких сайтов без богатой UI логики тоже немало. Например https://javaops.ru/ :)
Разница в обработке запросов:
- из UI контроллеров возвращаются как готовые HTML странички, так и данные в формате JSON (будет для AJAX запросов в следующих занятиях)
- для UI мы используем только GET и POST запросы
- при создании-обновлении в UI мы принимаем данные из формы
application/x-www-form-urlencoded(посмотрите вкладкуNetwork, не в формате JSON) - для REST запросы GET, POST, PUT, DELETE, PATCH и возвращают только данные (обычно JSON)
И в способе авторизации:
- для RESТ у нас будет базовая авторизация
- для UI - через cookies
Также часто бывают смешанные сайты - где есть и отдельное JS приложение и шаблоны.
При выполнении тестов через MockMvc никаких изменений на базе не видно, почему оно не сохраняет?
AbstractControllerTest аннотируется @Transactional - это означает, что тесты идут в транзакции, и после каждого теста JUnit делает rollback базы.
Что получается в результате выполнения запроса
SELECT DISTINCT(u) FROM User u LEFT JOIN FETCH u.roles ORDER BY u.name, u.email? В чем разница в SQL безDISTINCT.
Запросы SQL можно посмотреть в логах. Т.е. DISTINCT в JPQL влияет на то, как Hibernate обрабатывает дублирующиеся записи (с DISTINCT их исключает). Результат можно посмотреть в тестах или
приложении, поставив брекпойнт. По поводу SQL DISTINCT не стесняйтесь пользоваться google, например, оператор SQL DISTINCT
В чем заключается расширение функциональности hamcrest в нашем тесте, что нам пришлось его отдельно от JUnit прописывать?
hamcrest-all используется в проверках RootControllerTest: org.hamcrest.Matchers.*. JUnit 4 включает в себя hamcrest-core, в JUnit 5 его нужно подключать отдельно.
Jackson мы просто подключаем в помнике, и Spring будет с ним работать без любых других настроек?
Да, Spring смотрит в classpath и если видит там Jackson, то подключает интеграцию с ним.
Где-то слышал, что любой ресурс по REST должен однозначно идентифицироваться через url без параметров. Правильно ли задавать URL для фильтрации в виде
http://localhost/topjava/rest/meals/filter/{startDate}/{startTime}/{endDate}/{endTime}?
Так делают, только при отношении
агрегация, например, если давать админу право смотреть еду любого юзера, URL мог бы быть похож на http://localhost/topjava/rest/users/{userId}/meals/{mealId} (не рекомендуется, см ссылку ниже).
В случае критериев поиска или страничных данных они передаются как параметр. Смотри также:
- 15 тривиальных фактов о правильной работе с протоколом HTTP
- 10 Best Practices for Better RESTful
- REST resource hierarchy (если кратко: не рекомендуется)
Что означает конструкция в
JsonUtil:reader.<T>readValues(json);
См. Generic Methods. Когда компилятор не может вывести тип, можно его уточнить при вызове generic метода. Неважно, static или нет.
- 1: Добавить тесты контроллеров:
- 1.1
RootControllerTest.getMealsдляmeals.jsp - 1.2 Сделать
ResourceControllerTestдляstyle.css(проверитьstatusиContentType)
- 1.1
- 2: Реализовать
MealRestControllerи протестировать его черезMealRestControllerTest- 2.1 следите, чтобы url в тестах совпадал с параметрами в методе контроллера. Можно добавить логирование
<logger name="org.springframework.web" level="debug"/>для проверки маршрутизации. - 2.2 в параметрах
getBetweenприниматьLocalDateTime(конвертировать через @DateTimeFormat with Java 8 Date-Time API), пока без проверки наnull(используяtoLocalDate()/toLocalTime(), см. Optional п.3). В тестах передавать в форматеISO_LOCAL_DATE_TIME( например'2011-12-03T10:15:30').
- 2.1 следите, чтобы url в тестах совпадал с параметрами в методе контроллера. Можно добавить логирование
- 3: Переделать
MealRestController.getBetweenна параметрыLocalDate/LocalTimec раздельной фильтрацией по времени/дате, работающий приnullзначениях (см. демо иJspMealController.getBetween) . Заменить@DateTimeFormatна свои LocalDate/LocalTime конверторы или форматтеры. - 4: Протестировать
MealRestController(SoapUi, curl, IDEA Test RESTful Web Service, Postman). Запросыcurlзанести в отдельныйmdфайл (илиREADME.md) - 5: Добавить в
AdminRestControllerиProfileRestControllerметоды получения пользователя вместе с едой (getWithMeals,/with-meals).
- 6: Сделать тесты на методы контроллеров
getWithMeals()(п.5)
На следующем занятии используется JavaScript/jQuery. Если у вас там пробелы, пройдите его основы
- 1: Ошибка в тесте Invalid read array from JSON обычно расшифровывается немного ниже: читайте внимательно.
- 2: Jackson и неизменяемые объекты (для сериализации MealTo)
- 3: Если у meal, приходящий в контроллер, поля
null, проверьте@RequestBodyперед параметром (данные приходят в формате JSON) - 4: При проблемах с собственным форматтером убедитесь, что в конфигурации
<mvc:annotation-driven...не дублируется - 5: Тесты сервисов у нас идут на все профили работы с БД (JDBC, JPA и DATAJPA), а контроллеров - только на Profiles.REPOSITORY_IMPLEMENTATION.
Проверьте выполнение тестов контроллера через maven test для каждого из профилей (JDBC, JPA и DATAJPA). Обратите внимание, что
getWithMeals()реализован только для DATAJPA, для JDBC и JPA тестыgetWithMeals()должны игнорироваться. Также, в случае проблем, проверьте, что не портите эталонный образец изMealTestData - 6:
@Autowiredв тестах нужно делать в том месте, где класс будет использоваться. Общий принцип: не размазывать код по классам, объявление переменных держать как можно ближе к ее использованию, группировать (не смешивать) код с разной функциональностью. - 7: Попробуйте в
RootControllerTest.getMealsсделать сравнение черезmodel().attribute("meals", expectedValue). Учтите, что вывод результатов черезtoStringк сравнению отношения не имеет.


