Personnalisation de Django ORM sur l'exemple de ZomboDB

Souvent, lorsque vous travaillez avec Django et PostgreSQL, il existe un besoin d'extensions supplémentaires pour la base de données. Et si par exemple avec hstore ou PostGIS (grâce à GeoDjango) tout est assez pratique, alors avec des extensions plus rares - comme pgRouting, ZomboDB, etc. - vous devez soit écrire en RawSQL, soit personnaliser Django ORM. Ce que je propose dans cet article est de le faire en utilisant ZomboDB comme exemple et son tutoriel de démarrage . Et en même temps, considérons comment vous pouvez connecter ZomboDB à un projet Django.


PostgreSQL a sa propre recherche en texte intégral et cela fonctionne, à en juger par les derniers benchmarks, assez rapidement. Mais ses capacités de recherche laissent encore beaucoup à désirer. En conséquence, sans solutions basées sur Lucene - ElasticSearch, par exemple - est serré. ElasticSearch à l'intérieur possède sa propre base de données, qui effectue une recherche. La principale solution pour le moment est le contrôle manuel de la cohérence des données entre PostgreSQL et ElasticSearch à l'aide de signaux ou de fonctions de rappel manuel.


ZomboDB est une extension qui implémente son propre type d'index, transformant la valeur de la table en un pointeur vers ElasticSearch , qui permet des recherches de table en texte intégral à l'aide d'ElasticSearch DSL dans le cadre de la syntaxe SQL.


Au moment d'écrire ces lignes, une recherche sur le réseau n'a produit aucun résultat. Des articles sur Habré sur ZomboDB un seul. Il n'y a pas d'articles sur l'intégration de ZomboDB et Django.


La description de ZomboDB indique que les appels à Elasticsearch passent par l'API RESTful, donc les performances sont dans le doute, mais maintenant nous n'y reviendrons pas. Problèmes de suppression correcte de ZomboDB sans perte de données.


Ensuite, nous effectuerons tous les tests dans Docker , nous allons donc collecter un petit fichier docker-compose


docker-compose.yaml
version: '3' services: postgres: build: docker/postgres environment: - POSTGRES_USER=django - POSTGRES_PASSWORD=123456 - POSTGRES_DB=zombodb - PGDATA=/home/postgresql/data ports: - 5432:5432 # sudo sysctl -w vm.max_map_count=262144 elasticsearch: image: elasticsearch:6.5.4 environment: - cluster.name=zombodb - bootstrap.memory_lock=true - ES_JAVA_OPTS=-Xms512m -Xmx512m ulimits: memlock: soft: -1 hard: -1 ports: - 9200:9200 django: build: docker/python command: python3 manage.py runserver 0.0.0.0:8000 volumes: - ./:/home/ ports: - 8000:8000 depends_on: - postgres - elasticsearch 

La dernière version de ZomboDB fonctionne avec un maximum de la 10ème version de Postgres et nécessite une boucle à partir des dépendances (je suppose que pour faire des requêtes dans ElasticSearch).


 FROM postgres:10 WORKDIR /home/ RUN apt-get -y update && apt-get -y install curl ADD https://www.zombodb.com/releases/v10-1.0.3/zombodb_jessie_pg10-10-1.0.3_amd64.deb ./ RUN dpkg -i zombodb_jessie_pg10-10-1.0.3_amd64.deb RUN rm zombodb_jessie_pg10-10-1.0.3_amd64.deb RUN apt-get -y clean 

Le conteneur pour Django est typique. Nous y mettrons uniquement les dernières versions de Django et psycopg2.


 FROM python:stretch WORKDIR /home/ RUN pip3 install --no-cache-dir django psycopg2-binary 

ElasticSearch sous Linux ne démarre pas avec les paramètres de base de vm.max_map_count, nous devrons donc les augmenter un peu (qui sait comment automatiser cela via docker - écrivez dans les commentaires).


 sudo sysctl -w vm.max_map_count=262144 

L'environnement de test est donc prêt. Vous pouvez aller au projet sur Django. Je ne le donnerai pas dans son ensemble; ceux qui le souhaitent peuvent le voir dans le référentiel sur GitLab . Je ne m'attarderai que sur les points critiques.


