Stocker l'état du chat sur la pile

Tout nouveau est bien oublié ancien!

Maintenant, de nombreuses personnes écrivent divers robots qui communiquent avec l'utilisateur dans la messagerie instantanée et aident en quelque sorte l'utilisateur à vivre.



Si vous regardez le code de nombreux bots, cela revient généralement au même modèle:


  • le message arrive
  • il est transmis au gestionnaire de messages utilisateur ( callback )

C'est généralement un moyen universel d'écrire des bots. Il convient aux chats à une personne et aux robots connectés à des groupes. Avec cette méthode, tout va bien sauf un: le code même des robots simples est souvent assez déroutant.


Essayons de le démêler.


Je vais commencer par les avertissements:


  1. Ce qui est décrit dans cet article convient aux bots du type <-> .
  2. Le code de cet article est un code d'esquisse. Écrit spécifiquement pour cet article en 15 minutes. Alors ne jugez pas strictement.
  3. J'ai utilisé une approche similaire en entreprise: avec l'équilibrage de charge. Mais, hélas, mon code de production a beaucoup de dépendances d'infrastructure et il est si facile de ne pas le publier. Par conséquent, cette esquisse est utilisée dans l'article. Je vais aborder les questions de développement du paradigme (je décrirai où et comment nous nous sommes développés).

Eh bien, allons-y maintenant.


Comme support, considérez la bibliothèque asynchrone aiogram, python3.7 + . Le lien contient un exemple de bot d'écho simple.


Je vais le copier ici:


Afficher le code
 """ This is a echo bot. It echoes any incoming text messages. """ import logging from aiogram import Bot, Dispatcher, executor, types API_TOKEN = 'BOT TOKEN HERE' # Configure logging logging.basicConfig(level=logging.INFO) # Initialize bot and dispatcher bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) @dp.message_handler(regexp='(^cat[s]?$|puss)') async def cats(message: types.Message): with open('data/cats.jpg', 'rb') as photo: ''' # Old fashioned way: await bot.send_photo( message.chat.id, photo, caption='Cats are here ', reply_to_message_id=message.message_id, ) ''' await message.reply_photo(photo, caption='Cats are here ') @dp.message_handler() async def echo(message: types.Message): # old style: # await bot.send_message(message.chat.id, message.text) await message.answer(message.text) if __name__ == '__main__': executor.start_polling(dp, skip_updates=True) 

On voit que l'organisation du bot est traditionnelle. Chaque fois qu'un utilisateur nous écrit quelque chose, une fonction de gestionnaire est appelée.


Quel est le problème avec ce paradigme?


Que la fonction de gestionnaire pour implémenter des dialogues complexes doit restaurer son état à partir d'une sorte de stockage sur chacun de ses appels.


Si vous regardez la plupart des robots qui prennent en charge une sorte d'entreprise (par exemple, l'embauche), ils posent des questions à l'utilisateur 1..N, puis ils font quelque chose en fonction des résultats de ces questions (par exemple, enregistrez le profil dans la base de données).


S'il était possible d'écrire un bot dans un style traditionnel (plutôt qu'un style d'anneau), il serait alors possible de stocker les données utilisateur directement sur la pile.


Essayons de le faire.


J'ai esquissé un croquis du module, en le connectant que vous pouvez utiliser avec cette bibliothèque:


Afficher le code
 #  - chat_dispatcher.py import asyncio class ChatDispatcher: class Timeout(RuntimeError): def __init__(self, last_message): self.last_message = last_message super().__init__('timeout exceeded') def __init__(self, *, chatcb, shardcb = lambda message: message.from_user.id, inactive_timeout = 15 * 60): self.chatcb = chatcb self.shardcb = shardcb self.inactive_timeout = inactive_timeout self.chats = {} async def handle(self, message): shard = self.shardcb(message) loop = asyncio.get_event_loop() if shard not in self.chats: self.chats[shard] = { 'task': self.create_chat(loop, shard), 'messages': [], 'wait': asyncio.Event(), 'last_message': None, } self.chats[shard]['messages'].append(message) self.chats[shard]['wait'].set() def create_chat(self, loop, shard): async def _chat_wrapper(): try: await self.chatcb(self.get_message(shard)) finally: del self.chats[shard] return loop.create_task(_chat_wrapper()) def get_message(self, shard): async def _get_message(inactive_timeout=self.inactive_timeout): while True: if self.chats[shard]['messages']: last_message = self.chats[shard]['messages'].pop(0) self.chats[shard]['last_message'] = last_message return last_message try: await asyncio.wait_for(self.chats[shard]['wait'].wait(), timeout=inactive_timeout) except asyncio.TimeoutError: self.chats[shard]['wait'].set() raise self.Timeout(self.chats[shard]['last_message']) if not self.chats[shard]['messages']: self.chats[shard]['wait'].clear() return _get_message 

Une petite explication:


