4 дизайнерски модела, които трябва да знаете за уеб разработка: наблюдател, сингълтън, стратегия и декоратор

Били ли сте някога в екип, в който трябва да започнете проект от нулата? Обикновено това е така при много стартиращи фирми и други малки компании.

Има толкова много различни езици за програмиране, архитектури и други опасения, че може да е трудно да разберете откъде да започнете. Тук идват дизайнерските модели.

Моделът на дизайна е като шаблон за вашия проект. Той използва определени конвенции и от него можете да очаквате специфичен вид поведение. Тези модели са съставени от опита на много разработчици, така че те наистина са като различни набори от най-добри практики.

И вие и вашият екип трябва да решите кой набор от най-добри практики е най-полезен за вашия проект. Въз основа на избрания от вас модел на проектиране всички вие ще започнете да очаквате какво трябва да прави кода и какъв речник ще използвате всички.

Шаблоните за проектиране на програмиране могат да се използват във всички програмни езици и могат да се използват, за да се поберат във всеки проект, тъй като те ви дават само общ контур на решение.

Има 23 официални модела от книгата Design Patterns - Elements of Reusable Object-Oriented Software , която се смята за една от най-влиятелните книги за обектно-ориентирана теория и разработване на софтуер.

В тази статия ще разгледам четири от тези дизайнерски модели, само за да ви дам представа за това кои са някои от моделите и кога бихте ги използвали.

Моделът за дизайн на Singleton

Единичният модел позволява само на клас или обект да има един екземпляр и той използва глобална променлива за съхраняване на този екземпляр. Можете да използвате лениво зареждане, за да сте сигурни, че има само един екземпляр на класа, защото той ще създаде класа само когато имате нужда от него.

Това предотвратява активността на множество екземпляри едновременно, което може да причини странни грешки. През повечето време това се реализира в конструктора. Целта на единичния модел обикновено е да регулира глобалното състояние на приложението.

Пример за сингълтон, който вероятно използвате през цялото време, е вашият регистратор.

Ако работите с някои от интерфейсните рамки като React или Angular, знаете всичко колко сложно може да бъде да се справите с регистрационни файлове, идващи от множество компоненти. Това е чудесен пример за единични действия в действие, защото никога не искате повече от един екземпляр на обект на регистратор, особено ако използвате някакъв инструмент за проследяване на грешки.

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

Сега не е нужно да се притеснявате за загуба на регистрационни файлове от множество екземпляри, защото имате само един във вашия проект. Така че, когато искате да регистрирате поръчаната храна, можете да използвате един и същ екземпляр на FoodLogger в множество файлове или компоненти.

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

С този единичен модел на място не е нужно да се притеснявате, че просто ще вземете регистрационните файлове от основния файл на приложението. Можете да ги получите от всяко място в кодовата ви база и всички те ще отидат до абсолютно същия екземпляр на регистратора, което означава, че нито един от вашите регистрационни файлове не трябва да се губи поради нови екземпляри.

Моделът на стратегическия дизайн

Стратегията е модел е като усъвършенствана версия на оператор if else. Това е основно мястото, където правите интерфейс за метод, който имате в основния си клас. След това този интерфейс се използва за намиране на правилното изпълнение на този метод, който трябва да се използва в производен клас. В този случай изпълнението ще бъде взето по време на изпълнение въз основа на клиента.

Този модел е невероятно полезен в ситуации, когато имате необходими и незадължителни методи за клас. Някои екземпляри от този клас няма да се нуждаят от незадължителни методи и това създава проблем за решения за наследяване. Можете да използвате интерфейси за незадължителните методи, но тогава ще трябва да напишете изпълнението всеки път, когато използвате този клас, тъй като няма да има изпълнение по подразбиране.

Ето къде ни спасява стратегическият модел. Вместо клиентът да търси изпълнение, той делегира на стратегически интерфейс и стратегията намира правилното изпълнение. Една често използвана за това е със системите за обработка на плащания.

Може да имате количка за пазаруване, която позволява на клиентите да плащат само с кредитните си карти, но ще загубите клиенти, които искат да използват други начини на плащане.

