4 Entwurfsmuster, die Sie für die Webentwicklung kennen sollten: Observer, Singleton, Strategy und Decorator

Waren Sie schon einmal in einem Team, in dem Sie ein Projekt von Grund auf neu starten müssen? Dies ist normalerweise bei vielen Start-ups und anderen kleinen Unternehmen der Fall.

Es gibt so viele verschiedene Programmiersprachen, Architekturen und andere Probleme, dass es schwierig sein kann, herauszufinden, wo man anfangen soll. Hier kommen Designmuster ins Spiel.

Ein Entwurfsmuster ist wie eine Vorlage für Ihr Projekt. Es verwendet bestimmte Konventionen und Sie können eine bestimmte Art von Verhalten von ihm erwarten. Diese Muster setzen sich aus den Erfahrungen vieler Entwickler zusammen, sodass sie wirklich verschiedenen Best Practices ähneln.

Und Sie und Ihr Team können entscheiden, welche Best Practices für Ihr Projekt am nützlichsten sind. Basierend auf dem von Ihnen gewählten Entwurfsmuster werden Sie alle Erwartungen daran haben, was der Code tun soll und welches Vokabular Sie alle verwenden werden.

Programmierentwurfsmuster können in allen Programmiersprachen verwendet werden und für jedes Projekt verwendet werden, da sie nur einen allgemeinen Überblick über eine Lösung geben.

Es gibt 23 offizielle Muster aus dem Buch Design Patterns - Elements of Reusable Object-Oriented Software , das als eines der einflussreichsten Bücher zur objektorientierten Theorie und Softwareentwicklung gilt.

In diesem Artikel werde ich vier dieser Entwurfsmuster behandeln, um Ihnen einen Einblick zu geben, was einige der Muster sind und wann Sie sie verwenden würden.

Das Singleton-Entwurfsmuster

Das Singleton-Muster erlaubt einer Klasse oder einem Objekt nur eine einzelne Instanz und verwendet eine globale Variable zum Speichern dieser Instanz. Sie können das verzögerte Laden verwenden, um sicherzustellen, dass nur eine Instanz der Klasse vorhanden ist, da die Klasse nur dann erstellt wird, wenn Sie sie benötigen.

Dadurch wird verhindert, dass mehrere Instanzen gleichzeitig aktiv sind, was zu seltsamen Fehlern führen kann. Meistens wird dies im Konstruktor implementiert. Das Ziel des Singleton-Musters besteht normalerweise darin, den globalen Status einer Anwendung zu regulieren.

Ein Beispiel für einen Singleton, den Sie wahrscheinlich ständig verwenden, ist Ihr Logger.

Wenn Sie mit einigen Front-End-Frameworks wie React oder Angular arbeiten, wissen Sie genau, wie schwierig es sein kann, Protokolle zu verarbeiten, die von mehreren Komponenten stammen. Dies ist ein großartiges Beispiel für Singletons in Aktion, da Sie nie mehr als eine Instanz eines Logger-Objekts benötigen, insbesondere wenn Sie eine Art Fehlerverfolgungstool verwenden.

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

Jetzt müssen Sie sich keine Sorgen mehr machen, dass Protokolle mehrerer Instanzen verloren gehen, da Sie nur eine in Ihrem Projekt haben. Wenn Sie also das bestellte Lebensmittel protokollieren möchten, können Sie dieselbe FoodLogger- Instanz für mehrere Dateien oder Komponenten verwenden.

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

Mit diesem Singleton-Muster müssen Sie sich nicht darum kümmern, nur die Protokolle aus der Hauptanwendungsdatei abzurufen. Sie können sie von überall in Ihrer Codebasis abrufen und sie werden alle auf genau dieselbe Instanz des Loggers übertragen. Dies bedeutet, dass keines Ihrer Protokolle aufgrund neuer Instanzen verloren gehen sollte.

Das Strategie-Design-Muster

Die Strategie ist Muster ist wie eine erweiterte Version einer if else-Anweisung. Hier erstellen Sie im Grunde eine Schnittstelle für eine Methode, die Sie in Ihrer Basisklasse haben. Diese Schnittstelle wird dann verwendet, um die richtige Implementierung dieser Methode zu finden, die in einer abgeleiteten Klasse verwendet werden soll. Die Implementierung wird in diesem Fall zur Laufzeit basierend auf dem Client festgelegt.

Dieses Muster ist unglaublich nützlich in Situationen, in denen Sie optionale Methoden für eine Klasse benötigt haben. Einige Instanzen dieser Klasse benötigen keine optionalen Methoden, was zu Problemen bei Vererbungslösungen führt. Sie könnten Schnittstellen für die optionalen Methoden verwenden, aber dann müssten Sie die Implementierung jedes Mal schreiben, wenn Sie diese Klasse verwenden, da es keine Standardimplementierung geben würde.

Dort rettet uns das Strategiemuster. Anstatt dass der Client nach einer Implementierung sucht, delegiert er an eine Strategie-Schnittstelle und die Strategie findet die richtige Implementierung. Eine häufige Verwendung hierfür sind Zahlungsverarbeitungssysteme.

Sie könnten einen Einkaufswagen haben, in dem Kunden nur mit ihrer Kreditkarte auschecken können, aber Sie verlieren Kunden, die andere Zahlungsmethoden verwenden möchten.

Mit dem Strategie-Design-Muster können wir die Zahlungsmethoden vom Checkout-Prozess entkoppeln. Dies bedeutet, dass wir Strategien hinzufügen oder aktualisieren können, ohne den Code im Warenkorb oder im Checkout-Prozess zu ändern.

