Хабрахабр

Чего мне не хватает в Java после работы с Kotlin/Scala

В последнее время я часто слышу о том, что Java стала устаревшим языком, на котором сложно строить большие поддерживаемые приложения. В целом, я не согласен с этой точкой зрения. На мой взгляд, язык все еще подходит для написания быстрых и хорошо организованных приложений. Однако, признаюсь, бывает и такое, что при повседневном написании кода иногда думаешь: “как бы хорошо это решилось вот этой штукой из другого языка”. В этой статье я хотел поделиться своей болью и опытом. Мы посмотрим на некоторые проблемы Java и как они могли бы разрешиться в Kotlin/Scala. Если у вас возникает похожее чувство или вам просто интересно, что могут предложить другие языки, — прошу под кат.

Расширение существующих классов

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

Посмотрим, как это может выглядеть в Java с помощью паттерна Visitor

public class DotDemo public String accept(Visitor visitor) { return visitor.visit(this); } public int getX() { return x; } public int getY() { return y; } } public interface Visitor { String visit(Dot dot); } public static class JsonVisitor implements Visitor { @Override public String visit(Dot dot) { return String .format("" + "{" + "\"x\"=%d, " + "\"y\"=%d " + "}", dot.getX(), dot.getY()); } } public static class XMLVisitor implements Visitor { @Override public String visit(Dot dot) { return "<dot>" + "\n" + " <x>" + dot.getX() + "</x>" + "\n" + " <y>" + dot.getY() + "</y>" + "\n" + "</dot>"; } } public static void main(String[] args) { Dot dot = new Dot(1, 2); System.out.println("-------- JSON -----------"); System.out.println(dot.accept(new JsonVisitor())); System.out.println("-------- XML ------------"); System.out.println(dot.accept(new XMLVisitor())); }
}

Более подробно о паттерне и его использовании

Выглядит достаточно объемно, не так ли? Можно ли решить данную задачу более элегантно с помощью вспомогательных средств языка? Scala и Kotlin кивают положительно. Это достигается с помощью механизма method extension. Посмотрим, как это выглядит.

Расширения в Kotlin

data class Dot (val x: Int, val y: Int) // неявно получаем ссылку на объект
fun Dot.convertToJson(): String = "{\"x\"=$x, \"y\"=$y}" fun Dot.convertToXml(): String = """<dot> <x>$x</x> <y>$y</y> </dot>""" fun main() { val dot = Dot(1, 2) println("-------- JSON -----------") println(dot.convertToJson()) println("-------- XML -----------") println(dot.convertToXml())
}

Расширения в Scala

object DotDemo extends App { // val is default case class Dot(x: Int, y: Int) implicit class DotConverters(dot: Dot) { def convertToJson(): String = s"""{"x"=${dot.x}, "y"=${dot.y}}""" def convertToXml(): String = s"""<dot> <x>${dot.x}</x> <y>${dot.y}</y> </dot>""" } val dot = Dot(1, 2) println("-------- JSON -----------") println(dot.convertToJson()) println("-------- XML -----------") println(dot.convertToXml())
}

Смотрится намного лучше. Иногда этого очень не хватает при обильных маппингах и прочих преобразованиях.

Цепочка многопоточных вычислений

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

Схематично это можно представить следующим образом

Попробуем сначала решить задачу на Java

Пример на Java

private static CompletableFuture<Optional<String>> calcResultOfTwoServices ( Supplier<Optional<Integer>> getResultFromFirstService, Function<Integer, Optional<Integer>> getResultFromSecondService ) { return CompletableFuture .supplyAsync(getResultFromFirstService) .thenApplyAsync(firstResultOptional -> firstResultOptional.flatMap(first -> getResultFromSecondService.apply(first).map(second -> first + " " + second ) ) ); }

В этом примере наше число оборачивается в Optional для управления результатом. Кроме того, все действия выполняются внутри CompletableFuture для удобной работы с потоками. Основное действие разворачивается в методе thenApplyAsync. В этом методе мы в качестве аргумента получаем Optional. Далее вызывается flatMap для управления контекстом. Если полученный Optional вернулся как Optional.empty, то во второй сервис мы уже не пойдем.

С помощью CompletableFuture и возможностей Optional c flatMap и map нам удалось решить поставленную задачу. Итого, что мы получили? А что было бы в случае с двумя и более источниками данных? Хотя, на мой взгляд, решение выглядит не самым элегантным образом: прежде чем понять, в чем дело, необходимо вчитываться в код.

И снова обратимся к Scala. Мог ли нам как-то помочь решить проблему язык. Вот как это можно решить инструментами Scala.

Пример на Scala

def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firsResultOption => Future { firsResultOption.flatMap(first => getResultFromSecondService(first).map(second => s"$first $second" ) )} }

