4 modèles de conception à connaître pour le développement Web: observateur, singleton, stratégie et décorateur

Avez-vous déjà fait partie d'une équipe où vous avez besoin de démarrer un projet à partir de zéro? C'est généralement le cas dans de nombreuses start-ups et autres petites entreprises.

Il existe tellement de langages de programmation, d'architectures et d'autres problèmes différents qu'il peut être difficile de savoir par où commencer. C'est là que les modèles de conception entrent en jeu.

Un modèle de conception est comme un modèle pour votre projet. Il utilise certaines conventions et vous pouvez vous attendre à un type de comportement spécifique de sa part. Ces modèles étaient constitués d'expériences de nombreux développeurs, ils ressemblent donc à différents ensembles de bonnes pratiques.

Et vous et votre équipe décidez quel ensemble de bonnes pratiques est le plus utile pour votre projet. En fonction du modèle de conception que vous choisissez, vous commencerez tous à avoir des attentes quant à ce que le code devrait faire et au vocabulaire que vous utiliserez tous.

Les modèles de conception de programmation peuvent être utilisés dans tous les langages de programmation et peuvent être utilisés pour s'adapter à n'importe quel projet, car ils ne vous donnent qu'un aperçu général d'une solution.

Il existe 23 modèles officiels du livre Design Patterns - Elements of Reusable Object-Oriented Software , qui est considéré comme l'un des livres les plus influents sur la théorie orientée objet et le développement de logiciels.

Dans cet article, je vais couvrir quatre de ces modèles de conception juste pour vous donner un aperçu de ce que sont quelques-uns des modèles et quand vous les utiliseriez.

Le modèle de conception Singleton

Le modèle singleton autorise uniquement une classe ou un objet à avoir une seule instance et il utilise une variable globale pour stocker cette instance. Vous pouvez utiliser le chargement différé pour vous assurer qu'il n'y a qu'une seule instance de la classe car il ne créera la classe que lorsque vous en aurez besoin.

Cela empêche plusieurs instances d'être actives en même temps, ce qui pourrait provoquer des bogues étranges. La plupart du temps, cela est implémenté dans le constructeur. L'objectif du modèle singleton est généralement de réguler l'état global d'une application.

Un exemple de singleton que vous utilisez probablement tout le temps est votre enregistreur.

Si vous travaillez avec certains des frameworks frontaux tels que React ou Angular, vous savez à quel point il peut être difficile de gérer les journaux provenant de plusieurs composants. C'est un excellent exemple de singletons en action, car vous ne voulez jamais plus d'une instance d'un objet enregistreur, surtout si vous utilisez une sorte d'outil de suivi des erreurs.