Hier ist ein Beispiel für eine Implementierung eines Strategiemusters anhand des Beispiels für eine Zahlungsmethode.

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 } }

Um unsere Zahlungsmethodenstrategie umzusetzen, haben wir eine einzelne Klasse mit mehreren statischen Methoden erstellt. Jede Methode verwendet denselben Parameter, customerInfo , und dieser Parameter hat einen definierten Typ von customerInfoType . (Hallo all Ihre TypeScript-Entwickler! ??) Beachten Sie, dass jede Methode ihre eigene Implementierung hat und andere Werte als customerInfo verwendet .

Mit dem Strategiemuster können Sie auch die zur Laufzeit verwendete Strategie dynamisch ändern. Das bedeutet, dass Sie die Strategie oder Methodenimplementierung basierend auf Benutzereingaben oder der Umgebung, in der die App ausgeführt wird, ändern können.

Sie können auch eine Standardimplementierung in einer einfachen Datei config.json wie folgt festlegen :

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

Wenn ein Kunde den Checkout-Prozess auf Ihrer Website durchläuft , ist die PayPal-Implementierung, die von config.json stammt, die Standardzahlungsmethode, auf die er stößt . Dies kann leicht aktualisiert werden, wenn der Kunde eine andere Zahlungsmethode auswählt.

Jetzt erstellen wir eine Datei für unseren Checkout-Prozess.

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)

In dieser Checkout- Klasse zeigt sich das Strategiemuster. Wir importieren einige Dateien, damit wir die verfügbaren Zahlungsmethodenstrategien und die Standardstrategie aus der Konfiguration haben .

Dann erstellen wir die Klasse mit dem Konstruktor und einem Fallback-Wert für die Standardstrategie , falls in der Konfiguration keine festgelegt wurde . Als nächstes weisen wir den Strategiewert einer lokalen Zustandsvariablen zu.

Eine wichtige Methode, die wir in unserer Checkout- Klasse implementieren müssen, ist die Möglichkeit, die Zahlungsstrategie zu ändern. Ein Kunde kann die Zahlungsmethode ändern, die er verwenden möchte, und Sie müssen in der Lage sein, damit umzugehen. Dafür ist die changeStrategy- Methode gedacht .

Nachdem Sie einige ausgefallene Codierungen vorgenommen und alle Eingaben von einem Kunden erhalten haben, können Sie die Zahlungsstrategie sofort basierend auf deren Eingabe aktualisieren und die Strategie dynamisch festlegen , bevor die Zahlung zur Verarbeitung gesendet wird.

Irgendwann müssen Sie möglicherweise weitere Zahlungsmethoden in Ihren Warenkorb legen, und alles, was Sie tun müssen, ist, sie der PaymentMethodStrategy- Klasse hinzuzufügen . Es ist sofort überall dort verfügbar, wo diese Klasse verwendet wird.

Das Strategieentwurfsmuster ist leistungsstark, wenn Sie mit Methoden arbeiten, die mehrere Implementierungen haben. Es könnte sich so anfühlen, als würden Sie eine Schnittstelle verwenden, aber Sie müssen nicht jedes Mal eine Implementierung für die Methode schreiben, wenn Sie sie in einer anderen Klasse aufrufen. Es bietet Ihnen mehr Flexibilität als Schnittstellen.

Das Observer Design Pattern

Wenn Sie jemals das MVC-Muster verwendet haben, haben Sie bereits das Beobachter-Entwurfsmuster verwendet. Der Modellteil ist wie ein Motiv und der Ansichtsteil ist wie ein Beobachter dieses Motivs. Ihr Betreff enthält alle Daten und den Status dieser Daten. Dann haben Sie Beobachter, wie verschiedene Komponenten, die diese Daten vom Betreff erhalten, wenn die Daten aktualisiert wurden.

Das Ziel des Beobachterentwurfsmusters besteht darin, diese Eins-zu-Viele-Beziehung zwischen dem Subjekt und allen Beobachtern herzustellen, die auf Daten warten, damit sie aktualisiert werden können. Jedes Mal, wenn sich der Status des Subjekts ändert, werden alle Beobachter sofort benachrichtigt und aktualisiert.

Einige Beispiele für die Verwendung dieses Musters sind: Senden von Benutzerbenachrichtigungen, Aktualisieren, Filtern und Behandeln von Abonnenten.

Angenommen, Sie haben eine einseitige Anwendung mit drei Dropdown-Listen, die von der Auswahl einer Kategorie aus einer übergeordneten Dropdown-Liste abhängen. Dies ist auf vielen Einkaufsseiten wie Home Depot üblich. Auf der Seite befinden sich eine Reihe von Filtern, die vom Wert eines Filters der obersten Ebene abhängen.

Der Code für das Dropdown-Menü der obersten Ebene könnte ungefähr so ​​aussehen:

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)) } }

Diese CategoryDropdown- Datei ist eine einfache Klasse mit einem Konstruktor, der die Kategorieoptionen initialisiert, für die wir in der Dropdown-Liste verfügbar sind. Dies ist die Datei, mit der Sie eine Liste aus dem Back-End abrufen oder sortieren möchten, bevor der Benutzer die Optionen sieht.

Mit der Subscribe- Methode erhält jeder mit dieser Klasse erstellte Filter Aktualisierungen zum Status des Beobachters.

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