Выглядит знакомо. И это не случайно. Здесь используется библиотека scala.concurrent, которая является преимущественно оберткой над java.concurrent. Хорошо, а чем еще нам может помочь язык Scala? Дело в том, что цепочки вида flatMap, …, map можно представить в виде последовательности в for.

Вторая версия пример на Scala

def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firstResultOption => Future { for { first <- firstResultOption second <- getResultFromSecondService(first) } yield s"$first $second" } }

Стало лучше, но давайте попробуем еще изменить наш код. Подключим библиотеку cats.

Третья версия примера Scala

import cats.instances.future._ def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]): Future[Option[String]] = (for { first <- OptionT(Future { getResultFromFirstService() }) second <- OptionT(Future { getResultFromSecondService(first) }) } yield s"$first $second").value

Сейчас не так важно, что означает OptionT. Я просто хочу показать, насколько простой и короткой может быть данная операция.

Давайте попробуем сделать что-то подобное на корутинах. А как же Kotlin?

Пример на Kotlin

val result = async { withContext(Dispatchers.Default) { getResultFromFirstService() }?.let { first -> withContext(Dispatchers.Default) { getResultFromSecondService(first) }?.let { second -> "$first $second" } } }

В этом коде есть свои особенности. Во-первых, он использует механизм Kotlin корутин. Задачи внутри async выполняются в особом пуле потоков (не ForkJoin) с механизмом work stealing. Во-вторых, данный код требует особого контекста, из которого и берутся ключевые слова вроде async и withContext.

Типа такой. Если вам понравились Scala Future, но вы пишете на Kotlin, то можете обратить внимание на похожие Scala обертки.

Работа со стримами

Чтобы подробнее показать проблему выше, давайте попробуем расширить прошлый пример: обратимся к наиболее популярным инструментам программирования на Java — Reactor, на Scala — fs2.

Рассмотрим построчное чтение 3 файлов в стриме и попробуем найти там же совпадения.
Вот самый простой способ сделать это с Reactor на Java.

Пример с Reactor на Java

private static Flux<String> glueFiles(String filename1, String filename2, String filename3) { return getLinesOfFile(filename1).flatMap(lineFromFirstFile -> getLinesOfFile(filename2) .filter(line -> line.equals(lineFromFirstFile)) .flatMap(lineFromSecondFile -> getLinesOfFile(filename3) .filter(line -> line.equals(lineFromSecondFile)) .map(lineFromThirdFile -> lineFromThirdFile ) ) ); }

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

Пример с fs2 на Scala

def findUniqueLines(filename1: String, filename2: String, filename3: String): Stream[IO, String] = for { lineFromFirstFile <- readFile(filename1) lineFromSecondFile <- readFile(filename2).filter(_.equals(lineFromFirstFile)) result <- readFile(filename3).filter(_.equals(lineFromSecondFile)) } yield result

Вроде не так много перемен, но смотрится гораздо лучше.

Отделение бизнес-логики с помощью higherKind и implicit

Пойдем дальше и посмотрим, как еще мы можем улучшить наш код. Хочу предупредить, что следующая часть может быть понятной не сразу. Я хочу показать возможности, а способ реализации пока оставить за скобками. Подробное объяснение требует, как минимум, отдельной статьи. Если есть желание/замечания — буду следить в комментариях, чтобы ответить на вопросы и написать вторую часть с более подробным описанием 🙂

Например, мы можем сделать так, чтобы каждый следующий запрос к СУБД или стороннему сервису выполнялся в отдельном потоке. Итак, представьте себе мир, в котором мы можем задавать бизнес логику независимо от технических эффектов, которые могут возникнуть в ходе разработке. И так далее. В юнит тестах нам необходимо сделать глупый мок, в котором ничего не происходит.

Оказывается, такую проблему можно решить с помощью некоторых паттернов функционального программирования и поддержки языка. Возможно, некоторые подумали о BPM движке, но сегодня не про него. В одном месте мы можем описать логику примерно вот так.

В одном месте мы можем описать логику примерно вот так

def makeCatHappy[F[_]: Monad: CatClinicClient](): F[Unit] = for { catId <- CatClinicClient[F].getHungryCat memberId <- CatClinicClient[F].getFreeMember _ <- CatClinicClient[F].feedCatByFreeMember(catId, memberId) } yield ()

Здесь F[_] (читается как «эф с дыркой») означает тип над типом (иногда в русскоязычной литературе его называют видом). Это может быть List, Set, Option, Future и т.д. Все то, что является контейнером другого типа.

Например, для прод среды мы можем сделать что-то вроде такого. Далее просто меняем контекст выполнения кода.

Как может выглядеть боевой код

