Je travaille sur la refactorisation de certains aspects d'un service Web existant. La façon dont les API de service sont implémentées est d'avoir une sorte de "pipeline de traitement", où il y a des tâches qui sont exécutées en séquence. Sans surprise, les tâches ultérieures peuvent nécessiter des informations calculées par des tâches antérieures, et actuellement la manière de procéder consiste à ajouter des champs à une classe "état du pipeline".
J'ai pensé (et j'espère?) Qu'il y a une meilleure façon de partager des informations entre les étapes du pipeline que d'avoir un objet de données avec des champs de zillion, dont certains ont du sens pour certaines étapes de traitement et pas pour d'autres. Ce serait une douleur majeure de rendre cette classe thread-safe (je ne sais pas si ce serait même possible), il n'y a aucun moyen de raisonner sur ses invariants (et il est probable qu'elle n'en ait pas).
Je parcourais le livre sur les modèles de conception de Gang of Four pour trouver de l'inspiration, mais je n'avais pas l'impression qu'il y avait une solution (Memento était un peu dans le même esprit, mais pas tout à fait). J'ai également regardé en ligne, mais la seconde fois que vous recherchez "pipeline" ou "workflow", vous êtes inondé d'informations sur les tuyaux Unix ou de moteurs et frameworks de workflow propriétaires.
Ma question est la suivante: comment aborderiez-vous la question de l'enregistrement de l'état d'exécution d'un pipeline de traitement logiciel, afin que les tâches ultérieures puissent utiliser les informations calculées par les précédentes? Je suppose que la principale différence avec les canaux Unix est que vous ne vous souciez pas seulement de la sortie de la tâche immédiatement précédente.
Comme demandé, un pseudocode pour illustrer mon cas d'utilisation:
L'objet "contexte de pipeline" a un tas de champs que les différentes étapes du pipeline peuvent remplir / lire:
public class PipelineCtx {
... // fields
public Foo getFoo() { return this.foo; }
public void setFoo(Foo aFoo) { this.foo = aFoo; }
public Bar getBar() { return this.bar; }
public void setBar(Bar aBar) { this.bar = aBar; }
... // more methods
}
Chacune des étapes du pipeline est également un objet:
public abstract class PipelineStep {
public abstract PipelineCtx doWork(PipelineCtx ctx);
}
public class BarStep extends PipelineStep {
@Override
public PipelineCtx doWork(PipelieCtx ctx) {
// do work based on the stuff in ctx
Bar theBar = ...; // compute it
ctx.setBar(theBar);
return ctx;
}
}
De même pour une hypothétique FooStep
, qui pourrait avoir besoin de la barre calculée par BarStep avant elle, ainsi que d'autres données. Et puis nous avons le véritable appel API:
public class BlahOperation extends ProprietaryWebServiceApiBase {
public BlahResponse handle(BlahRequest request) {
PipelineCtx ctx = PipelineCtx.from(request);
// some steps happen here
// ...
BarStep barStep = new BarStep();
barStep.doWork(crx);
// some more steps maybe
// ...
FooStep fooStep = new FooStep();
fooStep.doWork(ctx);
// final steps ...
return BlahResponse.from(ctx);
}
}
la source
Réponses:
La principale raison d'utiliser une conception de pipeline est que vous souhaitez découpler les étapes. Soit parce qu'une étape peut être utilisée dans plusieurs pipelines (comme les outils shell Unix), soit parce que vous bénéficiez d'avantages de mise à l'échelle (c'est-à-dire que vous pouvez facilement passer d'une architecture à nœud unique à une architecture à nœuds multiples).
Dans les deux cas, chaque étape du pipeline doit recevoir tout ce dont elle a besoin pour faire son travail. Il n'y a aucune raison pour laquelle vous ne pouvez pas utiliser un magasin externe (par exemple, une base de données), mais dans la plupart des cas, il est préférable de passer les données d'une étape à l'autre.
Cependant, cela ne signifie pas que vous devez ou devez passer un gros objet de message avec tous les champs possibles (bien que voir ci-dessous). Au lieu de cela, chaque étape du pipeline doit définir des interfaces pour ses messages d'entrée et de sortie, qui identifient uniquement les données dont cette étape a besoin.
Vous disposez alors d'une grande flexibilité dans la manière d'implémenter vos objets de message réels. Une approche consiste à utiliser un énorme objet de données qui implémente toutes les interfaces nécessaires. Une autre consiste à créer des classes wrapper autour d'un simple
Map
. Une autre encore consiste à créer une classe wrapper autour d'une base de données.la source
Il y a quelques réflexions qui me viennent à l'esprit, la première étant que je n'ai pas assez d'informations.
Les réponses me feraient probablement réfléchir plus attentivement à la conception, mais sur la base de ce que vous avez dit, il y a 2 approches que je considérerais probablement en premier.
Structurez chaque étape comme son propre objet. La nième étape comporterait de 1 à n-1 étapes en tant que liste de délégués. Chaque étape encapsule les données et le traitement des données; réduire la complexité globale et les champs au sein de chaque objet. Vous pouvez également avoir des étapes ultérieures accéder aux données selon les besoins des étapes bien antérieures en parcourant les délégués. Vous avez toujours un couplage assez étroit entre tous les objets parce que ce sont les résultats des étapes (c'est-à-dire toutes les attrs) qui sont importants, mais ils sont considérablement réduits et chaque étape / objet est probablement plus lisible et compréhensible. Vous pouvez le rendre sûr pour les threads en rendant la liste des délégués paresseuse et en utilisant une file d'attente sécurisée pour les threads pour remplir la liste des délégués dans chaque objet selon vos besoins.
Sinon, je ferais probablement quelque chose de similaire à ce que vous faites. Un objet de données massif qui passe par des fonctions représentant chaque étape. C'est souvent beaucoup plus rapide et léger, mais plus complexe et sujet aux erreurs car il ne s'agit que d'un gros tas d'attributs de données. Évidemment pas thread-safe.
Honnêtement, j'ai fait le dernier plus souvent pour ETL et d'autres problèmes similaires. J'étais concentré sur les performances en raison de la quantité de données plutôt que de la maintenabilité. De plus, il s'agissait de pièces uniques qui ne seraient plus utilisées.
la source
Cela ressemble à un motif de chaîne dans GoF.
Un bon point de départ serait d'examiner ce que fait la chaîne des biens communs .
la source
Une première solution que je peux imaginer est de rendre les étapes explicites. Chacun d'eux devient un objet capable de traiter une donnée et de la transmettre à l'objet de processus suivant. Chaque processus produit un nouveau produit (idéalement immuable), de sorte qu'il n'y a pas d'interaction entre les processus et ensuite il n'y a aucun risque dû au partage de données. Si certains processus prennent plus de temps que d'autres, vous pouvez placer un tampon entre deux processus. Si vous exploitez correctement un planificateur pour le multithreading, il allouera plus de ressources pour vider les tampons.
Une deuxième solution pourrait être de penser «message» au lieu de pipeline, éventuellement avec un framework dédié. Vous avez alors des "acteurs" qui reçoivent des messages d'autres acteurs et envoient d'autres messages à d'autres acteurs. Vous organisez vos acteurs dans un pipeline et donnez vos données primaires à un premier acteur qui initie la chaîne. Il n'y a pas de partage de données puisque le partage est remplacé par l'envoi de messages. Je sais que le modèle d'acteur de Scala peut être utilisé en Java, car il n'y a rien de spécifique à Scala ici, mais je ne l'ai jamais utilisé dans un programme Java.
Les solutions sont similaires et vous pouvez implémenter la seconde avec la première. Fondamentalement, les concepts principaux sont de traiter des données immuables pour éviter les problèmes traditionnels dus au partage de données et de créer des entités explicites et indépendantes représentant les processus dans votre pipeline. Si vous remplissez ces conditions, vous pouvez facilement créer des pipelines clairs et simples et les utiliser dans un programme parallèle.
la source