Blog

Höherwertige Daten in Scala

Chris Birchall

Chris Birchall

Aktualisiert Oktober 15, 2025
6 Minuten

Dieser Artikel wurde ursprünglich auf 47deg.com am 19. Januar 2021 veröffentlicht.

Neulich bin ich auf einen netten Anwendungsfall für ein Konzept gestoßen, das als "Higher-kinded data" bekannt ist und das ich für interessant halte. Higher-kinded data" ist in der Haskell-Welt ein recht bekanntes Konzept, aber ich habe noch nicht viele Leute gesehen, die darüber in Scala gesprochen haben. Ich werde das Konzept anhand eines realen Beispiels erklären und zeigen, wie nützlich es sein kann.

Konfiguration von Batch-Aufträgen

Bei meinem Kunden haben wir einen Batch-Job in Scala und Spark implementiert. Er benötigt ein paar Befehlszeilenargumente, aber der größte Teil der Konfiguration erfolgt über eine Datei, die der Job beim Start von S3 herunterlädt.

Die Konfigurationsdatei wird geparst und mit PureConfig in eine Fallklasseninstanz dekodiert. Die entsprechende Fallklasse sieht etwa so aus:

final case class JobConfig(
  inputs: InputConfig,
  outputs: OutputConfig,
  filters: List[Filter],
  timeRange: TimeRange,
  tags: List[String]
)

Und so sieht der Kern der Arbeit aus:

def doStuff(config: JobConfig): Unit

Der Teil der Konfiguration, der uns am meisten interessiert, ist die TimeRange, die den Zeitraum der Daten angibt, die der Auftrag verarbeiten soll:

final case class TimeRange(
  min: LocalDateTime,
  max: LocalDateTime
)

Es gibt eine neue Anforderung, die Art und Weise zu ändern, wie diese Zeitspanne an den Auftrag übergeben wird. In einigen Fällen möchten wir sie weiterhin in der Konfigurationsdatei angeben, aber wir möchten auch die Möglichkeit haben, die Werte und über CLI-Argumente zu übergeben. Wenn Sie z.B. einen Job Scheduler wie Airflow verwenden, um einen Job jeden Tag mit den Daten des Vortags auszuführen, benötigt der Scheduler eine Möglichkeit, die entsprechenden Datumswerte an den Job zu übergeben.

Das erforderliche Verhalten ist wie folgt:

  1. Sie können optional --timeRangeMin und --timeRangeMax CLI-Argumente an den Auftrag übergeben. Diese Argumente werden als Set geliefert: wenn Sie eines übergeben, müssen Sie auch das andere übergeben.
  2. Sie können die Zeitspanne wie bisher in der Konfigurationsdatei angeben.
  3. Wenn der Zeitbereich sowohl über CLI-Argumente als auch über die Konfigurationsdatei angegeben wird, haben die CLI-Argumente Vorrang.
  4. Wenn Sie keine Zeitspanne angeben, entweder über CLI-Argumente oder in der Konfigurationsdatei, wird der Auftrag beim Start einen Fehler ausgeben.

Aktualisieren der Klasse JobConfig

Mit diesen neuen Anforderungen wird das Feld timeRange in der Konfigurationsdatei optional, während es vorher erforderlich war. Wir müssen also die entsprechende Fallklasse JobConfig aktualisieren, damit sie nicht fehlschlägt, wenn wir versuchen, eine Konfigurationsdatei zu dekodieren, in der dieses Feld fehlt:

final case class JobConfig(
  inputs: InputConfig,
  outputs: OutputConfig,
  filters: List[Filter],
  timeRange: Option[TimeRange], // this becomes an Option
  tags: List[String]
)

Der Einstiegspunkt für den Auftrag würde jetzt etwa so aussehen:

  1. Achten Sie auf die optionalen --timeRangeMin und --timeRangeMax CLI-Argumente.
  2. Dekodieren Sie die Konfigurationsdatei unter Verwendung der aktualisierten Fallklassendefinition.
  3. Wenn der Zeitbereich über CLI-Args übergeben wurde, ersetzen Sie das Feld timeRange durch diese Werte. Andernfalls, wenn der Zeitbereich nicht in der Konfigurationsdatei angegeben wurde, schlagen Sie mit einer nützlichen Fehlermeldung fehl.
  4. Rufen Sie an. doStuff(config)

Aber Moment mal, irgendetwas stimmt hier nicht ganz!

Wenn wir Schritt 4 erreicht haben, wissen wir, dass der Zeitbereich korrekt angegeben wurde, entweder über die CLI oder die Konfigurationsdatei. Aber das Feld timeRange in der JobConfig ist ein Option[TimeRange]. Mit anderen Worten, wir wissen, dass der Wert immer vorhanden sein wird, aber der Compiler weiß es nicht. Wir haben unser Wissen nicht richtig in den Typen kodiert.

Wir müssen eine Menge Code in der Methode doStuff ändern, um Option zu handhaben. Wahrscheinlich werden wir am Ende so etwas schreiben:

val min = config.timeRange.get.min // this won't blow up, trust me :)

Können wir das besser machen?

