Der richtige Dispatcher
In diesem Blogpost möchte ich gerne meine Erfahrungen aus der Entwicklung eines Microservice basierten Kleinprojekts teilen. Als technische Basis der einzelnen Services kommt CherryPy zum Einsatz.
Während des Projekts bin ich auf mehrere Themen gestoßen, die ich gerne vorstellen möchte, und zu denen es jeweils einen eigenen Blogpost geben wird:
Meine Erfahrungen die ich mit CherryPy zum Thema URL-Routing gemacht habe, stelle ich in diesem Blogpost vor.
Für jemanden wie mich, der die letzten vier Jahre als Product Owner und weniger als Entwickler gearbeitet hat, sind bereits vermeintliche Basics zu Erfolgserlebnissen geworden. Dazu gehört zunächst das Thema URL-Routing.
Generell braucht es in jedem Service Verbindungen zwischen den API-Endpunkten und entsprechenden internen Funktionen, die die Anfragen an diese Endpunkte verarbeiten, wie z.B.:
API-Endpunkt | Interne Funktion |
---|---|
your.tld/members/{uuid} |
getMemberInformation(uuid) |
In CherryPy gibt es dafür diverse Dispatcher, die diese Verbindung auf verschiedene Arten herstellen:
DefaultDispatcher
RoutesDispatcher
MethodDispatcher
Wird bei der Konfiguration von CherryPy nicht explizit ein bestimmter Dispatcher gewählt, so kommt der DefaultDispatcher
zum Einsatz. Dieser Dispatcher interpretiert die URL als Baumstruktur. Meine Anwendung könnte also z.B. wie folgt aufgebaut sein:
Um das gezeigte Beispiel nun mit CherryPy umzusetzen, brauchen wir zwei Dinge:
/
, /admin
und /album
bereitstellenSchauen wir uns zunächst an, wie eine solche Klasse aussehen könnte:
class SimpleWebGallery(object):
@cherrypy.expose
def index(self):
return "Welcome to my index site!"
@cherrypy.expose
def admin(self):
return "Let's administrate things!"
@cherrypy.expose
def album(self):
return "My album!"
Die Klasse enthält also Methoden, die nach der URL-Struktur benannt sind, mit der Ausnahme von /
, dessen Methode schlicht index
heißt.
Hinweis
Um Zugriffe auf nicht definierte URLs abzufangen, kann die Methode default
genutzt werden, die so gesehen als Catch All fungiert.
Zum jetzigen Zeitpunkt weiß CherryPy noch nicht, auf welcher Ebene index
einzusortieren ist. Dies passiert nun beim Mounting unserer Klasse in CherryPy.
cherrypy.quickstart(SimpleWebGallery(), '/', conf)
Dieser Befehl sorgt dafür, dass unsere Webapp SimpleWebGallery
im Verzeichnisbaum auf den Root /
gemappt wird, und damit folgendes Mapping hergestellt wird:
API-Endpunkt | Interne Funktion |
---|---|
/ |
SimpleWebGallery().index() |
/admin |
SimpleWebGallery().admin() |
/album |
SimpleWebGallery().album() |
Als Beispiel zur Verdeutlichung
cherrypy.quickstart(SimpleWebGallery(), '/foo', conf)
würde folgendes Mapping bewirken:
API-Endpunkt | Interne Funktion |
---|---|
/foo |
SimpleWebGallery().index() |
/foo/admin |
SimpleWebGallery().admin() |
/foo/album |
SimpleWebGallery().album() |
Rufen wir jetzt also http://localhost:8080/
auf, so wird das Ergebnis der folgenden Methode ausgeliefert.
@cherrypy.expose
def index(self):
return "Welcome to my index site!"
Der nächste Schritt ist interessanter. Meist bestehen Bereiche aus mehreren Sektionen, die sich um verschiedene Dinge kümmern, sprich die Teilbäume sind in der Regel tiefer. In meinem Beispiel müssen Alben und die darin enthaltenen Bilder und Subscriptions administriert werden. Wir haben also folgenden Aufbau:
Wie wir jetzt z.B. an /admin/album/{uuid}
kommen, ist wesentlich spannender, denn hier bietet CherryPy uns zwei Möglichkeiten. Auf diese zwei Arten des Routings möchte ich nur kurz eingehen, da sie zwar ihre Daseinsberechtigung haben, aber aus meiner Sicht den Code nur unverständlicher und schwerer wartbar gemacht haben.
*args
und **kwargs
In Möglichkeit 1 werden alle weiteren URL-Bestandteile als Parameter der entsprechenden Funktion übergeben. Wir müssen also bei der Implementierung antizipieren, wie viele weitere Bestandteile wir erwarten.
Beispiel zu 1. für /admin/album/{uuid}
Die Methode admin
bekommt hier zwei Parameter übergeben, da hinter /admin
zwei weitere URL-Bestandteile folgen. Diese Parameter müssen nun in admin
validiert und ausgewertet werden:
@cherrypy.expose
def admin(self, section, uuid):
if section == "album" and isValidUUID(uuid):
return "You requested the admin page for album %s" % uuid
return "Unknown URL section or invalid UUID"
Die Implementierung von admin
kann also beliebig komplex werden, je tiefer sich meine Web-Anwendung verschachtelt.
In Möglichkeit 2 werden die weiteren URL-Bestandteile als Liste *args
an meine Methode admin
übergeben.
Beispiel zu 2. für /admin/album/{uuid}
@cherrypy.expose
def admin(self, *args, **kwargs):
if len(args) == 2 and args[0] == "album" and isValidUUID(args[1]):
return "You requested the admin page for album %s" % uuid
return "No handling for this amount of URL parameters or unknown URL section or invalid UUID"
Auch diese Methode endet ab einer gewissen Tiefe in extrem unübersichtlichem und nicht direkt einleuchtendem Code.
Allgemein finde ich den DefaultDispatcher
in einfachen Szenarien gut nutzbar, allerdings sind Behandlungen für tiefere Teile der URL unschön.
Zum Schluss ein Komplettbeispiel für den DefaultDispatcher:
import cherrypy
def init_service():
# Do init stuff
return
def cleanup():
# Do cleanup stuff
return
class SimpleWebGallery(object):
@cherrypy.expose
def index(self):
return "Welcome to my index site!"
@cherrypy.expose
def admin(self, section=None, uuid=None):
if section == None and uuid == None:
return "Let's administrate things!"
if section == "album" and isValidUUID(uuid):
return "You requested the admin page for album %s" % uuid
return "Unknown URL section or invalid UUID"
@cherrypy.expose
def album(self):
return "My album!"
if __name__ == '__main__':
conf = {
'/': {
'tools.sessions.on': False,
'tools.staticdir.root': os.path.abspath(os.getcwd())
},
'/static': {
'tools.staticdir.on': True,
'tools.staticdir.dir': './static'
}
}
cherrypy.server.socket_host = '0.0.0.0'
cherrypy.server.socket_port = 8080
cherrypy.engine.subscribe('start', init_service)
cherrypy.engine.subscribe('stop', cleanup)
webapp = SimpleWebGallery()
cherrypy.quickstart(webapp, '/', conf)
Einen ähnlichen Ansatz verfolgt der MethodDispatcher
(MD). Hier wird in CherryPy ein Mounting Tree aufgebaut, der - analog zum Vorgehen beim DefaultDispatcher
- bestimmt, welche Funktionen für welche API-Endpunkte verantwortlich sind.
Im Speziellen nutzt der MD eine explizitiere Unterscheidung zwischen den verschiedenen Requestmethoden GET
, POST
, PUT
, DELETE
etc.
Beispiel
conf = {
[...]
'/photos': {
'request.dispatch': cherrypy.dispatch.MethodDispatcher()
},
'/rawcontent': {
'request.dispatch': cherrypy.dispatch.MethodDispatcher()
}
}
webapp = PhotoServiceRoot() # "/" -> PhotoServiceRoot()
webapp.photos = PhotoServicePhotos() # "/photos" -> PhotoServicePhotos()
webapp.rawcontent = PhotoServiceRawContent() # "/rawcontent" -> PhotoServiceRawContent()
cherrypy.quickstart(webapp, '/photo-service', conf)
In der Variable webapp
wird bestimmt, welche Klassen für welche Teilbäume des URL-Pfades verantwortlich sind. Diese Klassen enthalten dann jeweils die folgenden Methoden:
def GET(self):
[...]
def POST(self):
[...]
def PUT(self):
[...]
[...]
Hinweis
Im Gegensatz zum DefaultDispatcher
stellen also nicht die Methoden in webapp
die Implementierung des Handling, sondern die Klassen, die den URL-Pfad benannten Attributen zugewiesen sind.
Im gezeigten Beispiel wird ein Mounting Tree
aufgebaut und in der letzten Zeile am Einstiegspunkt /photo-service
eingebunden. Der Service reagiert also entsprechend auf:
Die Besonderheit des MD liegt nun darin, dass die Methoden GET
, POST
, PUT
etc. als explizite Funktionen in den Klassen vorhanden sein müssen und automatisch verbunden werden, also:
API-Endpunkt | Interne Funktion |
---|---|
POST /photo-service/photos |
PhotoServicePhotos.POST() |
GET /photo-service/photos/{uuid} |
PhotoServicePhotos.GET() |
… | … |
Was auf den ersten Blick ziemlich praktisch aussieht, hat im Endeffekt ähnliche Probleme wie unsere erste Variante mit dem DefaultDispatcher
. Eine Klasse oder Funktion ist für einen gesamten Teilbaum verantwortlich, Unterscheidungen bei mehreren Parametern müssen in den Funktionen getroffen werden. Dadurch steckt viel Logik tief im Code, was wenig deklarativ und somit nicht auf den ersten Blick ersichtlich ist.
Der RoutesDispatcher
erlaubt es uns, Routen anhand von URL-Templates anzugeben und direkt auf eine konkrete Funktion zu leiten. Der große Vorteil ist, dass die weiteren Bestandteile der URL expliziter im Code angegeben sind und nicht programmatisch aus Parametern herausgefischt werden müssen. An einem Beispiel wird dies schnell ersichtlich:
class SimpleWebGallery(object):
def getRoutesDispatcher(self):
d = cherrypy.dispatch.RoutesDispatcher()
d.connect('home_index', '/', # URL
controller=HomeController(), # Controller class
action='index', # Controller method to invoke
conditions=dict(method=['GET'])) # On method "GET"
d.connect('admin_album_index', '/admin/album/{uuid}',
controller=AdminController(),
action='album_index',
conditions=dict(method=['GET']))
return d
Wir können nun für jede URL eine direkte Verbindung zu einer Funktion aufbauen und haben für jeden API-Endpunkt einen konkreten Eintrag im Dispatcher, dadurch bleibt der Code wesentlich übersichtlicher und damit wartbarer. Weiterhin können variable Anteile in der URL direkt mit {}
kenntlich gemacht werden. Diese werden dann als entrechend benamte Variablen an die unter action
angegebene Funktion weitergereicht.
Zum Schluss noch ein lauffähiges Minimalbeispiel für den RoutesDispatcher
:
import os
import cherrypy
def init_service():
# Do init stuff
return
def cleanup():
# Do cleanup stuff
return
class HomeController(object):
@cherrypy.expose
def hello(self, user):
return "Hello %s!" % user
class SimpleWebGallery(object):
def getRoutesDispatcher(self):
d = cherrypy.dispatch.RoutesDispatcher()
d.connect('say_hello', '/user/{user}',
controller=HomeController(),
action='hello',
conditions=dict(method=['GET']))
return d
if __name__ == '__main__':
app = SimpleWebGallery()
conf = {
'/': {
'tools.sessions.on': False,
'request.dispatch': app.getRoutesDispatcher(),
'tools.staticdir.root': os.path.abspath(os.getcwd())
},
'/static': {
'tools.staticdir.on': True,
'tools.staticdir.dir': './static'
}
}
cherrypy.server.socket_host = '0.0.0.0'
cherrypy.server.socket_port = 8080
cherrypy.engine.subscribe('start', init_service)
cherrypy.engine.subscribe('stop', cleanup)
cherrypy.quickstart(None, '/', conf)
Rufen wir nun lokal http://localhost:8080/user/world
auf, so werden wir mit einem herzlichen “Hello world!” begrüßt.
Es sind eine Menge praktische Kleinigkeiten in der Implementierung, die letztlich die meiste Zeit kosten. Eine dieser Kleinigkeiten ist das URL-Routing. CherryPy bietet dazu diverse Wege an, die aber nicht unbedingt alle zu übersichtlichem und wartbarem Code führen. Der RoutesDispatcher
ist zwar leider schlecht dokumentiert, bietet aus meiner Sicht aber aktuell die expliziteste Form des Routing an und fördert damit verständlichen Code.