Wie man eine SQL-Abfrage auf Slick schreibt und kein Portal zur Hölle öffnet

  • Tutorial


Slick ist nicht nur der Name eines der größten Solisten aller Zeiten, sondern auch der Name des beliebten Scala-Datenbank-Frameworks. Dieses Framework bekennt sich zum "funktionalen relationalen Mapping", implementiert reaktive Muster und hat offizielle Unterstützung für Lightbend. Offen gesagt sind die Bewertungen der Entwickler jedoch uneinheitlich - viele halten dies für unangemessen kompliziert, und dies ist teilweise gerechtfertigt. In diesem Artikel teile ich meine Eindrücke darüber, worauf ein unerfahrener Scala-Entwickler bei der Verwendung achten sollte, damit Sie beim Schreiben von Anfragen nicht versehentlich ein Portal zur Hölle öffnen.

Das Slick-Framework wurde, wie es in der Scala-Welt häufig der Fall ist, kürzlich erheblich überarbeitet - Version 3 wurde durch die Reaktivität geschärft und hat die API erheblich verändert, sodass es noch funktionaler als zuvor ist - und jetzt gibt es eine große Anzahl von Artikeln und Antworten zu StackOverflow, die für die Version entwickelt wurden 2, ist irrelevant geworden. Die Dokumentation für das Framework ist ziemlich kurz und enthält eine Reihe von Beispielen. konzeptuelle dinge (insbesondere der aktive einsatz von monaden) werden darin recht oberflächlich erklärt. Es wird davon ausgegangen, dass dem Entwickler viele Aspekte der funktionalen Programmierung in Scala und erweiterte Funktionen der Sprache bereits bekannt sind.

Das Ergebnis waren ähnliche Fragen zu StackOverflowwofür ich mich jetzt ein wenig schäme: da habe ich mich über das nicht kompilieren von code gestritten, weil ich die architektur des frameworks und die darin eingebetteten monadischen muster nicht verstanden habe. Ich möchte in diesem Artikel auf diese Muster und ihre Anwendung in Slick eingehen: Vielleicht ersparen sie jemandem viele Stunden Qual, wenn er versucht, etwas Komplizierteres als eine einfache Anfrage zu schreiben.

Monaden und Query Builder


Eine der wichtigen Komponenten jeder typsicheren Datenbankbibliothek ist der Abfrage-Generator, mit dem Sie in SQL eine untypisierte Zeichenfolge aus typisiertem Code in einer Programmiersprache erstellen können. Hier ist ein Beispiel für die Erstellung einer Abfrage mit Slick aus der Dokumentation im Abschnitt "Monadic Joins" :

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)
// compiles to SQL:
//   select x2."COF_NAME", x3."SUP_NAME"
//     from "COFFEES" x2, "SUPPLIERS" x3
//     where x2."SUP_ID" = x3."SUP_ID"

Ich gebe zu, für einen Scala-Neuling sah das ziemlich komisch aus. Wenn Sie lange über diesen Code nachdenken, werden Sie die Entsprechung zwischen dieser kniffligen syntaktischen Konstruktion und der SQL-Abfrage unten bemerken, in die er umgewandelt wird. Es scheint, als ob etwas klar wird: Rechts von den Tabellenpfeilen, links von den Aliases, nach if - einer Bedingung, in Yield - den für die Projektion ausgewählten Feldern. Es sieht aus wie eine SQL-Abfrage von innen nach außen gedreht. Aber warum wird der Builder so implementiert? Was macht der denn? Gibt es eine Art von Iteration über den Inhalt der Tabellen? In der Tat erfüllen wir derzeit die Anfrage noch nicht, sondern bauen sie nur auf.

Ohne zu verstehen, wie diese Konstruktion funktioniert, können Sie sich natürlich an diese Syntax gewöhnen und analog ähnliche Abfragen nieten. Aber wenn wir versuchen, etwas komplexeres zu schreiben, laufen wir Gefahr, auf eine Wand des Missverständnisses zu stoßen und viel Zeit damit zu verbringen, den Compiler zu verfluchen, was das Licht wert ist, wie es damals bei mir war. Um zu verstehen, was sich hinter dieser Magie verbirgt und warum der Query Builder auf diese Weise implementiert ist, müssen Sie einen kleinen Exkurs über For-Inclusions und Monaden machen.

Monaden


