Points Clés
- Ocado Technology utilise Java pour développer avec succès des applications exigeant des performances élevées.
- L'utilisation de Discrete Event Simulations permet aux équipes de développement d'analyser les performances sur de longues périodes, sans attendre les résultats.
- Un logiciel déterministe est essentiel pour un débogage efficace. Les systèmes temps réel étant par nature non déterministes, Ocado Technology a travaillé d'arrache-pied pour concilier cette incohérence.
- L'onion architecture insiste sur la séparation des problèmes au sein de votre application. En utilisant cette architecture, Ocado Technology s'adapte et modifie continuellement ses applications avec une relative facilité.
Chez Ocado Technology, nous utilisons des systèmes robotiques de pointe dans nos centres de traitement hautement automatisés. Sur notre site d'Erith, le plus grand entrepôt automatisé pour épicerie en ligne, nous ferons appel à plus de 3 500 robots pour traiter 220 000 commandes par semaine. Si vous n'avez pas vu nos robots en action, consultez-les sur notre chaîne YouTube .
Nos robots se déplacent à 4 m/s et à moins de 5mm les uns des autres ! Pour orchestrer notre ensemble de robots et optimiser au maximum l'efficacité de nos entrepôts, nous avons développé un système de contrôle analogue à un système de contrôle du trafic aérien.
Nous allons passer en revue trois des décisions typiques que vous devez prendre lorsque vous commencez à développer une application, puis nous expliquerons le langage, les principes de développement et les choix d'architecture que nous avons effectués pour notre système de contrôle.
Choix de langage
Tout le monde n'a pas le luxe de choisir le langage de programmation qu'il utilise en se basant uniquement sur ses mérites techniques et son adéquation à un problème particulier. Un des avantages souvent cités des microservices et de la conteneurisation est la possibilité d'adopter un environnement de développement polyglotte, mais dans de nombreuses organisations, il convient de prendre en compte d'autres considérations, telles que :
- expérience et expertise existantes
- considérations d'embauche
- support de la chaîne d'outils
- stratégie d'entreprise
Chez Ocado Technology, nous investissons énormément dans Java. Notre système de contrôle est développé en Java. Une question courante que nous entendons (et nous nous posons fréquemment !) est la suivante: pourquoi utilisons-nous Java et non un langage comme le C ++ ou, plus récemment, Rust ? La réponse : nous optimisons non seulement notre système de contrôle, mais également la productivité de nos développeurs. Ce compromis nous conduit continuellement à l'utilisation de Java. Nous avons choisi Java pour ses performances, sa vitesse de développement, sa plate-forme en évolution et le recrutement. Examinons chacun de ces facteurs à tour de rôle.
Performance
Certaines personnes pensent que Java est "plus lent" qu'un programme comparable écrit en C ou C ++, mais il s'agit en réalité d'une erreur. Il existe des exemples bien connus d'applications hautes performances écrites en Java, qui prouvent ce qui est possible en Java, comme le LMAX Disruptor. De nombreux facteurs liés aux performances des applications doivent également être pris en compte lors de la comparaison de langages, tels que la taille de l'exécutable, le temps de démarrage, l'empreinte mémoire et la vitesse d'exécution brute. En outre, il est en soi difficile de comparer les performances d'une application donnée à travers deux langues, à moins que vous ne puissiez écrire l'application de manière comparable dans les deux langues.
Bien que de nombreuses pratiques logicielles recommandées soient suivies lors du développement d'applications hautes performances en Java, le compilateur Just-In-Time (JIT) au sein de la machine virtuelle est probablement le concept le plus important pour améliorer les performances des applications par rapport à d'autres langages. En profilant le byte-code en cours d'exécution et en compilant en le byte-code approprié en code natif au moment de l'exécution, les performances des applications Java peuvent être très proches de celles d'une application native. De plus, comme un compilateur JIT fonctionne au dernier moment, il dispose des informations qu'un compilateur AOT ne peut pas avoir, principalement le chipset réel sur lequel une application est exécutée et des statistiques sur l'application. Avec ces informations, un compilateur JIT peut effectuer des optimisations qu'un compilateur AOT ne serait pas en mesure de garantir de manière sûres, donc un compilateur JIT peut effectivement surpasser un compilateur AOT dans certains cas.
Vitesse de développement
De nombreux facteurs rendent le développement en Java plus rapide que d'autres langages :
Java étant un langage de haut niveau typé, les développeurs peuvent se concentrer sur les problèmes de l'entreprise et détecter les erreurs le plus tôt possible.
Les IDE modernes fournissent aux développeurs une multitude d'outils pour écrire du code correct dès la première fois.
Java a un écosystème mature et il existe des bibliothèques et des frameworks pour presque tout. La prise en charge de Java est presque omniprésente dans les technologies middleware.
Plateforme qui évolue
L'architecte Java Mark Reinhold a déclaré que depuis vingt ans, l'amélioration de la productivité des développeurs et des performances des applications constituait l'un des principaux moteurs du développement de la machine virtuelle Java (JVM). Ainsi, au fil du temps, nous avons pu tirer profit de nos deux premières préoccupations - performances et rapidité de développement - simplement en évoluant sur un langage et une plate-forme en constante évolution. Par exemple, l'une des améliorations de performances observées entre Java 8 et Java 11 concerne les performances du garbage collector G1, ce qui permet à notre système de contrôle de disposer de plus de temps pour appliquer des calculs intensifs.
Recrutement
Dernier point, mais non le moindre pour une entreprise en croissance, il est essentiel de pouvoir recruter facilement des développeurs. Dans tous les index des langages populaires, y compris Tiobe, GitHub, StackOverflow et ITJobsWatch, Java est toujours proche ou au sommet. Cette position signifie que nous disposons d'un très grand pool de développeurs pour recruter les meilleurs talents.
Principes de développement
Après le choix du langage, la seconde décision clé de notre système a été les principes ou pratiques de développement que nous avons adoptés en équipe pour développer notre application. L'ampleur des décisions discutées ici ressemble à la célèbre décision de Jeff Bezos de mettre Amazon au service de ses clients. Ces décisions ne sont pas faciles à changer, contrairement à une décision telle que l'utilisation de la programmation en binôme (pair programming).
Chez Ocado Technology, nous développons nos systèmes de contrôle selon trois principes principaux :
- Simulation intensive pour les tests et la recherche
- S'assurer que tout notre code peut être exécuté de manière déterministe pendant la recherche et le développement, et que le même code puisse également être exécuté dans un contexte en temps réel
- Éviter l'optimisation prématurée
Simulation
Cet article de Wikipedia sur la simulation la décrit comme suit :
Une simulation est une imitation approximative du fonctionnement d'un processus ou d'un système. L'acte de simuler d'abord nécessite qu'un modèle soit développé.
Dans le contexte d'un entrepôt robotisé, nous pouvons simuler de nombreux processus et systèmes, tels que notre matériel d'automatisation, nos agents d'entrepôt exécutant des processus métier ou même d'autres systèmes logiciels.
La simulation de ces aspects de nos entrepôts présente deux avantages principaux :
- Nous avons accru la confiance que la nouvelle conception d'entrepôt offrira le débit pour lequel nous l'avons conçu.
- Nous sommes en mesure de tester et de valider les modifications algorithmiques au sein de notre logiciel, sans test sur du matériel physique.
Pour obtenir des résultats significatifs dans les deux scénarios de simulation ci-dessus, nous devons souvent effectuer des simulations sur plusieurs jours ou plusieurs semaines d'exploitation de l'entrepôt. Nous pourrions choisir de faire fonctionner nos systèmes en temps réel et d'attendre plusieurs jours ou semaines que nos simulations soient terminées, mais cela est très inefficace et nous pouvons faire mieux en utilisant une forme de Discrete Event Simulation (DES).
Un DES fonctionne en supposant que l'état d'un système ne change que lors du traitement d'un événement. Compte tenu de cette hypothèse, un DES peut maintenir une liste d'événements à traiter et, entre les traitements, peut avancer dans le temps, jusqu'au moment du prochain événement. C'est ce "voyage dans le temps" qui permet aux DES, dans la plupart des cas, de s'exécuter beaucoup plus rapidement que le code en temps réel équivalent. Ce retour rapide à nos développeurs et à nos équipes de conception d'entrepôts améliore notre productivité.
Il vaut la peine de préciser que pour pouvoir utiliser la Discrete Event Simulation, nous avons dû concevoir nos systèmes de contrôle de manière à ce qu'ils soient basés sur des événements et garantissent qu'aucun état ne change avec le temps. Cette exigence d'architecture ouvre la voie au prochain principe de développement que nous utilisons : le déterminisme.
Déterminisme
Les systèmes temps réel, par nature, ne sont pas déterministes. À moins que votre système utilise un système d'exploitation en temps réel offrant des garanties strictes en matière de planification, une grande partie du comportement non déterministe peut provenir du système d'exploitation, de la planification incontrôlable des événements et du temps de traitement imprévisible observé d'un événement.
Le déterminisme est très important lors de la R&D de notre système de contrôle, notamment lorsque nous exécutons nos simulations. Sans déterminisme, si une erreur non déterministe se produit, les développeurs doivent souvent recourir à une analyse des logs et de tests ad hoc pour tenter de reproduire l'erreur, sans aucune garantie de pouvoir la reproduire. Cela peut épuiser le temps et la motivation des développeurs.
Étant donné que les systèmes temps réel ne seront jamais déterministes, notre défi est de produire un logiciel pouvant fonctionner de manière déterministe pendant un DES, mais également de manière non déterministe en temps réel. Nous faisons cela en utilisant nos propres abstractions - temps et planification.
L'extrait de code suivant montre l'abstraction du temps, introduite pour contrôler le passage du temps :
@FunctionalInterface
public interface TimeProvider {
long getTime();
}
En utilisant cette abstraction, nous pouvons fournir une implémentation qui nous permet de "voyager dans le temps" dans nos DES :
public class AdjustableTimeProvider implements TimeProvider {
private long currentTime;
@Override
public long getTime() {
return this.currentTime;
}
public void setTime(long time) {
this.currentTime = time;
}
}
Dans notre environnement de production en temps réel, nous pouvons remplacer cette implémentation par une implémentation qui s'appuie sur l'appel système standard pour obtenir l'heure :
public class SystemTimeProvider implements TimeProvider {
@Override
public long getTime() {
return System.currentTimeMillis();
}
}
Pour la planification, nous avons également introduit nos propres abstraction et implémentations, plutôt que de compter sur les interfaces Executor ou ExecutorService proposées par Java. Nous l'avons fait parce que les interfaces Executor de Java ne fournissent pas les garanties déterministes dont nous avons besoin. Nous allons explorer les raisons pour lesquelles plus tard dans l'article :
public interface Event {
void run();
void cancel();
long getTime();
}
public interface EventQueue {
Event getNextEvent();
}
public interface EventScheduler {
Event doNow(Runnable r);
Event doAt(long time, Runnable r);
}
public abstract class DiscreteEventScheduler implements EventScheduler {
private final AdjustableTimeProvider timeProvider;
private final EventQueue queue;
public DiscreteEventScheduler(AdjustableTimeProvider timeProvider, EventQueue queue) {
this.timeProvider = timeProvider;
this.queue = queue;
}
private void executeEvents() {
Event nextEvent = queue.getNextEvent();
while (nextEvent != null) {
timeProvider.setTime(nextEvent.getTime());
nextEvent.run();
nextEvent = queue.getNextEvent();
}
}
}
public abstract class RealTimeEventScheduler implements EventScheduler {
private final TimeProvider timeProvider = new AdjustableTimeProvider();
private final EventQueue queue;
public RealTimeEventScheduler(EventQueue queue) {
this.queue = queue;
}
private void executeEvents() {
Event nextEvent = queue.getNextEvent();
while (true) {
if (nextEvent.getTime() <= timeProvider.getTime()) {
nextEvent.run();
nextEvent = queue.getNextEvent();
}
}
}
}
Dans notre DiscreteEventScheduler, vous pouvez observer la ligne timeProvider.setTime(nextEvent.getTime()), qui représente le voyage dans le temps décrit ci-dessus.
Notre RealTimeEventScheduler est un exemple de busy-loop. Cette technique est généralement déconseillée car elle fait perdre du temps de calcul pour une activité inutile. Alors, pourquoi utilisons-nous un planificateur busy-loop dans notre système de contrôle ? Nous allons explorer cela ensuite.
Optimisation
Tous les développeurs de logiciels connaissent sûrement la citation de Donald Knuth :
"L'optimisation prématurée est la racine de tout Mal."
Mais combien de personnes connaissent la citation complète à partir de laquelle cela est tirée :
"Nous devrions oublier les petites économies, disons environ 97% du temps: l' optimisation prématurée est la racine de tous les maux . Cependant, nous ne devrions pas laisser passer nos opportunités dans ces 3% critiques."
Dans notre système de contrôle d'entrepôt, nous recherchons ces 3% d'opportunités qui permettent à notre système de fonctionner de manière aussi optimale que possible ! Le planificateur busy-loop précédent est l'une de ces possibilités.
En raison de la nature en temps réel de notre système, nous avons les exigences suivantes pour notre planificateur d'événements :
- Les événements doivent être programmés à des heures précises.
- Les événements individuels ne peuvent être arbitrairement retardés.
- Le système ne peut pas autoriser le backup arbitraire des événements.
Initialement, nous avons choisi d'implémenter la solution Java la plus simple et la plus idiomatique, basée sur ScheduledThreadPoolExecutor. Cette solution, par nature, répond à la première exigence. Pour déterminer si elle répondait à nos deuxième et troisième exigences, nous avons utilisé notre capacité de simulation pour tester en profondeur les performances de la solution. Nos simulations nous permettent de faire fonctionner notre système de contrôle à plein volume d'entrepôt pendant plusieurs jours afin de tester le comportement de l'application, généralement bien avant que tout entrepôt ne fonctionne réellement à plein volume. Ces tests ont révélé que la solution basée sur ScheduledThreadPoolExecutor n'était pas en mesure de prendre en charge le volume nécessaire à l'entrepôt. Pour comprendre pourquoi cette solution était insuffisante, nous nous sommes penchés sur le profil de notre système de contrôle, qui a mis en évidence deux domaines d'intervention :
- Le moment où un événement est prévu
- Le moment où un événement est prêt à être exécuté
À partir du moment où notre événement est planifié, la JavaDoc de ThreadPoolExecutor répertorie trois stratégies de mise en file d'attente :
- Transferts directs (Direct handoffs)
- Files d'attente non bornées (Unbounded queues)
- Files d'attente bornées (Bounded queues)
Un coup d'œil aux éléments internes de la JavaDoc de ScheduledThreadPoolExecutor montre qu'une file d'attente personnalisée sans limite est en cours d'utilisation et nous voyons dans la Javadoc de ThreadPoolExecutor :
Ce style de file d'attente peut être utile pour lisser les rafales transitoires de demandes, mais il admet la possibilité d'une croissance de la file d'attente de travail illimitée lorsque les commandes continuent d'arriver en moyenne plus rapidement qu'elles ne peuvent être traitées.
Cela nous indique que notre troisième exigence peut être violée car les événements peuvent être sauvegardés dans la file d'attente de travail non limitée.
Nous nous tournons à nouveau vers les JavaDocs pour comprendre le comportement du pool de threads lorsqu'un nouvel événement est prêt à être exécuté. En fonction de la configuration de votre pool de threads, il est possible de créer un nouveau thread pour l'exécution de l'événement. Encore une fois, à partir de la JavaDoc de ThreadPoolExecutor :
Si moins de corePoolSize threads sont en cours d'exécution, un nouveau thread est créé pour gérer la demande, même si d'autres threads de travail sont inactifs. Sinon, si moins de maximumPoolSize threads sont en cours d'exécution, un nouveau thread sera créé pour gérer la demande uniquement si la file d'attente est saturée.
La création de thread prend du temps, ce qui signifie que notre seconde exigence peut également être violée.
Il est tout à fait judicieux de théoriser ce qui pourrait mal tourner dans votre application, mais avant de le tester minutieusement, vous ne saurez pas si la solution choisie fonctionne correctement ou non. En ré-exécutant le même ensemble de tests de simulation, nous avons pu constater qu'une buzy-loop nous fournissait une latence plus faible pour les événements individuels : de <5ms à 0, ce qui correspond à un débit d'événements jusqu'à trois fois supérieur et répondait à l'ensemble de nos trois de nos besoins en matière de planification d'événements.
Architecture
Notre décision finale, l'Architecture, signifie différentes choses pour différentes personnes.
Pour certains, l'architecture fait référence aux choix d'implémentation, tels que :
- Monolithe ou microservices
- Transactions ACID ou cohérence éventuelle (ou plus naïvement, SQL vs NoSQL)
- EventSourcing ou CQRS
- REST ou GraphQL
Les décisions d'implémentation prises au début de la vie d'une application sont généralement valables à ce moment-là. Mais au fil de la vie des applications, avec l'ajout de fonctionnalités et de complexité inévitablement accrue, ces décisions doivent être réexaminées à maintes reprises.
Pour d'autres, l'architecture concerne la manière dont vous structurez votre code et votre application. Si vous reconnaissez que ces décisions d'implémentation vont changer, une bonne architecture garantit que ces modifications peuvent être apportées le plus facilement possible. Pour ce faire, nous avons notamment suivi l'Onion Architecture, qui met l'accent sur la séparation des problèmes au sein de votre application.
Les principes de développement influencent souvent l'architecture que vous avez choisie. Nos principes de développement ont orienté notre architecture de différentes manières :
- La DES nous a obligés à mettre en œuvre un système basé sur les événements.
- L'application du déterminisme nous a amenés à mettre en œuvre nos propres abstractions, plutôt que de nous appuyer sur des abstractions Java standard.
- En évitant l'optimisation prématurée et en démarrant simplement, notre application a commencé sa vie comme un simple artefact déployable. Au fil des années, l'application est devenue un monolithe, qui nous sert toujours bien. Nous évaluons continuellement si "maintenant" il est temps d'optimiser et de re-factoriser vers une structure différente.
Envisagez un changement dans la conception de votre système
Si vous êtes un concepteur de système ou un architecte logiciel responsable du choix du langage de programmation dans lequel implémenter un système hautes performances, cet article prouve que Java est un candidat clé aux langages plus "évidents" tels que C, C ++ ou Rust. Si vous êtes un programmeur Java, cet article vous montre un exemple de ce qui est possible avec le langage Java.
La prochaine fois que vous concevez un système, réfléchissez aux principes et aux décisions que vous prenez au début du projet, ce qui sera extrêmement difficile voire impossible à modifier. Pour nous, ce sont notre utilisation de la simulation et notre focalisation sur le déterminisme. Pour les aspects des systèmes susceptibles de changer, choisissez une architecture, telle que Onion Architecture, qui conserve la possibilité de changement ouverte et facile.
A propos de l'auteur
Matthew Cornford est responsable des produits pour l'automatisation OSP et les systèmes embarqués chez Ocado Technology. Il a contribué au développement du logiciel pionnier sous-jacent aux entrepôts hautement automatisés d'Ocado - les plus évolués de ce type au monde. Matthew a étudié les mathématiques à l'Université d'Oxford et a 10 ans d'expérience en tant que responsable de l'ingénierie logicielle et développeur Java.