Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Elasticsearch - Intégration avec Django REST Fr...

Elasticsearch - Intégration avec Django REST Framework

Elasticsearch est un moteur de recherche qui séduit de plus en plus de personnes. Et pour cause, les fonctionnalités qu'il propose en font un outil de choix pour quiconque souhaite analyser des documents :
- il est distribué, permettant ainsi d'évoluer pour tenir la charge ou rechercher à travers d'avantage de documents,
- propose un language de recherche avancé, pour répondre à un large ensemble de besoins,
- est facilement déployable, notamment en environnement de développement.

Elasticsearch est ainsi souvent utilisé pour stocker et rechercher des logs, ou en tant que "datastore" secondaire couplé à une base de données existante pour apporter plus de performance sur les recherches dites "full-text".

A travers un exemple d'intégration avec Django Rest Framework, cette présentation sera ainsi l'occasion de faire un rapide tour sur le fonctionnement d'Elasticsearch et de partager des trucs et astuces. Seront passés en revue :
- la gestion de l'authentification et des permissions,
- la sérialization des résultats retournés par Elasticsearch et leur pagination,
- l'optimisation des recherches sur des indices organisés par date,
- la gestion de mapping avec elasticsearch_dsl,
- l'écriture de tests unitaires,
- l'utilisation d'Elasticsearch sur un serveur d'intégration continue.

[Conférence donnée lors de la PyConFR 2015]

Alexandre Figura

October 17, 2015
Tweet

More Decks by Alexandre Figura

Other Decks in Programming