Was charakteristisch ist, ist, dass in Martin Oderskys Buch Programming in Scala das Wort "Monade" an einer einzigen Stelle verwendet wird - ganz am Ende des Kapitels zur Aufnahme, als ob dazwischen. In den meisten Abschnitten dieses Kapitels wird beschrieben, wie Sie mit der for-Syntax über Sammlungen, mehrere Sammlungen und Filter iterieren können. Und erst ganz am Ende wird gesagt, dass es so etwas wie eine „Monade“ gibt, mit der man auch bequem arbeiten kann, aber eine detaillierte Erklärung darüber, was dies ist und warum, wird nicht gegeben. Unterdessen ist die Verwendung von For-Inclusion für die Arbeit mit Monaden für den Anfänger ein sehr effektives und gleichzeitig unverständliches syntaktisches Konstrukt.

Ich werde hier kein vollständiges Tutorial über Monaden geben, zumal es eine große Anzahl von ihnen gibtund ihre Autoren werden das Thema besser erklären als ich. Ich kann ein gutes Video empfehlen , das dieses Konzept in Scala-Sprache erklärt. In diesem Artikel wird davon ausgegangen, dass es sich bei einer Monade um einen parametrisierten Typ handelt, etwa um einen funktionalen Wrapper, der über zwei grundlegende Operationen mit bestimmten Eigenschaften verfügt:

  • Operation return - bricht einen Wert in einem durch diesen Typ dargestellten Kontext um (oder "hebt", "hebt");
  • bind operation - Führt in diesem Kontext eine Transformationsfunktion für einen Wert aus.

Aus Sicht der Scala-Autoren wird die Rückgabeoperation in OOP im Wesentlichen vom Instanzkonstruktor implementiert, der einen Wert annimmt (mit dem Konstruktor können Sie nur den übergebenen Wert in ein Objekt einschließen), und die Bindeoperation entspricht der flatMap-Methode. Tatsächlich sind die Monaden in Scala keine Monaden für das Verständnis klassischer funktionaler Sprachen wie Haskell, sondern eher "Monaden auf eine Geruchsweise". Obwohl der Begriff „Monade“ in klassischen Scala-Büchern vermieden wird und selbst in der Standardbibliothek kaum eine Erwähnung dieses Wortes zu finden ist, scheuen Slick-Entwickler sich nicht, es in der Dokumentation und im Code zu verwenden, da sie glauben, dass der Leser bereits weiß, was es ist.

Einschlüsse


Tatsächlich ist For-Comprehension natürlich keine Schleife, und das Schlüsselwort for kann zunächst verwirrend sein. Übrigens habe ich versucht herauszufinden, wie "für-Verständnis" ins Russische übersetzt wird - es gibt Optionen, aber es gibt keine allgemein akzeptierten. Einige Polemiken zu diesem Thema können hier , hier und hier gelesen werden .

Ich habe mich für den Begriff „For-Inclusion“ entschieden, weil er in der Regel die Einbeziehung von Elementen in die Ausgabemenge nach bestimmten Regeln beschreibt. Wenn wir For-Comprehension als monadisches Verständnis betrachten, wird eine solche Übersetzung jedoch nicht so offensichtlich. Aufgrund der geringen Anzahl an Literatur zu FP und Kategorietheorie in russischer Sprache ist der Begriff noch nicht festgelegt.

Die Ironie ist, dass laut den Autoren von Programming in Scala kombinatorische Rätsel eines der besten Gebiete für die Verwendung von For-Inclusion sind:



All dies ist wunderbar und nützlich, aber was ist mit realen Anwendungsfällen?

Es stellt sich heraus, dass die Stärke des Monadenmusters, insbesondere in Kombination mit For-Inclusion, darin besteht, dass Sie eine übergeordnete Komposition einzelner Aktionen in einem ziemlich komplizierten Kontext durchführen können, dh komplexere Strukturen aus kleinen Cubes erstellen können (Bind / FlatMap-Operationen). Mit der Syntax für die Aufnahme können Aktionen erstellt werden, die in einer sequentiellen Kette nicht sequentiell ausgeführt werden können. Normalerweise liegt die Komplexität ihrer Implementierung im Vorhandensein eines komplexen Kontextes. Eine der am häufigsten verwendeten Monaden in Scala ist beispielsweise List:

  // списки
  val people = List("Воронин", "Гейгер", "Убуката")
  val positions = List("мусорщик", "следователь", "редактор")
  // декартово произведение списков с использованием for-включения:
  val peoplePositions = for {
    person <- people
    position <- positions
  } yield s"$person, $position"

Mit Hilfe der Einbeziehung ist es möglich, ein kartesisches Produkt an einzelnen Instanzen der Listenmonade durchzuführen, d.h. Zusammenstellung von Listen. Gleichzeitig verbirgt die Monade die Komplexität des Kontexts (Iteration über die Menge der Werte).