class FoodLogger { constructor() { this.foodLog = [] } log(order) { this.foodLog.push(order.foodItem) // do fancy code to send this log somewhere } } // this is the singleton class FoodLoggerSingleton { constructor() { if (!FoodLoggerSingleton.instance) { FoodLoggerSingleton.instance = new FoodLogger() } } getFoodLoggerInstance() { return FoodLoggerSingleton.instance } } module.exports = FoodLoggerSingleton

Vous n'avez plus à vous soucier de perdre les journaux de plusieurs instances, car vous n'en avez qu'un dans votre projet. Ainsi, lorsque vous souhaitez enregistrer les aliments commandés, vous pouvez utiliser la même instance FoodLogger sur plusieurs fichiers ou composants.

const FoodLogger = require('./FoodLogger') const foodLogger = new FoodLogger().getFoodLoggerInstance() class Customer { constructor(order) { this.price = order.price this.food = order.foodItem foodLogger.log(order) } // other cool stuff happening for the customer } module.exports = Customer
const FoodLogger = require('./FoodLogger') const foodLogger = new FoodLogger().getFoodLoggerInstance() class Restaurant { constructor(inventory) { this.quantity = inventory.count this.food = inventory.foodItem foodLogger.log(inventory) } // other cool stuff happening at the restaurant } module.exports = Restaurant

Avec ce modèle de singleton en place, vous n'avez pas à vous soucier d'obtenir simplement les journaux du fichier d'application principal. Vous pouvez les obtenir de n'importe où dans votre base de code et ils iront tous exactement à la même instance de l'enregistreur, ce qui signifie qu'aucun de vos journaux ne devrait être perdu en raison de nouvelles instances.

Le modèle de conception de la stratégie

La stratégie est le modèle est comme une version avancée d'une instruction if else. C'est essentiellement là que vous créez une interface pour une méthode que vous avez dans votre classe de base. Cette interface est ensuite utilisée pour trouver la bonne implémentation de cette méthode qui doit être utilisée dans une classe dérivée. L'implémentation, dans ce cas, sera décidée lors de l'exécution en fonction du client.

Ce modèle est incroyablement utile dans les situations où vous avez des méthodes obligatoires et facultatives pour une classe. Certaines instances de cette classe n'auront pas besoin des méthodes facultatives, ce qui pose un problème pour les solutions d'héritage. Vous pouvez utiliser des interfaces pour les méthodes facultatives, mais vous devrez alors écrire l'implémentation à chaque fois que vous utiliserez cette classe car il n'y aurait pas d'implémentation par défaut.

C'est là que le modèle de stratégie nous sauve. Au lieu que le client recherche une implémentation, il délègue à une interface de stratégie et la stratégie trouve la bonne implémentation. Une utilisation courante pour cela est avec les systèmes de traitement des paiements.

Vous pourriez avoir un panier qui ne permet aux clients de payer qu'avec leur carte de crédit, mais vous perdrez les clients qui souhaitent utiliser d'autres méthodes de paiement.

Le modèle de conception de stratégie nous permet de dissocier les méthodes de paiement du processus de paiement, ce qui signifie que nous pouvons ajouter ou mettre à jour des stratégies sans changer de code dans le panier ou le processus de paiement.

Voici un exemple de mise en œuvre d'un modèle de stratégie à l'aide de l'exemple de méthode de paiement.

class PaymentMethodStrategy { const customerInfoType = { country: string emailAddress: string name: string accountNumber?: number address?: string cardNumber?: number city?: string routingNumber?: number state?: string } static BankAccount(customerInfo: customerInfoType) { const { name, accountNumber, routingNumber } = customerInfo // do stuff to get payment } static BitCoin(customerInfo: customerInfoType) { const { emailAddress, accountNumber } = customerInfo // do stuff to get payment } static CreditCard(customerInfo: customerInfoType) { const { name, cardNumber, emailAddress } = customerInfo // do stuff to get payment } static MailIn(customerInfo: customerInfoType) { const { name, address, city, state, country } = customerInfo // do stuff to get payment } static PayPal(customerInfo: customerInfoType) { const { emailAddress } = customerInfo // do stuff to get payment } }

Pour mettre en œuvre notre stratégie de méthode de paiement, nous avons créé une seule classe avec plusieurs méthodes statiques. Chaque méthode prend le même paramètre, customerInfo , et ce paramètre a un type défini de customerInfoType . (Salut à tous les développeurs TypeScript! ??) Notez que chaque méthode a sa propre implémentation et utilise des valeurs différentes de customerInfo .

Avec le modèle de stratégie, vous pouvez également modifier dynamiquement la stratégie utilisée au moment de l'exécution. Cela signifie que vous serez en mesure de modifier la stratégie, ou l'implémentation de la méthode, utilisée en fonction de l'entrée de l'utilisateur ou de l'environnement dans lequel l'application s'exécute.

Vous pouvez également définir une implémentation par défaut dans un simple fichier config.json comme ceci:

{ "paymentMethod": { "strategy": "PayPal" } }

Chaque fois qu'un client commence à passer par le processus de paiement sur votre site Web, le mode de paiement par défaut qu'il rencontre sera l'implémentation PayPal qui provient de config.json . Cela pourrait facilement être mis à jour si le client choisit un autre mode de paiement.

Nous allons maintenant créer un fichier pour notre processus de paiement.

const PaymentMethodStrategy = require('./PaymentMethodStrategy') const config = require('./config') class Checkout { constructor(strategy='CreditCard') { this.strategy = PaymentMethodStrategy[strategy] } // do some fancy code here and get user input and payment method changeStrategy(newStrategy) { this.strategy = PaymentMethodStrategy[newStrategy] } const userInput = { name: 'Malcolm', cardNumber: 3910000034581941, emailAddress: '[email protected]', country: 'US' } const selectedStrategy = 'Bitcoin' changeStrategy(selectedStrategy) postPayment(userInput) { this.strategy(userInput) } } module.exports = new Checkout(config.paymentMethod.strategy)

Cette classe Checkout est l'endroit où le modèle de stratégie peut se montrer. Nous importons quelques fichiers afin d'avoir les stratégies de méthode de paiement disponibles et la stratégie par défaut de la configuration .

Ensuite, nous créons la classe avec le constructeur et une valeur de repli pour la stratégie par défaut au cas où il n'y en aurait pas eu un dans la configuration . Ensuite, nous attribuons la valeur de stratégie à une variable d'état locale.

Une méthode importante que nous devons mettre en œuvre dans notre classe Checkout est la possibilité de modifier la stratégie de paiement. Un client peut changer le mode de paiement qu'il souhaite utiliser et vous devrez être en mesure de gérer cela. C'est à cela que sert la méthode changeStrategy .

After you've done some fancy coding and gotten all of the inputs from a customer, then you can update the payment strategy immediately based on their input and it dynamically sets the strategy before the payment is sent for processing.

At some point you might need to add more payment methods to your shopping cart and all you'll have to do is add it to the PaymentMethodStrategy class. It'll instantly be available anywhere that class is used.

The strategy design pattern is a powerful one when you are dealing with methods that have multiple implementations. It might feel like you're using an interface, but you don't have to write an implementation for the method every time you call it in a different class. It gives you more flexibility than interfaces.

The Observer Design Pattern

If you've ever used the MVC pattern, you've already used the observer design pattern. The Model part is like a subject and the View part is like an observer of that subject. Your subject holds all of the data and the state of that data. Then you have observers, like different components, that will get that data from the subject when the data has been updated.

The goal of the observer design pattern is to create this one-to-many relationship between the subject and all of the observers waiting for data so they can be updated. So anytime the state of the subject changes, all of the observers will be notified and updated instantly.

Some examples of when you would use this pattern include: sending user notifications, updating, filters, and handling subscribers.

Say you have a single page application that has three feature dropdown lists that are dependent on the selection of a category from a higher level dropdown. This is common on many shopping sites, like Home Depot. You have a bunch of filters on the page that are dependent on the value of a top-level filter.

The code for the top-level dropdown might look something like this:

class CategoryDropdown { constructor() { this.categories = ['appliances', 'doors', 'tools'] this.subscriber = [] } // pretend there's some fancy code here subscribe(observer) { this.subscriber.push(observer) } onChange(selectedCategory) { this.subscriber.forEach(observer => observer.update(selectedCategory)) } }

This CategoryDropdown file is a simple class with a constructor that initializes the category options we have available for in the dropdown. This is the file you would handle retrieving a list from the back-end or any kind of sorting you want to do before the user sees the options.

The subscribe method is how each filter created with this class will receive updates about the state of the observer.

The onChange method is how we send out notification to all of the subscribers that a state change has happened in the observer they're watching. We just loop through all of the subscribers and call their update method with the selectedCategory.

The code for the other filters might look something like this:

class FilterDropdown { constructor(filterType) { this.filterType = filterType this.items = [] } // more fancy code here; maybe make that API call to get items list based on filterType update(category) { fetch('//example.com') .then(res => this.items(res)) } }

This FilterDropdown file is another simple class that represents all of the potential dropdowns we might use on a page. When a new instance of this class is created, it needs to be passed a filterType. This could be used to make specific API calls to get the list of items.

The update method is an implementation of what you can do with the new category once it has been sent from the observer.

Now we'll take a look at what it means to use these files with the observer pattern:

const CategoryDropdown = require('./CategoryDropdown') const FilterDropdown = require('./FilterDropdown') const categoryDropdown = new CategoryDropdown() const colorsDropdown = new FilterDropdown('colors') const priceDropdown = new FilterDropdown('price') const brandDropdown = new FilterDropdown('brand') categoryDropdown.subscribe(colorsDropdown) categoryDropdown.subscribe(priceDropdown) categoryDropdown.subscribe(brandDropdown)

What this file shows us is that we have 3 drop-downs that are subscribers to the category drop-down observable. Then we subscribe each of those drop-downs to the observer. Whenever the category of the observer is updated, it will send out the value to every subscriber which will update the individual drop-down lists instantly.

The Decorator Design Pattern

Using the decorator design pattern is fairly simple. You can have a base class with methods and properties that are present when you make a new object with the class. Now say you have some instances of the class that need methods or properties that didn't come from the base class.

You can add those extra methods and properties to the base class, but that could mess up your other instances. You could even make sub-classes to hold specific methods and properties you need that you can't put in your base class.

Either of those approaches will solve your problem, but they are clunky and inefficient. That's where the decorator pattern steps in. Instead of making your code base ugly just to add a few things to an object instance, you can tack on those specific things directly to the instance.

So if you need to add a new property that holds the price for an object, you can use the decorator pattern to add it directly to that particular object instance and it won't affect any other instances of that class object.

Have you ever ordered food online? Then you've probably encountered the decorator pattern. If you're getting a sandwich and you want to add special toppings, the website isn't adding those toppings to every instance of sandwich current users are trying to order.

Here's an example of a customer class:

class Customer { constructor(balance=20) { this.balance = balance this.foodItems = [] } buy(food) { if (food.price) < this.balance { console.log('you should get it') this.balance -= food.price this.foodItems.push(food) } else { console.log('maybe you should get something else') } } } module.exports = Customer

And here's an example of a sandwich class:

class Sandwich { constructor(type, price) { this.type = type this.price = price } order() { console.log(`You ordered a ${this.type} sandwich for $ ${this.price}.`) } } class DeluxeSandwich { constructor(baseSandwich) { this.type = `Deluxe ${baseSandwich.type}` this.price = baseSandwich.price + 1.75 } } class ExquisiteSandwich { constructor(baseSandwich) { this.type = `Exquisite ${baseSandwich.type}` this.price = baseSandwich.price + 10.75 } order() { console.log(`You ordered an ${this.type} sandwich. It's got everything you need to be happy for days.`) } } module.exports = { Sandwich, DeluxeSandwich, ExquisiteSandwich }

This sandwich class is where the decorator pattern is used. We have a Sandwich base class that sets the rules for what happens when a regular sandwich is ordered. Customers might want to upgrade sandwiches and that just means an ingredient and price change.

You just wanted to add the functionality to increase the price and update the type of sandwich for the DeluxeSandwich without changing how it's ordered. Although you might need a different order method for an ExquisiteSandwich because there is a drastic change in the quality of ingredients.

The decorator pattern lets you dynamically change the base class without affecting it or any other classes. You don't have to worry about implementing functions you don't know, like with interfaces, and you don't have to include properties you won't use in every class.

Now if we'll go over an example where this class is instantiated as if a customer was placing a sandwich order.

const { Sandwich, DeluxeSandwich, ExquisiteSandwich } = require('./Sandwich') const Customer = require('./Customer') const cust1 = new Customer(57) const turkeySandwich = new Sandwich('Turkey', 6.49) const bltSandwich = new Sandwich('BLT', 7.55) const deluxeBltSandwich = new DeluxeSandwich(bltSandwich) const exquisiteTurkeySandwich = new ExquisiteSandwich(turkeySandwich) cust1.buy(turkeySandwich) cust1.buy(bltSandwich)

Final Thoughts

I used to think that design patterns were these crazy, far-out software development guidelines. Then I found out I use them all the time!

A few of the patterns I covered are used in so many applications that it would blow your mind. They are just theory at the end of the day. It's up to us as developers to use that theory in ways that make our applications easy to implement and maintain.

Have you used any of the other design patterns for your projects? Most places usually pick a design pattern for their projects and stick with it so I'd like to hear from you all about what you use.

Thanks for reading. You should follow me on Twitter because I usually post useful/entertaining stuff: @FlippedCoding