Organisationschart

Hierarchische Strukturen mit Adjazenzlisten

Hierarchische Strukturen mit Adjazenzlisten in SQL zu implementieren, stellt den ersten Beitrag einer dreiteiligen Kurzreihe dar, die sich mit hierarchischen Strukturen, deren Modellierung und SQL-Implementierung beschäftigt.

Bäume und Hierarchien mit SQL zu modellieren und zu handhaben, geben immer wieder Anlass zu Fragen. Das spiegeln jedenfalls die einschlägigen Foren im Internet wider. In der Tat war die mangelnde Unterstützung hierarchischer Strukturen der Grund, weshalb viele den relationalen Datenbankmanagementsystemen nur wenige Erfolgschancen eingeräumt haben, als E.F. Codd ehedem das relationale Datenbankkonzept vorstellte.

Hierarchische Strukturen mit Adjazenzlisten

In einem Artikel beschrieb E.F. Codd beispielhaft eine Methode, die Implementierung hierarchischer Strukturen mithilfe von Adjazenzlisten und SQL umzusetzen. Sein Beispiel bestand darin, die Beziehungen zwischen den Namen von Mitarbeitern und deren Vorgesetzten in Form einer Adazenzliste abzubilden.

Oracle war dann das erste kommerzielle Datenbankmanagementsystem, welches auf dem relationalen Datenbankkonzept basierte und SQL verwendete. In der Beispieldatenbank, die dem Produkt beigefügt war, wurde das Beispiel von E.F. Codd in der „Scott/Tiger“-Datenbank aufgegriffen. Es nutze ebenfalls eine Adjazenzliste, um eine Personalhierarchie in einer einzigen Tabelle zu modellieren. Dieses Beispiel hat Schule gemacht. Wir finden analoge Implementierungen in vielfältigen Beiträgen, die hierarchische Strukturen mithilfe von SQL behandeln.

Einfaches Adjazenzlistenmodell

Das folgende Datenmodell greift das Oracle-Beispiel auf. Diese Datenstruktur findet man auch in vielfältigen Beiträgen, die sich mit der Abbildung hierarchischer Datenstrukturen in relationalen Datenbankmanagementsystemen befassen.

MitarbeiterHierarchie
Abbildung1: Mitarbeiterhierarchie

Die Idee ist einfach. Ein Mitarbeiter wird mit seinem direkten Vorgesetzten in Beziehung gesetzt. Der oberste Chef hat keinen Vorgesetzten. Das entsprechende Attribut wird daher auf NULL gesetzt.

Personennamen als Schlüssel zu verwenden, ist keine gute Programmierpraxis. Um die Diskussion einfach zu halten und auf das Wesentliche zu konzentrieren, ignorieren wir diesen Fakt vorerst. Das gewählte Modell ist nicht nur nicht normalisiert, es definiert auch keinerlei Regeln, welche die hierarchische Struktur gewährleisten, die abgebildet werden soll. Es stellt lediglich einen allgemeinen Graphen dar, in dem grundsätzlich beliebige Beziehungen abgebildet werden können.

Fehlende Normalisierung

Ein Fakt, der scheinbar oft nicht realisiert wird, ist die fehlende Normalisierung des Modells. Das ändert sich auch dadurch nicht, wenn IDs als Schlüssel eingeführt werden. So findet sich beispielsweise im MySQL-Tutorial das folgende Beispiel.

Ein Vorgesetzter oder eine übergeordnete Kategorie sind keine Attribute eines Mitarbeiters beziehungsweise einer Kategorie. Mitarbeiter und Vorgesetzte stehen in einem Über-/Unterordnungsverhältnis zueinander in Beziehung. Gleiches gilt für das zitierte Beispiel der Kategorien.

Mitarbeiterinformationen und Beziehungen zwischen den handelnden Personen werden vermischt, um beim Eingangsbeispiel, der Mitarbeiterhierarchie, zu bleiben. Beide Informationen werden gemeinsam in einem Datensatz gespeichert.

In einem Punkt ist das MySQL-Beispiel aber schon besser als das Modell in Listing 1. Das MySQL-Beispiel verwendet numerische Schlüssel statt der Kategoriebezeichner als Verweise. So bleibt einem wenigstens die UPDATE-Anomalie des zuerst gezeigten Modells erspart.

