Was ich in Java nach der Arbeit mit Kotlin / Scala vermisse

In letzter Zeit höre ich oft, dass Java zu einer veralteten Sprache geworden ist, in der es schwierig ist, große unterstützte Anwendungen zu erstellen. Im Allgemeinen stimme ich diesem Standpunkt nicht zu. Meiner Meinung nach eignet sich die Sprache immer noch zum Schreiben schneller und gut organisierter Anwendungen. Ich gebe jedoch zu, dass es auch vorkommt, dass Sie beim täglichen Schreiben von Code manchmal denken: „Wie gut würde es mit dieser Sache aus einer anderen Sprache gelöst werden?“. In diesem Artikel wollte ich meine Schmerzen und Erfahrungen teilen. Wir werden uns einige Java-Probleme ansehen und wie sie in Kotlin / Scala gelöst werden können. Wenn Sie ein ähnliches Gefühl haben oder sich nur fragen, was andere Sprachen bieten können, frage ich Sie unter Katze.



Bestehende Klassen erweitern


Manchmal ist es erforderlich, eine vorhandene Klasse zu erweitern, ohne ihren internen Inhalt zu ändern. Das heißt, nachdem wir die Klasse erstellt haben, ergänzen wir sie durch andere Klassen. Betrachten Sie ein kleines Beispiel. Angenommen, wir haben eine Klasse, die ein Punkt im zweidimensionalen Raum ist. An verschiedenen Stellen in unserem Code müssen wir ihn sowohl in Json als auch in XML serialisieren.

Mal sehen, wie es in Java mit dem Besuchermuster aussehen kann
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())); } } 

Mehr über das Muster und seine Verwendung

Es sieht ziemlich voluminös aus, oder? Ist es möglich, dieses Problem mit Hilfe von Sprachwerkzeugen eleganter zu lösen? Scala und Kotlin nicken positiv. Dies wird unter Verwendung des Methodenerweiterungsmechanismus erreicht. Mal sehen, wie es aussieht.

Erweiterungen in 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()) } 


Erweiterungen in 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()) } 


Es sieht viel besser aus. Manchmal reicht dies bei zahlreichen Mappings und anderen Transformationen nicht aus.

Multithread-Computerkette


Jetzt sprechen alle über asynchrones Rechnen und die Verbote des Sperrens von Ausführungsthreads. Stellen wir uns das folgende Problem vor: Wir haben mehrere Zahlenquellen, wobei die erste nur die Zahl und die zweite die Antwort nach der Berechnung der ersten zurückgibt. Daher müssen wir eine Zeichenfolge mit zwei Zahlen zurückgeben.

Schematisch kann dies wie folgt dargestellt werden


Versuchen wir zunächst, das Problem in Java zu lösen

Java-Beispiel
  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 ) ) ); } 


In diesem Beispiel wird unsere Nummer in Optional eingeschlossen, um das Ergebnis zu steuern. Darüber hinaus werden alle Aktionen in CompletableFuture ausgeführt, um das Arbeiten mit Threads zu vereinfachen. Die Hauptaktion findet in der thenApplyAsync-Methode statt. Bei dieser Methode erhalten wir Optional als Argument. Als nächstes wird flatMap aufgerufen, um den Kontext zu steuern. Wenn das empfangene Optional als Optional.empty zurückgegeben wird, werden wir nicht zum zweiten Service wechseln.

Die Summe, die wir erhalten haben? Mit den Funktionen CompletableFuture und Optional mit flatMap und map konnten wir das Problem lösen. Obwohl die Lösung meiner Meinung nach nicht besonders elegant aussieht: Bevor Sie verstehen, worum es geht, müssen Sie den Code lesen. Und was würde mit zwei oder mehr Datenquellen passieren?

Könnte uns die Sprache irgendwie helfen, das Problem zu lösen? Und wieder wenden Sie sich an Scala. Hier erfahren Sie, wie Sie es mit Scala-Tools lösen können.

Scala Beispiel
 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" ) )} } 


