:orphan: Adding Real-Time to Django Applications ======================================= WAMP has a `lot of potential `__, but it's asynchronous and most current Python Web stacks are synchronous. Still, you may want to benefit from WAMP realtime notifications right now in your synchronous applications. Crossbar.io enables you to trigger realtime notifications from your synchronous Python Web stack since it comes with a HTTP Pusher service: just configure a few lines of JSON in the Crossbar.io config file, and Crossbar.io provides a HTTP REST endpoint so that you can publish to a WAMP topic with a simple POST request. .. code:: javascript "transports": [ { "type": "web", "endpoint": { "type": "tcp", "port": 8080 }, "paths": { ... "notify": { "type": "pusher", "realm": "realm1", "role": "anonymous" } } } ] Publishing to an example topic "great\_topic" is then just: .. code:: python import requests requests.post("http://router_ip/notify", json={ 'topic': 'great_topic' 'args': [some, params, to, pass, along, if, you, need, to] }) This sends a POST request to an endpoint at the path ``notify``, and Crossbar.io dispatches a WAMP PubSub event based on it. In order to illustrate this, I'm going to show you how to build a little monitoring service with Crossbar.io and Django. To follow this article, you'll need: - a basic knowledge of JS. - an understanding of the basic WAMP concepts. - to know how to install Python 2.7 libs with C extensions on your machine. - to know Django. (Even if the concepts of the tutorial apply to Flask, Pyramid and others.) You can get the source code from the `Crossbar.io examples repo `__. First steps =========== Our goal is to have a little WAMP monitoring client that we run on each machine we wish to monitor. It will retrieve CPU, RAM and disk usage every X seconds and then publish this data using WAMP. The client will talk to a server with a Django Website containing a model for each monitored machine, with values to say whether we are interested in the CPU, the RAM or the disk usage, and the currently set publishing interval for the data. A web page displays all readings for all machines in real time. When we change a model in the Django admin, the page reflects the change immediately. So, we will need Django .. code:: sh pip install django requests .. code:: sh pip install requests and `psutil `__ "psutil" is the Python lib which will enable us to retrieve all the values for the RAM, the disk and the CPU. It uses C extensions, so you'll need a compiler and Python headers. Under Ubuntu, you'll need to do: .. code:: sh sudo apt-get install gcc python-dev For CentOS, that would be: .. code:: sh yum groupinstall "Development tools" yum install python-devel In Mac, Python headers should be included, but you'll need GCC. If you have xcode, you already have a compiler, otherwise, there is a light installer for it. Windows installer is a wheel, so you don't need to do anything in particular. Then you can .. code:: sh pip install psutil At last, we will need to `install Crossbar.io `__. The basic install can be done by doing .. code:: sh pip install crossbar but note that Windows users will need to install `PyWin32 `__ first. Also, as usual, make sure you got your Python installation directories added in your system PATH otherwise none of the commands will be found. The HTML ======== The monitoring front end is just a single page. Since this article is framework agnostic, it's written using pure JS, not jQuery or AngularJS, which makes it verbose. .. code:: html Monitoring