La première chose que nous devons faire est de brancher ZomboDB en tant qu'extension dans PostgreSQL. Vous pouvez bien sûr vous connecter à la base de données et activer l'extension via SQL CREATE EXTENSION zombodb; . Vous pouvez même utiliser le hook docker-entrypoint-initdb.d dans le conteneur officiel Postgres pour cela. Mais puisque nous avons Django, nous allons suivre son chemin.
Après avoir créé le projet et créé la première migration, ajoutez-y une connexion d'extension.


 from django.db import migrations, models from django.contrib.postgres.operations import CreateExtension class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ CreateExtension('zombodb'), ] 

Deuxièmement, nous avons besoin d'un modèle qui décrira le modèle de test. Pour ce faire, nous avons besoin d'un champ qui fonctionne avec le type de données zdb.fulltext. Eh bien, écrivons le vôtre . Étant donné que ce type de données pour django se comporte de la même manière que le texte natif postgresql, lorsque nous créerons notre champ, nous hériterons notre classe de models.TextField. De plus, deux choses importantes doivent être faites: désactiver la possibilité d'utiliser l'index Btree sur ce champ et restreindre le backend pour la base de données. Le résultat final est le suivant:


 class ZomboField(models.TextField): description = "Alias for Zombodb field" def __init__(self, *args, **kwargs): kwargs['db_index'] = False super().__init__(*args, **kwargs) def db_type(self, connection): databases = [ 'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgis' ] if connection.settings_dict['ENGINE'] in databases: return 'zdb.fulltext' else: raise TypeError('This database not support') 

Troisièmement, expliquez à ZomboDB où chercher notre ElasticSearch. Dans la base de données elle-même, un index personnalisé de ZomboDB est utilisé à cet effet. Par conséquent, si l'adresse change, alors l'index doit être modifié.
Django nomme les tables selon le modèle app_model: dans notre cas, l'application est appelée main et le modèle est article. elasticsearch est le nom DNS que Docker attribue par nom de conteneur.
En SQL, cela ressemble à ceci:


 CREATE INDEX idx_main_article ON main_article USING zombodb ((main_article.*)) WITH (url='elasticsearch:9200/'); 

Dans Django, nous devons également créer un index personnalisé. Les index n'y sont pas encore très flexibles: en particulier, l'index zombodb n'indique pas une colonne spécifique, mais la table entière. Dans Django, un index nécessite une référence de champ obligatoire. J'ai donc remplacé statement.parts['columns'] par ((main_article.*)) , Mais les méthodes de construction et de déconstruction nécessitent toujours de spécifier l'attribut fields lors de la création du champ. Nous devons également transmettre un paramètre supplémentaire aux paramètres. Pourquoi remplacer la __init__ , deconstruct et get_with_params .
En général, la conception s'est avérée efficace. Les migrations sont appliquées et annulées sans problème.


 class ZomboIndex(models.Index): def __init__(self, *, url=None, **kwargs): self.url = url super().__init__(**kwargs) def create_sql(self, model, schema_editor, using=''): statement = super().create_sql(model, schema_editor, using=' USING zombodb') statement.parts['columns'] = '(%s.*)' % model._meta.db_table with_params = self.get_with_params() if with_params: statement.parts['extra'] = " WITH (%s) %s" % ( ', '.join(with_params), statement.parts['extra'], ) print(statement) return statement def deconstruct(self): path, args, kwargs = super().deconstruct() if self.url is not None: kwargs['url'] = self.url return path, args, kwargs def get_with_params(self): with_params = [] if self.url: with_params.append("url='%s'" % self.url) return with_params 

Ceux qui n'aiment pas cette approche peuvent utiliser les migrations à partir de RunSQL en ajoutant directement un index. Il vous suffit de suivre le nom de la table et de vous indexer.


 migrations.RunSQL( sql = ( "CREATE INDEX idx_main_article " "ON main_article " "USING zombodb ((main_article.*)) " "WITH (url='elasticsearch:9200/');" ), reverse_sql='DROP INDEX idx_main_article' ) 