Geben Sie Daten höherer Art ein

Das Problem ist, dass wir unsere Klasse JobConfig für zwei verschiedene Zwecke wiederverwenden möchten: die Dekodierung des Inhalts einer Konfigurationsdatei und die Übergabe einer vollständig validierten und ausgefüllten Auftragskonfiguration an den Kern des Auftrags.

Wir könnten zwei verschiedene (aber sehr ähnliche) Case-Klassen erstellen und die Konvertierung von einer in die andere implementieren, aber das ist ziemlich mühsam, potenziell fehleranfällig und nicht sehr DRY. Ein alternativer Ansatz ist die Verwendung eines Tricks namens "höherwertige Daten", bei dem wir JobConfig mit einem höherwertigen Typparameter polymorph machen:

final case class JobConfig[F[_]](
  inputs: InputConfig,
  outputs: OutputConfig,
  filters: List[Filter],
  timeRange: F[TimeRange], // this is now polymorphic
  tags: List[String]
)

Die Einführung dieser Abstraktion bedeutet, dass wir dem Compiler nun beibringen können, was wir bereits wussten: Beim Dekodieren der Konfigurationsdatei kann das Feld timeRange vorhanden sein oder auch nicht, aber zu dem Zeitpunkt, an dem wir doStuff(config) aufrufen, ist das Feld definitiv vorhanden.

Dekodierung der Konfigurationsdatei

Die Änderung am PureConfig-Code ist sehr geringfügig, von:

val config: JobConfig = configSource.loadOrThrow[JobConfig]

zu:

val config: JobConfig[Option] = configSource.loadOrThrow[JobConfig[Option]]

Hier setzen wir den Parameter F[_] type auf Option, da das Feld timeRange in der Konfigurationsdatei fehlen könnte.

Die Zeitspanne muss vorhanden sein

In der Methode doStuff erwarten wir, dass der Zeitbereich festgelegt wird, und wir wollen nicht mit Option arbeiten. Also können wir die Signatur der Methode von aktualisieren:

def doStuff(config: JobConfig): Unit

zu:

def doStuff(config: JobConfig[Id]): Unit

Hier verwenden wir den TypId (kurz für "Identity") von Cats. Dies ist nichts weiter als ein Typ-Alias, Sie können ihn also auch selbst definieren, wenn Sie dies bevorzugen:

type Id[A] = A

Mit anderen Worten, der Typ Id[TimeRange] ist genau TimeRange, aber er hat die Form, die wir brauchen, um unserem Typparameter F[_] zu entsprechen. Innerhalb der Methode doStuff können wir direkt auf das Feld timeRange verweisen, ohne es zu entpacken, so wie wir es zuvor getan haben:

val min = config.timeRange.min

Sicherstellen, dass der Zeitbereich eingestellt ist

Der Einstiegspunkt für den Auftrag sieht jetzt etwa so aus:

val timeRangeFromCLI: Option[TimeRange] = parseCLIArgs()

val configFromFile: JobConfig[Option] = loadConfigFile()

val configWithTimeRange: JobConfig[Id] = setTimeRange(configFromFile, timeRangeFromCLI)

doStuff(configWithTimeRange)

wobei die Methode setTimeRange wie folgt definiert ist:

def setTimeRange(
    config: JobConfig[Option],
    timeRangeFromCLI: Option[TimeRange]
): JobConfig[Id] = {
  val timeRange = timeRangeFromCLI
    .orElse(config.timeRange)
    .getOrElse(
      throw new Exception(
        "Time range must be specified either as CLI args or in the config file"
      )
    )
  config.copy[Id](timeRange = timeRange)
}

Parsen der CLI-Argumente

Es gab noch eine weitere Anforderung, die wir nicht berücksichtigt haben: Es sollte nicht möglich sein, nur ein CLI-Argument zu setzen und das andere nicht. Schauen wir uns einmal an, wie man das umsetzen kann.

Wir verwenden die Decline-Bibliothek zum Parsen von CLI-Argumenten. Sie macht es wirklich einfach, die beiden Argumente so zusammenzusetzen, wie wir es brauchen:

private val timeRangeMinOpt: Opts[LocalDateTime] =
  Opts.option[LocalDateTime]("timeRangeMin", help = "...")

private val timeRangeMaxOpt: Opts[LocalDateTime] =
  Opts.option[LocalDateTime]("timeRangeMax", help = "...")

val timeRangeOpt: Opts[Option[TimeRange]] =
  (timeRangeMinOpt, timeRangeMaxOpt).mapN(TimeRange).orNone

Dies verhält sich wie gewünscht: Sie können keine Argumente oder beide übergeben, aber nicht nur eines.

Fazit

Mit Polymorphismus höherer Art können Sie Ihre Fallklassen in verschiedenen Situationen wiederverwenden, was die Kohärenz Ihres Modells erhöht und Code-Duplizierung reduziert.

Verfasst von

Chris Birchall

Chris is a Principal Software Developer at Xebia Functional. His technical interests include distributed systems, functional domain modelling, metaprogramming and property-based testing.

Contact

Let’s discuss how we can support your journey.