Weiter möchte ich an dieser Stelle gar nicht auf die Normalisierung und mögliche Anomalien eingehen, die daraus resultieren, ein nicht normalisiertes Datenbankmodell zu verwenden. Vielmehr möchte ich auf die strukturelle Integrität und mögliche Anomalien, diese betreffend, zu sprechen kommen.

Strukturelle Anomalien

Bäume und Hierarchien, wie wir sie hier betrachten, stellen eine besondere Form von Graphen dar. Zum einen handelt es sich dabei um einen Baum. Zum anderen, und das differenziert die Hierarchie vom gemeinen Baum, drücken die Beziehungen in einer Hierarchie jeweils ein Über-/Unterordnungsverhältnis aus. Sie haben also eine ganze spezielle semantische Bedeutung. Eine Hierarchie ist also auch ein gerichteter Graph, bei welchem wir die Beziehungen als „hat Vorgesetzten“ oder „hat Mitarbeiter“ lesen können, je nach Blickwinkel. Ansonsten ist einer Hierarchie ein ganz normaler Baum.

Ein Baum ist entweder leer oder besteht aus einem Knoten, dem null oder mehrere Bäume zugeordnet sind.

Aus dieser Definition lassen sich Regeln, die für Bäume und Hierarchien gelten, ableiten:

  • Eine Hierarchie ist ein Baum.
  • Ist der Baum nicht leer, besitzt er genau einen Wurzelknoten.
  • Jeder Knoten im Baum besitzt genau einen übergeordneten Knoten. Hiervon Ausgenommen ist der Wurzelknoten, der keinen Vorgänger übergeordneten Knoten hat.

Weitere Implikationen hieraus sind:

  • Mit Ausnahme des Wurzelknotens haben alle Knoten im Baum den Eingangsgrad 1. Der Wurzelknoten hat den Eingangsgrad 0.
  • Ein Baum ist zyklenfrei.

Für die praktische Implementierung einer Prsonalhierarchie, die wir hier als Beispiel betrachten, können wir umgangssprachlich das Folgende aus den Regeln sowie den üblichen Integritätsanforderungen formulieren:

  • In einer Adjazenztabelle dürfen nur Mitarbeiter referenziert werden, die bereits in einer Personaltabelle erfasst sind.
  • Die Vorgesetztenverweise dürfen nur auf Mitarbeiter in der Adjazenztabelle verweisen, die darin bereits als Mitarbeiter erfasst sind. Ausnahme: Wurzelknoten (Chef).
  • Mitarbeiter können/dürfen sich nicht selbst als Vorgesetzten referenzieren.
  • Die Anzahl der Mitarbeiter (Knoten) ist um 1 größer als die Anzahl der Vorgesetzten (Kanten).
  • Die Mitarbeiter-Vorgesetzten-Verweise dürfen keinen Zyklus etablieren.

Allein die Normalisierung verlangt, die Daten in zwei Tabellen aufzuteilen, personal und organisation:

Auf eine DELETE-Kaskade wurde bewusst verzichtet. Sie ist potentiell gefährlich. Mit nur einem einzigen DELETE-Statement könnte der Inhalt der gesamten Hierarchie gelöscht werden.

Hinweis: Bei beispielsweise MySQL funktioniert die UPDATE-Kaskade nicht, obgleich sie syntaktisch akzeptiert wird. Das ist jedoch kein Fehler. Potentiell vorhandene Zyklen, wie sie bei sich selbst referenzierenden Tabellen auftreten können, könnten eine Quasi-Endlosschleife von UPDATE-Operationen auslösen. Das woll(t)en die MySQL-Entwickler vermeiden. Eine DELETE-Kaskade funktioniert aber auch in MySQL. Das hier verwendete PostgreSQL akzeptiert und kann beides..

Mittels dieser einfachen Maßnahmen, Normalisierung, die Wahl geigneter Primäschlüssel und Fremdschlüsselbeziehungen, haben wir das Folgende bereits erreicht:

  • Das Datenmodell ist normalisiert.
  • Die referentielle Integrität zwischen Personal und den Mitarbeitern in der Organisationshierarchie ist gewährleistet.
  • Die referentielle Integrität zwischen Vorgesetzten und Mitarbeitern ist sichergestellt.
  • Jeder Mitarbeiter kann nur einen Vorgesetzten haben.

Die strukturelle Integrität der abzubildenden Hierarchie ist noch nicht gewährleistet. Noch ist es möglich, einem Mitarbeiter sich selbst als Vorgesetzten zuzuweisen. Das stellt einen Zirkelschluss dar. Weitere Zirkelschlüsse sind ebenso möglich. So könnte bei einer UPDATE-Operation, Rainer zum Vorgesetzten von Lisa erklärt werden. Die ist jedoch über Silke bereits auch Rainer vorgesetzt, so dass hier ein Zirkelschluss vorläge.

Bei INSERT- und/oder DELETE-Operationen kann ein solcher Zirkelschluss nicht eintreten. Delete-Operationen sind diesbezüglich immer unkritisch. Da bei einer INSERT-Operation vorausgesetzt ist, dass die als Vorgesetzter zu benennende Person bereits vorhanden ist, kann ein bereits vorhandener Mitarbeiter nicht auf die noch einzufügende Person verweisen. Ein Zirkelschluss kann daher, mit den implementierten Regeln, ausschließlich durch UPDATE-Operationen bewirkt werden.

Neben möglichen Zirkelschlüssen sichert die aktuelle Implementierung auch nicht zu, genau eine Wurzel zu haben, wenn die Zuordnungstabelle nicht leer ist. Enthält die Hierarchie keinen Datensatz, so gibt es keinen Wurzelknoten. Der erste Datensatz, welcher eingefügt wird, muss zwingend der Wurzelknoten sein. Andernfalls verstießen wir gegen die referentielle Integrität beim Einfügen weiterer Datensätze. Von diesen darf jedoch kein Datensatz einen NULL-Wert im Vorgesetztenattribut besitzen. Dieser spezielle Wert kennzeichnet in unserer Implementierung die Wurzel, von der es, wenn Datensätze in der Hierarchie gespeichert sind, nur genau eine geben darf.

Hierarchische Strukturen mit Adjazenzlisten optimiert

Schutz vor Selbstbezügen

Am einfachsten auszuschließen ist ein Selbstbezug. Dazu reicht es, eine CHECK-Klausel zu definieren, die auf Spaltenebene prüft, ob die Nummer eines Mitarbeiters der eines Vorgesetzten entspricht. Wenn ja, handelt es sich um einen Verstoß gegen die oben formulierten Regeln.

Größere Zyklen, also Zirkelbezüge über mehrere Hierarchieebenen, werden hierdurch nicht ausgeschlossen. Wir behandeln sie im übernächsten Abschnitt.

Zusicherung der Wurzeleigenschaft

Es gibt keine Wurzel, wenn die Hierachie keinen Datensatz beinhaltet. Und es gibt genau eine Wurzel, wenn mindestens ein Datensatz in der Hierachie gespeichert ist. Dies zu gewährleisten ist einfach. Wir haben oben die Regel abgeleitet, dass die Anzahl der Mitarbeiter immer um 1 größer ist als die Anzahl der Vorgesetzten, wenn mindestens ein Datensatz in der Hierarchie gespeichert ist. Eine CHECK-Klausel, die auf Tabellenebene definiert wird, kann/könnte dies leicht prüfen. – Wir werden gleich noch sehen, ob solche eine Regel, auch wenn sie naheliegend ist, eine gute Idee ist.

Hinweis: Je nach Datenbankmanagementsystem können Select-Statements und/oder der Aufruf von Funktionen in einer CHECK-Klausel erlaubt sein oder eben auch nicht. Das hier verwendete Datenbankmanagementsystem, PostgreSQL 13.4, erlaubt den Aufruf von Funktionen. Unterstützt ein Datenbankmanagementsystem weder SQL-Statements noch Funktionen in CHECK-Klauseln, so können im Ergebnis gleiche Regeln mithilfe von berechneten Spalten sowie Triggern erzielt werden.

Um die notwendigen Funktionen zur Bestimmung der Anzahlen von Mitarbeitern und Vorgesetzten bestimmen zu können, muss bereits eine Tabelle organisation existieren. Umgekehrt müssen die genannten Funktionen implementiert sein, um sie in der Tabelledefinition verwenden zu können. Das Vorgehen ist daher so, zuerst die Tabelle zu erzeugen, die Funktionen zu erstellen und danach die erforderliche CHECK-Klausel, welche die neuen Funktionen nutzt, via ALTER TABLE in die Tabelle zu integrieren.

Mit dem Vorhandensein der beiden opbigen Funktionen, können wir nun eine Regel formulieren, welche uns die erforderliche Wurzeleigenschaft zusichert.

Diese Regel erfüllt ihren Zweck. Leider verhindert Sie aber auch, die Mitarbeiternummer der Wurzel aktualisieren zu können. Die Regel greift, unabhängig von der auszuführenden Aktion. Ist bereits eine Wurzel vorhanden, und soll deren Mitarbeiternummer geändert werden, feuert der erste Teil von CASE, was zu einem Fehler führt. – Eine einmal festgelegt Wurzel nicht ändern zu können, halt ich für eine zu starke Einschränkung.

Statt einer Tabellenregel verwenden wir daher Trigger. Mit ihrer Hilfe können wir die Fälle des Einfügens und des Änderns von Daten einfach unterscheiden. Das ist notwendig, um die oben genannte Regel situativ angepasst anwenden zu können.

Für den INSERT-Trigger gilt: Ist noch kein Datensatz erfasst, ist als erster Datensatz der Wurzelknoten zu setzen. Um eine Wurzel einfügen zu können, muss die Anzahl der Mitarbeiter gleich der Anzahl der Vorgesetzen sein. Daher wird im Falle von vnr IS NULL nichts auf die Vorgesetztenanzahl addiert. Da sowohl Mitarbeiter- als auch Vorgesetztenanzahl bei einer leeren Tabelle 0 sind, ist die Welt in Ordnung. Würde versucht, einen „normalen“ Mitarbeiter einzufügen, würde 1 auf die Vorgesetztenanzahl addiert, was dann zu einem Fehler führen würde. Dadurch wird sichergestellt, als ersten Datensatz den Wurzelknoten zu setzen. Bei später einzufügenden Datensätzen verhält es sich dann analog.

UPDATE-Operationen können nur ausgeführt werden, wenn Datensätze vorhanden sind. Die Anzahl der Mitarbeiter muss also immer den Anzahl der Vorgesetzten plus eins sein. Bei einem UPDATE-Trigger muss diese Bedingung nach der Operation überprüft werden, weil ein Update einen inkonsistenten Zustand erzeugen könnte.

Die Wurzel kann aufgrund der etablierten Fremdschlüsselbeziehungen auch nicht gelöscht werden, sodass die Wurzeleigenschaften eines Hierarchie, eines Baumes ganz allgemein, gewährleistet sind.

Zyklen

Die bereits getroffene Maßnahme, Selbstbezüge zu verhindern, reicht nicht aus, einen Zyklus über mehrere Ebenen zu unterbinden. Ein Zyklus kann, dank der bereits implementierten Regeln, nicht beim Einfügen oder Löschen von Datensätzen etabliert werden. Lediglich UPDATE-Operationen können einen Zirkelschluss zwischen vorhandenen Datensätzen bewirken.

Wenn die folgenden Daten in die Hierarchie eingefügt seien, dann bewirkt ein UPDATE-Statement, wie das in Zeile 12, einen Zirkelschluss. Damit einhergehend würde auch die Gesamtstruktur der Hierarchie zerstört, ohne die bisher implementierten Regeln zu verletzen.

Zur Vermeidung von Zirkelschlüssen brauchen wir einen weiteren UPDATE-Trigger. Die Idee, die diesem Trigger zugrundeliegt, ist im Prinzip einfach. Wir analysieren den Teilbaum, zu welchem der zu ändernde Datensatz die Wurzel darstellt. Kommt die neu zu setzende Vorgesetztennummer im Teilbaum als Mitarbeiternummer vor, würde eine solche Datensatzaktualsierung einen Zirkelschluss bewirken.

Ein UPDATE-Statement, welches einen Zirkelschluss erzeugen würde, ist nunmehr nicht ausführbar. Die Hierarchie ist damit fertig implementiert. Sie ist normalisiert und Regeln für die referentielle und strukturelle Integrität wurden etabliert.

Zu guter letzt spendieren wir uns aber doch noch einen Index, um Vorgesetzte schneller finden zu können. Bei der Anzahl der Beispielhaften ein absolutes MUST HAVE (* GRINS *)

Navigation und Operationen im hierarchischen Adjazenzlistenmodell

Die wohl einfachste Aufgabe besteht darin, die Wurzel einer Hierarchie zu finden. Die hier implementierte Hierarchie zeichnet sich dadurch aus, dass die Wurzel keinen Vorfahren hat, das entsprechende Attribut daher mit NULL besetzt ist. Es gibt abweichende Modellierungen, was dann natürlich auch Auswirkungen auf die zu implementierenden Regeln und Abfragen hat. Wir nutzen das hier implementierte Datenmodell.

Finden der Wurzel

Wollen wir auch den Namen des Chefs in Erfahrung bringen, bilden wir einen Join mit der Personaltabelle.

Finden von Blättern

Blätter in einem Baum sind diejenigen Knoten, die keinen Nachfolger haben. Das bedeutet, diejenigen Knoten finden zu müssen, die nicht in der Menge der Mitarbeiter sind, deren Mitarbeiternummer als Vorgesetztennummer auftritt. Diese Aufgabe können wir mit einer korrelierten Unterabfrage lösen.

Ebenen ausgeben

Die klassische Abfragemethode, nutzt Self-Joins, um eine Hierarchie ebenenweise auszugeben.

Eine weitere Ebene in die Ausgabe aufzunehmen, bedeutet einen weiteren Self-Join. Dieses Prinzip ist für jede weitere Ebene, die ausgegeben werden soll, zu wiederholen. Weil die Anzahl der Ebenen nicht unbedingt im Vornherein bekannt ist, stellt sich die Frage, wieviele Joins erforderlich sind, die gesamte Hierarchie auszugeben.

Moderne Datenbankmanagementsysteme unterstützen rekursive CTE (Common Table Expression). Damit lassen sich rekursive Datenstrukturen, wie die von Bäumen, einfach traversieren.

Die erste Abfrage eines rekursiven CTE ist die so genannte Ankerabfrage. Die mit UNION oder UNION ALL verknüpfte Abfrage ist die Rekursionsabfrage, die den CTE selbst referenziert.

Der Left-Join ist erforderlich, weil der Vorgesetztenverweis der Wurzel (Wolfang) NULL ist.

Teilbaum ausgeben

Gelegentlich sind wir nur an einem Teilbaum der Hierarchie interessiert.

Wollen wir lediglich wissen, welche Mitarbeiter einem bestimmten Mitarbeiter unterstellt sind, so erreichen wir das durch eine kleine Änderung in der Ankerabfrage. Wir haben dies bereits im UPDATE-Trigger, Listing 9, genutzt.

Knoten und/oder Teilbaum löschen

Das Löschen eines Blattes ist trivial. Wir brauchen das nicht zu besprechen. Hängen von einem Knoten jedoch weitere Knoten ab, dann kommt es darauf an, ob die Tabelle eine DELETE-Kaskade definiert hat oder nicht. Ist eine solche definiert, wird mit Löschen eines Knotens der gesamte Teilbaum, der von ihm abhängig ist, gelöscht. Mit dem Löschen des Wurzelknotens wird dann der ganze Baum gelöscht. Im vorliegenden Beispiel haben wir daher auf eine DELETE-Kaskade verzichtet.

Soll ein Teilbaum gelöscht werden, so haben wir dessen Knoten zu ermitteln. Das Resultat verwenden wir im DELETE-Statement, um die zu löschenden Knoten zu benennen.

Das Statement lässt sich deshalb so einfach formulieren, ohne die Reihenfolge der zu löschenden Knoten zu beachten, weil gemäß SQL-Standard die Integritätsbedingungen aufgeschoben geprüft werden. Datenbankmanagementsysteme, die sich nicht an den Standard halten, und eine Prüfung nach jedem einzelnen Datensatz, der gelöscht wird, vornehmen, müssen dagegen die Reihenfolge beachten.

Als Beispiel sei hier gezeigt, wie das in MySQL zu bewirken ist, wenn, so wie hier, keine ON DELETE CASCADE-Klausel definiert wurde. Die würde mit dem Löschen eines Datensatzes gleich alle abhängigen Datensätze mit löschen. Das Problem wäre dann auf einfachste Art und Weise gelöst. Allerdings nimmt man dann in Kauf, auch versehentlich ganz schnell einen Teilbaum zu löschen. Will man das verhindern, so verzichtet man auf das ON DELETE CASCADE. Dann wird das Löschen eines Teilbaums allerdings etwas trickreich, weil wir die zu löschenden Mitarbeiternummern, von den Blättern des Baums aufsteigend, benötigen, um sie löschen zu können.