Monitoring

    As you can see, most of it is ordinary JS, and DOM manipulations. The only WAMP specific parts are: .. code:: javascript var connection = new autobahn.Connection({ url: 'ws://127.0.0.1:8080/ws', realm: 'realm1' }); connection.onopen = function(session) { ... } connection.open(); which etablishes the connection to the router, and .. code:: javascript session.subscribe('clientstats', function(args){ ... } which subscribes us to the topic ``clientstats`` and provides the function to extecute on each WAMP publication to this topic. Client monitoring ================= This is the code that will run on each machine we want to monitor: .. code:: python # -*- coding: utf-8 -*- from __future__ import division import socket import requests import psutil from autobahn.twisted.wamp import Application from autobahn.twisted.util import sleep from twisted.internet.defer import inlineCallbacks def to_gib(bytes, factor=2**30, suffix="GiB"): """ Convert a number of bytes to Gibibytes Ex : 1073741824 bytes = 1073741824/2**30 = 1GiB """ return "%0.2f%s" % (bytes / factor, suffix) def get_stats(filters={}): """ Returns the current values for CPU/memory/disk usage. These values are returned as a dict such as: { 'cpus': ['x%', 'y%', etc], 'memory': "z%", 'disk':{ '/partition/1': 'x/y (z%)', '/partition/2': 'x/y (z%)', etc } } The filter parameter is a dict such as: {'cpus': bool, 'memory':bool, 'disk':bool} It's used to decide to include or not values for the 3 types of ressources. """ results = {} if (filters.get('show_cpus', True)): results['cpus'] = tuple("%s%%" % x for x in psutil.cpu_percent(percpu=True)) if (filters.get('show_memory', True)): memory = psutil.phymem_usage() results['memory'] = '{used}/{total} ({percent}%)'.format( used=to_gib(memory.used), total=to_gib(memory.total), percent=memory.percent ) if (filters.get('show_disk', True)): disks = {} for device in psutil.disk_partitions(): # skip mountpoint not actually mounted (like CD drives with no disk on Windows) if device.fstype != "": usage = psutil.disk_usage(device.mountpoint) disks[device.mountpoint] = '{used}/{total} ({percent}%)'.format( used=to_gib(usage.used), total=to_gib(usage.total), percent=usage.percent ) results['disks'] = disks return results # We create the WAMP client. app = Application('monitoring') # This is my set to localhost to enable running a first # test client instance on the machine that Crossbar.io & Django # are running on. You should change this value # to the pulbic IP of the machine for external clients. SERVER = '127.0.0.1' # First, we use a trick to know the public IP for this # machine. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) # We attach a dict to the app, so that its # reference is accessible from anywhere. app._params = {'name': socket.gethostname(), 'ip': s.getsockname()[0]} s.close() @app.signal('onjoined') @inlineCallbacks def called_on_joinded(): """ Loop sending the state of this machine using WAMP every x seconds. This function is executed when the client joins the router, which means it's connected and authenticated, ready to send WAMP messages. """ print("Connected") # Then we make a POST request to the server to notify it we are active # and to retrieve the configuration values for our client. response = requests.post('http://' + SERVER + ':8080/clients/', data={'ip': app._params['ip']}) if response.status_code == 200: app._params.update(response.json()) else: print("Could not retrieve configuration for client: {} ({})".format(response.reason, response.status_code)) # The we loop for ever. print("Entering stats loop ..") while True: print("Tick") try: # Every time we loop, we get the stats for our machine stats = {'ip': app._params['ip'], 'name': app._params['name']} stats.update(get_stats(app._params)) # If we are requested to send the stats, we publish them using WAMP. if not app._params['disabled']: app.session.publish('clientstats', stats) print("Stats published: {}".format(stats)) # Then we wait. Thanks to @inlineCallbacks, using yield means we # won't block here, so our client can still listen to WAMP events # and react to them. yield sleep(app._params['frequency']) except Exception as e: print("Error in stats loop: {}".format(e)) break # We subscribe to the "clientconfig" WAMP event. @app.subscribe('clientconfig.' + app._params['ip']) def update_configuration(args): """ Update the client configuration when Django asks for it. """ app._params.update(args) # We start our client. if __name__ == '__main__': app.run(url="ws://%s:8080/ws" % SERVER, debug=False, debug_wamp=False) ``app = Application('monitoring')`` creates a WAMP client, and ``@app.signal('onjoined')`` tells us how to start the function when our client is connected and ready to send events. ``@inlineCallbacks`` is a specific feature of Twisted allowing us to write asynchronous code without using explicit callbacks everywhere: instead of them, we use ``yield``. All the work of our client happens in the loop: ``app.session.publish('clientstats', infos)`` publishes new stats for the CPU/RAM/Disk via WAMP, then waits for some time (``yield sleep(app._params['frequency']``) before doing it again. Waiting is not blocking, thanks to the ``sleep()`` from Twisted. Let's not forget: .. code:: python @app.subscribe('clientconfig.' + app._params['ip']) def update_configuration(args): app._params.update(args) The ``update_configuration()`` function is called every time a WAMP publication is made to the topic "clientconfig.". Our function only updates the client configuration, which is a dict, looking like: .. code:: python {'cpus': True, 'memory': False, 'disk': True, 'disabled': False, 'frequency': 1} It's this dict which is used by ``get_stats()`` to choose which values to retrieve, and also in the loop to know how many seconds to wait until the next measurements or if we send the stats at all. The initial value for this dict is retrieved when the client starts, by doing: .. code:: python app._params.update(requests.post('http://' + SERVER + ':8080/clients/', data={'ip': app._params['ip']}).json()) ``requests.post(server_url, data={'ip': app._params['ip']}).json()`` does a POST request to a Django URL which we'll see later, returning the client's configuration matching this IP, as JSON. We use HTTP once to get the values at the beginning, then WAMP for all future udpates. WAMP and HTTP are not excluding each other: they are complementary. A little digression: .. code:: python SERVER = '192.168.0.104' s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(("8.8.8.8", 80)) app._params = {'name': socket.gethostname(), 'ip': s.getsockname()[0]} s.close() As you can see I hard coded the IP of the Crossbar.io and Django server out of pure laziness. But in production this should, obviously, be a parameter or an environment variable. Remember you can get this IP on Linux and Mac doing (from the server machine): .. code:: sh ifconfig And on Windows: .. code:: sh ipconfig Then, since I need to identify my client, I do it with its IP address too. So I need its public IP, which I get by using a little trick involving opening a connection to some reliable external IP (here the Google DNS 8.8.8.8) and by closing it right after that. This lets me know how other machines see me from the outside world. .. raw:: html

    The Django Web site .. raw:: html

    Since this article requires that you know Django, this will be easier. We create a project and an app: .. code:: sh django-admin startproject django_project ./manage.py startapp django_app And we add the app to ``settings.INSTALLED_APPS``. Then we write a small model containing the configuration for each client (remember our dict ? This is where it comes from): .. code:: python # -*- coding: utf-8 -*- import requests from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.forms.models import model_to_dict class Client(models.Model): """ Our client configuration """ # Client unique identifier ip = models.GenericIPAddressField() # What data to send to the dashboard show_cpus = models.BooleanField(default=True) show_memory = models.BooleanField(default=True) show_disk = models.BooleanField(default=True) # Stop sending data disabled = models.BooleanField(default=False) # Data refresh frequency frequency = models.IntegerField(default=1) def __unicode__(self): return self.ip @receiver(post_save, sender=Client, dispatch_uid="server_post_save") def notify_server_config_changed(sender, instance, **kwargs): """ Notifies a client that its config has changed. This function is executed when we save a Client model, and it makes a POST request on the WAMP-HTTP bridge, allowing us to make a WAMP publication from Django. """ requests.post("http://127.0.0.1:8080/notify", json={ 'topic': 'clientconfig.' + instance.ip, 'args': [model_to_dict(instance)] }) The model part is known territory. The fun part is actually: .. code:: python @receiver(post_save, sender=Client, dispatch_uid="server_post_save") def notify_server_config_changed(sender, instance, **kwargs): requests.post("http://127.0.0.1:8080/notify", json={ 'topic': 'clientconfig.' + instance.ip, 'args': [model_to_dict(instance)] }) Here we use Django signals, a framework feature allowing us to trigger a function when something happens. In our case, we say 'run this function when one Client model is modified'. So ``notify_server_config_changed()`` is executed when a client configuration is modified, such as when using the Django admin, and will receive the modified object as the "instance" parameter. Now we make a small POST request to ``http://127.0.0.1:8080/notify``, which is the URL we will later use to configure our PUSH service. By doing a request to it, we are asking Crossbar.io to turn this HTTP request into a WAMP publication about the 'clientconfig.' topic. For all intents and purposes, we are publishing a WAMP message from Django. This works from anywhere, not just Django. From the shell, from Flask, from any place you can make an HTTP request you can publish using the Crossbar.io push service. The message we sent is going to be received by our clients, whereever they are, since they are all connected to the same WAMP router. Indeed, our client did: .. code:: python @app.subscribe('clientconfig.' + app._params['ip']) def update_configuration(args): app._params.update(args) So it will receive the message, the content of ``args``: ``[model_to_dict(instance)]``, meaning the new configuration which has just changed in the data base. This way it can update itself immediately. To illustrate this, we add the model in our Django admin: .. code:: python from django.contrib import admin # Register your models here. from django_app.models import Client admin.site.register(Client) Doing this makes the client configurations editable from the Django admin, and when clicking the "save" button, it sends our WAMP publication, which triggers the right client update. The rest is just small tweaks: .. code:: python # -*- coding: utf-8 -*- import json from django.http import HttpResponse from django_app.models import Client from django.views.decorators.csrf import csrf_exempt from django.forms.models import model_to_dict @csrf_exempt def clients(request): """ Retrieve a client config from DB and send it back to the client """ ip = request.POST.get('ip', None) try: client, created = Client.objects.get_or_create(ip=ip) data = model_to_dict(client) except Exception as e: print("Could not retrieve client config for IP '{}': {}".format(ip, e)) else: print("Client config for retrieved for IP '{}'".format(ip, data)) return HttpResponse(json.dumps(data), content_type='application/json') We disable the CSRF protection for the demo, but once again, in production, you should do that in a clean way, with ``@login_required``, protected views and CSRF token exchanges. This view retrieves the client configuration matching this IP (creating it if needed), and returns it as JSON. Remember, this allows our client to do: .. code:: python app._params.update(requests.post('http://' + SERVER + ':8080/clients/', data={'ip': app._params['ip']}).json()) So at startup it declares itself in the database, and gets its config back. You plug all the moving parts in urls.py: .. code:: python from django.conf.urls import patterns, include, url from django.contrib import admin from django.views.generic import TemplateView urlpatterns = patterns('', url(r'^admin/', include(admin.site.urls)), url(r'^clients/', 'django_app.views.clients'), url(r'^$', TemplateView.as_view(template_name='dashboard.html')), ) This contains the routes for the admin, our new view, and some generic code to serve the HTML we saw at the beginning of this article. Then you need to create your database and collect static files : .. code:: sh ./manage.py syncdb ./manage.py collectstatic Crossbar.io =========== Finally, we just need to configure Crossbar.io. On the command line go to your project's base directory and do :: crossbar init This creates the ``.crossbar`` directory which contains a ``config.json`` file. We need to edit this to look like: .. code:: javascript { "workers": [ { "type": "router", "options": { "pythonpath": [".."] }, "realms": [ { "name": "realm1", "roles": [ { "name": "anonymous", "permissions": [ { "uri": "*", "allow": { "publish": true, "subscribe": true, "call": true, "register": true } } ] } ] } ], "transports": [ { "type": "web", "endpoint": { "type": "tcp", "port": 8080 }, "paths": { "/": { "type": "wsgi", "module": "django_project.wsgi", "object": "application" }, "ws": { "type": "websocket", "debug": false }, "notify": { "type": "pusher", "realm": "realm1", "role": "anonymous" }, "static": { "type": "static", "directory": "../static" } } } ] } ] } The first part is more or less Crossbar.io's equivalent of chmod 777: .. code:: javascript "type": "router", "realms": [ { "name": "realm1", "roles": [ { "name": "anonymous", "permissions": [ { "uri": "*", "allow":{ "publish": true, "subscribe": true, "call": true, "register": true } } ] } ] } ] "Set me up a router with an access named 'realm1' authorizing anonymous clients to do anything". A realm is security notion in Crossbar.io used to isolate connected clients and give them permissions, but we are going to put them all in the same realm to make the demo simple. Then we add transports for each desired technology. We are going to group them all under the "8080" port as Twisted can listen to HTTP and Websocket on a single port at the same time. .. code:: javascript "transports": [ { "type": "web", "endpoint": { "type": "tcp", "port": 8080 } The root URL will serve our Django app: .. code:: javascript "/": { "type": "wsgi", "module": "django_project.wsgi", "object": "application" } Yes, Crossbar.io can server your Django app. It's not mandatory, but it will exempt you from needing Gunicorn and Nginx. The Web server in Twisted can take a real life traffic load without problems. For our example, we use Crossbar.io for everything, making the setup easier. To do that, we just need to tell it which variable (application) from which WSGI file (django\_project/wsgi.py) to load. On '/ws', we listen for Websocket traffic: .. code:: javascript "ws": { "type": "websocket" } This is where WAMP comes in, and that's why our clients connect to the router by doing ``app.run(url="ws://%s:8080/ws" % SERVER)`` and ``autobahn.Connection({url: 'ws://127.0.0.1:8080/ws', realm: 'realm1'})``. Then, '/notify' is for our WAMP-HTTP bridge: .. code:: javascript "notify": { "type": "pusher", "realm": "realm1", "role": "anonymous" } All anonymous clients from ``realm1`` can use the HTTP REST endpoint created by this. It's thanks to this that we were able to do this in our Django signal: .. code:: python requests.post("http://127.0.0.1:8080/notify", json={ 'topic': 'clientconfig.' + instance.ip, 'args': [model_to_dict(instance)] }) and publish a WAMP message via a HTTP POST. At last, we serve Django static files: .. code:: javascript "static": { "type": "static", "directory": "../static" } Now that everything is in place, we can start Crossbar.io: .. code:: sh crossbar start Let's visit http:127.0.0.1:8080/ to see your Django template dashboard.The HTML comes to life! For each machine running a client (``python client.py``), new stats appear on the dashboard, and are be updated in real time. (Remember to change the server IP to the one your Django/Crossbar.io instance are on!) Now if you open a new tab to http:127.0.0.1:8080/admin/ and change a client's configuration, our client adapts, and our dashboard updates automatically. .. raw:: html

    Last words .. raw:: html

    In the end our project looks like this: .. code:: sh . client.py .crossbar config.json db.sqlite3 django_app admin.py __init__.py models.py templates dashboard.html views.py django_project __init__.py settings.py urls.py wsgi.py static manage.py You can get the source code from the `Crossbar.io examples repo `__. As you can see, we used very little WAMP code: a few lines for the JS part, and a few lines for the Python client. The only thing linking WAMP to Django is the Crossbar.io configuration which adds the HTTP pusher service and our POST request in ``models.py``. This solution is not limited to Django, and works well for all synchronous technologies unable to run WAMP clients directly. For now, the HTTP-WAMP bridge only allows publishes, not subscriptions or RPC. But having real time notifications available everywhere is already a nice touch, and the other actions will be implemented by the Crossbar.io team in the near future. At moment you can already see that we can mix HTTP, WAMP, Python, clients, servers and build our own architecture to fit our needs. Crossbar.io can also serve the WSGI app, and actually could manage any WAMP client life cycle on the same machine, or if needed, any command line process (such as NodeJS). We could have written the client in Python 3 since it's on other machines. In fact, if we run Django by itself (not using Crossbar.io), then Django can be coded using Pyton 3 too. Crossbar.io is the only bit still needing Python 2.7 (because Twisted doesn't run on Python 3 yet). Still, this is just a component which we configure and then forget about. I tried this small system with several docker images running Python clients inside them and it's great to see the machines being added in real time. The immediate feedback you get by seeing any changes applied to the Django admin reflected on the page is also a nice touch.