In der Tat ist For-Inclusion nur syntaktischer Zucker mit genau definierten Umrechnungsregeln. Insbesondere werden alle Pfeile mit Ausnahme des letzten zu flatMap-Aufrufen für Bezeichner auf der rechten Seite und der letzte Pfeil wird zu einem Kartenaufruf. Die Bezeichner auf der linken Seite werden in Funktionsargumente für die flatMap-Methoden umgewandelt, und der Ertragsinhalt wird von der letzten Funktion zurückgegeben.

Daher können Sie dasselbe mit einem direkten Aufruf der flatMap-Methode und der map-Methode schreiben, dies ist jedoch weniger offensichtlich, insbesondere wenn die Größe und die Verschachtelung dieser Strukturen um ein Vielfaches größer sind:

  // декартово произведение списков прямым вызовом flatMap и map:
  val peoplePositions2 = people.flatMap {person =>
    positions.map { position =>
      s"$person, $position"
    }
  }

In ähnlicher Weise können wir mit der monadischen Future-Implementierung Aktionen auf Werten in Ketten aufbauen und dabei die Komplexität des Kontexts verbergen (asynchrone Ausführung von Aktionen und die Tatsache, dass die Berechnung von Werten verzögert ist):

  // первая футура формирует и возвращает строку
  def getFuture1 = Future {
    "1337"
  }
  // вторая футура из строки делает число
  def getFuture2(string: String) = Future {
    string.toInt
  }
  // комбинированная футура, созданная с использованием for-включения
  val composedFuture = for {
    result1 <- getFuture1
    result2 <- getFuture2(result1)
  } yield result2

Wenn wir einen Parameter an das Futur übergeben müssen, kann dies wie oben gezeigt mit dem Closure erfolgen: Wir wickeln das Futur in eine Funktion mit einem Argument ein und verwenden das Argument innerhalb des Futur. Dadurch ist es möglich, einzelne Futures miteinander zu verbinden. Dementsprechend sieht der "desaccharisierte" Code aus wie viele verschachtelte flatMap-Aufrufe, die mit einem Kartenaufruf enden:

  // комбинированная футура, созданная с использованием flatMap и map
  val composedFuture2 = getFuture1.flatMap { result1 =>
    getFuture2(result1).map { result2 =>
      result2
    }
  }

For-Inclusion, Monaden und Abfrageerstellung


Die flatMap-Operation ist also ein Mittel, um monadische Objekte zusammenzusetzen oder komplexe Strukturen aus einfachen Bausteinen zu erstellen. Was die SQL-Sprache betrifft, gibt es dort auch ein Werkzeug für die Komposition - dies ist eine JOIN-Klausel. Kehren wir nun zu der For-Inclusion und ihrer Verwendung zum Erstellen von Abfragen zurück, wird deutlich, dass flatMap und JOIN viel gemeinsam haben, und dass die Zuordnung zueinander durchaus sinnvoll und vernünftig ist. Schauen wir uns noch einmal das Beispiel an, in dem eine Abfrage mit einem internen Join erstellt wird, das am Anfang des Artikels angegeben wurde. Nun sollte die Idee, die in eine solche Syntax eingebettet ist, etwas klarer werden:

val monadicInnerJoin = for {
  c <- coffees
  s <- suppliers if c.supID === s.id
} yield (c.name, s.name)

Aber hier ist eine der Ecken und Kanten dieses Ansatzes: Es gibt immer noch linke und rechte Joins in SQL, und diese Funktionen eignen sich nicht sehr gut für die monadische Einbeziehung: Es gibt keine syntaktischen Tools, um solche Join-Typen für die Einbeziehung und für die linke und die rechte Einbeziehung auszudrücken Right Joins werden aufgefordert, eine alternative Syntax zu verwenden - Applicative Joins . Übrigens ist dies das große und schwerwiegende Problem vieler Ansätze in Scala, wenn komplexe Konzepte anhand der Sprache modelliert werden - alle Mittel der Sprache haben Einschränkungen, denen dieses Konzept früher oder später entgegensteht. Aber über diese Funktion Scala - ein anderes Mal.