class RealCatClinicClient extends CatClinicClient[Future] { override def getHungryCat: Future[Int] = Future { Thread.sleep(1000) // doing some calls to db (waiting 1 second) 40 } override def getFreeMember: Future[Int] = Future { Thread.sleep(1000) // doing some calls to db (waiting 1 second) 2 } override def feedCatByFreeMember(catId: Int, memberId: Int): Future[Unit] = Future { Thread.sleep(1000) // happy cat (waiting 1 second) println("so testy!") // Don't do like that. It is just for debug }
}

Как может выглядеть тестовый код

class MockCatClinicClient extends CatClinicClient[Id] { override def getHungryCat: Id[Int] = 40 override def getFreeMember: Id[Int] = 2 override def feedCatByFreeMember(catId: Int, memberId: Int): Id[Unit] = { println("so testy!") // Don't do like that. It is just for debug }
}

Наша бизнес логика теперь не зависит от того, какими фреймворками, http-клиентами и серверами мы пользовались. В любой момент мы можем поменять контекст, и инструмент изменится.

Рассмотрим первое, а для этого вернемся к Java. Достигается это такими особенностями, как higherKind и implicit.

Посмотрим на код

public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { }
}

Сколько в нем способов вернуть результат? Достаточно много. Мы можем вычитать, складывать, менять местами и многое другое. А теперь представьте, что нам даны четкие требования. Нам надо сложить первое число со вторым. Сколькими способами мы можем это сделать? если сильно постараться и много изощряться... вообще только один.

Вот он

public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { return CompletableFuture.supplyAsync(() -> x + y); }
}

Но что, если вызов данного метода скрыт, а мы хотим провести тестирование в однопоточной среде? Или что, если мы хотим поменять реализацию класса, убрав/заменив CompletableFuture. К сожалению, в Java мы бессильны и должны поменять API метода. Взглянем на альтернативу в Scala.

Рассмотрим trait

trait Calcer[F[_]] { def getCulc(x: Int, y: Int): F[Int]
}

Создаем траит (ближайший аналог — интерфейс в Java) без указаний типа контейнера нашего целочисленного значения.

Далее мы просто можем по необходимости создавать различные реализации.

Например так

val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} val optionCalcer: Calcer[Option] = (x, y) => Option(x + y)

Кроме того, есть такая интересная штука, как Implicit. Она позволяет создать контекст нашего окружения и неявно подбирать реализацию трейта его основе.

Например так

def userCalcer[F[_]](implicit calcer: Calcer[F]): F[Int] = calcer.getCulc(1, 2) def doItInFutureContext(): Unit = { implicit val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} println(userCalcer) } doItInFutureContext() def doItInOptionContext(): Unit = { implicit val optionCalcer: Calcer[Option] = (x, y) => Option(x + y) println(userCalcer) } doItInOptionContext()

Упрощенно implicit перед val — добавление переменной в текущее окружение, а implicit в качестве аргумента функции означает забор переменной из окружения. Это чем-то напоминает неявное замыкание.

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

А как же kotlin

На самом деле похожим образом мы можем сделать и в kotlin:

interface Calculator<T> { fun eval(x: Int, y: Int): T
} object FutureCalculator : Calculator<CompletableFuture<Int>> { override fun eval(x: Int, y: Int) = CompletableFuture.supplyAsync { x + y }
} object OptionalCalculator : Calculator<Optional<Int>> { override fun eval(x: Int, y: Int) = Optional.of(x + y)
} fun <T> Calculator<T>.useCalculator(y: Int) = eval(1, y) fun main() { with (FutureCalculator) { println(useCalculator(2)) } with (OptionalCalculator) { println(useCalculator(2)) }
}

Здесь мы тоже задаем контекст выполнения нашего кода, но в отличае от Scala явно помечаем это.
Спасибо Beholder за пример.

Вывод

В целом, это не все мои боли. Есть и еще. Я думаю, что у каждого разработчика накопились свои. Для себя я понял, что главное понимать, что действительно необходимо для пользы проекта. К примеру, на мой взгляд, если у нас есть rest сервис, который выступает в качестве некого адаптера с кучей маппинга и несложной логикой, то весь функционал выше не особо и полезен. Для таких задач отлично подойдет Spring Boot + Java/Kotlin. Бывают и другие случаи с большим количеством интеграций и агрегацией какой-то информации. Для таких задач, на мой взгляд, последний вариант смотрится очень хорошо. В общем, классно, если вы можете выбирать инструмент отталкиваясь от задачи.

Полезные ресурсы:

  1. Ссылка на все полные версии примеров выше
  2. Более подробно о корутинах в Kotlin
  3. Неплохая вводная книга по функциональному программированию на Scala
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть