...

вторник, 25 июня 2019 г.

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

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


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


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

    public static class Dot {
        private final int x;
        private final int y;

        public Dot(int x, int y) {
            this.x = x;
            this.y = y;
        }

        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-клиентами и серверами мы пользовались. В любой момент мы можем поменять контекст, и инструмент изменится.

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

Посмотрим на код
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 в качестве аргумента функции означает забор переменной из окружения. Это чем-то напоминает неявное замыкание.

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

Вывод


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

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

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

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий