Im heutigen Artikel möchten wir die Haskell-Bibliothek
large-hashable vorstellen, die es
ermöglicht, von beliebigen Haskell-Datentypen große Hashes z. B. mittels
MD5 oder SHA512 zu berechnen. Wir haben diese Bibliothek im Rahmen
unseres Produkts Checkpad MED entwickelt, um effizient feststellen zu können,
ob sich das Ergebnis einer Berechnung möglicherweise geändert hat. In diesem Artikel
zeigen wir zum einen wie man die Bibliothek benutzt, zum anderen
beleuchten wir exemplarisch einen wichtigen Implementierungsaspekt, nämlich die
Integration von C-Code in Haskell.
Motivation
Bevor wir uns die large-hashable Bibliothek genauer anschauen, möchten
wir zuerst noch kurz auf die Motiviation eingehen, die zur Entwicklung
der Bibliothek geführt hat. Eine wichtige Funktionalität unseres Produkts
Checkpad MED ist die Aufbereitung von Krankenhausdaten für Smartphones
und Tablets. Daten im Krankenhaus (wie z. B. Laborbefunde) ändern sich aber
häufig. Daher benutzen wir in Checkpad ein selbstentwickeltes Framework,
welches das Aufbereiten der Daten von der Reaktion auf Änderungen trennt.
Mit dem Framework schreibt man dann den Code zur Aufbereitung der Daten
so, als ob die Daten statisch wären und das Framework kümmert sich
darum, dass die Aufbereitung erneut getriggert wird, sobald sich relevante
Daten geändert haben. Wer bei dieser Beschreibung an ein kontinuierlich
laufendes Buildsystem denkt, liegt genau richtig: Auch bei Buildsystem
sind typischerweise die Regeln zur Erstellung von Buildartefakten getrennt
von der Logik zur Neuausführung der Regeln bei Änderungen an den
Quelldateien.
Bei der Entwicklung des Frameworks liegt eine wichtige Fragestellung im
effizienten Erkennen von Änderungen an den Eingabedaten. Buildsysteme
benutzen hierfür neben dem Änderungszeitpunkt der Quelldateien auch häufig
Hashes, die z. B. mittels des MD5-Algorithmus aus den Quelldateien
berechnet werden. Da unser Framework nicht nur Dateien sondern beliebige
Haskell-Werte als Eingaben und Zwischenergebnisse
unterstützt, können wir nicht immer auf den Zeitpunkt
der letzten Änderung zurückgreifen. Daher benutzt das Framework
große Hashwerte, um zu prüfen, ob sich Eingabedaten oder
Zwischenergebnisse geändert haben. Dieses Vorgehen ist in Ordnung, so lange
die Wahrscheinlichkeit von Kollisionen beim Hashing sehr sehr klein ist.
Am Anfang haben wir solche Hashwerte naiv berechnet; d. h. um die
MD5-Prüfsumme von einem Haskell-Wert zu berechnen haben wir
zuerst den Wert serialisiert, um danach aus dem resultierende Byte-Array
die MD5-Prüfsumme zu berechnen. Dieses Vorgehen ist natürlich alles andere
als effizient. Wie kann man dieses Problem also effizienter lösen?
Für Haskell gibt es bereits die Bibliothek hashable, die aus einem
Haskell-Wert einen Hash berechnet. Der Hash ist dabei einfach ein Integer. Für
unser Problem ist die hashable-Bibliothek nicht geeignet, da die
Wahrscheinlichkeit von Kollisionen zu groß ist: Zum einen liegt das an der
Größe eines Integers (typischerweise 64 Bit), zum anderen aber auch daran,
dass die hashable-Bibliothek im Kontext von Datenstrukturen wie
Hash-Tabellen verwendet werden und für solche Datenstrukturen ist es
absolut normal, dass ab und an Hash-Kollisionen auftreten.
Wir mussten also eine eigene Bibliothek zum Berechnen von großen Hashes
entwicklen. Die Bibliothek heißt large-hashable und ist unter der BSD3 Lizenz auf
hackage verfügbar. Der Quellcode
wird auf github verwaltet. Durch die Verwendung von large-hashable konnte
wir die Performanz von einer unserer Komponenten um bis zu 50% steigern!
Benutzung der Bibliothek
Herzstück der large-hashable Bibliothek ist die Typklasse
LargeHashable.
Jeder Typ, von dem wir einen großen
Hash berechnen möchten, muss eine Instanz dieser Typklasse sein. Man kann
solche Instanzen von Hand schreiben oder auch generieren lassen. Schreiben
wir zunächst für einen beispielhaften Typ Name die Instanz erstmal von Hand:
import Data.LargeHashable
data Name = Name { firstName :: String, lastName :: String }
instance LargeHashable Name where
updateHash name =
do updateHash (firstName name)
updateHash (lastName name)
Der Typ der updateHash Funktion ist in diesem Fall
Name -> LH (). Dabei ist LH
eine Monade, die sich um das Verwalten
des bisher akkumulierten Hash-Werts kümmert. Mittels der do-Notation
hashen wir also den Vor- und den Nachnamen.
Wenn wir diese Definition nun in den GHCi
laden, können wir interaktiv die MD5-Prüfsumme eines Namens berechnen. Der Beispielcode ist auch
hier verfügbar.
$ ghci Example.hs
*Main> runMD5 (updateHash (Name "Stefan" "Wehr"))
4d2f940ee9d0d540ff847cd3c74b36ce
Die zweite Möglichkeit, an eine Instanz von LargeHashable zu kommen, ist
durch generisches Programmieren.
Hierbei wird über die Struktur der
Datenkonstruktoren der Hashwert zur Laufzeit berechnet. Um diese Funktionalität zu nutzen
muss man lediglich die Sprachoption DeriveGeneric einschalten
(z. B. durch das Pragma {-# LANGUAGE DeriveGeneric #-} ganz am Anfang der
Datei) und das Modul GHC.Generics importieren. Dann können wir uns
für jede Instanz der Typklasse Generic automatisch eine Instanz von
LargeHashable erzeugen lassen. Zum Beispiel so:
{-# LANGUAGE DeriveGeneric #-}
import Data.LargeHashable
import GHC.Generics
data Person = Person { name :: Name, age :: Int }
deriving (Generic)
instance LargeHashable Person
Die dritte Möglichkeit ist Metaprogrammierung mit Hilfe von
TemplateHaskell.
Dabei wird beim Kompilieren Code ausgeführt, der für
einen gegebenen Typen eine Instanz von LargeHashable erzeugt. Dazu muss
man die Sprachoption TemplateHaskell aktivieren (am besten wieder durch
ein Pragma zu Beginn der Datei: {-# LANGUAGE TemplateHaskell #-}). Dann
kann man Folgendes schreiben:
{-# LANGUAGE TemplateHaskell #-}
import Data.LargeHashable
data Car = Car { company :: String, model :: String, year :: Int }
$(deriveLargeHashable ''Car)
Der Code innerhalb von $(...) wird während des Kompilierens
ausgeführt. An dieser Stelle wird dann die LargeHashable-Instanz von
Car erzeugt.
Hier nochmal eine GHCi-Sitzung:
$ ghci Example.hs
*Main> let stefan = Name "Stefan" "Wehr"
*Main> runMD5 (updateHash (Person stefan 37))
c03533a58e15759f8f3aea6ccaec09d7
*Main> runMD5 (updateHash (Car "Ford" "Fiesta" 2009))
432c598b0b6810b4191cda88ed38348c
Welche dieser drei Arten soll ein Programmierer nun wählen, um eine
Instanz von LargeHashable zu schreiben? Die manuelle Art gibt dem
Programmierer volle Kontrolle über die Instanz, allerdings muss er oder
sie dabei viel Boilerplate-Code schreiben. Die andere beiden Arten
unterscheiden sich unter anderem durch Performanz-Eigenschaften. Die Variante mit
TemplateHaskell führt zu einer deutlichen längeren Kompilierzeit des
Programms, allerdings ist die Performanz zur Laufzeit identisch zu einer
vergleichbaren, von Hand geschriebenen Instanz. Die Variante über das
generische Programmieren bringt wenig Einbußen bei der Kompilierzeit,
allerdings ist die Performanz zur Laufzeit schlechter. Der Hash wird nämlich
nicht direkt aus dem Datentyp sondern aus dessen generischer
Repräsentation berechnet.
Implementierung
Wir möchten nun noch auf die Implementierung
eingehen. Dabei ist der komplette Sourcecode der large-hashable Bilbiothek
auf github
verfügbar.
Um den Rahmen des Artikels bezüglicher der detaillierten Implementierung
nicht zu sprengen, greifen wir uns einen interessanten Aspekt heraus:
die Integration mit C-Code. Aus
Performanzgründen benutzt large-hashable in C geschriebene
Routinen, um den Hashwert zu berechnen.
Dabei wird am Anfang ein Kontext für die Hash-Berechnung
initialisiert. Danach wird der Kontext fortlaufend
aktualisiert. Schlussendlich wird aus dem Kontext dann der eigentliche
Hash extrahiert.
Für MD5 benutzt
large-hashable dabei C Funktionen wie z. B.
void md5_init(struct md5_ctx *ctx);
void md5_update(struct md5_ctx *ctx, uint8_t *data, uint32_t len);
// Komplettes Headerfile unter: https://github.com/factisresearch/large-hashable/blob/master/cbits/md5.h
void md5_finalize(struct md5_ctx *ctx, uint8_t *out);
Um nun diese Funktionen in Haskell aufrufen zu können,
bedarf es folgender Importe und Deklaration:
import Foreign.Ptr
import Foreign.Storable
import Foreign.Marshal.Alloc
import Data.Word
data RawCtx
foreign import ccall unsafe "md5.h md5_init"
c_md5_init :: Ptr RawCtx -> IO ()
foreign import ccall unsafe "md5.h md5_update"
c_md5_update :: Ptr RawCtx -> Ptr Word8 -> Int -> IO ()
foreign import ccall unsafe "md5.h md5_finalize"
c_md5_finalize :: Ptr RawCtx -> Ptr Word8 -> IO ()
Damit machen wir die im Header md5.h deklarierten C-Funktion
md5_init, md5_update und md5_finalize unter den Namen
c_md5_init, c_md5_update bzw/ c_md5_finalize in Haskell bekannt.
Der Typ RawCtx ist ein sogannter „Phantomtyp“: Es gibt keine Werte des
Typs, er dient lediglich dazu den Pointer auf den Kontext der MD5-Berechnung als
einen Typ zu repräsentieren, nämlich als Ptr RawCtx. Der Typ Ptr Word8
ist analog die Haskell-Repräsentation eines Pointers auf ein Word8,
entspricht also dem Typen uint8_t * in C. Wichtig hierbei ist allerdings, dass diese
Typen reine Konvention sind, d. h. der Compiler kann uns nicht daran hindern einen
Pointer mit dem falschen Haskell-Typ zu verwenden.
Z. B. könnten wir fälschlicherweise auch den C-Typ uint8_t * als
Ptr RawCtx in Haskell repräsentieren.
Um zu demonstrieren wie man mit solchen C-Funktionen arbeitet, zeigen
wir zum Abschluss die Funktion aus large-hashable, die einen Kontext zur MD5-Berechnung
alloziert, damit eine Berechnung durchführt und am Schluss den Hash
extrahiert.
withCtx :: (RawCtx -> IO ()) -> IO (Word64, Word64)
withCtx action =
allocaBytes 96 $ \(ptr :: Ptr RawCtx) ->
do c_md5_init ptr
action ptr
allocaBytes 16 $ \(resPtr :: Ptr Word8) ->
do c_md5_finalize ptr resPtr
let first = castPtr resPtr :: Ptr Word64
w1 <- peek first
let second = castPtr (plusPtr resPtr (sizeOf w1)) :: Ptr Word64
w2 <- peek second
return (w1, w2)
Die Funktion allocaBytes
alloziert einen Speicher der angegebenen Größe (hier: 96 Bytes, was der Größe eines Kontexts zur MD5-Berechnung entspricht),
führt damit eine Aktion aus und gibt den Speicher dann wieder frei. In unserem Fall passiert in der Aktion Folgendes:
wir initialisieren zunächst mittels c_md5_init den Speicher als MD5-Kontext. Dann lassen wir die IO-Aktion action laufen,
was typischerweise den Kontext zur Hash-Berechnung aktualisiert.
Nach Abschluss von action benutzen wir nochmals allocaBytes, um 16 Bytes für den
finalen Hashwert zu allozieren. Mittels c_md5_finalize schreiben wir den Hashwert in den Pointer resPtr. Dann extrahieren
wir mittels Pointerarithmetik und der Funktion peek
das erste und das zweite 64-Bit Wort aus dem resPtr und geben dieses Paar zurück.
Sie sehen also: C-Funktion in Haskell einzubinden ist gar nicht so
schwer. Natürlich kann es aber in einer Funktion wie withCtx
zu Segmentation Faults und ähnlichen schlimmen Fehlern kommen; Dinge die in einer Sprache wie Haskell normalerweise nicht vorkommen.
Daher sollte man auch immer zweimal darüber Nachdenken und gute Gründe haben, wenn man C-Funktionen in Haskell benutzen will.
Das soll es für heute gewesen sein. Ich hoffe, es war etwas Interessantes dabei! Ich freue mich auf jeden Fall über
Ihre Fragen und Anregungen.