Wozu mehrere Datenbanksysteme?
Dass ich einen Großteil meines täglichen Werks in einer Python-Umgebung vollbringe, ist kein Geheimnis. In meinen vorigen Blog-Einträgen (Links finden sich am Ende des Artikels) habe ich bereits kleine Einblicke in die Bandbreite der Werkzeuge zur Datenverarbeitung gegeben, die sie bereitstellt. Neben der Verarbeitung ist aber auch die Speicherung von Daten ein Spielfeld mit sehr viel Optimierungspotential.
Normalerweise kommen bei unserer Datenanalyse sogenannte relationale Datenbanken (bekanntester Vertreter: MySQL) zum Einsatz, die Daten in Tabellen mit festgelegten Spalten und Beziehungen untereinander halten.
MongoDB hingegen ist eine dokumentenorientierte Datenbank: Es gibt keine Tabellen, sondern lediglich Sammlungen (Collections) von Dokumenten (Documents). Erstere dienen hauptsächlich der Gruppierung und der Indizierung, während letztere die einzelnen Datensätze darstellen und je ein beliebig verschachteltes Datenobjekt repräsentieren.
Welches Datenbankkonzept nun besser geeignet ist, hängt vom Einsatzzweck ab: In der relationalen Datenbank weiß man beispielsweise immer, welche Werte in einer Spalte stehen können, oder dass sie überhaupt existiert, was enorme Vorteile für die effiziente Analyse großer Datenmengen hat. Die dokumentenbasierte hingegen bietet unter anderem:
- Jedes Objekt lässt sich um beliebige Attribute erweitern.
- Hierarchische Strukturen lassen sich direkt abbilden.
- Es muss keine Struktur vorgegeben werden.
Daraus ergibt sich, dass man sie sehr leicht ad hoc benutzen kann. Man muss beispielsweise keine Struktur in der Datenbank konfigurieren, da sie Teil der gespeicherten Objekte ist. Zudem sind viele Objekte in Python bereits hierarchisch strukturiert, womit die Datenkonvertierung in beide Richtungen meist reibungslos verläuft.
Sie hat somit viele Einsatzgebiete, bei denen relationale Datenbanken enormen Overhead erzeugen würden. Für einige wird dieser Artikel Python-Rezepte liefern, nachdem die Grundzüge der MongoDB-Schnittstelle für Python behandelt werden:
- Caching: Besonders für die on-demand-Reports unseres Webservers lohnt sich intelligentes Caching, das benötigte Objekte mit Ablaufdatum in die Datenbank speichert.
- Logging: Wir verbinden das logging-Modul von Python mit der MongoDB und können beliebige Informationen speichern, durchsuchen und abrufen.
Die Datenbank hat eine ganze Reihe weiterer Fähigkeiten, die aber in diesem Artikel unberührt bleiben. Sein Hauptzweck ist also, dem Python-Affinen die ersten Schritte mit MongoDB zu erleichtern.
Grundlagen
MongoDB
Es gibt eine Reihe von Möglichkeiten, MongoDB in der Cloud zu betreiben, aber für erste Schritte und auch für viele kleine Anwendungen im Intranet reicht eine lokale Installation, die auf allen gängigen Betriebssystemen möglich und i.d.R. eine Sache weniger Minuten ist.
In der aktuellen Standardkonfiguration erfolgt keine Authentifizierung und es ist nur lokaler Zugriff erlaubt – man kann also direkt loslegen. Jedoch weise ich ausdrücklich darauf hin, dass der Leser die Verantwortung hat, dies zu prüfen, bevor er sensible Daten ablegt oder dauerhaft eine Schnittstelle im Netzwerk exponiert!
Für die Schritte zur Forcierung der Authentifizierung verweise ich auf die offizielle Dokumentation. Als Hilfe habe ich aber anzumerken, dass Nutzer nur an eine Datenbank (database) gebunden existieren, d.h. ein Nutzer auf einer Database “test” existiert nur dort und kann sich auch nur dort authentifizieren – ausgenommen sind Nutzer der Datenbank “admin”, die globale Rechte haben. Hierbei kann auch verwirrend sein, dass MongoDB Databases auf Anfrage automatisch erzeugt, sie aber nicht speichert, wenn sie leer bleiben.
PyMongo
Die Schnittstelle zu Python heißt PyMongo und lässt sich auf Systemen mit sauber konfigurierter Python-Umgebung einfach über pip installieren:
$ pip install pymongo
Verbindungsaufbau
Die Basisklasse, über die alle Operationen auf dem Datenbanksystem laufen, ist MongoClient. Zur Verbindung benötigt man lediglich den Hostnamen (default: „localhost“) und den Port (default: 27017):
from pymongo import MongoClient client = MongoClient(host='localhost', port=27017)
Hauptkomponenten
Die wichtigste Methode des client-Objektes ist get_database. Zur Verwaltung kann man auch beispielsweise die Namen der Datenbanken mit database_names erhalten, dazu sind allerdings admin-Rechte erforderlich, und typischerweise arbeitet man in einer Session nur auf einer Datenbank. MongoClient ist auch als Kontextmanager (= with statement in Python) verwendbar, der die Verbindung selbständig mit der close-Methode schließt:
>>> with MongoClient() as client: >>> database = client.get_database('mitarbeiter') >>> print(type(database)) <class 'pymongo.database.Database'>
Die Database-Klasse repräsentiert also die einzelne Datenbank. Falls sie noch nicht existiert, wird sie bei Bedarf einfach erzeugt. Sie ist die Instanz, auf der man sich authorisieren muss – sofern der Server dies erfordert:
>>> database.authenticate('horst', 'katze123')
Dies ist das Objekt, mit dem man hauptsächlich arbeitet. Es lohnt sich daher, die Prozedur in einen Kontextmanager einzubauen:
from contextlib import contextmanager from functools import partial @contextmanager def get_mongo_database(host='localhost', port=27017, database='admin', user=None, password=None): with MongoClient(host, port) as client: db = client.get_database(database) if user and password: db.authenticate(user, password) yield db get_default_mongo_database = partial( get_mongo_database, database='mitarbeiter', user='horst', password='katze123')
Die Funktion get_default_mongo_database nutzt hierbeit Authentifizierungsdaten, die man natürlich vorher für die entsprechende Datenbank festlegen muss, und dient der Bequemlichkeit im weiteren Verlauf. Sie lässt sich natürlich in vielerlei Weise implementieren.
Die Elemente der Database sind die Collections, die Datensätze thematisch zusammenfassen und auf denen beispielsweise auch Indices erstellt werden. Man erhält eine Liste der existierenden Collections mit der collection_names-Methode der Database. Wie Databases auch, werden sie bei Bedarf direkt erzeugt und nur gespeichert, wenn sie nicht leer bleiben. Man greift über ihren Namen auf sie zu, was auf mehrere Arten möglich ist:
>>> with get_default_mongo_database() as db: >>> collection = db.getraenke >>> print(collection) >>> print(db['getraenke'] == collection) Collection(Database(MongoClient(host=['localhost:27017'], document_class=dict, tz_aware=False, connect=True), 'mitarbeiter'), 'getraenke') True
Da die Dokumente (Documents) in den Collections bei Queries direkt zu Python-Dictionaries konvertiert werden, ist die Collection bereits das unterste für uns relevante Objekt des pymongo-Moduls. Auf ihr finden alle Such-, Lösch- und Ersetzungsoperationen von Datensätzen statt.
Storage
Die Datensätze in MongoDB basieren auf BSON, was bis auf einige Details (Datumsfelder bspw.) einem binären Format von JSON entspricht. Jeder Datensatz lässt sich daher in ein Python-Dictionary mit beliebiger Verschachtelung und eindeutigen Datentypen übersetzen. Einige Besonderheiten sind dabei zu beachten:
- Ein Key/Value-Paar in einem Dictionary wird als Feld (Field) bezeichnet.
- Wie bei JSON auch, müssen Feldnamen immer Strings sein.
- BSON kennt auch Datumsfelder und setzt sie in Python in datetime.datetime Werte um – und umgekehrt.
- In Feldnamen sind weder Leerzeichen noch Punkte noch einleitende „$“-Zeichen erlaubt.
- Jeder Eintrag hat ein eindeutiges _id-Feld, das automatisch erzeugt wird.
Schreiben
Zum Einfügen von Documents hat man die Wahl zwischen einzelner Ausführung mit insert_one und Stapelausführung mit insert_many:
>>> result_one = collection.insert_one({ >>> 'name': 'bier', >>> 'eigenschaften': {'temperatur': 'kalt'}}) >>> result_one.acknowledged True >>> result_one.inserted_id ObjectId('5995578535edfc76e2a60499') >>> result_many = collection.insert_many([ >>> {'name': 'kaffee', >>> 'eigenschaften': { >>> 'temperatur': 'heiss' >>> }}, >>> {'name': 'korn', >>> 'eigenschaften': { >>> 'temperatur': 'egal', >>> 'vor_18_uhr': False >>> }} >>> ]) >>> result_many.acknowledged True >>> result_many.inserted_ids [ObjectId('5995578535edfc76e2a6049a'), ObjectId('5995578535edfc76e2a6049b')]
Das collection-Objekt ist hierbei natürlich eine zuvor erzeugte Instanz einer Collection in pymongo. Die insert_x-Methoden geben (wie später alle Lösch- und Ersetzungsmethoden auch) Objekte mit dem Ergebnis der Operation zurück. Ob sie erfolgreich war, sieht man am Wert von acknowledged, und inserted_id(s) liefert die _ids der betreffenden Dokumente in der Datenbank, über die sie eindeutig zu identifizieren sind.
Lesen
Das Finden und Lesen von Documents funktioniert über die find_one und find (nicht find_many!) Methoden. Erstere gibt genau ein Document als Dictionary zurück (falls findbar), letztere hingegen ein Cursor-Objekt, mit dem über die gefundenen Documents iteriert werden kann, oder weitere Operationen angewendet, bspw. die gefundenen Documents zu zählen mit count(), oder sie zu sortieren mit sort(). In der Handhabung sind die beiden Methoden sonst identisch.
>>> collection.find_one() {'_id': ObjectId('5995578535edfc76e2a60499'), 'eigenschaften': {'temperatur': 'kalt'}, 'name': 'bier'} >>> collection.find().count() 3 >>> for doc in collection.find(): >>> print(doc['_id']) 5995578535edfc76e2a60499 5995578535edfc76e2a6049a 5995578535edfc76e2a6049b
find_one gibt also immer das erste gefundene Document zurück. Ohne Argumente aufgerufen, finden die Methoden alle existierenden Documents. Ihr erstes und wichtigstes Argument ist der Suchfilter filter, der selbst ein JSON-kompatibles Dictionary ist, ebenso wie das zweite, die Projektion projection, mit dem man die zurück gegebenen Documents auf bestimmte Felder beschneidet. Die Projektion ist immer ein Dictionary, dessen Keys Feldnamen sind, und die Werte True oder False, je nachdem ob sie im Resultat mitgeliefert werden sollen, oder nicht. Wird ein Feld darin True gesetzt, werden automatisch alle anderen Felder False – außer _id, das man dann explizit False setzen kann – also eine Whitelist statt Blacklist verwendet. Hiervon mache ich im Folgenden Gebrauch, da es die Resultate deutlich übersichtlicher macht:
>>> def find_filtered_names(filter={}): >>> return [x for x in collection.find(filter=filter, projection={'name': True, '_id': False})] >>> >>> find_filtered_names({'name':'fanta'}) [] >>> find_filtered_names({'name':'bier'}) [{'name': 'bier'}] >>> find_filtered_names({'eigenschaften.temperatur':'egal'}) [{'name': 'korn'}] >>> find_filtered_names({'eigenschaften.temperatur': {'$regex': 'al'}}) [{'name': 'bier'}, {'name': 'korn'}] >>> find_filtered_names({'eigenschaften.vor_18_uhr': {'$exists': True}}) [{'name': 'korn'}] >>> find_filtered_names({'$or': [{'eigenschaften.vor_18_uhr': True}, {'eigenschaften.vor_18_uhr': {'$exists': False}}]}) [{'name': 'bier'}, {'name': 'kaffee'}]
Die ersten beiden Beispiele sind einfache Abfragen nach Werten. Was ab dem dritten Beispiel verwendet wird, ist die Schreibweise „FeldA.FeldB“: Sie dient dazu, sich an verschachtelten Documents entlang zu hangeln und somit nach Werten auf tieferer Ebene zu filtern. Ab dem vierten Beispiel kommen Operatoren hinzu, erkennbar am vorangestellten „$“-Zeichen. Erst wird mit „$regex“ nach einer Zeichenkette gesucht, dann wird mit „$exists“ die Existenz eines Feldes geprüft, und zuletzt über „$or“ eine logische Verknüpfung zwischen dem Wert eines Feldes und dessen Existenz erzeugt. Darüber hinaus gibt es viele weitere Operatoren.
Löschen
Zum Löschen gibt es analog zu den Inserts die delete_one und delete_many Methoden, die einen Filter als Argument nehmen, der genauso wie bei find funktioniert. Sie geben ein Objekt mit dem Resultat zurück, dessen deleted_count überprüft werden kann.
Ersetzen und Updaten
Zum Ersetzen gibt es nur die Methode replace_one, die als Argumente einen Filter und das Ersatzdokument nimmt, und den ersten Suchtreffer damit ersetzt. Interessant ist hierbei das optionale upsert-Argument, das das Ersatzdokument auch dann schreibt, wenn kein existierendes Dokument auf den Filter passt.
Bei Updates hat man mehr Möglichkeiten: Es gibt update_one und update_many, die einen Filter als Argument nehmen und den ersten bzw. alle Treffer modifizieren. Das zweite Argument ist die Update-Operation: Man kann Feldern bestimmte Werte geben, oder sie auch basierend auf dem aktuellen Wert modifizieren, beispielsweise inkrementieren, oder im Falle von Arrays Werte zufügen oder entfernen.
>>> collection.find_one({'name': 'bier'}, {'_id': False}) {'eigenschaften': {'temperatur': 'kalt'}, 'name': 'bier'} >>> collection.update_one({'name': 'bier'}, {'$set': {'eigenschaften.getrunken': 1}}) >>> collection.find_one({'name': 'bier'}, {'_id': False}) {'eigenschaften': {'getrunken': 1, 'temperatur': 'kalt'}, 'name': 'bier'} >>> collection.update_one({'name': 'bier'}, {'$inc': {'eigenschaften.getrunken': 4}}) >>> collection.find_one({'name': 'bier'}, {'_id': False}) {'eigenschaften': {'getrunken': 5, 'temperatur': 'kalt'}, 'name': 'bier'}
Auch hierbei gibt es ein upsert-Argument, um zu garantieren, dass nach der Operation mindestens ein passendes Document existiert.
>>> assert collection.find_one({'name': 'fanta'}) is None >>> collection.update_one({'name': 'fanta'}, {'$inc': {'eigenschaften.getrunken': 2}}, upsert=True) >>> collection.find_one({'name': 'fanta'}, {'_id': False}) {'eigenschaften': {'getrunken': 2}, 'name': 'fanta'}
Weitergehende Informationen zu Schreib- und Lesevorgängen findet man in der Dokumentation der Collections.
Einfache Methoden
Ein simples aber häufiges Szenario ist das Speichern und Laden eines Python-Objektes, wobei ein evtl. vorhandenes überschrieben werden soll, um beispielsweise den Zustand eines Prozesses zwischenzuspeichern. Dazu kann man das Objekt als Wert eines Documents in einer bestimmten Collection ablegen und mit einem Feld zur identifizierung versehen:
def to_mongodb(data, collection, key, pickling=False): """ write object to default MongoDB database. :param data: BSON compatible object :param str collection: collection name :param str key: key to identify :param bool pickling: store data pickled :return: None """ with get_default_mongo_database() as db: collection = db[collection] if pickling: data = pickle.dumps(data) collection.delete_many({'name': key}) collection.insert_one({'name': key, 'data': data}) def from_mongodb(collection, key, pickling=False): """ retrieve object from MongoDB. :param str collection: collection name :param key: key to identify :param bool pickling: stored data is pickled :return: data """ with get_default_mongo_database() as db: collection = db[collection] data = collection.find_one({'name': key})['data'] if pickling: data = pickle.loads(data) return data
Man muss also lediglich einen Namen einer Collection und einen für den Eintrag wählen, und das Objekt wird im Feld „data“ gespeichert bzw. daraus gelesen. Mit vielen nativen Python-Objekten funktioniert das direkt, bei komplexeren (bspw. pandas.DataFrame) kann man mit der pickling-Flag die Konvertierung in einen gepickleten String erwirken.
Caching
Dieses Prinzip lässt sich nun weiter spinnen: Viele meiner Reports werden on-demand auf einem Flask-Webserver generiert, wie in meinem letzten Blogeintrag gezeigt. Dahinter steckt mitunter erheblicher Rechenaufwand, und sie werden mehrmals am Tag von verschiedenen Kollegen aufgerufen, wobei es viele Überschneidungen gibt, beispielsweise von Parametern, aber auch von prozessierten Daten, die dem Report zugrunde liegen. Warum also nicht einfach Zwischenstände von Daten, oder ganze Websites mit einem Zeitstempel in der MongoDB ablegen, und bei deren Abfrage eine Schonfrist mitgeben? Das ganze sieht in etwa so aus:
import pickle import datetime def _mongodb_upsert_entry(name, attributes, data, collection): """ Update objects in the MongoDB, set a timestamp for caching purposes. :param str name: main identifier :param dict attributes: attributes for further identification :param object data: data to be stored :param PyCollection collection: pymongo collection object to operate on :return: None """ doc_filter = attributes.copy() doc_filter['name'] = name new_doc = doc_filter.copy() new_doc['timestamp'] = datetime.datetime.now() new_doc['data'] = data collection.replace_one(doc_filter, new_doc, upsert=True) def _mongodb_get_entry(name, attributes, collection, grace=None): """ Get an entry created by :fcn:`mongodb_upsert_entry` with an optional grace period to discard old entries. :param str name: main identifier :param dict attributes: attributes for further identification :param PyCollection collection: pymongo collection object to operate on :param datetime.timedelta grace: positive timedelta limiting the age of the existing entry :return: stored data :rtype: dict """ doc_filter = attributes.copy() doc_filter['name'] = name doc = collection.find_one(doc_filter, {'timestamp': True}) if doc is None: return None if grace is not None and datetime.datetime.now() - doc['timestamp'] > grace: return None return collection.find_one({'_id': doc['_id']})['data'] def get_cached_object(name, fcn, grace_hours=12, **attributes): """ helper function to retrieve or update cached objects. :param str name: key for storage in MongoDB :param callable fcn: function to retrieve final value :param int grace_hours: hours until cached content is invalidated :param int return_val_index: index of the desired obeject in the return tuple from fcn """ with get_default_mongo_database() as db: collection = db.website_cache obj = None data = None if grace_hours <= 0 else _mongodb_get_entry(name, attributes, collection, grace=datetime.timedelta(hours=grace_hours)) if data is not None: obj = pickle.loads(data) print('cache hit') else: obj = fcn(**attributes) data = pickle.dumps(obj) _mongodb_upsert_entry(name, attributes, data, collection) print('cache miss') return obj
Eintrittspunkt ist die get_cached_object-Methode. Sie wird dort eingesetzt, wo man eigentlich eine Funktion mit bestimmten Argumenten aufrufen würde, um ein gewünschtes Objekt (DataFrame, HTML-String etc.) zu erhalten. Die Funktion wird als fcn-Argument übergeben, deren Argumente als Keyword-Argumente. Dazu packt man einen Namen zur Identifikation und eine Schonfrist in Stunden. Die Collection website_cache ist hier hardgecodet. Falls diese Schonfrist null oder negativ ist, wird der Cache nicht bemüht. Andernfalls sucht _mongodb_get_entry nach einem Eintrag diesen Namens und mit den selben Argumenten, prüft, ob der Zeitstempel in die Schonfrist fällt und gibt den Datensatz ggf. zurück, wo er dann entpicklet wird. Falls kein Datensatz aus dem Cache geholt werden konnte, wird er aus der Funktion erzeugt und gepicklet mit allen notwendigen Informationen über _mongodb_upsert_entry in die Datenbank geschrieben. Der Einsatz wird im Folgenden demonstriert:
>>> def test_fcn(x, y): >>> return '%i * %i = %i' % (x, y, x * y) >>> get_cached_object('test_function', test_fcn, x=3, y=4) cache miss '3 * 4 = 12' >>> get_cached_object('test_function', test_fcn, x=3, y=4) cache hit '3 * 4 = 12' >>> get_cached_object('test_function', test_fcn, x=4, y=3) cache miss '4 * 3 = 12' >>> get_cached_object('test_function', test_fcn, x=2, y=5) cache miss '2 * 5 = 10' >>> get_cached_object('test_function', test_fcn, x=3, y=4, grace_hours=0) cache miss '3 * 4 = 12'
Hier sei noch erwähnt, dass es sich in schnell wachsenden Collections schnell lohnen kann, Indices zu erstellen mittels der create_index-Methode.
Logging
Python stellt über das logging-Modul eine sehr flexible Schnittstelle bereit, über die sich auch eine MongoDB nutzen lässt. Vorteile davon sind beispielsweise, dass der Zugriff darauf vom Dateisystem des loggenden Systems entkoppelt ist oder dass man die Logs nach einzelnen Feldern und ohne Kommandozeileninstrumentarium durchsuchen kann. Kernstück der Anbindung ist ein logging.Handler, der die Information strukturiert in die MongoDB schreibt:
import datetime import traceback import logging class ToMongoHandler(logging.Handler): """ A very basic logger that commits a LogRecord to the MongoDb """ def emit(self, record): trace = None exc = record.__dict__['exc_info'] if exc: trace = traceback.format_exception(*exc) log = {'logger': record.__dict__['name'], 'level': record.__dict__['levelname'], 'trace': trace, 'msg': record.__dict__['msg'], 'timestamp': datetime.datetime.today()} collection_name = 'log_' + log['logger'].replace('.', '__') with get_default_mongo_database() as db: db.get_collection(collection_name).insert_one(log)
Der Handler speichert im Falle einer Exception auch das verfügbare Stack-Traceback. Als Collection wählt er „log_“ plus den Namen des Loggers – was nach Python-Konvention dafür sorgt, dass jedes Modul eine eigene Collection erhält. Die Ersetzung von Punkten im Namen ist notwendig, da sie in Collection-Namen nicht erlaubt sind. Die Einbindung des Loggers in ein Python-Modul läuft beispielsweise über
if ToMongoHandler not in [type(h) for h in logging.getLogger(__name__).handlers]: logging.getLogger(__name__).addHandler(ToMongoHandler())
im Kopf des Moduls. Zu beachten ist, dass __name__ als Name des Loggers auch „__main__“ sein kann, wenn das modul selbst ausgeführt wird, was in der wahrscheinlich unerwünschten Collection „log___main__“ resultiert.
Nun benötigt man nur Zugriff auf die entsprechende Database in MongoDB, um die Logs gezielt zu durchsuchen:
>>> with get_default_mongo_database() as db: >>> for doc in db.log_my_module.find( >>> {'level': 'ERROR', >>> 'timestamp': {'$gt': datetime.datetime(2017, 4, 1)}}): >>> print(doc['timestamp']) 2017-04-04 07:34:49.623000 2017-04-04 07:34:49.706000 2017-04-05 07:33:11.187000 2017-04-05 07:33:11.271000
Ein einfaches Interface zum bequemen Durchsuchen lässt sich z. B. auch in Jupyter Notebooks mit ipywidgets realisieren.
Fazit
In großen Produktivsystemen gibt es meist klare Argumente für und wider einen bestimmten Datenbanktyp und der Wechsel zwischen den Typen wäre auch oft unrealistisch. Es zeigt sich aber, dass sich eine MongoDB mit sehr wenig Aufwand und ohne starke Infrastruktur für vielerlei kleine aber nützliche Aufgaben an eine Python-Umgebung anbinden lässt – weshalb sich auch in Umgebungen mit sonst konträren Anforderungen an die Datenhaltung ein Blick über den Tellerrand sehr lohnen kann. Ich freue mich auf Ihre Fragen, Erfahrungen und Ideen dazu!
Lust auf mehr? Hier finden Sie meine bisherigen Beiträge:
-
STOLPERSTEINE DES ALLTÄGLICHEN DATENAUSTAUSCHS – TEIL 1
-
STOLPERSTEINE DES ALLTÄGLICHEN DATENAUSTAUSCHS – TEIL 2
-
BUSINESS INTELLIGENCE ALS WEBSERVICE MIT FLASK