Большая миграция

в 10:18, , рубрики: java, junit, kotlin, spring framework, Альфа-Банк, Блог компании «Альфа-Банк», Программирование

Большая миграция - 1

Предисловие

Привет, %username%! Этот год принес много интересных новинок и приятных новостей. Вышел долгожданный релиз Spring 5, с реактивным ядром и встроенной поддержкой Kotlin, для которой еще появится много всего интересного. Sébastien представил новый функциональный подход конфигурации Spring на Kotlin. Зарелизился JUnit 5. Близится релиз Kotlin 1.2 c улучшенной поддержкой мульти-платформенных приложений. И в этом году произошло знаменательное событие! Теперь Kotlin перешел от сборки на Groovy Dsl в Gradle на сборку с помощью Kotlin Dsl.

Как правило, начать сразу с нового стека проще, но всегда возникают вопросы насчет того, как реализовать старые подходы. Поэтому рассмотрим как на примере приложения написанного на Java, Spring Boot 1.5 (Spring 4+) с использованием Lombok и Groovy Dsl в Gradle, поэтапно перейти на Spring boot 2 (Spring 5), JUnit 5, Kotlin, и попробовать реализовать проект в функциональном стиле на spring-webflux без spring-boot. А также как перейти с Groovy Dsl на Kotlin Dsl. В посте основное внимание будет уделяться именно переходу, поэтому будет неплохо, если уже знакомы со Spring, Spring Boot и Gradle.

Для тех, кому лень читать, можно посмотреть пример кода на github, для всех остальных — прошу под кат:

1. Начнем с базового приложения

В качестве примера возьмем простое приложение по управлению пользователями на основе Spring Boot 1.5.8 и Spring 4.3.12

Для примера чтения конфигурации, создадим файл в src/main/resources/application.ym, в котором укажем порт запуска приложения, и секцию db, которую будем использовать в приложении.

server:
  port: 8080

db:
  url: localhost:8080
  user: vasia
  password: vasiaPasswordSecret

Подключим Lombok:

compileOnly("org.projectlombok:lombok:1.16.18")

Секцию из конфигурационного файла будем читать при помощи класса DBConfiguration с аннотациями @ConfigurationProperties и @Configuration. В этом же конфигурационном файле будем создавать bean DbConfig с настройками подключения к базе.

@Configuration
@ConfigurationProperties
@Getter
@Setter
public class DBConfiguration {
    private DbConfig db;

    @Bean
    public DbConfig configureDb() {
        return new DbConfig(db.getUrl(), db.getUser(), unSecure(db.getPassword()));
    }

    private String unSecure(String password) {
        int secretIndex = password.indexOf("Secret");

        return password.substring(0, secretIndex);
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class DbConfig {
        private String url;
        private String user;
        private String password;
    }
}

Чтобы не усложнять, пользователей будем хранить в памяти. Для этого добавим репозиторий UserRepository. И чтобы проверить, что у нас нормально создался Bean с настройками подключения, выведем DbConfig в консоль.

UserRepository

@Repository
public class UserRepository {
    private DBConfiguration.DbConfig dbConfig;

    public UserRepository(DBConfiguration.DbConfig dbConfig) {
        this.dbConfig = dbConfig;

        System.out.println(dbConfig);
    }

    private Long index = 3L;

    private List<User> users = Arrays.asList(
            new User(1L, "Oleg", "BigMan", 21),
            new User(2L, "Lesia", "Listova", 25),
            new User(3L, "Bin", "Bigbanovich", 30)
    );

    public List<User> findAllUsers() {
        return new ArrayList<>(users);
    }

    public synchronized Optional<Long> addUser(User newUser) {
        Long newIndex = nextIndex();
        boolean addStatus = users.add(newUser.copy(newIndex));

        if (addStatus) {
            return Optional.of(newIndex);
        } else {
            return Optional.empty();
        }
    }

    public Optional<User> findUser(Long id) {
        return users.stream()
                .filter(user -> user.getId().equals(id))
                .findFirst();
    }

    public synchronized boolean deleteUser(Long id) {
        Optional<User> findUser = users.stream()
                .filter(user -> user.getId().equals(id))
                .findFirst();

        Boolean status = false;
        if (findUser.isPresent()) {
            users.remove(findUser.get());
            status = true;
        }

        return status;
    }

    private Long nextIndex() {
        return index++;
    }
}

Добавим несколько контроллеров:

  • StatsController, с одним методом, который будет выдавать статистику по пользователям, полученную от сервиса.

StatsController

RestController("stats")
public class StatsController {

    private StatsService statsService;

    public StatsController(StatsService statsService) {
        this.statsService = statsService;
    }

    @GetMapping
    public StatsResponse stats() {
        Stats stats = statsService.getStats();

        return new StatsResponse(true, "user stats", stats);
    }
}

  • UserController, с простыми методами по управлению списком пользователей.

UserController

@RestController
public class UserController {
    private UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping("users")
    public UserResponse users() {
        List<User> users = userRepository.findAllUsers();

        return new UserResponse(true, "return users", users);
    }

    @GetMapping("user/{id}")
    public UserResponse users(@PathVariable("id") Long userId) {
        Optional<User> user = userRepository.findUser(userId);

        return user
                .map(findUser -> new UserResponse(true, "find user with requested id", Collections.singletonList(findUser)))
                .orElseGet(() -> new UserResponse(false, "user not found", Collections.emptyList()));
    }

    @PutMapping(value = "user")
    public Response addUser(@RequestBody User user) {
        Optional<Long> addIndex = userRepository.addUser(user);

        return addIndex
                .map(index -> new UserAddResponse(true, "user add successfully", index))
                .orElseGet(() -> new UserAddResponse(false, "user not added", -1L));
    }

    @DeleteMapping("user/{id}")
    public Response deleteUser(@PathVariable("id") Long id) {
        boolean status = userRepository.deleteUser(id);

        if (status) {
            return new Response(true, "user has been deleted");
        } else {
            return new Response(false, "user not been deleted");
        }
    }
}

И добавим небольшой сервис для контроллера статистики с “бизнес логикой”, который будет подготавливать данные для статистики:

StatsService

@Service
public class StatsService {

