4 รูปแบบการออกแบบที่คุณควรรู้สำหรับการพัฒนาเว็บ: Observer, Singleton, Strategy และ Decorator

คุณเคยอยู่ในทีมที่ต้องเริ่มโครงการตั้งแต่เริ่มต้นหรือไม่? โดยปกติจะเป็นเช่นนั้นใน บริษัท สตาร์ทอัพและ บริษัท ขนาดเล็กอื่น ๆ

มีภาษาโปรแกรมสถาปัตยกรรมและข้อกังวลอื่น ๆ มากมายซึ่งอาจเป็นเรื่องยากที่จะคิดว่าจะเริ่มต้นที่จุดใด นั่นคือที่มาของรูปแบบการออกแบบ

รูปแบบการออกแบบเปรียบเสมือนแม่แบบสำหรับโครงการของคุณ มันใช้อนุสัญญาบางอย่างและคุณสามารถคาดหวังพฤติกรรมที่เฉพาะเจาะจงจากมันได้ รูปแบบเหล่านี้ประกอบขึ้นจากประสบการณ์ของนักพัฒนาซอฟต์แวร์จำนวนมากดังนั้นจึงเหมือนกับแนวทางปฏิบัติที่ดีที่สุดชุดต่างๆ

และคุณและทีมของคุณจะต้องตัดสินใจว่าแนวทางปฏิบัติที่ดีที่สุดชุดใดที่มีประโยชน์มากที่สุดสำหรับโครงการของคุณ จากรูปแบบการออกแบบที่คุณเลือกคุณทุกคนจะเริ่มมีความคาดหวังว่าโค้ดควรจะทำอะไรและคุณจะใช้คำศัพท์อะไร

รูปแบบการออกแบบการเขียนโปรแกรมสามารถใช้ได้กับภาษาการเขียนโปรแกรมทั้งหมดและสามารถใช้เพื่อให้พอดีกับโครงการใด ๆ ได้เพราะจะให้เพียงโครงร่างทั่วไปของโซลูชัน

มีรูปแบบที่เป็นทางการ 23 แบบจากหนังสือDesign Patterns - Elements of Reusable Object-Oriented Softwareซึ่งถือเป็นหนังสือที่มีอิทธิพลมากที่สุดเล่มหนึ่งเกี่ยวกับทฤษฎีเชิงวัตถุและการพัฒนาซอฟต์แวร์

ในบทความนี้ฉันจะพูดถึงรูปแบบการออกแบบเหล่านั้นสี่แบบเพื่อให้คุณเข้าใจว่ารูปแบบบางส่วนคืออะไรและคุณจะใช้เมื่อใด

รูปแบบการออกแบบ Singleton

รูปแบบซิงเกิลตันอนุญาตให้คลาสหรืออ็อบเจ็กต์มีอินสแตนซ์เดียวเท่านั้นและใช้ตัวแปรส่วนกลางเพื่อจัดเก็บอินสแตนซ์นั้น คุณสามารถใช้การโหลดแบบขี้เกียจเพื่อให้แน่ใจว่ามีเพียงอินสแตนซ์เดียวของคลาสเนื่องจากจะสร้างคลาสเมื่อคุณต้องการเท่านั้น

ซึ่งป้องกันไม่ให้มีการใช้งานหลายอินสแตนซ์พร้อมกันซึ่งอาจทำให้เกิดข้อบกพร่องแปลก ๆ เวลาส่วนใหญ่จะถูกนำไปใช้ในตัวสร้าง โดยทั่วไปเป้าหมายของรูปแบบซิงเกิลตันคือการควบคุมสถานะทั่วโลกของแอปพลิเคชัน

ตัวอย่างของซิงเกิลตันที่คุณอาจใช้ตลอดเวลาคือคนตัดไม้ของคุณ

หากคุณทำงานกับเฟรมเวิร์กส่วนหน้าเช่น React หรือ Angular คุณจะรู้ดีว่าการจัดการบันทึกที่มาจากหลายองค์ประกอบนั้นยุ่งยากเพียงใด นี่เป็นตัวอย่างที่ดีในการใช้งาน singletons เนื่องจากคุณไม่ต้องการมากกว่าหนึ่งอินสแตนซ์ของวัตถุคนตัดไม้โดยเฉพาะอย่างยิ่งหากคุณใช้เครื่องมือติดตามข้อผิดพลาดบางประเภท

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 devs! ??) จดว่าแต่ละวิธีมีการดำเนินงานของตัวเองและใช้ค่าที่แตกต่างจาก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 สามารถใช้ได้ทันทีทุกที่ที่ใช้คลาส

รูปแบบการออกแบบกลยุทธ์เป็นรูปแบบที่มีประสิทธิภาพเมื่อคุณจัดการกับวิธีการที่มีการใช้งานหลายวิธี อาจรู้สึกเหมือนคุณกำลังใช้อินเทอร์เฟซ แต่คุณไม่จำเป็นต้องเขียนการนำไปใช้งานสำหรับเมธอดทุกครั้งที่คุณเรียกมันในคลาสอื่น ให้ความยืดหยุ่นมากกว่าอินเทอร์เฟซ

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