Transcript

  1. 2-51 Qui suis-je ? • Ancien sysadmin. • Aujourd'hui développeur

    web Python. • Travaille sur des projets d'autopartage (comme Autolib), chez Polyconseil.
  2. 4-51 Pourquoi Elasticsearch ? • 400+ millions de logs stockés

    / 115 requêtes par seconde. • Utilisation d'une table SQL par le passé. • Problèmes de scalibilité, performances en chute. • Elasticsearch est : – distribué (un à plusieurs noeuds), – un moteur de recherche, pas une base de données.
  3. 5-51 Pourquoi Django REST Framework ? • Elasticsearch n'est pas

    RESTful. • Gestion de l'authentification/permissions. • Agit comme une couche intermédiaire : – Optimisation des recherches, – Apport logique métier.
  4. 7-51 Alias Index Document Concepts de base 1/2 • Index

    = collection de documents. • Peut-être associé à un ou plusieurs alias. • Documents organisés par types. Document Document Document
  5. 8-51 Concepts de base 2/2 • Manipulation des documents à

    travers une API web (GET, POST, UPDATE, DELETE) : – http://<host>/<index/alias>/<type>/… • Documents au format JSON. • Schemaless, mais avec gestion de mapping (dynamique et statique) par type de document.
  6. 9-51 2 types de recherches • Queries : – Recherches

    full-text, – Calcul d'un score de pertinence pour chaque document. • Filters : – Recherches de type oui/non, – Comparaison de valeurs exactes, – Résultats pouvant être mis en cache. • Queries et filters peuvent être mélangés.
  7. 11-51 Concepts de base • Manipulation de ressources à l'aide

    de serializers. • Accès aux ressources à travers des vues (GET, POST, UPDATE, DELETE). • Sélection d'un sous-ensemble de ressources grâce à des filtres.
  8. 13-51 Environnement de développement • Elasticsearch et Django REST Framework.

    • Elasticsearch-py (librairie bas niveau) : – Fournit le client Elasticsearch, – Utilisé pour faire de la pagination. • Elasticsearch-dsl-py (librairie haut niveau) : – Utilisé pour effectuer des recherches, – Offre des querysets, à la manière de Django.
  9. 14-51 Etapes de réalisation • Nous allons: – Modéliser nos

    ressources (logs), – Créer des vues avec gestion de permissions, – Utiliser des filtres pour restreindre et optimiser les recherches. – Paginer les résultats de recherche.
  10. 16-51 Modèles et serializers • Nous avons besoin : –

    d'un mapping de base pour nos logs dans Elasticsearch, – de serializers pour manipuler les résultats retournés par Elasticsearch. • Notre API est en lecture seule : – Les logs sont indexés par un worker séparé.
  11. 17-51 Commençons par le modèle Elasticsearch. class LogModel(elasticsearch_dsl.DocType): timestamp =

    elasticsearch_dsl.field.Date() message = elasticsearch_dsl.field.String() category = elasticsearch_dsl.field.String() class Meta: doc_type = 'logs' using = elasticsearch_client # elasticsearch.Elasticsearch()
  12. 18-51 Problème : si un document est créé avant son

    index, le mapping n'est pas appliqué. class LogModel(elasticsearch_dsl.DocType): …... def save(self, using=None, index=None, **kwargs): try: self.init(index, using) except KeyError: # Index already exists pass return super().save(using, index, **kwargs)
  13. 19-51 Continuons avec le serializer. from rest_framework import serializers class

    LogSerializer(serializers.Serializer): timestamp = serializers.DateTimeField(format=None) message = serializers.CharField() category = serializers.CharField() class Meta: list_serializer_class = LogListSerializer
  14. 20-51 Problème : DRF n'arrive pas à sérializer un document

    lorsqu'il comporte des champ de type InnerObject (AttributeError: no attribute 'items'). from elasticsearch_dsl.utils import AttrDict # DocType's parent class class LogSerializer(serializers.Serializer): …... def to_representation(self, instance): if isinstance(instance, AttrDict): instance = instance.to_dict() return super().to_representation(instance)
  15. 21-51 Comment sérializer le résultat d'une recherche ? • Notre

    serializer gère pour l'instant un seul document. • Une recherche renvoie une liste de documents. • Piège : nous devons gérer les résultats retournés par elasticsearch-dsl, mais aussi elasticsearch-py (pour la pagination).
  16. 22-51 from elasticsearch_dsl import Search class LogListSerializer(serializers.ListSerializer): def to_representation(self, data):

    if isinstance(data, dict): # elasticsearch-py result iterable = (doc['_source'] for doc in data['hits']['hits']) elif isinstance(data, Search): # elasticsearch_dsl queryset iterable = data.execute().hits else: iterable = data return [self.child.to_representation(item) for item in iterable]
  17. 24-51 Vues génériques et spécifiques • Nous allons créer 5

    vues : – 1 vue générique de base, – 2 vues génériques, pour récupérer un ou plusieurs documents, – 2 vues spécifiques à notre API, utilisant les vues génériques précédentes. • URLs de notre API : – <index_id>/ – <index_id>/<doc_id>
  18. 25-51 from rest_framework import exceptions, generics class ElasticsearchGenericAPIView(generics.GenericAPIView): model =

    None def get_queryset(self): queryset = self.model.search() index_id = self.kwargs.get('index_id') if index_id is not None: queryset = queryset.index() # Flush previously used indices queryset = queryset.index(index_id) return queryset Vue générique de base 1/2
  19. 26-51 def get_object(self): doc_id = self.kwargs.get('doc_id') index_id = self.kwargs.get('index_id') obj

    = self.model.get(doc_id, index=index_id, ignore=404) if not obj: raise exceptions.NotFound # Explicitly check permissions as we override get_object(). self.check_object_permissions(self.request, obj) return obj Vue générique de base 2/2
  20. 27-51 Pour les vues génériques “augmentées”, on utilise des Mixins

    fournis par Django REST Framewok. from rest_framework import mixins class ElasticsearchListAPIView(mixins.ListModelMixin, ElasticsearchGenericAPIView): def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) class ElasticsearchRetrieveAPIView(mixins.RetrieveModelMixin, ElasticsearchGenericAPIView): def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs)
  21. 28-51 Terminons avec les vues spécifiques à notre API. from

    rest_framework import permissions class LogDetail(ElasticsearchRetrieveAPIView): model = LogModel serializer_class = LogSerializer permission_classes = ( permissions.IsAuthenticated, LogPermissions, ) Vues spécifiques 1/2
  22. 29-51 class LogList(ElasticsearchListAPIView): model = LogModel serializer_class = LogSerializer permission_classes

    = (permissions.IsAuthenticated,) pagination_class = ElasticsearchScrollPagination time_field = 'timestamp' timed_indices = ( 'pytong_2015', date(2015, 09, 26), date(2015, 09, 28), 'pyconfr_2015', date(2015, 10, 17), date(2015, 10, 21)) filter_backends = (LogPermissionsFilter, ElasticsearchQuerySearchFilter, ElasticsearchTimeRangeFilter, ElasticsearchTimedIndicesFilter) Vues spécifiques 2/2
  23. 31-51 Rechercher avec elasticsearch_dsl • Rechercher avec elasticsearch_dsl s'apparente à

    utiliser des querysets Django. • 2 types d'objets : Filter et Query • Exemples : – filter1 = F('<filter_type>', **kwargs) – query1 = Q('<query_type>', **kwargs) – search1 = model.search().filter(filter1) – search2 = model.search().query(query1)
  24. 32-51 Pour rappel... class LogList(ElasticsearchListAPIView): model = LogModel serializer_class =

    LogSerializer permission_classes = (permissions.IsAuthenticated,) pagination_class = ElasticsearchScrollPagination time_field = 'timestamp' timed_indices = ( 'pytong_2015', date(2015, 09, 26), date(2015, 09, 28), 'pyconfr_2015', date(2015, 10, 17), date(2015, 10, 21)) filter_backends = (LogPermissionsFilter, ElasticsearchQuerySearchFilter, ElasticsearchTimeRangeFilter, ElasticsearchTimedIndicesFilter)
  25. 33-51 Rechercher par query string • Besoin : effectuer des

    recherches basiques. • Query string : – ?search=<term1>:<value1>,<term2>:<value2>,… • Exemple : – term1 = F('term', key=value) – F('and', [term1, term2]) • Code snippet : – https://gist.github.com/alexandre-figura/13a1540c1f 49e8bf765c
  26. 34-51 Filtrer par date 1/2 • Besoin : sélectionner des

    logs parmi un intervalle de temps donné. • Query string : – ?from_time=<time1>,to_time=<time2> • Exemple : – Q('range', {'timestamp': {'gte': time1, 'lt': time2}}) • Code snippet : – https://gist.github.com/alexandre-figura/6028c3 8d6dcadd252616
  27. 35-51 Filtrer par date 2/2 • Problème : 1 index

    = 1 journée de logs. Les recherches sont effectuées sur l'ensemble des indices. • Solution : utiliser seulement les indices faisant parti de l'intervalle de temps. • Code snippet : – https://gist.github.com/alexandre-figura/8cb3a5 1c9758de80bad0
  28. 37-51 Exemple de permissions • Nous utilisons les catégories de

    logs pour définir des permissions. • Les utilisateurs ont le droit d'accéder à certaines categories : – logs métiers, – logs financiers, – etc.
  29. 38-51 Pour rappel... class LogList(ElasticsearchListAPIView): model = LogModel serializer_class =

    LogSerializer permission_classes = (permissions.IsAuthenticated,) filter_backends = (LogPermissionsFilter, ElasticsearchQuerySearchFilter, ElasticsearchTimeRangeFilter, ElasticsearchTimedIndicesFilter) …. class LogDetail(ElasticsearchRetrieveAPIView): model = LogModel serializer_class = LogSerializer permission_classes = (permissions.IsAuthenticated, LogPermissions)
  30. 39-51 Commençons par gérer les permissions avec une granularité très

    fine (un seul log). def has_perm(user, log_category): …. from rest_framework import permissions class LogPermissions(permissions.BasePermission): def has_object_permission(self, request, view, obj): if has_perm(request.user, obj['category']): return True return False
  31. 40-51 Continuons avec les résultats de recherches. from rest_framework import

    filters class LogPermissionsFilter(filters.BaseFilterBackend): def filter_queryset(self, request, queryset, view): restricted_categories = [F('term', category=ctg) for ctg in LOG_CATEGORIES if not has_perm(request.user, ctg)] if restricted_categories: queryset = queryset.filter( 'bool', must_not=restricted_categories) return queryset
  32. 42-51 Paginer avec du scrolling • Elasticsearch offre un mécanisme

    de scrolling. • Initialisation du scroll avec elasticsearch-dsl. • Parcours du scroll avec elasticsearchy-py. • Query string : – ?scroll_id=…&scroll_duration=...&scroll_size=... • Code snippet : – https://gist.github.com/alexandre-figura/44 f2c11be8e48c575d50
  33. 44-51 Tests unitaires 1/2 • Elasticsearch offre une recherche quasi

    temps réel (à 1s près par défaut). • Problème : ce n'est pas assez rapide pour des tests unitaires. • Solution : après insertion de documents, pensez à rafraichir vos indices : – elasticsearch_client.indices.refresh() – ou time.sleep(1)
  34. 45-51 Tests unitaires 2/2 Après chaque test, il faut aussi

    nettoyer Elasticsearch. class ElasticsearchTestMixin: elasticsearch = elasticsearch_client # elasticsearch.Elasticsearch() @classmethod def setUpClass(cls): super().setUpClass() cls._clean_namespace() TestCase Mixin 1/2
  35. 46-51 def setUp(self): self.addCleanup(self._clean_namespace) super().setUp() @classmethod def _clean_namespace(cls): cls.elasticsearch.indices.delete('*') try:

    cls.elasticsearch.indices.get_template('*') except elasticsearch.exceptions.NotFoundError: pass else: cls.elasticsearch.indices.delete_template('*') TestCase Mixin 2/2
  36. 47-51 Intégration continue 1/2 • Sur un serveur CI, plusieurs

    projets sont testés, plusieurs builds sont exécutés, un seul serveur Elasticsearch est installé. • Problème : les tests risquent de se marcher sur les pieds en utilisant les mêmes indices. • Solution : utiliser des Namespaces.
  37. 48-51 Intégration continue 2/2 • On pourrait réécrire toutes les

    méthodes qui utilisent des indices en argument. • Mais c'est irréaliste et insuffisant. • Résultat : on travaille plus bas niveau, en modifiant la classe de transport utilisée par le client Elasticsearch. • Code snippet : – https://gist.github.com/alexandre-figura/f dd15fe8592db25e7ad7
  38. 50-51 Trop de filtres... • Trop de filtres tue le

    filtre. • La puissance du language de recherche d'Elasticsearch est perdue. • Solution : – Faire une API qui se comporte comme un proxy. – Transférer les recherches originales et les analyser pour les optimiser, etc.