Flashback
fin 2012, je créais un petit projet et en mai 2013, je sortais une version de ce dernier, nommé "Trigger Happy", entierement conçu avec l'excellentissime framework web Django.
7 ans plus tard, en l'écrivant je m'étouffe en réalisant, le projet n'est pas mort mais mais mais depuis l'arrivée d'asyncio, je me suis demandé comment lui apporter un seconde souflle funky, en terme de techno. Django-Channel vit le jour et promettait de l'async partout mais l'archi me paraissait beaucoup beaucoup trop lourde, en témoigne la palanquée de pré-requis.
Avant hier
Lors de l'hacktoberfest 2018 dernier, j'aperçu le projet Responder, tout asynchrone, et sortie vitesse lumière à la fin du mois d'octobre, avec moult talentueux contributeurs, dont Tom Christie, "Mr Dango Rest Framework". Le projet m'attira très fortement, mais encore un petit quelque chose m'empecha de me l'approprier et je laissais alors tomber, avec la flemme et envie d'autre chose.
Aujourd'hui
Mais depuis quelques mois, en décortiquant d'avantage les prerequis un à un de responder, je me suis arrêté sur starlette (The little ASGI framework that shines) L'auteur de Starlette n'est autre que Tom Christie ;) Comme d'hab pour s'approprier le projet, rien de mieux qu'un tuto.
Je tombe alors donc sur https://github.com/encode/starlette-example/ et hop c'était parti.
Le principe de starlette, pour qui ne connait pas encore le projet, est d'éviter la moindre friction entre les couches que sont la vue, la base de données, les formulaires, etc.
Du coup Starlette propose un socle de base très efficace, et l'auteur de starlette s'est, dans la foulée attelé, à produire des projets péripheriques tels que
- orm ; pour comme avec Django ; permettre d'utiliser vos tables avec juste Models.object.all()
- typeschema ; pour la validation de vos formulaires
- database ; pour l'abstraction aux SGBD
Évidemment tous sont async.
Si vous souhaitez voir une video qui detaille Starlette, Tom Christie donne un talk, commencez donc la vidéo à partir de 7h30 vous aurez une heure à vous régaler ;)
Comme un poisson dans l'eau
Passer de Django "synchrone" à starlette "asynchrone" devrait être un jeu d'enfant pour vous autres, utilisateurs de Django, comme la plupart des éléments dans starlette, devraient furieusement, vous rappeler des strates de Django.
Comparatif des couches entre les 2 projets
Périmètre | Django | Starlette |
---|---|---|
Templating | Template Django | Jinja2 |
Vue | *View | fonctions dans le(s) module(s) de votre application |
Formulaire | *Form | typesystem (*) |
ORM | Django ORM | ORM (*) |
"Migration" | manage.py migrate | utilisation d'SQL Alchemy |
Routage | urls.py | Route() |
(*)
= Fonctionnalités non incluses dans starlette, application tierce.
Les projets jumeaux
Pour vous illustrer le propos, revenons à Trigger Happy.
Alors non je ne l'ai pas refait intégralement avec starlette, mais j'en ai pris 2 morceaux pour produire un petit projet:
- le premier : l'extraction des flux RSS
- le second : l'éditeur markdown nommé Joplin
avec ces 2 là, je créé des notes automatiquement dans Joplin à partir du flux RSS de mon choix
Mon but étant, au départ, de faire la veille techno en créant des notes dans joplin pour les lire ultérieurement à partir de flux RSS de mes sites favoris.
Le premier projet en Django est nommé JONG : JOplin Note Generator
Le second projet avec Starlette est nommé Yeoboseyo
C'est au pif que je me suis mis à recorder Jong à la sauce Starlette, c'est au 3/4 de la fin que je me suis dit
ho marrant j'ai refait Jong avec Starlette finallement
et finallement ce n'etait pas la mer à boire, loin s'en faut, et puis c'est modulaire souple et c'est tout ce qu'on attend ;)
Comment fait on ...
suis ci dessous, chaque élément du tableau, une fois pour django une fois pour starlette
... une Vue
enfin ici il m'aura fallu 3 CBV * Django
class RssListView(ListView):
"""
list of Rss
"""
context_object_name = "rss_list"
queryset = Rss.objects.all()
def get_queryset(self):
return self.queryset.filter().order_by('-date_triggered')
def get_context_data(self, **kw):
data = self.queryset.filter().order_by('-date_triggered')
# paginator vars
record_per_page = 10
page = self.request.GET.get('page')
# paginator call
paginator = Paginator(data, record_per_page)
try:
data = paginator.page(page)
except PageNotAnInteger:
# If page is not an integer, deliver first page.
data = paginator.page(1)
except EmptyPage:
# If page is out of range (e.g. 9999),
# deliver last page of results.
data = paginator.page(paginator.num_pages)
context = super(RssListView, self).get_context_data(**kw)
context['data'] = data
return context
class RssCreateView(RssMixin, CreateView):
"""
list of Rss
"""
template_name = 'jong/rss.html'
def get_context_data(self, **kw):
context = super(RssCreateView, self).get_context_data(**kw)
context['mode'] = 'add'
return context
class RssUpdateView(RssMixin, UpdateView):
"""
Form to update description
"""
template_name = 'jong/rss.html'
def get_context_data(self, **kw):
context = super(RssUpdateView, self).get_context_data(**kw)
context['mode'] = 'Edit'
return context
- Starlette
avec Starlette, pas de ClassBasedView :P on a une class HTTPEndPoint si on veut mais là pour la petitesse du projet je m'en suis affranchi
async def homepage(request):
"""
get the list of triggers
:param request:
:return:
"""
triggers = await Trigger.objects.all()
template = "index.html"
if request.method == 'GET':
# trigger_id provided, form to edit this one
if 'trigger_id' in request.path_params:
trigger_id = request.path_params['trigger_id']
trigger = await Trigger.objects.get(id=trigger_id)
form = forms.Form(TriggerSchema, values=trigger)
# empty form
else:
trigger_id = 0
form = forms.Form(TriggerSchema)
for trigger in triggers:
print(trigger)
context = {"request": request, "form": form, "triggers_list": triggers, "trigger_id": trigger_id}
return templates.TemplateResponse(template, context)
# POST
else:
data = await request.form()
trigger, errors = TriggerSchema.validate_or_error(data)
if errors:
form = forms.Form(TriggerSchema, values=data, errors=errors)
context = {"request": request, "form": form, "triggers_list": triggers}
return templates.TemplateResponse(template, context)
if 'trigger_id' in request.path_params:
trigger_id = request.path_params['trigger_id']
trigger_to_update = await Trigger.objects.get(id=trigger_id)
print(trigger.rss_url, trigger.joplin_folder, trigger.description)
await trigger_to_update.update(rss_url=trigger.rss_url,
joplin_folder=trigger.joplin_folder,
status=bool(trigger.status),
description=trigger.description)
else:
await Trigger.objects.create(rss_url=trigger.rss_url,
joplin_folder=trigger.joplin_folder,
description=trigger.description)
return RedirectResponse(request.url_for("homepage"))
voilà qui remplace les 3 CBV :P
... un Template
- Django
<table class="table table-striped table-hover">
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "URL" %}</th>
<th>{% trans "Triggered" %}</th>
<th>{% trans "Notebook" %}</th>
<th>{% trans "Bypass Errors" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
{% for data in rss_list %}
<tr>
<td><a href="{% url 'edit' data.id %}" title="Edit this feed">{{ data.name }}</a></td>
<td><a href="{{ data.url }}" title="Go to this feed">{{ data.url }}</a></td>
<td>{{ data.date_triggered }}</td>
<td>{{ data.notebook }}</td>
<td>{% if data.bypass_bozo %}<span class="label label-danger">{% trans "Yes" %}</span>{% else %}<span class="label label-success">{% trans "No" %}</span>{% endif %}</td>
<td><a class="btn btn-sm btn-md btn-lg btn-success" role="button" href="{% url 'edit' data.id %}" title="Edit this feed"><span class="glyphicon glyphicon-pencil icon-white"></span></a>
{% if data.status %}
<a class="btn btn-sm btn-md btn-lg btn-primary" role="button" href="{% url 'switch' data.id %}" title="{% trans 'Set this Feed off' %}"><span class="glyphicon glyphicon-off icon-white"></span></a>
{% else %}
<a class="btn btn-sm btn-md btn-lg btn-warning" role="button" href="{% url 'switch' data.id %}" title="{% trans 'Set this Feed on' %}"><span class="glyphicon glyphicon-off icon-white"></span></a>
{% endif %}
<a class="btn btn-sm btn-md btn-lg btn-danger" role="button" href="{% url 'delete' data.id %}" title="Delete this feed"><span class="glyphicon glyphicon-trash icon-white"></span></a>
</td>
</tr>
{% endfor %}
</table>
- Starlette
{% extends "base.html" %}
{% block content %}
<div class="col-xs-5 col-md-5 col-lg-5">
{% if trigger_id > 0 %}
<form method="POST" action='{{ url_for('homepage', trigger_id=trigger_id) }}'>
{% else %}
<form method="POST" action='{{ url_for('homepage') }}'>
{% endif %}
{{ form }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
<div class="col-xs-7 col-md-7 col-lg-7">
<table class="table table-striped table-hover">
<thead>
<tr><th scope="col">Description</th>
<th scope="col">RSS URL</th>
<th scope="col">Joplin Folder</th>
<th scope="col">Triggered</th>
<th scope="col">Created</th>
<th scope="col">Status</th>
<th scope="col">Action</th>
</tr>
</thead>
{% if triggers_list %}
<tbody>
{% for trigger in triggers_list %}
<tr><td><a href="{{ url_for('homepage', trigger_id=trigger.id)}}" title="Edit this trigger">{{ trigger.description }}</a></td>
<td><a href="{{ trigger.rss_url }}" title="go to this URL">{{ trigger.rss_url }}</a> </td>
<td>{{ trigger.joplin_folder }}</td>
<td>{{ trigger.date_triggered }}</td>
<td>{{ trigger.date_created}}</td>
<td>{{ trigger.status }}</td>
<td>
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#trigger{{ trigger.id }}">Delete</button>
<div class="modal fade" id="trigger{{ trigger.id }}" tabindex="-1" role="dialog" aria-labelledby="trigger{{ trigger.id}}Label" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Deletion : {{ trigger.description }}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>are your sure you want to delete this trigger ? {{ trigger.description }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<a href="{{ url_for('delete', trigger_id=trigger.id)}}" class="btn btn-danger" role="button">Delete it</a>
</div>
</div>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
{% endif %}
</table>
</div>
{% endblock %}
... un Model
- Django
from django.db import models
class Rss(models.Model):
"""
Rss
"""
name = models.CharField(max_length=200, unique=True)
status = models.BooleanField(default=True)
notebook = models.CharField(max_length=200)
url = models.URLField()
tag = models.CharField(max_length=40, null=True, blank=True)
date_triggered = models.DateTimeField(auto_now=True, auto_created=True)
# to ignore the not well formed RSS feeds
# bozo detection https://pythonhosted.org/feedparser/bozo.html?highlight=bozo
# default is False : we do not ignore not well formed Feeds.
bypass_bozo = models.BooleanField(default=False)
def show(self):
"""
:return: string representing object
"""
return "RSS %s %s" % (self.name, self.status)
def __str__(self):
return self.name
- Starlette
import orm
class Trigger(orm.Model):
__tablename__ = "trigger"
__database__ = database
__metadata__ = metadata
id = orm.Integer(primary_key=True)
rss_url = orm.String(max_length=255)
joplin_folder = orm.String(max_length=80)
description = orm.String(max_length=200)
date_created = orm.DateTime(default=datetime.datetime.now)
date_triggered = orm.DateTime(allow_null=True)
status = orm.Boolean(default=False)
result = orm.Text(allow_null=True)
date_result = orm.DateTime(allow_null=True)
provider_failed = orm.Integer(allow_null=True)
consumer_failed = orm.Integer(allow_null=True)
... Un Forms
- Django
je vous fais grâce du template du formulaire, le but étant de se focaliser sur les éléments identiques ;)
class RssForm(forms.ModelForm):
"""
RSS Form
"""
def __init__(self, *args, **kwargs):
# Get initial data passed from the view
super(RssForm, self).__init__(*args, **kwargs)
self.fields['notebook'].choices = folders()
class Meta:
model = Rss
exclude = ('date_triggered',)
widgets = {
'name': TextInput(attrs={'class': 'form-control'}),
'url': TextInput(attrs={'class': 'form-control'}),
'notebook': TextInput(attrs={'class': 'form-control'}),
'tag': TextInput(attrs={'class': 'form-control'}),
'status': TextInput(attrs={'class': 'form-control'}),
'bypass_bozo': CheckboxInput(attrs={'class': 'checkbox'}),
}
notebook = forms.ChoiceField()
- Starlette
import typesystem
class TriggerSchema(typesystem.Schema):
"""
Schema to define the structure of a Trigger
"""
rss_url = typesystem.String(title="RSS URL", max_length=255)
joplin_folder = typesystem.String(title="Joplin Folder", max_length=80)
description = typesystem.String(title="Description", max_length=200)
status = typesystem.Boolean(title="Status", default=False)
... Un Routage
- Django
urlpatterns = [
path('admin/', admin.site.urls),
path('', RssListView.as_view(), name='base'),
path('', RssListView.as_view(), name='rss'),
path('add/', RssCreateView.as_view(), name='add'),
url(r'^edit/(?P<pk>\d+)$', RssUpdateView.as_view(), name='edit'),
[...]
]
- Starlette
app = Starlette(
debug=True,
routes=[
Route('/', homepage, methods=['GET', 'POST'], name='homepage'),
Route('/id/{trigger_id:int}', homepage, methods=['POST', 'GET'], name='homepage'),
Route('/delete/{trigger_id:int}', delete, methods=['GET'], name='delete'),
Mount('/static', StaticFiles(directory='static'), name='static')
],
)
Voilou pour l'essentiel, si vous souhaitez comparer intégralement les 2 projets, au dossier/fichier pret, vous avez les liens sur github au dessus.
Pour ma part je trouve starlette et ses amis, modulaires comme il faut. On dirait qu'on plug des pièces de tétris au fur et à mesure pour obtenir à la fin un projet costaud.