Darüber hinaus werden Slick-Monaden auf zwei Ebenen verwendet - im Abfrage-Designer (als separate Abfragekomponenten, die kombiniert werden können) und in der Zusammenstellung von Aktionen mit der Datenbank (sie können zu komplexen Aktionen kombiniert werden, die dann in eine Transaktion eingeschlossen werden). Um ehrlich zu sein, hatte ich anfangs viele Probleme, weil man mit Hilfe der Einbeziehung sowohl monadische Wünsche als auch monadische Handlungen kombinieren kann, und ich hatte lange Zeit ein gutes Auge, bis ich lernte, eine Monade im Code von einer anderen zu unterscheiden. Monadisches Handeln ist genau das Thema des nächsten Kapitels ...

Monaden und Zusammensetzung von Datenbankaktionen


Genug der Theorie, lasst uns zum Hardcore kommen. Lassen Sie uns versuchen, etwas Nützlicheres auf Slick zu schreiben als eine einfache Anfrage. Beginnen wir noch einmal mit einer Abfrage mit einem internen Join:

  val monadicInnerJoin = for {
    ph <- phones
    pe <- persons if ph.personId === pe.id
  } yield (pe.name, ph.number)

Aus dem result-Attribut des resultierenden Werts können Sie ein Objekt vom Typ DBIOAction extrahieren - eine weitere Monade, die jedoch bereits zum Erstellen einzelner Aktionen mit Datenbanken vorgesehen ist.

// делаем из запроса DBIO-действие
val action1 = monadicInnerJoin.result

Jede Aktion, einschließlich einer zusammengesetzten, kann als Teil einer Transaktion ausgeführt werden:

val transactionalAction1 = action1.transactionally

Aber was ist, wenn wir mehrere separate Aktionen in eine Transaktion einbinden müssen, von denen einige überhaupt nicht mit der Datenbank zusammenhängen? Die Methode DBIO.successful hilft uns dabei:

// делаем DBIO-действие из какой-то произвольной функции
val action2 = DBIO.successful {
  println("Делаем что-то между запросами в транзакции...")
}

Übrigens, wenn Sie die Erstellung einer Aktion in eine Funktion mit einem Argument einschließen, können Sie diese Aktion wie im Fall der obigen Zukünfte parametrisieren, aber das werden wir nicht tun. Stattdessen fügen wir dem Mix einfach ein paar weitere DBIO-Aktionen hinzu, um Daten in die Tabellen einzufügen und dies alles mit for-include zu einer zusammengesetzten Aktion zusammenzusetzen:

// ещё парочка DBIO-действий...
val action3 = persons += (1, "Grace")
val action4 = phones += (1, 1, "+1 (800) FUC-KYOU")
// делаем композитное действие из всех четырёх действий
val compositeAction = for {
  result <- action1
  _ <- action2
  personCount <- action3
  phoneCount <- action4
} yield personCount + phoneCount

Bitte beachten Sie: Wenn uns das Ergebnis der Aktion nicht interessiert (es wird für einen Nebeneffekt ausgeführt), können Sie links neben dem Pfeil einen Unterstrich einfügen. Nun wickeln wir die zusammengesetzte Aktion in eine Transaktion ein und erstellen darauf basierend ein Futurum:

// заворачиваем композитное действие в транзакцию и делаем из него футуру
val actionFuture = db.run(compositeAction.transactionally)

Nun, zum Schluss kombinieren wir diese Futur mit einer anderen Futur, indem wir das omnipotente for verwenden und warten, bis sie mit Await.result ausgeführt wird (dieser Ansatz ist übrigens nur für Tests geeignet, wiederholen Sie dies nicht in der Produktion - verwenden Sie End-to-End-Asynchronität):

val databaseFuture = for {
  i <- actionFuture
  _ <- Future {
    println(s"Вставлено записей: $i")
  }
} yield ()
Await.result(databaseFuture, 1 second)

So einfach.

Fazit


Monaden und For-Inclusion-Syntax werden häufig in verschiedenen Scala-Bibliotheken verwendet, um große Strukturen aus kleinen Bausteinen zu erstellen. Alleine in Slick können sie an mindestens drei verschiedenen Stellen verwendet werden - zum Zusammenstellen von Tabellen zu einer Abfrage, zum Zusammenstellen von Aktionen in einer großen Aktion und zum Zusammenstellen von Zukünften in einer großen Zukunft. Das Verstehen der Philosophie von Slick und das Erleichtern des Arbeitens wird erheblich erleichtert, indem verstanden wird, wie die Inklusion funktioniert, was Monaden sind und wie die Inklusion die Arbeit mit Monaden erleichtert.

Ich hoffe, dieser Artikel hilft Neulingen bei Scala und Slick, nicht zu verzweifeln und die volle Leistungsfähigkeit dieses Frameworks einzuschränken. Der Quellcode des Artikels ist verfügbar auf GitHub .


Jetzt auch beliebt: