POE - Perl Object Environment

Copyright © 2007 - Jérôme Quelin

[+ del.icio.us] [+ Developers Zone] [+ Bookmarks.fr] [Digg this] [+ My Yahoo!]

Chapeau

POE est un puissant framework permettant de réaliser plusieurs tâches en même temps, tout en restant simple à utiliser. Les composants de haut niveau disponibles permettent de réaliser facilement des applications complexes. POE s'intègre très bien avec les toolkits graphiques existants et ses capacités de passage de message permettent de distribuer une application sur un réseau de machines... Bref, POE est un framework intéressant que je vous propose de découvrir.

Principes généraux de POE

Multitâche... coopératif

POE [0] est un environnement multitâche coopératif. C'est-à-dire que chaque tâche doit explicitement permettre à une autre de s'exécuter.

Cela rappellera de mauvais souvenirs à certains : Microsoft Windows 3.x (ainsi que les vieux Mac OS) utilisait cette forme de multitâche. Ce qui pose des problèmes à l'échelle d'un OS :

Cependant, une application est généralement écrite par une personne ou une équipe réduite : il est donc (relativement) facile de s'assurer que chaque développeur respecte l'environnement - au contraire d'un OS où l'utilisateur peut faire tourner n'importe quel logiciel au fonctionnement plus ou moins stable.

Ce couplage fort réduit les inconvénients du multitâche coopératif, pour révéler la puissance d'un environnement multitâche.

Ni fork(), ni threads.

Bien que POE soit multitâche, POE n'utilise ni fork() ni threads.

Les applications basées sur POE sont donc facilement portables, même sur les environnements ne supportant pas l'appel système fork() ou les threads - ce qui inclut la majorité des distributions Perl, très souvent compilées sans le support pour les threads.

De plus, les applications POE sont plus faciles à écrire :

Événements

Une application POE est une suite de traitements d'événements que les tâches s'envoient entre elles.

Un événement est simplement un message indiquant qu'il s'est passé quelque chose d'intéressant : un fichier est prêt à être traité, une alarme est arrivée à expiration, une nouvelle connexion vient d'arriver... POE va surveiller ces événements ainsi que d'autres, et notifiera les applications lors de leur occurrence.

Les gestionnaires d'événements sont des fonctions assignées par les applications, destinées à être appelées lorsque des événements arrivent. POE s'assure que ces callbacks sont appelés : lorsqu'il y a du trafic entrant, POE appelle le gestionnaire idoine, lorsqu'une alarme se produit, POE réveille l'application en appelant la fonction qui attendait, etc.

Utilisation

Du fait de sa nature multitâche et événementielle, POE est un framework de choix pour toute application en réseau - d'autant plus que POE fournit un ensemble conséquents de composants de haut niveau. Ces composants permettent de créer très rapidement une application complexe avec un minimum de lignes.

D'autre part, POE s'interface très bien avec les toolkits graphiques classiques tels que Tk, Gtk, et autres Curses. La nature événementielle des GUIs fait tout naturellement de POE un environnement de choix pour ces applications.

Performances

Comme tous les frameworks avancés, POE implique une perte d'efficacité comparé à un modèle strictement procédural. Cependant, POE reste performant et suffira pour la majorité des besoins - surtout que les avantages que POE apporte sont nombreux. Seules les applications les plus exigeantes en terme de performance devront étudier si POE leur convient réellement.

Pour parler chiffres, POE convient pour tout besoin de l'ordre du millier de messages par seconde. Donc, au delà de 10000 messages par seconde, POE n'est plus le framework idéal, et il faut se tourner vers autre chose.

Fonctionnement de POE

Le noyau

Le noyau de toute application POE est implémenté dans POE::Kernel. [1]

Le kernel s'occupe d'acheminer les événements entre les tâches et d'appeler le code censé s'en occuper. Il vérifie certaines conditions et lance des événements si besoin (alarmes, fichiers prêts à être traités, etc.), déclenchant alors le code les gérant.

La méthode run() de POE::Kernel constitue la boucle principale d'un programme. Cette méthode ne return pas tant que des tâches sont actives : tout code placé après run() devra attendre pour être exécuté que la dernière tâche se soit arrêtée.

Un programme POE suivra donc généralement le découpage suivant :

        #!/usr/bin/env perl

        use strict;
        use warnings;
        use POE;

        # initialisation et création des tâches POE...

        POE::Kernel->run();
        exit;

        # déclaration des fonctions gérant les événements.

Notez que la ligne use POE va implicitement charger d'autres modules indispensables au fonctionnement de POE, tels que POE::Kernel. Il est donc inutile d'écrire use POE::Kernel, et la ligne appelant run() est donc tout à fait valide.

Les sessions