La classe ChatDispatcher est ChatDispatcher avec les paramètres suivants:


  1. fonctions de sharding des messages entrants (pourquoi cela s'appelle sharding - plus tard, lorsque nous touchons à de grandes charges). La fonction renvoie un numéro unique indiquant une boîte de dialogue. Dans l'exemple, il renvoie simplement l'ID utilisateur.
  2. fonctions qui effectueront le travail du service de chat.
  3. Valeur de délai d'attente pour l'inactivité de l'utilisateur.

Description du poste:


  1. En réponse au premier message de l'utilisateur, une tâche asynchrone est créée qui servira au dialogue. Cette tâche fonctionnera jusqu'à la fin du dialogue.
  2. Pour recevoir un message d'un utilisateur, nous le demandons explicitement. Exemple de chat echo :
     async def chat(get_message): message = await get_message() await message.answer(message.text) 
  3. Nous répondons aux messages comme nous le propose la bibliothèque ( message.answer ).

Essayons d'écrire un bot dans ce paradigme


Exemple de code complet ici
 #  bot.py import asyncio import re from .chat_dispatcher import ChatDispatcher import logging from aiogram import Bot, Dispatcher, executor, types API_TOKEN ='    ' logging.basicConfig(level=logging.INFO) bot = Bot(token=API_TOKEN) dp = Dispatcher(bot) async def chat(get_message): try: message = await get_message() await message.answer('  ,   ') first = await get_message() if not re.match('^\d+$', str(first.text)): await first.answer('  ,  : /start') return await first.answer('  ') second = await get_message() if not re.match('^\d+$', str(second.text)): await second.answer('  ,  : /start') return result = int(first.text) + int(second.text) await second.answer(' %s (/start - )' % result) except ChatDispatcher.Timeout as te: await te.last_message.answer('-   ,  ') await te.last_message.answer(' - /start') chat_dispatcher = ChatDispatcher(chatcb=chat, inactive_timeout=20) @dp.message_handler() async def message_handle(message: types.Message): await chat_dispatcher.handle(message) if __name__ == '__main__': executor.start_polling(dp, skip_updates=True) 

Un exemple de bot écrit - il ajoute simplement quelques nombres et produit un résultat.


Le résultat ressemble à ceci:



Eh bien, regardons maintenant de plus près le code. Les instances ne devraient pas soulever de questions.


L'intégration avec notre croquis se fait donc dans le gestionnaire standard que nous appelons await chat_dispatcher.handle(message) . Et nous avons décrit le chat dans la fonction chat , je vais répéter son code ici:


 async def chat(get_message): try: message = await get_message() await message.answer('  ,   ') first = await get_message() if not re.match('^\d+$', str(first.text)): await first.answer('  ,  : /start') return await first.answer('  ') second = await get_message() if not re.match('^\d+$', str(second.text)): await second.answer('  ,  : /start') return result = int(first.text) + int(second.text) await second.answer(' %s (/start - )' % result) except ChatDispatcher.Timeout as te: await te.last_message.answer('-   ,  ') await te.last_message.answer(' - /start') 

Code de service de chat - demande simplement les données une à une à l'utilisateur. Les réponses des utilisateurs sont simplement empilées sur la pile (variables first , second , message ).


La fonction get_message peut get_message une exception si l'utilisateur n'entre rien pendant le délai d'expiration défini (et vous pouvez lui transmettre le délai d'expiration au même endroit).


État de la boîte de dialogue - directement lié au numéro de ligne dans cette fonction. En descendant dans le code, nous suivons le schéma de dialogue . Apporter des modifications au fil de dialogue n'est pas facile, mais très simple!
Ainsi, les machines d'état ne sont pas nécessaires. Dans ce paradigme, vous pouvez écrire des dialogues très complexes et comprendre que leur code sera beaucoup plus simple que du code avec des callback .


Inconvénients


Où sans eux.


  1. Pour chaque utilisateur actif, il existe une tâche-corutine. En moyenne, un processeur sert normalement environ 1 000 utilisateurs, puis les retards commencent.
  2. Redémarrage de l'ensemble du démon - met fin à toutes les boîtes de dialogue (et les redémarre).
  3. Le code [de l'exemple] n'est pas adapté à la mise à l'échelle de la charge et à l'internationalisation.

Si avec le deuxième problème, il est clair que faire: intercepter le signal d'arrêt et dire aux utilisateurs "J'ai une urgence ici, feu, je reviendrai un peu plus tard." Ce dernier problème peut entraîner des difficultés. Regardons ça:


Mise à l'échelle de la charge


De toute évidence, les bots chargés doivent être lancés sur de nombreux backends à la fois. En conséquence, le mode de fonctionnement webHook sera utilisé.


Si vous équilibrez simplement le webHook entre, disons, deux backends, alors vous devez évidemment vous assurer que le même utilisateur arrive à la même coroutine qui lui parle.


Nous l'avons fait comme suit.


  1. Sur l'équilibreur, analysez le JSON du message entrant ( message )
  2. Choisissez un ID utilisateur
  3. En utilisant l'identifiant, nous calculons le nombre de backend (== shard). Par exemple, en utilisant l' user_id % Nshards .
  4. Nous redirigeons la demande vers le fragment.

ID utilisateur - devient la clé du partage entre les coroutines des boîtes de dialogue et la base de calcul du nombre de fragments du backend dans l'équilibreur.


Le code d'un tel équilibreur est simple - il est écrit dans n'importe quelle langue en 10 minutes. Je ne l'apporterai pas.


Conclusion


Si vous écrivez des bots dans ce paradigme, vous pouvez tout simplement refaire les dialogues de l'un à l'autre. De plus, ce qui est important, c'est que le nouveau programmeur comprenne facilement le code des dialogues que quelqu'un a fait avant lui.


Pourquoi la plupart des gens écrivent des robots dans l'architecture en anneau - je ne sais pas.


Ils écrivaient dans un tel paradigme. Servir des salles de chat dans ce style a été adopté à l'ère de l'IRC et des bots pour cela. Je ne prétends donc pas être une nouveauté.


Et bien plus. Si vous utilisez ce paradigme dans un langage avec l'opérateur goto , alors ce ne sera qu'un bel exemple d'utilisation de goto (les boucles dans les dialogues sont magnifiquement faites sur goto ). Malheureusement, il ne s'agit pas de Python.

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


All Articles