Der hier angewendete Trick besteht darin, die Mitarbeiternummern in umgekehrter Ebenenreihenfolge mithilfe von group_concat() zu sammeln und in einer Liste, einer Sessionvariablen, zu speichern. Damit die folgende DELETE-Operation nun auch diese Reihenfolge befolgt, müssen wir dem DELETE noch mitteilen, sie in der Reihenfolge ihres Auftretens in der Liste zu löschen.

Weil die Statements zum Löschen eines Teilbaumes grundsätzlich etwas länglich sind, bietet es sich in der Praxis an, sie in eine Stored Procedure zu hüllen.

Teilbaum verschieben

Das Verschieben eines Knotens (inklusive seines eventuell vorhandenen Unterbaums) ist trivial. Man ändert einfach desssen Vorgesetztenverweis. Komplexer wird es jedoch, wenn zwei Knoten vertauscht werden sollen.

Weil das Prozedere mehrere Schritte erfordert, die zudem in der korrekten Reihenfolge auszuführen sind, bietet es sich an, dafür eine Prozedur zu schreiben. Wir brauchen außerdem einen temporären Dummy als Hilfsvariable, weil sich zwei Werte nicht direkt tauschen lassen. Nach Gebrauch, kann der Dummy wieder gelöscht werden.

Einfügen von Knoten

INSERT- und UPDATE-Operationen sind trivial. Diese Operationen bedürfen im Adjazenzlistenmodell keiner weiteren Behandlung.

Die notwendigen Regeln, die zur Einhaltung der referentiellen und strukturellen Integrität einzuhalten sind, haben wir implementiert.

Pfade und Pfadlängen

Ein Weg zwischen zwei Knoten wird als Pfad bezeichnet, wenn der Weg keine doppelten Knoten aufweist. Üblicherweise interessieren wir uns in einer Hierarchie für die Pfade und deren Längen, die Anzahl der Kanten, zwischen zwei Knoten, die in einem Über-/Unterordnungsverhältnis zueinander stehen.

Der Pfad von Lisa zu Rainer hat beispielsweise die Länge 2 und geht über Silke.

Das Statement untersucht die Hierarchie von oben nach unten. Das ist insoweit ineffzient, als alle Pfade beschritten werden, die vom Mitarbeiter der Ankerabfrage ausgehen. Bottom-Up wird dies vermieden.

Zwischenfazit

Bevor wir uns in den kommenden zwei Beiträgen mit alternativen Implementierungen hierarchischer Strukturen befassen, wollen wir ein kurzes Zwischenfazit ziehen.

Je nach Datenbankmanagementsystem, ist es mehr oder minder unkritisch, die notwendigen Regeln zu implementieren, welche uns die referentielle und strukturelle Integrität unserer Daten gewährleisten. Hierarchische Strukturen mit Adjazenzlisten abzubilden ist ein relativ einfach zu beschreitender Weg. Wir sollten jedoch nicht vergessen, das Rekursionsabfragen immer auch viel Rechenleistung benötigen. Bäume werden oftmals, so auch in diesem Beitrag, rekursiv definiert. Rekursive Strukturen und Algorithmen zu verwenden, um sie zu handhaben, ist daher naheliegend. Wer viele Einfüge-, Lösch- und Änderungsoperationen an einer hierarchischen Datenstruktur vorzunehmen hat, der ist mit einer Adjazenzlistenimplementierung sicher gut beraten. Geht es jedoch vorwiegend um Abfragegeschwindigkeit, so werden wir an dieser Stelle noch alternative Implementierungen vorstellen und diskutieren.

Teil 2: Hierarchische Strukturen mit Nested Sets

Karsten Brodmann

Karsten Brodmann hat an der Universität Osnabrück BWL/Wirtschaftsinformatik studiert. Er hat viele Jahre in der IT gearbeitet und dort Web- und Datenbankanwendungen entwickelt. Seit Gründung der Punkt-Akademie veröffentlicht Karsten Brodmann auch Schulungsvideos zur Datenbankentwicklung, Unix und Programmierung bei Udemy. In seiner Freizeit fotografiert Karsten Brodmann gerne. Seit vielen Jahren fotografiert er analog und digital. Dabei behält er jeweils den gesamten Workflow in der eigenen Hand, von der Aufnahme über die Dunkelkammer oder auch den Scanner sowie die Bildbearbeitung und den Ausdruck am PC.

Weitere Beiträge

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.