Структурата на дизайна на стратегията ни позволява да отделим методите за плащане от процеса на плащане, което означава, че можем да добавяме или актуализираме стратегии, без да променяме какъвто и да е код в пазарската количка или процеса на плащане.

Ето пример за прилагане на модел на стратегия, използващ пример за метод на плащане.

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

За да приложим нашата стратегия за начин на плащане, направихме един клас с множество статични методи. Всеки метод приема един и същ параметър, customerInfo , и този параметър има определен тип customerInfoType . (Хей, всички вие, разработчици на TypeScript! ??) Обърнете внимание, че всеки метод има собствена реализация и използва различни стойности от customerInfo .

С модела на стратегията можете също така динамично да променяте използваната стратегия по време на изпълнение. Това означава, че ще можете да промените стратегията или изпълнението на метода, използвани въз основа на въведените от потребителя или средата, в която работи приложението.

Можете също така да зададете изпълнение по подразбиране в прост файл config.json по следния начин:

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

Всеки път, когато клиент започне да преминава през процеса на плащане на вашия уебсайт, методът на плащане по подразбиране, който среща, ще бъде изпълнението на PayPal, което идва от config.json . Това може лесно да бъде актуализирано, ако клиентът избере различен начин на плащане.

Сега ще създадем файл за нашия процес на плащане.

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)

Този клас на Checkout е мястото, където моделът на стратегията се показва. Импортираме няколко файла, за да имаме налични стратегии за метода на плащане и стратегията по подразбиране от конфигурацията .

След това създаваме класа с конструктора и резервна стойност за стратегията по подразбиране , в случай че в конфигурацията не е зададена такава . След това присвояваме стратегическата стойност на променлива на локално състояние.

Важен метод, който трябва да внедрим в нашия клас Checkout, е възможността за промяна на стратегията за плащане. Клиентът може да промени начина на плащане, който иска да използва, и ще трябва да можете да се справите с това. За това е методът changeStrategy .

След като сте направили някакво изискано кодиране и сте получили всички входове от клиент, можете да актуализирате стратегията за плащане незабавно въз основа на техния вход и тя динамично задава стратегията, преди плащането да бъде изпратено за обработка.

В даден момент може да се наложи да добавите още методи за плащане към пазарската си кошница и всичко, което трябва да направите, е да го добавите към класа PaymentMethodStrategy . Той незабавно ще бъде достъпен навсякъде, където се използва клас.

Моделът на стратегически дизайн е мощен, когато се занимавате с методи, които имат множество реализации. Може да ви се струва, че използвате интерфейс, но не е нужно да пишете реализация за метода всеки път, когато го извикате в различен клас. Това ви дава по-голяма гъвкавост от интерфейсите.

Моделът за дизайн на наблюдателя

Ако някога сте използвали шаблона MVC, вече сте използвали шаблона за дизайн на наблюдателя. Частта от модела е като обект, а частта View е като наблюдател на този обект. Вашият обект съхранява всички данни и състоянието на тези данни. След това имате наблюдатели, като различни компоненти, които ще получат тези данни от субекта, когато данните бъдат актуализирани.

Целта на модела за проектиране на наблюдател е да създаде тази връзка "един към много" между субекта и всички наблюдатели, които чакат данни, за да могат да бъдат актуализирани. Така че всеки път, когато състоянието на обекта се промени, всички наблюдатели ще бъдат уведомени и актуализирани незабавно.

Някои примери за това кога бихте използвали този модел включват: изпращане на потребителски известия, актуализиране, филтри и работа с абонати.

Да предположим, че имате приложение на една страница, което има три падащи списъка с функции, които зависят от избора на категория от падащото меню от по-високо ниво. Това е често срещано в много сайтове за пазаруване, като Home Depot. Имате куп филтри на страницата, които зависят от стойността на филтър от най-високо ниво.

Кодът за падащото меню от най-високо ниво може да изглежда по следния начин:

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

Този файл CategoryDropdown е прост клас с конструктор, който инициализира опциите за категория, за които имаме достъп в падащото меню. Това е файлът, с който бихте се справили, за да извлечете списък от задната страна или от някакъв вид сортиране, който искате да направите, преди потребителят да види опциите.

Методът за абониране е как всеки филтър, създаден с този клас, ще получава актуализации за състоянието на наблюдателя.

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