En POE, les tâches sont appelées des sessions. Ce sont des objets POE::Session (ce module étant lui aussi automatiquement chargé lors d'un use POE).

Les sessions ont leurs propres ressources. Chaque session a ses propres descripteurs de fichier, son propre espace de stockage (appelé la heap), accepte ses propres événements, et peut même disposer de ses propres sessions filles.

Par bien des aspects, on peut assimiler les sessions aux processus d'un OS. POE s'assure que les données d'une session sont séparées des autres sessions.

Une session reste en vie tant qu'elle a quelque chose à faire. Beaucoup de choses qui appartiennent à une session requièrent qu'elle fasse quelque chose. Les sessions s'arrêtent dès qu'elles n'ont plus rien à faire. L'une des meilleures façons d'arrêter une session est de s'assurer qu'elle ne possède plus rien.

Par exemple, une socket aura besoin d'être gérée. En conséquence, POE va garder la session en vie tant qu'elle possède la socket. Dès que la socket sera fermée et libérée, POE pourra arrêter la session - en supposant bien sûr qu'elle ne possédait rien d'autre.

Les alias

Une session est référencée par un nombre (son ID), alloué par le kernel lors de sa création. Il est possible d'envoyer un message à une session en la référençant par son ID. Toutefois cela ne reste pas pratique pour les développeurs.

POE permet donc l'affectation d'alias, qui sont des noms symboliques pour les sessions. Cela permet aux sessions de se poster des messages en utilisant des noms. Une session peut avoir plusieurs alias.

POE ne peut pas savoir quelles sessions vont envoyer des événements à un alias, il va donc garder les sessions aliasées en vie pour se prémunir d'arrêter une session par erreur. Ce qui explique que l'attribution d'un alias "garde les sessions en vie".

Cependant, si toutes les sessions ne restent en vie que parce qu'elles ont un alias, attendant passivement que les autres sessions leur envoient des messages, on arrive en position de deadlock. Le kernel va détecter ces situations et enverra un signal IDLE à toutes les sessions pour essayer de les réveiller. Si les sessions ne se réveillent pas après le signal IDLE, le kernel va leur envoyer un signal ZOMBIE qui n'est pas gérable par les sessions : celles-ci vont donc mourir.

Différents niveaux d'abstraction

POE a été conçu (entre autres) pour implémenter de manière extrêmement complète les usages courants qu'on peut trouver dans une application réseau. Cela permet de réduire le temps et l'effort demandés pour écrire une application.

Mais tout le monde n'a pas besoin du même degré d'abstraction : c'est pourquoi POE fournit trois niveaux d'abstraction au développeur. Chaque niveau fournit une balance différente entre effort du développeur et contrôle du développement. Le résultat est une flexibilité accrue pour chaque tâche, et la possibilité pour une application de mélanger les composants de différents niveaux pour un résultat optimum.

C'est au développeur de choisir entre toutes ces possibilités, pour obtenir la recette qui lui permettra d'être le plus efficace pour résoudre le problème dont il a la charge.

Un peu de pratique...

Voyons maintenant comment tout cela s'articule. Lorsqu'une session est créée, elle récupère une liste d'événements auxquels elle doit répondre, avec le callback associé. Chaque callback est une référence de fonction :

        POE::Session->create(
            inline_states => {
                _start     => \&on_start,
                file_ready => \&on_file_ready,
            }
        );

Quand un événement file_ready survient, la fonction correspondante est appelée pour le gérer.

Certains événements sont prédéfinis par POE, tels que _start et _stop appelés lorsque la session est respectivement créée et stoppée. Les autres événements font partie de l'API de la session, et sont choisis librement par le développeur.

Hello, world!

Commençons par le traditionnel programme d'entraînement. Le voici dans toute sa splendeur, version POE :

        #!/usr/bin/env perl

        use warnings;
        use strict;
        use POE;

        POE::Session->create(
            inline_states => {
                _start => sub { print "Hello, world!\n"; }
            },
        );
        POE::Kernel->run();
        exit;

La partie intéressante est bien sûr la création de session. Dans cet exemple, on ne réagit qu'à l'événement _start, et le traitement associé est tellement simple qu'on utilise une fonction anonyme.

Passage de paramètres

Il est possible de passer des arguments à l'événement _start en fournissant un paramètre args à la fonction create(). Le callback les retrouvera dans ses arguments. Reprenons notre "Hello, world!" qui va cette fois saluer en fonction des arguments passés en ligne de commande :

        #!/usr/bin/env perl

        use warnings;
        use strict;
        use POE;

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
            },
            args => \@ARGV,
        );
        POE::Kernel->run();
        exit;

        sub on_start {
            my @args = @_[ARG0 .. $#_];
            print "Hello, @args!\n";
        }

Notez que la fonction on_start() récupère bien ses arguments dans @_... Mais comme un gestionnaire d'événements POE a besoin d'un contexte pour fonctionner, POE va passer d'autres arguments en plus, tels que le kernel, la heap ainsi que beaucoup d'autres choses (objet, session active, etc.).

Pour récupérer les vrais arguments (au sens arguments passés par le développeur), il faut utiliser des constantes définies (et exportées) par POE. Pour les arguments, il s'agit de ARG0 à ARG9. Et s'il y a besoin de plus, comme les vrais arguments sont placés en fin de liste, il est toujours possible d'utiliser ARG9+1 pour le onzième argument, voire $#_ pour l'indice du dernier élément de @_. C'est ce qui est utilisé dans l'exemple ci-dessus.

Passage de messages

Le but de POE est de faire cohabiter plusieurs sessions, chacune gérant son périmètre. POE leur permet de s'échanger des messages.

Prenons donc maintenant le cas d'une application un peu plus évoluée. On va créer deux sessions :

Cette application n'est pas très utile, d'autant plus qu'on n'a pas forcément besoin de deux sessions pour effectuer cela... Mais elle permettra d'étudier le passage de messages avec POE.

Commençons par regarder la création de logger. Celle-ci doit réagir aux événements debug :

        POE::Session->create(
            inline_states => {
                _start => \&on_logger_start,
                debug  => \&on_logger_debug,
            },
        );

Le callback de départ va mettre en place un alias logger avec la méthode alias_set() du kernel. Celui-ci peut être récupéré via $_[KERNEL], avec KERNEL une autre constante déclarée et exportée par POE :

        sub on_logger_start {
            my $kernel = $_[KERNEL];
            $kernel->alias_set( 'logger' );
        }

Le callback de log proprement dit n'a rien d'exceptionnel, on appelle juste warn() avec tous les arguments reçus :

        sub on_logger_debug {
            my @args = @_[ARG0 .. $#_];
            warn @args;
        }

Passons maintenant à la création de l'autre session :

        POE::Session->create(
            inline_states => {
                _start => \&on_alarm_start,
                tick   => \&on_alarm_tick,
            },
        );

Là encore, rien de nouveau. Mais la fonction d'initialisation est plus intéressante. Comme on ne souhaite se réveiller que dix fois avant de s'arrêter, cela veut dire qu'on doit garder un compteur quelque part. Il est bien sûr hors de question d'utiliser une variable globale. On va donc garder cette information avec les données de la session, c'est-à-dire dans la heap (autrement dit le tas, par analogie avec la partie de mémoire d'un processus où la mémoire sera allouée à coups de malloc()). Chaque session dispose d'une référence à un hash anonyme (par défaut), accessible via $_[HEAP] (HEAP étant là encore une constante définie et exportée par POE). La fonction d'initialisation va aussi armer une alarme, qui devra lancer le message tick au bout d'une seconde :

        sub on_alarm_start {
            $_[HEAP]->{nb} = 0;
            $_[KERNEL]->delay_set( 'tick', 1 );
        }

Le kernel sait quelle session est active à tout moment, il n'a donc pas besoin de cette information pour la méthode delay_set() : celle-ci va poster l'événement tick à la session courante.

Lors de la réception d'un tick, ne reste plus qu'à poster un événement debug à destination de logger grâce à la méthode post() du kernel, incrémenter notre compteur, et si celui-ci le permet, alors relancer une alarme :

        sub on_alarm_tick {
            my ($k,$h) = @_[KERNEL, HEAP];
            $k->post( 'logger', 'debug', 'this is a test: msg ', $h->{nb} );
            return if ++$h->{nb} > 9;
            $k->delay_set( 'tick', 1 );
        }

La méthode post() prend comme arguments la session destinatrice, l'événement à déclencher, et des paramètres éventuels pour le callback du dit événement.

Et voici donc le programme complet :

        #!/usr/bin/env perl

        use strict;
        use warnings;
        use POE;

        POE::Session->create(
            inline_states => {
                _start => \&on_logger_start,
                debug  => \&on_logger_debug,
            },
        );
        POE::Session->create(
            inline_states => {
                _start => \&on_alarm_start,
                tick   => \&on_alarm_tick,
            },
        );
        POE::Kernel->run();
        exit;

        # -- logger
        sub on_logger_start {
            my $kernel = $_[KERNEL];
            $kernel->alias_set( 'logger' );
        }
        sub on_logger_debug {
            my @args = @_[ARG0 .. $#_];
            warn @args;
        }

        # -- alarm
        sub on_alarm_start {
            $_[HEAP]->{nb} = 0;
            $_[KERNEL]->delay_set( 'tick', 1 );
        }
        sub on_alarm_tick {
            my ($k,$h) = @_[KERNEL, HEAP];
            $k->post( 'logger', 'debug', 'this is a test: msg ', $h->{nb} );
            return if ++$h->{nb} > 9;
            $k->delay_set( 'tick', 1 );
        }

On peut remarquer que bien qu'un alias ait été défini pour logger, nul besoin de l'enlever : le kernel détecte que plus aucune session n'est active et arrête donc bien toutes les sessions.

Utilisation avancée

Modulariser le code

Il est généralement plus propre de gérer chaque session dans un module : de cette façon on obtient des composants bien encapsulés, et plus facilement réutilisables.

Dans ce cas-là, le module va fournir une méthode spawn() (c'est une convention de nommage que chacun est libre de respecter... ou non) qui lancera une nouvelle session. Toutes les fonctions de gestion d'événements seront alors dans le module, pour une encapsulation forte.

Une autre méthode de modularisation consiste à utiliser plusieurs objets dans la même session, chacun gérant un ensemble de messages. Il est même possible de mélanger, au sein d'une même session, gestion par objets et gestion par fonctions.

Ainsi, en reprenant l'exemple précédent, et en supposant qu'on a une classe Logger qui dispose des méthodes debug() et error() :

        my $logger = Logger->new( ... );
        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
            },
            object_states => [
                $logger => [ 'debug', 'error' ],
            ],
        );

Ici, le paramètre object_states est identique à inline_states, sauf qu'il attend une référence de liste. En effet, les clefs d'un hash sont automatiquement transformés en chaîne par Perl, et la référence de l'objet serait perdue. Donc on fournit à object_states une liste composée de paires : chaque objet est suivi par une référence vers la liste des messages qu'il va traiter.

Cette liste des messages peut être :

Ainsi, voici l'exemple ci-dessus qui utilise deux objets : le premier pour logger les messages bénins, et un deuxième qui va gérer les erreurs fatales :

        my $logger = Logger->new( ... );
        my $error  = Error->new( ... );
        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
            },
            object_states => [
                $logger => [ 'debug', 'warning' ],
                $error  => {
                    critical  => 'graceful_exit',
                    emergency => 'emergency_exit',
                },
            ],
        );