    private UserRepository userRepository;

    public StatsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public Stats getStats() {
        List<User> allUsers = userRepository.findAllUsers();

        User oldestUser = allUsers.stream()
                .max(Comparator.comparingInt(User::getAge))
                .get();

        User youngestUser = allUsers.stream()
                .min(Comparator.comparingInt(User::getAge))
                .get();

        return new Stats(
                allUsers.size(),
                oldestUser,
                youngestUser
        );
    }
}

Для тестирования работы приложения добавим для каждого контроллер тест, запускаемый при помощи @SpringRunner. В нем будет подниматься весь контекст Spring c запуском приложения на случайном порту. Ниже код теста для контроллера StatsControllerTest. В нем, в качестве экземпляра сервиса, будем создавать mock с помощью @MockBean. Запросы в контроллер будем отправлять с помощью TestRestTemplate, который есть из коробки вместе с spring-boot-starter-test.

StatsControllerTest

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StatsControllerTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @MockBean
    private StatsService statsServiceMock;

    @Test
    public void statsControllerShouldReturnValidResult() {
        Stats expectedStats = new Stats(
                2,
                new User(1L, "name1", "surname1", 25),
                new User(2L, "name2", "surname2", 30)
        );
        when(this.statsServiceMock.getStats()).thenReturn(expectedStats);
        StatsResponse expectedResponse = new StatsResponse(true, "user stats", expectedStats);

        StatsResponse actualResponse = restTemplate.getForObject("/stats", StatsResponse.class);

        assertEquals("invalid stats response", expectedResponse, actualResponse);
    }
}

Также добавим простой тест на основе mockito для сервиса StatsService:

StatsServiceTest

public class StatsServiceTest {
    @Test
    public void statsServiceShouldReturnRightData() {
        UserRepository userRepositoryMock = mock(UserRepository.class);

        User youngestUser = new User(1L, "UserName1", "Sr1", 21);
        User someOtherUser = new User(2L, "UserName2", "Sr2", 25);
        User oldestUser = new User(3L, "UserName3", "Sr3", 30);

        when(userRepositoryMock.findAllUsers()).thenReturn(Arrays.asList(
                youngestUser,
                someOtherUser,
                oldestUser
        ));

        StatsService statsService = new StatsService(userRepositoryMock);

        Stats actualStats = statsService.getStats();

        Stats expectedStats = new Stats(
                3,
                oldestUser,
                youngestUser
        );

        Assert.assertEquals("invalid stats", expectedStats, actualStats);
    }
}

Итоговый скрипт сборки будет выглядеть следующим образом:

group 'evgzakharov'
version '1.0-SNAPSHOT'