Le résultat est un tel modèle. ZomboField accepte les mêmes arguments que TextField, à une exception près - index_db n'affecte rien, tout comme l'attribut fields de ZomboIndex.


 class Article(models.Model): text = ZomboField() class Meta: indexes = [ ZomboIndex(url='elasticsearch:9200/', name='zombo_idx', fields=['text']) ] 

Au final, le fichier de migration devrait ressembler à ceci:


 from django.db import migrations, models from django.contrib.postgres.operations import CreateExtension import main.models class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ CreateExtension('zombodb'), migrations.CreateModel( name='Article', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', main.models.ZomboField()), ], ), migrations.AddIndex( model_name='article', index=main.models.ZomboIndex(fields=['text'], name='zombo_idx', url='elasticsearch:9200/'), ) ] 

Pour ceux qui sont intéressés, je joins SQL qui produit Django ORM (vous pouvez regarder à travers sqlmigrate , bien, ou en tenant compte de docker: sudo docker-compose exec django python3 manage.py sqlmigrate main 0001 )


 BEGIN; -- -- Creates extension zombodb -- CREATE EXTENSION IF NOT EXISTS "zombodb"; -- -- Create model Article -- CREATE TABLE "main_article" ("id" serial NOT NULL PRIMARY KEY, "text" zdb.fulltext NOT NULL); -- -- Create index zombo_idx on field(s) text of model article -- CREATE INDEX "zombo_idx" ON "main_article" USING zombodb ((main_article.*)) WITH (url='elasticsearch:9200/') ; COMMIT; 

Donc, nous avons un modèle. Il reste maintenant à faire une recherche par filtre. Pour ce faire, décrivez votre recherche et enregistrez-la.


 @ZomboField.register_lookup class ZomboSearch(models.Lookup): lookup_name = 'zombo_search' def as_sql(self, compiler, connection): lhs, lhs_params = self.process_lhs(compiler, connection) rhs, rhs_params = self.process_rhs(compiler, connection) params = lhs_params + rhs_params return "%s ==> %s" % (lhs.split('.')[0], rhs), params 

La recherche dans ce cas ressemblera à ceci:


 Article.objects.filter(text__zombo_search='(call OR box)') 

Mais généralement, une recherche ne suffit pas. Il faut également classer le résultat et mettre en évidence les mots trouvés.
Eh bien, le classement est assez simple. Nous écrivons notre propre fonction :


 from django.db.models import FloatField, Func class ZomboScore(Func): lookup_name = 'score' function = 'zdb.score' template = "%(function)s(ctid)" arity = 0 @property def output_field(self): return FloatField() 

Vous pouvez maintenant créer des requêtes assez complexes sans aucun problème.


 scores = (Article.objects .filter(text__zombo_search='delete') .annotate(score=ZomboScore()) .values_list(F('score')) .order_by('-score')) 

La mise en évidence du résultat (surbrillance) s'est avérée un peu plus compliquée, cela n'a pas fonctionné à merveille. Le backend de Django psycopg2 dans n'importe quelle situation convertit _ en _ . S'il y avait du text , alors il y aurait "main_article"."text" , ce que ZomboDB n'accepte catégoriquement pas. La désignation de colonne doit être un nom de colonne textuel. Mais ici, RawSQL vient à la rescousse.


 from django.db.models.expressions import RawSQL highlighted = (Article.objects .filter(text__zombo_search='delete') .values(highlight_text=RawSQL("zdb.highlight(ctid, %s)", ('text',)))) 

La version complète du projet avec des tests peut être consultée dans le référentiel . Tous les exemples de l'article y sont écrits sous forme de tests. J'espère que pour quelqu'un, cet article sera utile et vous encouragera à ne pas écrire de vélo sur les signaux, avec la possibilité de vous tirer toute la cohérence et d'utiliser une solution prête à l'emploi sans perdre tous les aspects positifs de l'ORM. Les ajouts et corrections sont également les bienvenus.


UPD: La bibliothèque django-zombodb est apparue

Source: https://habr.com/ru/post/fr442512/


All Articles