Es kommt mir bekannt vor. Und das ist kein Zufall. Es verwendet die Bibliothek scala.concurrent, bei der es sich hauptsächlich um einen Wrapper über java.concurrent handelt. Womit kann uns Scala noch helfen? Tatsache ist, dass Ketten der Form flatMap, ..., map als Sequenz in for dargestellt werden können.

Beispiel für die zweite Version von 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" } } 


Es ist besser geworden, aber versuchen wir noch einmal, unseren Code zu ändern. Verbinden Sie die Katzenbibliothek.

Dritte Version des Scala-Beispiels
 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 


Jetzt ist es nicht so wichtig, was OptionT bedeutet. Ich möchte nur zeigen, wie einfach und kurz diese Operation sein kann.

Aber was ist mit Kotlin? Versuchen wir, etwas Ähnliches an Coroutinen zu tun.

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


Dieser Code hat seine eigenen Besonderheiten. Erstens verwendet es den Kotlin-Mechanismus von Corutin. Aufgaben innerhalb von Async werden in einem speziellen Thread-Pool (nicht ForkJoin) mit einem Arbeitsdiebstahlmechanismus ausgeführt. Zweitens erfordert dieser Code einen speziellen Kontext, aus dem Schlüsselwörter wie async und withContext übernommen werden.

Wenn Ihnen Scala Future gefallen hat, Sie aber auf Kotlin schreiben, können Sie auf ähnliche Scala-Wrapper achten. Geben Sie so ein.

Arbeite mit Streams


Um das Problem oben genauer darzustellen, versuchen wir, das vorherige Beispiel zu erweitern: Wir wenden uns den beliebtesten Java-Programmiertools zu - Reactor auf Scala - fs2 .

Betrachten Sie das zeilenweise Lesen von 3 Dateien in einem Stream und versuchen Sie, dort Übereinstimmungen zu finden.
Hier ist der einfachste Weg, dies mit Reactor in Java zu tun.

Reaktorbeispiel in 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 ) ) ); } 


Nicht der optimalste Weg, aber bezeichnend. Es ist nicht schwer zu erraten, dass mit mehr Logik und Zugriff auf Ressourcen von Drittanbietern die Komplexität des Codes zunehmen wird. Sehen wir uns die Alternative zum Verständnis der Syntaxzucker an.

Beispiel aus fs2 auf 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 


Es scheint, dass es nicht viele Änderungen gibt, aber es sieht viel besser aus.

Trennen der Geschäftslogik mit HigherKind und implizit


Lassen Sie uns sehen, wie wir unseren Code noch verbessern können. Ich möchte warnen, dass der nächste Teil möglicherweise nicht sofort verständlich ist. Ich möchte die Möglichkeiten aufzeigen und die Implementierungsmethode vorerst aus den Klammern herauslassen. Eine ausführliche Erklärung erfordert mindestens einen separaten Artikel. Wenn es einen Wunsch / Kommentare gibt - ich werde in den Kommentaren folgen, um Fragen zu beantworten und den zweiten Teil mit einer detaillierteren Beschreibung zu schreiben :)

Stellen Sie sich also eine Welt vor, in der wir die Geschäftslogik unabhängig von den technischen Auswirkungen festlegen können, die während der Entwicklung auftreten können. Beispielsweise können wir jede nachfolgende Anforderung an ein DBMS oder einen Drittanbieter-Service in einem separaten Thread ausführen. In Unit-Tests müssen wir einen dummen Mok machen, in dem nichts passiert. Usw.

Vielleicht haben einige Leute über die BPM-Engine nachgedacht, aber heute geht es nicht mehr um ihn. Es stellt sich heraus, dass dieses Problem mit Hilfe einiger Muster der funktionalen Programmierung und Sprachunterstützung gelöst werden kann. An einer Stelle können wir die Logik so beschreiben.

An einer Stelle können wir die Logik so beschreiben
  def makeCatHappy[F[_]: Monad: CatClinicClient](): F[Unit] = for { catId <- CatClinicClient[F].getHungryCat memberId <- CatClinicClient[F].getFreeMember _ <- CatClinicClient[F].feedCatByFreeMember(catId, memberId) } yield () 