buildscript {
    ext {
        springBootVersion = '1.5.8.RELEASE'
    }
    repositories {
        jcenter()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: "java"
apply plugin: "org.springframework.boot"

sourceCompatibility = 1.8

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    compileOnly("org.projectlombok:lombok:1.16.18")

    testCompile("org.springframework.boot:spring-boot-starter-test")
}

Как видно, тут мы не используем ничего экзотического. Все так, как все обычно делают.
Остальные классы и полный пример кода можно посмотреть тут.
Запустим и проверим что все работает

cd step1_start_project
gradle build && gradle build && java -jar build/libs/step1_start_project-1.0-SNAPSHOT.jar

После этого приложение должно запуститься на порту 8080. Проверяем что по endpoint “/stats” у нас возвращается правильный ответ. Для этого в терминале выполняем команду:

curl -XGET "http://localhost:8080/stats" --silent | jq

Ответ должен быть следующим:

{
  "success": true,
  "description": "user stats",
  "stats": {
    "userCount": 3,
    "oldestUser": {
      "id": 3,
      "name": "Bin",
      "surname": "Bigbanovich",
      "age": 30
    },
    "youngestUser": {
      "id": 1,
      "name": "Oleg",
      "surname": "BigMan",
      "age": 21
    }
  }
}

Рабочее приложение готово. Теперь приступим к обновлению и переписыванию кода.

2. Переходим на Spring Boot 2 (Spring 5) и JUnit 5

Начнем с самого простого перехода. Для начала обновим версию Spring Boot на 2.0.0.M5. К сожалению, на момент написания поста, релизная версия еще не вышла, поэтому добавляем следующий репозиторий в скрипт сборки:

maven { url = "http://repo.spring.io/milestone" }

Пробуем обновить проект в студии и ловим ошибки, что у нас теперь не находятся зависимости spring-starter-*. Это связано с тем, что теперь автоматическая настройка версий зависимостей переехала в другой плагин. Добавляем его в скрипт сборки:

 apply plugin: "io.spring.dependency-management"

Обновляем приложение и теперь у нас все находится. Для такого простого проекта это все, что нужно сделать для перехода на новую версию spring-boot, но в реальных проектах, конечно, могут возникнуть другие сложности.

Перейдем теперь к переходу на JUnit 5.

В новой версии фреймворка появилось много всего интересного, достаточно хотя бы глянуть новую документацию.
Теперь JUnit 5 состоит из трех основных подпроектов:

JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage

где,
JUnit Platform — основа для запуска тестов на JVM. Она также предоставляет TestEngine API для запуска тестовых фреймворков.
JUnit Jupiter — состоит из объединения новой программной модели и модели расширений для написания тестов и расширений для JUnit 5. Также содержит подпроект, содержащий TestEngine для запуска тестов, написанных на платформе Jupiter.
JUnit Vintage — предоставляет TestEngine для запуска тестов, написанных на JUnit 3 и JUnit 4.

Для запуска новых тестов из gradle нам понадобится подключить их новый плагин:

buildscript {
    ...
    dependencies {
        ….
        classpath("org.junit.platform:junit-platform-gradle-plugin:1.0.1")
    }
}
apply plugin: "org.junit.platform.gradle.plugin"

Он позволяет запускать любые тесты, которые поддерживаются текущей версией TestEngine. Помимо этого, учитывая, что мы полностью переходим на новые тесты, добавляем следующие зависимости:

testCompile("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testRuntime("org.junit.jupiter:junit-jupiter-engine:$junitVersion")

Теперь все готово для запуска новых тестов, осталось только переписать старые. Хотя правильнее даже будет — немного изменить. Переход к JUnit 5 достаточно безболезненный. Основное что нужно сделать, это изменить импорт аннотации JUnit:

//old
import org.junit.Test;

//new
import org.junit.jupiter.api.Test;

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

Так, теперь будет выглядеть обновленный тест StatsServiceTest:

@DisplayName("Service test with mockito")
public class StatsServiceTest {

    @Test
    @DisplayName("stats service should return right data")
    public void test() {
        // ...
    }
}

Intellij Idea уже поддерживает JUnit 5, поэтому можем прямо в ней запустить тест:

Большая миграция - 2

Но даже если вы используете другую студию, которая еще не поддерживает JUnit5, то можно воспользоваться возможностями из JUnit 4 для запуска тестов, добавив для класса аннотацию @RunWith(JUnitPlatform.class).

В Spring, также начиная с версии 5, появилась поддержка Jupiter тестов. Для этого был добавлен класс SpringExtension, который теперь используется вместо SpringRunner. StatsControllerTest теперь будет выглядеть следующим образом:

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("StatsController test")
public class StatsControllerTest {

    // ..

    @Test
    @DisplayName("stats controller should return valid result")
    public void test() {
        // ...
    }
}

Запускаем тест и проверяем что все работает:

Большая миграция - 3

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

cd step2_migration_to_spring5_junit5
gradle build && gradle build && java -jar build/libs/step2_migration_to_spring5_junit5-1.0-SNAPSHOT.jar

Перед запуском приложения будет обновленный формат вывода информации о тестах:

Test run finished after 3535 ms
[         4 containers found      ]
[         0 containers skipped    ]
[         4 containers started    ]
[         0 containers aborted    ]
[         4 containers successful ]
[         0 containers failed     ]
[         6 tests found           ]
[         0 tests skipped         ]
[         6 tests started         ]
[         0 tests aborted         ]
[         6 tests successful      ]
[         0 tests failed          ]

Дожидаемся запуска приложения и также, как в разделе 1, с помощью curl убеждаемся, что все работает. На этом переход на JUnit 5 и Spring 5 можно считать завершенным.

3. Переходим на Kotlin

Не хотелось бы касаться вопроса, почему именно на Kotlin (все же это достаточно холиварная тема). Но если коротко, мое субъективное мнение, что на текущий момент он единственный статически типизированный язык, который позволяет писать красивый лаконичный код не уходя далеко от JVM, обладая при этом, что очень важно, достаточно плавной и бесшовной интеграцией с существующими библиотеками Java, особенно учитывая что Kotlin не привносит своих коллекций а использует стандартные из Java. Помимо этого, я возлагаю большие надежды на написание мульти-платформенных приложений полностью на Kotlin.

Подключим Kotlin. Для этого в скрипте сборки добавляем плагин kotlin и убираем плагин java.

buildscript {
    ext {
        ...        
        kotlinVersion = "1.1.51"
    }
    dependencies {
        …
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
    }
}
...
apply plugin: 'kotlin'

И нам нужно еще добавить небольшую kolin-stdlib, который было бы достаточно для Spring 4, но для Spring 5 еще требуется еще подключить kotlin-reflect, благодаря тому, что в нем появилась встроенная поддержка Kotlin и местами используется его рефлексия.

compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlinVersion")
compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")

Начнем конвертацию Java кода. Тут, нам на первом этапе, поможет студия Intellij Idea. Открываем любой Java файл и нажимаем 2 раза shift, где в поле поиска пишем: “convert java file to kotlin”. Аналогично повторяем действие для всех остальных файлов *.java.

Большая миграция - 4

Встроенный конвертор достаточно хороший, но после него нужно немного подправить код руками. В основном, это касается определения какие типы могут быть nullable а какие not-nullable. И во многом конвертация идет один-в-один. Другими словами, на выходе получается, по сути, тот же код на Java, только написанный на Kotlin (правда без значительной части шаблонного кода)

Посмотрим конвертацию на примере DBConfiguration. После конвертации студией код будет выглядеть следующим образом:

@Configuration
@ConfigurationProperties
@Getter
@Setter
class DBConfiguration {
    var db: DbConfig? = null
        set(db) {
            field = this.db
        }

    @Bean
    fun configureDb(): DbConfig {
        return DbConfig(this.db!!.url, this.db!!.user, unSecure(this.db!!.password))
    }

    private fun unSecure(password: String?): String {
        val secretIndex = password!!.indexOf("Secret")

        return password.substring(0, secretIndex)
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    class DbConfig {
        var url: String? = null
            set(url) {
                field = this.url
            }
        var user: String? = null
            set(user) {
                field = this.user
            }
        var password: String? = null
            set(password) {
                field = this.password
            }
    }
}

Пока не очень красиво, поэтому проделываем следующие действия:

  • убираем Lombok. Он нам теперь на не нужен, благодаря тому, что функциональность Kotlin полностью покрывает все его возможности.
  • убираем все set у свойств. В них тут тоже нет необходимости, так как для работы аннотации @ConfigurationProperties требуется только чтобы был field c публичными getter и setter. И переходим к non-nullable типам, у которых, в качестве значения по умолчанию, указываем пустые строки. Хотя тут можно оставить и nullable типы, но тогда дальше по коду придется с этим мириться (и как показала практика, лучше делать non-nullable значения для данных из конфигурации). Для класса DbConfig тут можно добавить модификатор data, правда для всех его свойств приходится указывать значение по умолчанию, чтобы у класса был создан конструктор без аргументов, который требуется для инициализации спрингом значения из конфигурации.
  • добавляем open для класса DBConfiguration. Это необходимо для того, чтобы правильно работал встроенный в Spring механизм переопределения классов. Есть еще вариант не добавлять open, а подключить в gradle плагин kotlin-spring, который сам на этапе компиляции откроет для переопределения нужные классы (и методы), но мне больше нравится явный подход.
  • аналогично предыдущему пункту, добавляем модификатор open для метода configureDb с аннотацией @Bean, все по той же причине, так как методы по умолчанию final.
  • упрощаем метод unSecure с помощью extension метода substringBefore

После доработок получаем следующее:

@Configuration
@ConfigurationProperties
open class DBConfiguration {
    var db: DbConfig = DbConfig()

    @Bean
    open fun configureDb(): DbConfig {
        return DbConfig(db.url, db.user, unSecure(db.password))
    }

    private fun unSecure(password: String): String {
        return password.substringBefore("Secret")
    }

    data class DbConfig(
            var url: String = "",
            var user: String = "",
            var password: String = ""
    )
}

Аналогичным образом конвертируем и упрощаем сервис StatsService. Получаем следующее:

@Service
open class StatsService(private val userRepository: UserRepository) {

    open fun getStats(): Stats {
        val allUsers = userRepository.findAllUsers()

        if (allUsers.isEmpty())
            throw RuntimeException("not find any user")

        val oldestUser = allUsers.maxBy { it.age }

        val youngestUser = allUsers.minBy { it.age }

        return Stats(
                allUsers.size,
                oldestUser!!,
                youngestUser!!
        )
    }
}

В Java для каждого варианта ответа контроллера нам приходилось создавать отдельный файл под каждый класс. В итоге было 4 класса и каждый в отдельном файле:

//src/main/java/migration/simple/responses/Response.java
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode
public class Response {
    private Boolean success;
    private String description;
}

//src/main/java/migration/simple/responses/StatsResponse.java
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class StatsResponse extends Response {
    private Stats stats;

    public StatsResponse(Boolean success, String description, Stats stats) {
        super(success, description);
        this.stats = stats;
    }
}

//src/main/java/migration/simple/responses/UserAddResponse.java
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class UserAddResponse extends Response {
    private Long userId;

    public UserAddResponse(Boolean success, String description, Long userId) {
        super(success, description);
        this.userId = userId;
    }
}
//src/main/java/migration/simple/responses/UserResponse.java
@Getter
@Setter
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class UserResponse extends Response {
    private List<User> users;

    public UserResponse(Boolean success, String description, List<User> users) {
        super(success, description);
        this.users = users;
    }
}

С приходом Kotlin теперь можно все уместить в одном файле с достаточно лаконичной записью:

interface Response {
    val success: Boolean
    val description: String
}

data class DeleteResponse(
        override val success: Boolean,
        override val description: String
) : Response

data class StatsResponse(
        override val success: Boolean,
        override val description: String,
        val stats: Stats
) : Response

data class UserAddResponse(
        override val success: Boolean,
        override val description: String,
        val userId: Long
) : Response

data class UserResponse(
        override val success: Boolean,
        override val description: String,
        val users: List<User>
) : Response

Правим аналогичным образом все остальные классы и переходим к тестам. Тут мы активно используем mockito, для работы которого в Kotlin требуется подключить дополнительную библиотеку:

testCompile("com.nhaarman:mockito-kotlin-kt1.1:1.5.0")

И нужно во всех тестах убрать импорты на старый mockito и поменять их на импорты из com.nhaarman.mockito_kotlin:

//old
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

//new
import com.nhaarman.mockito_kotlin.mock
import com.nhaarman.mockito_kotlin.whenever

when в Kotlin является ключевым словом, и чтобы в тестах не оставлять `when` вместо него используем whenever из библиотеки. И теперь мы можем создавать моки с помощью простой конструкции mock<T>(), причем тип может быть опциональным если он известен на момент объявления.

Стоит еще обратить внимание на объявленные field, значения которых инициализировались тестовым фреймворком при старте теста. Kotlin для всех объявлений требует чтобы значение было сразу инициализировано, поэтому мы можем для таких field указать только либо nullable тип и в качестве значения инициализации указать null, либо использовать lateinit (который в этом случае предпочтительней). lateinit в Kotin работает следующим образом: допускается не инициализировать значение переменной при объявлении, но при попытке получить значение происходит проверка, что она инициализирована, и если это не так, то бросается исключение. Поэтому стоит использовать эту возможность с осторожностью. Такая возможность по сути и появилась для удобного взаимодействия с существующими Java фреймворками.

Пример перехода от Java к Kotlin для field, который инициализируются не в момент объявления:

//Java
@Autowired
private TestRestTemplate restTemplate;

//Kotlin
@Autowired
private lateinit var restTemplate: TestRestTemplate

Приятным дополнение с приходом приходом Kotlin становится то, что нам теперь не нужна аннотация @DisplayName. Мы можем переписать длинные имена методов на имена с пробелами. И студия даже в этом сама предлагает помощь:

Большая миграция - 5

Одним из ключевых преимуществ Kotlin является встраивание nullablity в систему типов, и чтобы раскрыть это преимущество на полную, мы можем добавить флаг компиляции “-Xjsr305=strict”. Для этого в скрипт сборки добавляем:

compileKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

С этой опцией Kotlin будет учитывать аннотации в Spring 5 на счет nullability типов, и вероятность получить NPE значительно уменьшается.
Еще дополнительно указываем что целевая jvm 8 (если хотим чтобы Kotlin компилировался в байткод jvm 8, но пока существует и возможность компиляции в байткод jvm 6).

На этом конвертацию можно закончить. Собираем и запускаем приложение:

cd step3_migration_to_kotlin
gradle build && java -jar build/libs/step3_migration_to_kotlin-1.0-SNAPSHOT.jar

И убеждаемся что мы ничего не сломали и что все работает. Если все так, то идем дальше.

4. Переходим на spring-webflux и функциональный Kotlin

На этот переход я вдохновился увидев пост в блоге Spring. В нем Sébastien Deleuze показывает пример инициализации Spring приложения в функциональном подходе на основе spring-webflux и без spring-boot.

С приходом Spring 5 появилась возможность разнообразной инициализации приложений на самых различных web-серверах и различных подходах:

Большая миграция - 6

В своем примере Sébastien запускает приложение на Netty, пример можно посмотреть тут. Для разнообразия запустим приложение на Undertow.

Начнем с запуска Spring без spring-boot. Для этого нам потребуется настроить GenericApplicationContext, который привязывается к запускаемому web-серверу при помощи адаптеров. Так, после создания и настройки GenericApplicationContext он настраивается с помощью WebHttpHandlerBuilder, на выходе которого получаем HttpHandler, который, в свою очередь, потом привязывается к web-серверу с помощью соответствующего ему адаптера.
В документации к Spring есть следующие примеры использования адаптеров:

// Tomcat and Jetty (also see notes below)
HttpServlet servlet = new ServletHttpHandlerAdapter(handler);
...

// Reactor Netty
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler);
HttpServer.create(host, port).newHandler(adapter).block();

// RxNetty
RxNettyHttpHandlerAdapter adapter = new RxNettyHttpHandlerAdapter(handler);
HttpServer server = HttpServer.newServer(new InetSocketAddress(host, port));
server.startAndAwait(adapter);

// Undertow
UndertowHttpHandlerAdapter adapter = new UndertowHttpHandlerAdapter(handler);
Undertow server = Undertow.builder().addHttpListener(port, host).setHandler(adapter).build();
server.start();

Чтобы иметь возможность настраивать бины и порт запуска, добавим их в параметры класса. Итоговый класс будет выглядеть следующим образом:

class Application(port: Int? = null, beanConfig: BeanDefinitionDsl = beansConfiguration()) {
    private val server: Undertow

    init {
        val context = GenericApplicationContext().apply {
            beanConfig.initialize(this)
            loadConfig()

            refresh()
        }

        val build = WebHttpHandlerBuilder.applicationContext(context).build()
        val adapter = build
                .run { UndertowHttpHandlerAdapter(this) }

        val startupPort = port
                ?: context.environment.getProperty("server.port")?.toInt()
                ?: DEFAULT_PORT

        server = Undertow.builder()
                .addHttpListener(startupPort, "localhost")
                .setHandler(adapter)
                .build()
    }

    fun start() {
        server.start()
    }

    fun stop() {
        server.stop()
    }

    private fun GenericApplicationContext.loadConfig() {
        val resource = ClassPathResource("/application.yml")
        val sourceLoader = YamlPropertySourceLoader()
        val properties = sourceLoader.load("main config", resource, null)
        environment.propertySources.addFirst(properties)
    }

    companion object {
        private val DEFAULT_PORT = 8080
    }
}

fun main(args: Array<String>) {
    Application().start()
}

Учитывая, что мы запускаем приложение без spring-boot нам приходится самим загружать конфигурацию из файла. Поэтому в примере выше нам приходится также в явном виде загружать конфигурацию из файла application.yml, причем аннотация @ConfigurationProperties есть только в зависимости spring-boot, а классы парсинга yml (в частности snakeyaml) есть только spring-boot-starter. Поэтому все же зависимость от spring-boot-starter у нас будет, хотя и запускать приложение будет без spring-boot.

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

fun beansConfiguration(beanConfig: BeanDefinitionDsl.() -> Unit = {}): BeanDefinitionDsl = beans {
    bean<DBConfiguration>()

    //controllers
    bean<StatsController>()
    bean<UserController>()

    //repository
    bean<UserRepository>()

    //services
    bean<StatsService>()

    //routes
    bean<Routes>()
    bean("webHandler") {
        RouterFunctions.toWebHandler(ref<Routes>().router(),     HandlerStrategies.builder().viewResolver(ref()).build())
    }

    //view resolver
    bean {
        val prefix = "classpath:/templates/"
        val suffix = ".mustache"
        val loader = MustacheResourceTemplateLoader(prefix, suffix)
        MustacheViewResolver(Mustache.compiler().withLoader(loader)).apply {
            setPrefix(prefix)
            setSuffix(suffix)
        }
    }
    //processors
    bean<CommonAnnotationBeanPostProcessor>()
    bean<ConfigurationClassPostProcessor>()
    bean<ConfigurationPropertiesBindingPostProcessor>()

    beanConfig()
}

Для конфигурации бинов мы используем функцию beans, которая появилась в Spring 5 и которая находится в библиотеке spring-context. В саму библиотеку добавлены целый набор функций на Kotlin для более удобного взаимодействия с ним. В этой же библиотеке есть функция bean, вызов которой регистрирует класс в контексте. Тут же мы можем, при необходимости, и сами настроить бин по своему желанию, как например, настраивается ViewResolver.
Чтобы все необходимые нам аннотации Spring работали, в частности @Bean, @Configuration, @ConfigurationProperties, @PostConstruct, нам необходимо, помимо наших классов, подключить нужные BeanPostProcessor.
И в конце настройки бинов мы вызываем функцию, переданную в качестве аргумента, с помощью которой мы выполняем донастройку конфигурации.
Класс Routes используется для настройки роутинга. При настройки бина “webHandler” мы его в явном виде используем в вызове RouterFunctions.toWebHandler(ref<Routes>().router(), …).

В примере выше функция ref<Routes>(), которая также из spring-context, используется для получения ссылки на бин:

inline fun <reified T : Any> ref(name: String? = null) : T = when (name) {
            null -> context.getBean(T::class.java)
            else -> context.getBean(name, T::class.java)
        }

Посмотрим на то, как выглядит класс Routes:

open class Routes(
        private val userController: UserController,
        private val statsController: StatsController
) {
    fun router() = router {
        accept(APPLICATION_JSON).nest(userController.nest())

        accept(APPLICATION_JSON).nest(statsController.nest())

        GET("/") { ok().render("index") }
    }
}

Здесь мы, по сути, делегируем настройку маршрутов в каждый из контроллеров, также при помощи функции router из spring-context. Sébastien в своем примере чуть по другому настраивает маршрутизацию:

accept(TEXT_HTML).nest {
            GET("/") { ok().render("index") }
            GET("/sse") { ok().render("sse") }
            GET("/users", userHandler::findAllView)
        }
        "/api".nest {
            accept(APPLICATION_JSON).nest {
                GET("/users", userHandler::findAll)
            }
            accept(TEXT_EVENT_STREAM).nest {
                GET("/users", userHandler::stream)
            }

        }

Он прям здесь же для некоторых маршрутов сразу прописывает логику, а часть делегирует другим классам. Но учитывая, что у нас уже было ранее написано два контроллера, мне мой подход показался более подходящим.

Для контроллеров выделяем интерфейс, в котором будет только один метод nest. В нем мы будем полностью описывать логику маршрутизации в контроллере:

interface Controller {
    fun nest(): RouterFunctionDsl.() -> Unit
}

StatsController будет выглядеть следущим образом:

open class StatsController(private val statsService: StatsService) : Controller {
    override fun nest(): RouterFunctionDsl.() -> Unit = {
        GET("/stats") { ok().body(stats()) }
    }

    open fun stats(): Mono<StatsResponse> {
        val stats = statsService.getStats()

        return Mono.just(StatsResponse(true, "user stats", stats))
    }
}

В нем мы определяем что у нас будет только один путь “/stats”, при GET запросе на который нужно вернуть результат метода stats. И у нас теперь должен возвращаться либо Flux либо Mono, благодаря переходу на spring-webflux. Похожим образом будет выглядеть UserController:

open class UserController(private val userRepository: UserRepository) {
    fun nest(): RouterFunctionDsl.() -> Unit = {
        GET("/users") {
            ok().body(users())
        }
        GET("/user/{id}") {
            ok().body(user(it.pathVariable("id").toLong()))
        }
        PUT("/user") {
            ok().body(addUser(it.bodyToMono(User::class.java)))
        }
        DELETE("/user/{id}") {
            ok().body(deleteUser(it.pathVariable("id").toLong()))
        }
    }

    open fun users(): Mono<UserResponse> {
       // ….
    }

    open fun user(userId: Long): Mono<UserResponse> {
             // ….
    }

    open fun addUser(user: Mono<User>): Mono<UserAddResponse> = user.map {
               // ….
    }

    open fun deleteUser(id: Long): Mono<DeleteResponse> {
               // ….
    }
}

Здесь нам также приходится самим вытаскивать нужные параметры из запроса, и передавать их в методы. Может показаться, на первый взгляд, что это неудобно, но на самом деле все методы интуитивно понятны и легко разобраться в том, что происходит.

С тестами тоже происходят небольшие изменения. И если StatsServiceTest остается без изменений, то тесты контроллеров немного видоизменятся. Так, теперь будет выглядеть StatsControllerTest:

@DisplayName("StatsController test")
open class StatsControllerTest {
    private val statsServiceMock = mock<StatsService>()

    private val port = 8181
    private val configuration = beansConfiguration {
        bean { statsServiceMock }
    }
    private val application = Application(port, configuration)

    @BeforeEach
    fun before() {
        reset(statsServiceMock)
        application.start()
    }

    @AfterEach
    fun after() {
        application.stop()
    }

    @Test
    fun `stats controller should return valid result`() {
        val expectedStats = Stats(
                2,
                User(1L, "name1", "surname1", 25),
                User(2L, "name2", "surname2", 30)
        )
        whenever(statsServiceMock.getStats()).thenReturn(expectedStats)

        val expectedResponse = StatsResponse(true, "user stats", expectedStats)
        val response: StatsResponse = "http://localhost:$port/stats".GET()

        assertEquals(expectedResponse, response, "invalid response")
    }
}

В нем мы теперь можем самостоятельно настроить бины и запустить приложение. Теперь нам не нужен специальные расширения для Spring, все полностью под контролем. Мы сами мокируем класс и сами в явном виде указываем на каком порту запускаться и как. При необходимости можно предусмотреть и еще большую возможность кастомизации.
Наверное вы уже обратили внимание что теперь у нас нет restTemplate. Вместо него мы тут используем конструкцию: "http://localhost:$port/stats".GET(). Функция GET являются функцией расширения, которую я написал для упрощения тестирования. Вызовы внутри делаются с помощью OkHttp3:

var client = OkHttpClient()

val JSON = MediaType.parse("application/json; charset=utf-8")

val mapper: ObjectMapper = ObjectMapper()
        .registerKotlinModule()

inline fun <reified T> String.GET(): T {
    val request = Request.Builder()
            .url(this)
            .build()

    return client.newCall(request).executeAndGet(T::class.java)
}

inline fun <reified T> String.PUT(data: Any): T {
    val body = RequestBody.create(JSON, mapper.writeValueAsString(data))
    val request = Request.Builder()
            .url(this)
            .put(body)
            .build()

    return client.newCall(request).executeAndGet(T::class.java)
}

inline fun <reified T> String.POST(data: Any): T {
    val body = RequestBody.create(JSON, mapper.writeValueAsString(data))
    val request = Request.Builder()
            .url(this)
            .post(body)
            .build()

    return client.newCall(request).executeAndGet(T::class.java)
}

inline fun <reified T> String.DELETE(): T {
    val request = Request.Builder()
            .url(this)
            .delete()
            .build()

    return client.newCall(request).executeAndGet(T::class.java)
}

fun <T> Call.executeAndGet(clazz: Class<T>): T {
    execute().use { response ->
        return mapper.readValue(response.body()!!.string(), clazz)
    }
}

Аналогичным образом будет выглядеть и тест для UserControllerTest.

UserControllerTest

@DisplayName("UserController test")
class UserControllerTest {
    private var port = 8181

    private lateinit var userRepositoryMock: UserRepository

    private lateinit var configuration: BeanDefinitionDsl

    private lateinit var application: Application

    @BeforeEach
    fun before() {
        userRepositoryMock = mock()

        configuration = beansConfiguration {
            bean { userRepositoryMock }
        }
        application = Application(port, configuration)
        application.start()
    }

    @AfterEach
    fun after() {
        application.stop()
    }

    @Test
    fun `all users should be return correctly`() {
        val users = listOf(
                User(1L, "name1", "surname1", 25),
                User(2L, "name2", "surname2", 30)
        )

        whenever(userRepositoryMock.findAllUsers()).thenReturn(users)
        val expectedResponse = UserResponse(true, "return users", users)

        val response: UserResponse = "http://localhost:$port/users".GET()

        assertEquals(expectedResponse, response, "invalid response")
    }

    @Test
    fun `user should be return correctly`() {
        val user = User(1L, "name1", "surname1", 25)
        whenever(userRepositoryMock.findUser(1L)).thenReturn(user)
        whenever(userRepositoryMock.findUser(2L)).thenReturn(null)

        val expectedResponse = UserResponse(true, "find user with requested id", listOf(user))
        val response: UserResponse = "http://localhost:$port/user/1".GET()

        assertEquals(expectedResponse, response, "not find exists user")

        val expectedMissedResponse = UserResponse(false, "user not found", emptyList())
        val missingResponse: UserResponse = "http://localhost:$port/user/2".GET()

        assertEquals(expectedMissedResponse, missingResponse, "invalid user response")
    }

    @Test
    fun `user should be added correctly`() {
        val newUser1 = User(null, "name", "surname", 15)
        val newUser2 = User(null, "name2", "surname2", 18)

        whenever(userRepositoryMock.addUser(newUser1)).thenReturn(15L)
        whenever(userRepositoryMock.addUser(newUser2)).thenReturn(null)

        val expectedResponse = UserAddResponse(true, "user add successfully", 15L)
        val response: UserAddResponse = "http://localhost:$port/user".PUT(newUser1)

        assertEquals(expectedResponse, response, "invalid add response")

        val expectedErrorResponse = UserAddResponse(false, "user not added", -1L)
        val errorResponse: UserAddResponse = "http://localhost:$port/user".PUT(newUser2)

        assertEquals(expectedErrorResponse, errorResponse, "invalid add response")
    }

    @Test
    fun `user should be deleted correctly`() {
        whenever(userRepositoryMock.deleteUser(1L)).thenReturn(true)
        whenever(userRepositoryMock.deleteUser(2L)).thenReturn(false)

        val expectedResponse = DeleteResponse(true, "user has been deleted")
        val response: DeleteResponse = "http://localhost:$port/user/1".DELETE()

        assertEquals(expectedResponse, response, "invalid response")

        val expectedErrorResponse = DeleteResponse(false, "user not been deleted")
        val errorResponse: DeleteResponse = "http://localhost:$port/user/2".DELETE()

        assertEquals(expectedErrorResponse, errorResponse, "invalid response")
    }
}

Итоговый скрипт сборки будет выглядеть следующим образом:

group 'evgzakharov'
version '1.0-SNAPSHOT'

buildscript {
    ext {
        springBootVersion = "2.0.0.M5"
        junitVersion = "5.0.1"
        kotlinVersion = "1.1.51"
    }
    repositories {
        jcenter()
        maven { url = "http://repo.spring.io/milestone" }
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("org.junit.platform:junit-platform-gradle-plugin:1.0.1")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
    }
}

apply plugin: "org.springframework.boot"
apply plugin: "org.junit.platform.gradle.plugin"
apply plugin: 'kotlin'
apply plugin: "io.spring.dependency-management"

sourceCompatibility = 1.8

repositories {
    jcenter()
    maven { url = "http://repo.spring.io/milestone" }
}

dependencies {
    compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlinVersion")
    compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")

    compile("org.springframework.boot:spring-boot-starter")
    compile("org.springframework:spring-webflux")

    compile("io.undertow:undertow-core")
    compile("com.samskivert:jmustache")
    compile("com.fasterxml.jackson.module:jackson-module-kotlin")

    testCompile("com.nhaarman:mockito-kotlin-kt1.1:1.5.0")

    testCompile("com.squareup.okhttp3:okhttp:3.9.0")

    testCompile("org.junit.jupiter:junit-jupiter-api:$junitVersion")
    testRuntime("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
}

compileKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

compileTestKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = ["-Xjsr305=strict"]
    }
}

На этом переход можно считать законченным, можно собрать и запустить приложение.

cd step4_migration_to_webflux
gradle build && java -jar build/libs/step4_migration_to_webflux-1.0-SNAPSHOT.jar

Аналогично предыдущим пунктам делаем запрос с помощью curl и проверяем что все работает

5. Переходим на Kotlin Dsl в скриптах сборки

Хоть на текущий момент еще не вышел релиз Kotlin Dsl (на момент написания поста вышла 0.12.1 версия), уже сейчас на нем можно вполне комфортно писать.

Из преимуществ, по сравнению с Groovy Dsl, мы получаем полноценные подсказки IDE, можно в любом месте легко понять какие параметры мы можем переопределить и как их настроить. В том же Gradle Kotlin Dsl поддерживается из коробки еще с версии 3.0 (сейчас уже версия 4.2.1), да и Intellij Idea тоже прекрасно понимает свой “почти родной” Kotlin.

Минусы пока тоже есть. Первое, все может еще немного изменится с выходом релиза. И второе, тесно связанное с первым, что пока не очень много документации и примеров того как сделать аналогичные вещи. Но тут есть несколько выходов из этой ситуации:

  • смотреть примеры и если подходящая вам конструкция есть в них, то вам повезло
  • даже если вы не нашли примеров, еще не повод отчаиваться, все конструкции Kotlin Dsl очень похожи на то, как они выглядят в Groovy Dsl, только там где в Groovy передается абстрактный Closure в Kotlin нужно будет обязательно в явном виде определить его тип. Так, к примеру, пока будет выглядеть секция artifactory:

artifactory {
    setContextUrl("${project.findProperty("artifactory_contextUrl")}")
    publish(delegateClosureOf<PublisherConfig> {
        repository(delegateClosureOf<GroovyObject> {
            setProperty("repoKey", "ecomm")
            setProperty("username", project.findProperty("artifactory_user"))
            setProperty("password", project.findProperty("artifactory_password"))
            setProperty("mavenCompatible", true)
            defaults(delegateClosureOf<GroovyObject> {
                invokeMethod("publishConfigs", "wgReports")
            })
        })
    })
}

Для сравнения в Groovy она выглядит так:

artifactory {
    contextUrl = "${artifactory_contextUrl}"
    publish {
        repository {
            repoKey = 'ecomm'
            username = "${artifactory_user}"
            password = "${artifactory_password}"
            mavenCompatible = true
        }
        defaults {
            publishConfigs('wgReports')
        }

    }
}

Но на самом деле все не так плохо, команда Gradle с каждой новой версией улучшает свой API, и теперь уже не так много мест где гуляют абстрактные Closure, и чуть ниже мы увидите что наш скрипт будет вполне неплохо выглядеть в Kotlin Dsl.

Приступим к конвертации. Для этого добавляем постфикс “.kts” для файл build.gradle и приступаем к правке скрипта:

  • чтобы compile в секции dependencies нормально определялся, на данные момент нужно для плагина Kotlin использовать новый синтаксис добавления плагинов в Gradle:
    plugins {
    id("org.jetbrains.kotlin.jvm") version "1.1.51"
    }
  • в Groovy конструкция group 'evgzakharov', вполне валидна, но в Kotlin необходимо в явном виде присваивать значение. Поэтому такая конструкция превращается в group = "evgzakharov"
  • в секции buildscript нужно тоже сделать небольшие изменения. Нужно поправить присваивание дополнительных значений. Так, вместо ext.springBootVersion = "2.0.0.M5" нужно будет использовать следующую конструкцию: extra[“springBootVersion”] = "2.0.0.M5", либо если мы еще будем использовать в buildscript заданную переменную то конструкцию val springBootVersion by extra { "2.0.0.M5" }
  • чтобы в dependencies использовать заданные версии, теперь нужно объявить переменные следующим образом:
    val springBootVersion: String by project.extra
    val junitVersion: String by project.extra
    val kotlinVersion: String by project.extra

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

    и последнее, что нужно изменить, это настройка таска compileKotlin. Теперь нужно будет типизировать его вызов и только после этого приступать к настройке:

    tasks.withType<KotlinCompile> {
    kotlinOptions {
        jvmTarget = "1.8"
        freeCompilerArgs = listOf("-Xjsr305=strict")
    }
    }

    В итоге получаем следующий скрипт сборки на Kotlin Dsl:

    import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
    
    group = "evgzakharov"
    version = "1.0-SNAPSHOT"
    
    buildscript {
        val springBootVersion by extra { "2.0.0.M5" }
        extra["junitVersion"] = "5.0.1"
        extra["kotlinVersion"] = "1.1.51"
    
        repositories {
            jcenter()
            maven { setUrl("https://repo.spring.io/milestone") }
        }
    
        dependencies {
            classpath("org.springframework.boot:spring-boot-gradle-plugin:$springBootVersion")
            classpath("org.junit.platform:junit-platform-gradle-plugin:1.0.1")
            classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.1.51")
        }
    }
    
    plugins {
        id("org.jetbrains.kotlin.jvm") version "1.1.51"
    }
    
    apply {
        plugin("org.springframework.boot")
        plugin("org.junit.platform.gradle.plugin")
        plugin("io.spring.dependency-management")
    }
    
    repositories {
        jcenter()
        maven { setUrl("https://repo.spring.io/milestone") }
    }
    
    val junitVersion: String by project.extra
    val kotlinVersion: String by project.extra
    
    dependencies {
        compile("org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlinVersion")
        compile("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
    
        compile("org.springframework.boot:spring-boot-starter")
        compile("org.springframework:spring-webflux")
    
        compile("io.undertow:undertow-core")
        compile("com.samskivert:jmustache")
        compile("com.fasterxml.jackson.module:jackson-module-kotlin")
    
        testCompile("com.nhaarman:mockito-kotlin-kt1.1:1.5.0")
    
        testCompile("com.squareup.okhttp3:okhttp:3.9.0")
    
        testCompile("org.junit.jupiter:junit-jupiter-api:$junitVersion")
        testRuntime("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
    }
    
    tasks.withType<KotlinCompile> {
        kotlinOptions {
            jvmTarget = "1.8"
            freeCompilerArgs = listOf("-Xjsr305=strict")
        }
    }

    И по традиции собираем и запускаем:

    cd step5_migration_kotlin_dsl
    gradle build && java -jar build/libs/step5_migration_kotlin_dsl-1.0-SNAPSHOT.jar 

    Это был крайний этап миграции :)

    Итоги

    Всем спасибо кто дочитал до конца. На ваших глазах мы перешли к Kotlin, обновили версию Spring и JUnit, перешли к сборке с помощью Kotlin Dsl, и даже настроили Spring без Spring-Boot (почти)

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

    В остальном Spring Boot 2 и JUnit 5 вполне заслуживают внимания, и следующий проект уже точно стоит делать на них. Стоит ли сейчас переводить существующие? Вопрос, конечно, спорный.

    Ну и последнее что хотел добавить… всем Kotlin! Кто не пробовал писать на нем — обязательно попробуйте. Язык полностью готовый к промышленный эксплуатации с хорошей поддержкой в студии. На том же stackoverflow уже полно решений многих болячек, как например, с тем же Mockito, и крайне маловероятно что вы столкнетесь с проблемами, которые не сможете решить. В байткоде конструкции тоже не выглядят сложно, и всегда, в крайнем случае, можно часть критичного кода, вынести в Java а основную разработку вести на Kotlin (хотя мне еще не довелось встречать такие ситуации)

    Все исходники доступны на github

    Спасибо за внимание и хорошего дня! :)

Автор: Евгений Захаров

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js