La session ainsi créée va donc répondre aux événements :

Tout ceci laisse donc énormément de place au développeur pour choisir le type de modularisation souhaité.

POE référence les sessions tout seul

Vous avez très certainement remarqué que les objets POE::Session ne sont quasiment jamais stockés. C'est parce que POE s'occupe de les gérer lui-même, pour plusieurs raisons.

Envoi de messages à soi-même

Il est fréquent que les sessions s'envoient des messages à elles-mêmes, pour différentes raisons. Il est possible de faire cela en récupérant $_[SESSION] qui est la session courante, et de lui post()-er un message :

        $_[KERNEL]->post( $_[SESSION], $event, @args );

Mais cela est plutôt maladroit pour une opération finalement assez banale. POE propose donc une méthode yield() qui fait exactement cela en beaucoup plus simple :

        $_[KERNEL]->yield( $event, @args );

Puisqu'on parle des messages, ceux-ci sont traités dans un ordre FIFO, c'est-à-dire que les premiers messages lancés seront les premiers livrés.

Remplacement de la heap

POE permet de remplacer la heap par n'importe quel scalaire au moment de la création. Ceci peut être intéressant si on souhaite utiliser un objet afin de vraiment encapsuler l'accès aux données de la session avec des accesseurs comme get_nb() et set_nb() (pour notre exemple ci-dessus). Il faut dans ce cas utiliser le paramètre heap lors de l'appel à create() :

        POE::Session->create(
            # ...
            heap => My::Heap->new( ... ),
            # ...
        );

Chaque session doit gérer un événement _start()

L'événement _start notifie à une session qu'elle a démarrée. POE envoie ce message automatiquement car il est très fréquent d'initialiser une session.

Le gestionnaire de l'événement _start() sera déclenché immédiatement. Son but est d'initialiser la session et de créer des choses qui appartiendront à la session. En effet, la session va très certainement s'arrêter immédiatement si elle ne gère rien après le retour du callback de _start(), car POE la passera au ramasse-miettes.

À propos des événements non gérés

Les sessions définissent lors de leur création les événements auxquels elles vont réagir. Lorsqu'une session reçoit par la suite un événement non géré, celui-ci sera discrètement ignoré.

Ce n'est en effet pas une erreur d'envoyer un message à une session qui ne sait pas le gérer. POE va donc le supprimer sans avertissement, ce qui permet à des programmes modulaires de fonctionner quand des sessions optionnelles ne sont pas présentes. Par exemple, on peut imaginer un programme contenant une session de debug qui loggue des informations dans un fichier pendant le développement. Mais quand la session est omise (dans le programme de production), les événements contenant les messages de debug sont tout simplement éliminés, et le programme fonctionne correctement.

Cependant, les messages non traités peuvent être une source d'erreur énervante. Il est en effet banal de mal taper le nom d'un événement, ou d'oublier d'ajouter son handler dans le constructeur de la session. Dans ces cas, les programmes cessent de fonctionner normalement, sans qu'on sache pourquoi. On peut changer POE pour qu'il traite certaines choses optionnelles en tant qu'erreur, ce qui inclut les situations où des événements sont éliminés silencieusement. Il suffit pour cela de définir une nouvelle fonction constante dans le module POE::Kernel, mais avant que celui-ci soit chargé :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        sub POE::Kernel::ASSERT_DEFAULT () { 1 }
        use POE;
        POE::Session->create(
            inline_states => {
                _start => sub { $_[KERNEL]->yield( 'unknown' ); },
            }
        );
        POE::Kernel->run();
        exit;

La fonction ASSERT_DEFAULT déclenche les assertions pour tous les cas marginaux de POE. Il déclenche la validation des paramètres et des valeurs de retour, ainsi que la détection avancée de fuites de mémoire dans POE. Bien sûr, cela va ralentir le programme, mais cela peut toujours être intéressant lors des phases de développement et déboguage.

En lançant l'exemple ci-dessus, le résultat ne se fait pas attendre :

        $ ./unknown.pl
        a 'unknown' event was sent from unknown.pl at 8 to session 2 (POE::Session=ARRAY(0x808b8e8)) but session 2 (POE::Session=ARRAY(0x808b8e8)) has neither a handler for it nor one for _default

Comme le message d'erreur ci-dessus le précise, on peut aussi fournir à la session un gestionnaire pour l'événement _default, qui capturera tous les messages non nommés durant la création de la session. Dans le gestionnaire d'événement associé, $_[ARG0] correspond au nom du message envoyé initialement, et $_[ARG1] est une référence de liste contenant les arguments initiaux du message envoyé. Cela permet de créer un gestionnaire fourre-tout, qui peut être utile pour faire son propre dispatch.

Gestion des signaux

Les signaux POSIX sont aussi gérés via POE. Il suffit pour cela d'enregistrer avec la méthode sig() du kernel les signaux qu'on souhaite traiter en association avec le message qui sera généré lors de cet événement, et le kernel enverra ce signal à toutes les sessions. Le paramètre $_[ARG0] contiendra le nom du signal.

        $_[KERNEL]->sig( USR1 => 'usr1' );

Les signaux dits terminaux (HUP, INT, KILL, QUIT et TERM) arrêtent le programme s'ils ne sont pas gérés. Pour cela, ils doivent utiliser la méthode sig_handled() du kernel pour signifier que le signal a été traité.

Le bon citoyen POE

Du fait de la nature de POE, les programmes ne vont fonctionner correctement que si leurs composants coopèrent entre eux.

Une session qui va appeler sleep() ou toute autre construction qui ne revient pas immédiatement va mettre l'ensemble du programme en pause. Ainsi, les appels à sleep() doivent être remplacés avec des appels à delay_set(), qui appelleront un événement déterminé, avec des arguments éventuels :

        $_[KERNEL]->delay_set( 'tick', 10, @args ); # wait 10 secs

Il est aussi possible de positionner une alarme à une date donnée :

        $_[KERNEL]->alarm_set( 'alarm', $epoch, @args );

De même, une longue boucle peut empêcher les autres sessions de recevoir leur quota de temps. Pour cela, il faut donc les transformer de façon à ce que la boucle soit coupée en petits morceaux :

        sub long_loop_start {
            print "start...\n";
            $_[HEAP]->{i} = 0;
            $_[KERNEL]->yield('long_loop');
        }

        sub long_loop {
            my $h = $_[HEAP];
            if ( $h->{i} < 1_000_000 ) {
                # ... 
                $h->{i}++;
                $_[KERNEL]->yield('long_loop');
            } else {
                print "done.\n";
            }
        }

Bien sûr, appeler un nouvel événement pour chaque itération va énormément ralentir la boucle. Dans ce cas, une solution intermédiaire consiste à faire plusieurs itérations dans le même événement. Par exemple, avec 100 itérations par événement :

        sub long_loop {
            my $h = $_[HEAP];
            my $i = 0;
            while ( $i++ < 100 &&
                    $h->{i} < 1_000_000 ) {
                # ...
                $h->{i}++;
            }
            if ( $h->{i} < 1_000_000 ) {
                $_[KERNEL]->yield('long_loop');
            } else {
                print "done.\n";
            }
        }

Ainsi, la boucle devrait prendre beaucoup moins de temps, tout en permettant aux autres sessions (ou aux autres tâches de la session) d'avoir une chance de s'exécuter.

C'est la même chose pour les appels à die() ou exit() : la tâche faisant cet appel doit bien être consciente qu'elle va arrêter toutes les sessions et les tâches en cours, et non pas elle-même uniquement. Il y a des chances que cela ne soit pas l'effet désiré !

Interfaces graphiques et POE

POE et les boucles d'événements

POE fournit sa propre boucle d'événements, basée sur des appels à select() et écrite entièrement en Perl.

Cependant, POE peut très bien s'adapter à d'autres boucles d'événements. En particulier, les toolkits graphiques utilisent en interne une boucle d'événements qui leur est propre. C'est ainsi que POE peut s'interfacer avec Gtk, Glib (pour les interfaces Gtk2), Tk, ou Wx quand l'un de ces modules est chargé avant POE::Kernel :

        use Gtk;
        use POE;

POE::Kernel détecte que Gtk a été chargé, et il chargera automatiquement la bonne interface pour l'utiliser. L'interface publique de POE demeure bien sûr la même, quelle que soit la boucle d'événements chargée et utilisée.

Note : ceci est aussi valable pour d'autres boucles d'événements tels que Event.pm pour les programmes basés sur Event ou bien IO::Poll, qui est potentiellement plus efficace que la boucle par défaut de POE basée sur select(), en particulier pour les clients et serveurs fortement sollicités.

Il est aussi possible de charger une autre boucle d'événements avec la construction suivante :

        use POE qw[ Loop::Gtk ];

ou alors :

        use POE::Kernel { loop => "Gtk" };
        use POE::Session;

Dans le cas de Tk, qui refuse de s'exécuter si aucun widget n'est créé, POE va créer une fenêtrer principale et l'exporter via $poe_main_window.

POE offre aussi un mécanisme simple pour avertir de la destruction d'un widget. Il suffit d'enregistrer via la méthode signal_ui_destroy() le widget à surveiller. POE lancera alors un signal UIDESTROY lorsque le widget sera détruit :

        $heap->{gtk_toplevel_window} = Gtk::Window->new('toplevel');
        $kernel->signal_ui_destroy( $heap->{gtk_toplevel_window} );

Un exemple simple

Voyons maintenant comment cela marche. Imaginons un simple compteur incrémenté toutes les secondes, dont on affiche la valeur.

On commence donc tout classiquement en chargeant les modules souhaités :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use Tk;
        use POE;

On charge le module Tk avec POE, ce qui va donc changer la boucle d'événements interne de POE, ainsi qu'on l'a vu précédemment.

On continue en créant une session qui va répondre (en plus de l'initialisation) à l'événement tick qui sera lancé toutes les secondes. Puis on lance la boucle interne de POE, qui ne reviendra que pour arrêter le programme :

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
            }
        );
        POE::Kernel->run();
        exit;

La partie initialisation va créer l'interface graphique, en utilisant $poe_main_window. Dans notre cas simpliste, on crée deux labels : le premier servira d'en-tête, tandis que le second affichera le contenu du compteur stocké dans la heap :

        sub on_start {
            my ($k, $h) = @_[KERNEL, HEAP];

            $poe_main_window->Label( -text => 'counter' )->pack;
            $poe_main_window->Label( -textvariable => \$h->{counter} )->pack;

            $k->delay_set('tick', 1);
        }

Bien sûr, la dernière action de la fonction d'initialisation est de démarrer une alarme qui sera délivrée une seconde plus tard. Cette alarme sera traitée dans la fonction on_tick() qui incrémentera le compteur et réinitialisera l'alarme. Évidemment, comme le second label affiche le contenu de la variable incrémentée, on n'a pas besoin de demander le rafraîchissement du label.

        sub on_tick {
            $_[HEAP]->{counter}++;
            $_[KERNEL]->delay_set('tick', 1);
        }

Ce qui donne le programme suivant :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use Tk;
        use POE;

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
            }
        );
        POE::Kernel->run();
        exit;

        sub on_start {
            my ($k, $h) = @_[KERNEL, HEAP];

            $poe_main_window->Label( -text => 'counter' )->pack;
            $poe_main_window->Label( -textvariable => \$h->{counter} )->pack;

            $k->delay_set('tick', 1);
        }

        sub on_tick {
            $_[HEAP]->{counter}++;
            $_[KERNEL]->delay_set('tick', 1);
        }

Bien sûr, ce programme est très simple et ne propose aucune interactivité. Voyons comment faire pour ajouter des widgets qui permettront à l'utilisateur d'influer sur le fonctionnement du programme.

Ajoutons de l'interactivité

Reprenons notre exemple ci-dessus, et ajoutons un bouton qui permet de réinitialiser le compteur. La majorité du programme reste la même, bien sûr, mais il faut rajouter quelques lignes.

Cela commence lors de la création de la session, qui va maintenant réagir à un nouvel événement zero envoyé lorsque l'utilisateur souhaite réinitialiser le compteur :

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
                zero   => \&on_zero,
            }
        );

La fonction d'initialisation, responsable de la construction de l'interface, va donc créer un bouton. Et c'est là qu'on va voir une différence avec un programme Tk : on ne va pas spécifier un callback directement, callback qui ne serait pas intégré à POE et qui ne respecterait pas l'ordonnancement de POE. À la place, on va demander à la session de se poster un événement qui sera acheminé par POE et donc géré comme un événement normal. Ceci est fait grâce à la méthode postback() de la session courante. On obtient donc le code suivant :

        sub on_start {
            my ($k, $s, $h) = @_[KERNEL, SESSION, HEAP];

            # ... création des labels 

            $poe_main_window->Button(
                -text    => 'zero',
                -command => $s->postback('zero')
            )->pack;

            # ... fin de l'initialisation
            
        }

On rajoute donc tout simplement un bouton 'zero' qui, lorsqu'il sera pressé, lancera un événement POE nommé 'zero'. Il sera traité dans sa fonction associée, et son seul rôle sera de remettre le compteur stocké sur la heap à zéro :

        sub on_zero { $_[HEAP]->{counter} = 0; }

Voilà donc notre programme interactif :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use Tk;
        use POE;

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                tick   => \&on_tick,
                zero   => \&on_zero,
            }
        );
        POE::Kernel->run();
        exit;

        sub on_start {
            my ($k, $h, $s) = @_[KERNEL, HEAP, SESSION];

            $poe_main_window->Label( -text => 'counter' )->pack;
            $poe_main_window->Label( -textvariable => \$h->{counter} )->pack;

            $poe_main_window->Button(
                -text    => 'zero',
                -command => $s->postback('zero')
            )->pack;

            $k->delay_set('tick', 1);
        }

        sub on_tick {
            $_[HEAP]->{counter}++;
            $_[KERNEL]->delay_set('tick', 1);
        }

        sub on_zero { $_[HEAP]->{counter} = 0; }

On se retrouve, grâce à POE, avec un code facile à lire et modulaire. Il est ainsi aisé de créer des fenêtres différentes qui seront gérées par des sessions dédiées, tout en permettant à des sessions non-graphiques de continuer à gérer en arrière-plan des connexions réseau ou toute autre fonction qui n'a rien à voir avec l'interface graphique.

POE distribué

IKC

Rien qu'avec ce qu'on a vu ci-dessus, POE est un framework de choix pour bien des applications. Mais POE ne s'arrête pas là et permet beaucoup plus, en fournissant un moyen de distribuer des applications sur plusieurs machines - ou sur la même machine mais dans des processus différents.

POE permet en fait la communication inter-kernel (ou IKC, Inter Kernel Communication). Ceci est implémenté dans POE::Component::IKC qui fournit tout ce qu'il faut.

Logger implémenté en tant que service

Pour comprendre un peu la puissance d'IKC, reprenons notre session logger vue précédemment. Mais cette fois-ci, on va l'implémenter dans son propre processus, accessible de n'importe quelle session utilisant IKC.

Outre les en-têtes habituels, on va charger la partie serveur d'IKC via POE::Component::IKC::Server :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use POE;
        use POE::Component::IKC::Server;

Cela va permettre de créer un serveur IKC, qui sera en fait un composant POE utilisant sa propre session. On fournit l'IP (ou le nom), le port sur lequel écouter, ainsi que le nom du serveur qui servira à retrouver ce kernel via IKC. Dans cet exemple, on prend le nom my-server :

        POE::Component::IKC::Server->spawn(
            ip   => 'localhost',
            port => 17000,
            name => 'my-server',
        );

On crée ensuite notre session logger qui va s'occuper de logger les messages. Elle ne diffère pas de celle qu'on a créé dans l'exemple ci-dessus. On peut ensuite lancer le kernel de la manière standard :

        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                debug  => \&on_debug,
            }
        );
        POE::Kernel->run();
        exit;

Pour garder cet exemple aussi simple que possible, la fonction on_debug() ne change pas et fera juste un warn() avec les paramètres reçus. La fonction on_start(), elle non plus, ne change pas tellement. La seule chose différente consiste à publier via IKC notre alias de session ainsi que les messages acceptés dans un contexte de communication inter-kernel :

        sub on_start {
            my $k = $_[KERNEL];
            $k->alias_set('logger');
            $k->call( IKC => publish => 'logger', ['debug'] )
        }

        sub on_debug {
            warn @_[ARG0 .. $#_];
        }

On a donc un serveur en bonne et due forme :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use POE;
        use POE::Component::IKC::Server;

        POE::Component::IKC::Server->spawn(
            ip   => 'localhost',
            port => 17000,
            name => 'my-server',
        );
        POE::Session->create(
            inline_states => {
                _start => \&on_start,
                debug  => \&on_debug,
            }
        );
        POE::Kernel->run();
        exit;

        sub on_start {
            my $k = $_[KERNEL];
            $k->alias_set('logger');
            $k->call( IKC => publish => 'logger', ['debug'] )
        }

        sub on_debug {
            warn @_[ARG0 .. $#_];
        }

Le client n'est pas tellement plus dur à écrire. Cette fois-ci on va charger la partie client d'IKC :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use POE;
        use POE::Component::IKC::Client;

On va ensuite créer un client IKC qui va se connecter sur le serveur, en lui fournissant un callback lorsque la connexion sera effective. On ne crée pas d'autre session pour l'instant, car cela ne sert à rien tant que le client n'est pas connecté.

        POE::Component::IKC::Client->spawn( 
            host => 'localhost', 
            port => 17000, 
            name => "client-$$",
            on_connect => \&on_connect,
        );
        POE::Kernel->run();
        exit;

Par contre, lorsque le client s'est connecté, on peut maintenant créer notre session, qui va envoyer un message de debug toutes les secondes. La création de session est tout à fait classique :

        sub on_connect {
            POE::Session->create(
                inline_states => {
                    _start => \&on_start,
                    tick   => \&on_tick,
                }
            );
        }

De même, la fonction on_start() ne change pas beaucoup, mis à part la souscription au service IKC publié par le serveur. Les souscriptions à des services POE distants sont du type poe://<kernel>/<session>/<événement>. Bien sûr, kernel doit correspondre à un nom de kernel tel que publié via le paramètre name, session doit correspondre à un nom de session et de même pour événement. On peut utiliser des jokers à la place, tel que poe://<kernel>/logger/*.

        sub on_start {
            my $k = $_[KERNEL];
            $_[HEAP]->{nb} = 0;
            $k->delay_set('tick', 1);
            $k->post('IKC', 'subscribe', [ 'poe://my-server/logger/debug' ]);
        }

Une fois souscrit à cet événement, on peut poster des événements de manière transparente de la façon suivante :

        sub on_tick {
            my ($k, $h) = @_[KERNEL, HEAP];

            $k->post('poe://my-server/logger', 'debug', $h->{nb}++ );
            $k->delay_set('tick', 1);
        }

Notre client complet est donc :

        #!/usr/bin/env perl
        use strict;
        use warnings;
        use POE;
        use POE::Component::IKC::Client;

        POE::Component::IKC::Client->spawn( 
            host => 'localhost', 
            port => 17000, 
            name => "client-$$",
            on_connect => \&on_connect,
        );
        POE::Kernel->run();
        exit;


        sub on_connect {
            POE::Session->create(
                inline_states => {
                    _start => \&on_start,
                    tick   => \&on_tick,
                }
            );
        }

        sub on_start {
            my $k = $_[KERNEL];
            $_[HEAP]->{nb} = 0;
            $k->delay_set('tick', 1);
            $k->post('IKC', 'subscribe', [ 'poe://my-server/logger/debug' ]);
        }

        sub on_tick {
            my ($k, $h) = @_[KERNEL, HEAP];
            $k->post('poe://my-server/logger', 'debug', $h->{nb}++ );
            $k->delay_set('tick', 1);
        }

À noter que ce qui passe sur le réseau est automatiquement sérialisé et dé-sérialisé, ce qui fait qu'on peut passer de manière transparente toute structure de donnée évoluée. Cependant, la sérialisation se faisant avec Data::Dumper, l'eval() correspondant peut poser des problèmes de sécurité.

Aller plus loin...

IKC permet beaucoup d'autres choses, dont voici une liste non exhaustive :

Mais tout ceci va bien au-delà de cette introduction à POE, et je vous invite à lire la documentation associée à IKC [2] pour découvrir toutes les possibilités offertes.

Conclusion

Bien qu'on n'ait fait que survoler les mécanismes associés à POE, on peut d'ores et déjà s'apercevoir que le framework est puissant et bien pensé. Rocco Caputo (l'auteur de POE) nous livre donc un monument d'ingénierie extrêmement intéressant et plaisant à utiliser.

Je ne peux que vous recommander de l'essayer, et de l'adopter pour simplifier vos applications.

Liens

Auteur

Jérôme Quelin <jquelin@gmail.com>

Merci aux mongueurs de Perl pour leur relecture attentive.

[IE7, par Dean Edwards] [Validation du HTML] [Validation du CSS]