Hier bedeutet F [_] (gelesen als "ef mit einem Loch") einen Typ über einem Typ (manchmal wird es in der russischen Literatur eine Art genannt). Dies kann List, Set, Option, Future usw. sein. Das alles ist ein Container eines anderen Typs.

Als nächstes ändern wir einfach den Kontext der Codeausführung. Zum Beispiel können wir für die Produktumgebung so etwas tun.

Wie könnte der Kampfcode aussehen?
 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 } } 


Wie der Testcode aussehen könnte
 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 } } 


Unsere Geschäftslogik hängt jetzt nicht mehr davon ab, welche Frameworks, http-Clients und Server wir verwendet haben. Wir können den Kontext jederzeit ändern, und das Tool wird sich ändern.

Dies wird durch Funktionen wie highKind und implicit erreicht. Betrachten wir das erste, und dafür kehren wir zu Java zurück.

Schauen wir uns den Code an
 public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { } } 


Wie viele Möglichkeiten, das Ergebnis zurückzugeben? Sehr viel. Wir können subtrahieren, addieren, tauschen und vieles mehr. Stellen Sie sich nun vor, wir hätten klare Anforderungen erhalten. Wir müssen die erste Zahl zur zweiten hinzufügen. Wie viele Möglichkeiten können wir dies tun? Wenn Sie sich anstrengen und viel verfeinern ... im Allgemeinen nur eine.

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


Was aber, wenn der Aufruf dieser Methode ausgeblendet ist und wir in einer Single-Thread-Umgebung testen möchten? Oder was ist, wenn wir die Implementierung der Klasse ändern möchten, indem wir CompletableFuture entfernen / ersetzen. Leider sind wir in Java machtlos und müssen die Methoden-API ändern. Schauen Sie sich die Alternative in Scala an.

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


Wir erstellen Merkmale (das nächste Analogon ist die Schnittstelle in Java), ohne den Containertyp unseres ganzzahligen Werts anzugeben.

Außerdem können wir bei Bedarf einfach verschiedene Implementierungen erstellen.

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


Darüber hinaus gibt es so etwas Interessantes wie Implizit. Sie können den Kontext unserer Umgebung erstellen und implizit die Implementierung des darauf basierenden Merkmals auswählen.

Wie so
  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() 


Vereinfachtes implizites Vor-Val - Hinzufügen einer Variablen zur aktuellen Umgebung und implizites Argument für eine Funktion bedeutet, dass die Variable aus der Umgebung entfernt wird. Dies erinnert etwas an eine implizite Schließung.

Insgesamt stellt sich heraus, dass wir eine Kampf- und Testumgebung ziemlich präzise erstellen können, ohne Bibliotheken von Drittanbietern zu verwenden.
Aber was ist mit Kotlin?
In ähnlicher Weise können wir dies in Kotlin tun:
 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)) } } 

Hier legen wir auch den Ausführungskontext unseres Codes fest, aber im Gegensatz zu Scala kennzeichnen wir dies explizit.
Vielen Dank an Beholder für das Beispiel.


Fazit


Im Allgemeinen sind dies nicht alle meine Schmerzen. Es gibt noch mehr. Ich denke, dass jeder Entwickler seinen eigenen hat. Für mich selbst wurde mir klar, dass es vor allem darum geht, zu verstehen, was für den Nutzen des Projekts wirklich notwendig ist. Wenn wir beispielsweise einen Rest-Service haben, der als eine Art Adapter mit einer Reihe von Zuordnungen und einfacher Logik fungiert, sind meiner Meinung nach alle oben genannten Funktionen nicht sehr nützlich. Spring Boot + Java / Kotlin ist perfekt für solche Aufgaben. Es gibt andere Fälle mit einer großen Anzahl von Integrationen und Aggregationen einiger Informationen. Für solche Aufgaben sieht meiner Meinung nach die letzte Option sehr gut aus. Im Allgemeinen ist es cool, wenn Sie ein Werkzeug basierend auf einer Aufgabe auswählen können.

Nützliche Ressourcen:

  1. Link zu allen obigen Beispielen
  2. Mehr zu Corotin in Kotlin
  3. Ein gutes Einführungsbuch zur funktionalen Programmierung in Scala

Source: https://habr.com/ru/post/de456774/


All Articles