แผนการเรียน
/

Design Patterns และ
หลักการ SOLID

สัปดาห์ที่ 10: สถาปัตยกรรมซอฟต์แวร์ที่ยั่งยืนและดูแลรักษาได้

หัวข้อการบรรยาย

1
ปัญหา: ทำไมซอฟต์แวร์ถึงกลายเป็น "สปาเก็ตตี้โค้ด" (Spaghetti Code) [1]
2
SOLID: 5 บัญญัติของการออกแบบเชิงวัตถุ (SRP, OCP, LSP, ISP, DIP) [2]
3
Design Patterns: แนะนำรูปแบบ Creational, Structural และ Behavioral [3]

ทำไมเราถึงต้องการ Patterns?

วิกฤตของซอฟต์แวร์

เมื่อซอฟต์แวร์โตขึ้น ความยุ่งเหยิงมักจะตามมา (Entropy):

  • ความแข็งกระด้าง (Rigidity): แก้จุดหนึ่ง กระทบไปพังอีกจุดหนึ่ง [4]
  • ความเปราะบาง (Fragility): ระบบพังง่ายเมื่อมีการเปลี่ยนแปลง
  • การยึดติด (Immobility): โค้ดนำกลับมาใช้ใหม่ (Reuse) ไม่ได้เพราะผูกติดกันแน่นเกินไป

ทางออก (Solution)

Design Patterns คือแนวทางแก้ปัญหาที่ผ่านการพิสูจน์แล้ว [3]:

  • ภาษากลาง (Shared Vocabulary): นักพัฒนาสื่อสารกันได้ง่ายขึ้น เช่น "ใช้ Factory ตรงนี้สิ" แทนที่จะอธิบายโค้ดยาวๆ
  • แนวปฏิบัติที่ดี (Best Practices): พิมพ์เขียวที่บังคับให้โค้ดมีความยืดหยุ่นและแยกส่วนกันชัดเจน (Decoupling)

หลักการ S.O.L.I.D.

บัญญัติโดย Robert C. Martin ("Uncle Bob") เพื่อเป็นรากฐานของการออกแบบเชิงวัตถุที่ดี (Clean Object-Oriented Design) [2, 5]
S
Single Responsibility Principle (SRP)
หนึ่งคลาสควรมีเหตุผลเดียวในการเปลี่ยนแปลง (หน้าที่เดียว) [6]
O
Open/Closed Principle (OCP)
เปิดรับการขยายความสามารถ แต่ปิดกั้นการแก้ไขโค้ดเดิม [7]
L
Liskov Substitution Principle (LSP)
คลาสลูกต้องสามารถแทนที่คลาสแม่ได้โดยไม่ทำให้โปรแกรมทำงานผิดพลาด [8]
I
Interface Segregation Principle (ISP)
ไม่ควรบังคับให้ Client พึ่งพา Interface ที่พวกเขาไม่ได้ใช้งาน [9]
D
Dependency Inversion Principle (DIP)
โมดูลระดับสูงไม่ควรขึ้นกับโมดูลระดับต่ำ ทั้งคู่ควรขึ้นอยู่กับ Abstraction [10]

SRP: ความรับผิดชอบเดียว

การละเมิด (God Object)

คลาสนี้ทำทั้งคำนวณ, แสดงผล, และบันทึกข้อมูล [6]

class Report:
  def calculate_data(self):
    ...
  def format_html(self):
    ...
  def save_to_db(self):
    ...

ถ้า HTML เปลี่ยน ก็ต้องแก้คลาสนี้ ถ้า DB เปลี่ยน ก็ต้องแก้คลาสนี้

แนวทางที่ถูกต้อง

แยกความรับผิดชอบออกเป็นคลาสเฉพาะทาง [7]

class ReportData:
  def calculate(self): ...

class ReportFormatter:
  def to_html(self, data): ...

class ReportRepository:
  def save(self, data): ...

แต่ละคลาสมีเหตุผลเดียวที่จะเปลี่ยนแปลง

OCP: เปิด/ปิด

"ซอฟต์แวร์ควร เปิดกว้างต่อการขยาย แต่ ปิดต่อการแก้ไข" [7]

สถานการณ์: เพิ่มรูปทรงใหม่ (Shape)

การละเมิด (ต้องแก้ไขโค้ดเดิม)

ต้องแก้ฟังก์ชัน `get_area` ทุกครั้งที่มีรูปทรงใหม่ [7]

def get_area(shapes):
  for s in shapes:
    if type(s) == Rectangle:
       ...
    elif type(s) == Circle:
       ...
    # ต้องแก้ตรงนี้ถ้ามี Triangle!

ทำตามหลักการ (ขยายด้วยคลาสใหม่)

ใช้ Abstract Base Class เพื่อขยาย ไม่ต้องแตะโค้ดเดิม [8]

class Shape(ABC):
  @abstractmethod
  def area(self): pass

# เพิ่ม Triangle โดยไม่กระทบโค้ดเดิม
class Triangle(Shape):
  def area(self): ...

LSP: การแทนที่ของ Liskov

"ถ้า S เป็นคลาสลูกของ T, ออบเจกต์ประเภท T จะต้องถูกแทนที่ด้วยออบเจกต์ประเภท S ได้ โดยไม่ทำให้โปรแกรมทำงานผิดพลาด" [8]

กับดักคลาสสิก: สี่เหลี่ยมจัตุรัส "เป็น" สี่เหลี่ยมผืนผ้า?

ทางคณิตศาสตร์ใช่ แต่ในเชิงพฤติกรรม (Behavior) ไม่ใช่! [11]

class Rectangle:
  def set_w(self, w): ...
  def set_h(self, h): ...

class Square(Rectangle):
  def set_w(self, w):
     self.width = w
     self.height = w # Side effect!
def resize(rect):
  rect.set_w(10)
  rect.set_h(20)
  # คาดหวังพื้นที่ 200
  # ถ้าส่ง Square มา พื้นที่จะเป็น 400!
  assert rect.area() == 200

บทเรียน: การสืบทอด (Inheritance) คือเรื่องของพฤติกรรม ไม่ใช่แค่โครงสร้าง ควรใช้ Composition หรือแยกเป็นคลาส Shape ที่อิสระต่อกัน [12]

ISP: การแยกอินเทอร์เฟซ

"ไม่ควรบังคับให้ Client พึ่งพาเมธอดที่พวกเขาไม่ได้ใช้งาน" (อย่าทำ Interface ที่เทอะทะ) [9]

Fat Interface
class Machine:
  • def print(self)
  • def fax(self)
  • def scan(self)
คลาส OldPrinter ต้อง Implement scan() ทั้งที่สแกนไม่ได้ [12]
แยกส่วนแล้ว
class Printer: def print(self)
class Scanner: def scan(self)
คลาสสามารถเลือกสืบทอดเฉพาะสิ่งที่ทำได้:
class AllInOne(Printer, Scanner): [13]

DIP: การกลับด้านความขึ้นต่อกัน

โมดูลระดับสูงไม่ควรขึ้นกับโมดูลระดับต่ำ ทั้งคู่ควรขึ้นอยู่กับ Abstractions (นามธรรม) [10]

ผูกมัดแน่น (Tightly Coupled)

class App:
  def __init__(self):
    # พึ่งพา MySQL โดยตรง!
    self.db = MySQLDatabase()

  def get_data(self):
    self.db.query()

กลับด้าน (Dependency Injection)

class App:
  # พึ่งพา Abstraction
  def __init__(self, db: DataSource):
    self.db = db

# ส่งสิ่งที่ต้องใช้เข้าไป (Injection)
app = App(MySQLDatabase())
app = App(PostgresDatabase())

Design Patterns: The Gang of Four

โซลูชันสำหรับปัญหาที่พบบ่อยในการออกแบบซอฟต์แวร์ แบ่งออกเป็น 3 ประเภท: [3]

Creational (การสร้าง)

เกี่ยวกับการสร้างออบเจกต์

  • Singleton
  • Factory Method
  • Builder

Structural (โครงสร้าง)

เกี่ยวกับการประกอบคลาสและออบเจกต์

  • Adapter
  • Decorator
  • Facade

Behavioral (พฤติกรรม)

เกี่ยวกับการสื่อสารระหว่างออบเจกต์

  • Observer
  • Strategy
  • Command

Creational Patterns (รูปแบบการสร้าง)

The Singleton

รับประกันว่าคลาสจะมี Instance เพียงอันเดียว และให้จุดเข้าถึงระดับ Global [3]

class Database:
  _instance = None

  def __new__(cls):
    if not cls._instance:
      cls._instance = super().__new__(cls)
    return cls._instance

The Factory Method

สร้างออบเจกต์โดยไม่ต้องระบุคลาสที่แน่นอน ให้คลาสลูกตัดสินใจว่าจะสร้างอะไร [3]

class Dialog:
  def create_button(self):
    return Button() # Default

class WindowsDialog(Dialog):
  def create_button(self):
    return WindowsButton()

Structural Patterns: Adapter

Adapter Pattern (ตัวแปลง): ทำหน้าที่เป็นสะพานเชื่อมระหว่าง 2 อินเทอร์เฟซที่ไม่เข้ากัน ให้สามารถทำงานร่วมกันได้

ตัวอย่างในโลกจริง

หัวปลั๊กไฟ (US Plug) เสียบกับเต้ารับไทยไม่ได้ เราจึงต้องใช้ Adapter ตัวแปลงเพื่อเชื่อมต่อ

class OldSystem:
    def legacy_method(self):
        return "DATA_XML"

class NewSystem:
    def request(self):
        return "DATA_JSON"

class Adapter(NewSystem):
    def __init__(self, old_obj):
        self.old_obj = old_obj

    def request(self):
        # แปลงข้อมูลเก่าให้เข้ากับระบบใหม่
        data = self.old_obj.legacy_method()
        return convert_xml_to_json(data)

Structural Patterns: Decorator

ใช้สำหรับเพิ่มพฤติกรรม (Behavior) ให้กับวัตถุแบบไดนามิก โดยไม่ต้องแก้ไขโค้ดเดิมหรือสร้าง Subclass ใหม่ที่ซับซ้อน

  • ยืดหยุ่นกว่าการสืบทอด (Inheritance)
  • ใน Python เราใช้ Syntax @decorator ได้โดยตรง
# Base Component
class Coffee:
    def cost(self): return 50

# Decorator
class MilkDecorator:
    def __init__(self, coffee):
        self._coffee = coffee
    
    def cost(self):
        return self._coffee.cost() + 10

my_coffee = Coffee()
my_coffee = MilkDecorator(my_coffee) 
print(my_coffee.cost()) # 60

Behavioral: Observer Pattern

กลไกการแจ้งเตือน (Publisher/Subscriber)

เมื่อวัตถุหลัก (Subject) เปลี่ยนแปลงสถานะ มันจะแจ้งเตือนไปยังผู้สังเกตการณ์ (Observers) ทั้งหมดโดยอัตโนมัติ

การใช้งานทั่วไป
  • ระบบแจ้งเตือนข่าวสาร (Newsletter)
  • Event Listeners ใน JavaScript
ใน Python GUI (PySide6)

ระบบ Signals & Slots คือการประยุกต์ใช้ Observer Pattern (ปุ่มถูกกด -> ฟังก์ชันทำงาน)

Behavioral: Strategy Pattern

ช่วยให้คุณกำหนดกลุ่มของอัลกอริทึม (Algorithms) และทำให้มันสลับสับเปลี่ยนกันได้ (Interchangeable) ขณะโปรแกรมทำงาน

ตัวอย่าง: ระบบจ่ายเงิน

ลูกค้าเลือกจ่ายผ่าน CreditCard หรือ PayPal โค้ดส่วนประมวลผลไม่ต้องแก้ แค่เปลี่ยน Strategy Object

class PaymentContext:
    def __init__(self, strategy):
        self.strategy = strategy
    
    def pay(self, amount):
        self.strategy.execute(amount)

# Strategies
class CreditCardStrategy:
    def execute(self, amount): 
        print(f"Paid {amount} via Card")

class PayPalStrategy:
    def execute(self, amount): 
        print(f"Paid {amount} via PayPal")

# Usage
context = PaymentContext(CreditCardStrategy())
context.pay(100) # Paid 100 via Card

Composition เหนือกว่า Inheritance?

Inheritance (เป็น Is-A)

  • กำหนดตายตัวตอน Compile (Static)
  • ลูกต้องรับมรดกทั้งหมด (แม้ไม่ต้องการ)
  • เกิดปัญหา Class Explosion (คลาสเยอะเกินไป)

Composition (มี Has-A)

  • ปรับเปลี่ยนได้ตอน Runtime (Dynamic)
  • ประกอบความสามารถจากชิ้นส่วนเล็กๆ
  • ลดความผูกมัด (Decoupling)
"เปลี่ยนพฤติกรรมด้วยการเปลี่ยนชิ้นส่วน ไม่ใช่การสร้างคลาสใหม่"

Dependency Injection (DI)

เทคนิคสำคัญในการทำตามหลักการ DIP (Dependency Inversion)

แบบเดิม (Hard Coded)

คลาสสร้าง Dependencies เองภายใน ผูกติดกันแน่น

class App:
  def __init__(self):
    # ผูกติดกับ MySQL
    self.db = MySQLDatabase()

แบบ DI (Injection)

รับ Dependencies เข้ามาทาง Constructor

class App:
  def __init__(self, db):
    # ยืดหยุ่น รับอะไรก็ได้ที่เป็น DB
    self.db = db

app = App(PostgresDatabase())

วิถีแห่ง Python: Duck Typing & Protocols

🦆

Duck Typing

"ถ้ามันเดินเหมือนเป็ด และร้องเหมือนเป็ด ก็ถือว่ามันเป็นเป็ด"

Python ไม่เช็คชนิดตัวแปร (Type) แต่เช็คพฤติกรรม (Behavior/Method) ว่ามีให้เรียกใช้หรือไม่

Protocols (Python 3.8+)

วิธีกำหนด Interface แบบ Static Type Checking โดยไม่ต้องสืบทอดจริง (Structural Subtyping)

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

def render(obj: Drawable):
    obj.draw() # ไม่ต้องสืบทอดจาก Drawable แค่มี method draw ก็พอ

สิ่งที่ควรระวัง (Anti-Patterns)

God Object

คลาสรู้ทุกอย่าง ทำทุกอย่าง (ละเมิด SRP) ไฟล์เดียวยาวพันบรรทัด ยากต่อการแก้ไข

Spaghetti Code

การไหลของโปรแกรมยุ่งเหยิง ขาดโครงสร้าง (Structure) พึ่งพา if-else ซ้อนกันลึกๆ แทนการใช้ Polymorphism

Copy-Paste Coding

คัดลอกโค้ดซ้ำๆ ไปหลายที่ (ละเมิด DRY - Don't Repeat Yourself) เมื่อต้องแก้บั๊ก ต้องตามแก้ทุกที่

สรุปบทเรียน: Design Patterns

SOLID Principles คือเข็มทิศ ช่วยให้โค้ดแข็งแรงและยืดหยุ่น

Design Patterns คือเครื่องมือ (Tools) แก้ปัญหาที่พบบ่อย

เลือกใช้ Composition มากกว่า Inheritance เพื่อลดความซับซ้อน

ใน Python, ใช้ Duck Typing และ Decorators เพื่อโค้ดที่กระชับแบบ Pythonic

เข้าสู่ ห้องปฏิบัติการ

การสร้าง "Log Viewer Application" ด้วย PySide6

ภารกิจ (Mission)

ออกแบบแอปพลิเคชัน GUI ที่สามารถอ่านข้อมูล Log จากแหล่งต่างๆ (ไฟล์ Text, Mock API) ได้โดยใช้หลักการ SOLID

สิ่งที่จะได้ฝึก

  • การใช้ Abstract Base Class สร้าง Interface
  • การทำ Dependency Injection เพื่อแยกส่วนแสดงผลและข้อมูล
  • การใช้ Signals (Observer) ใน PySide6

ชั่วโมงปฏิบัติการ:
SOLID Principles in Action

สร้างแอปพลิเคชัน "Log Viewer" ด้วย PySide6 และสถาปัตยกรรมที่ยั่งยืน

ภารกิจวันนี้

1
สร้าง Interface สำหรับแหล่งข้อมูล (Data Source) ตามหลัก DIP & OCP
2
ประยุกต์ใช้ Dependency Injection เพื่อแยก UI ออกจาก Logic
3
ใช้ Factory Pattern และ Strategy Pattern ในการจัดการข้อมูล

สถาปัตยกรรม โปรเจกต์

เราจะไม่เขียนทุกอย่างใน `main.py` แต่จะแยกส่วนประกอบตามหน้าที่ (SRP)

log_viewer/
├── interfaces/
└── data_source.py # Abstract Class
├── services/
├── file_source.py # Concrete Class
└── mock_source.py # Concrete Class
├── ui/
└── main_window.py # View (PySide6)
└── main.py # Composition Root
Abstraction (Interfaces)

กำหนดสัญญาว่า "แหล่งข้อมูล" ต้องทำอะไรได้บ้าง

Implementation (Services)

การทำงานจริง (อ่านไฟล์, สุ่มข้อมูล) ที่สับเปลี่ยนได้

Presentation (UI)

แสดงผลอย่างเดียว ไม่ยุ่งกับ Business Logic

Step 1: สร้าง Abstraction

ตามหลัก DIP และ OCP เราเริ่มที่การกำหนด Interface ก่อน เพื่อให้ UI ไม่ผูกติดกับแหล่งข้อมูลจริง

  • สร้างไฟล์ interfaces/data_source.py
  • ใช้ ABC (Abstract Base Class)
  • กำหนดเมธอด get_logs() ที่ทุกคลาสต้องมี
from abc import ABC, abstractmethod
from typing import List

# Interface (Contract)
class ILogSource(ABC):
    
    @abstractmethod
    def get_logs(self) -> List[str]:
        """ดึงข้อมูล Logs กลับมาเป็น List ของ String"""
        pass

Step 2: สร้าง Concrete Classes

สร้างคลาสที่ทำงานจริง 2 แบบ (ไฟล์ services/file_source.py และ services/mock_source.py)

1. FileLogSource (อ่านไฟล์จริง)

class FileLogSource(ILogSource):
    def __init__(self, filepath):
        self.filepath = filepath

    def get_logs(self) -> List[str]:
        try:
            with open(self.filepath, 'r') as f:
                return f.readlines()
        except FileNotFoundError:
            return ["Error: File not found"]

2. MockLogSource (ข้อมูลจำลอง)

class MockLogSource(ILogSource):
    def get_logs(self) -> List[str]:
        return [
            "[INFO] System started",
            "[WARN] Memory usage high",
            "[ERROR] Connection lost"
        ]
*สังเกต: ทั้งคู่สืบทอดจาก ILogSource ทำให้แทนที่กันได้ทันที (Liskov Substitution)*

Step 3: Dependency Injection ใน UI

ในไฟล์ ui/main_window.py เราจะไม่สร้าง FileLogSource โดยตรง (ห้ามใช้ new ภายในคลาส)

แบบที่ผิด (Tightly Coupled):

self.source = FileLogSource("log.txt")

แบบที่ถูก (Dependency Injection):

รับ source เข้ามาผ่าน Constructor

from PySide6.QtWidgets import *

class MainWindow(QMainWindow):
    # รับ Abstraction เข้ามา (DIP)
    def __init__(self, source: ILogSource):
        super().__init__()
        self.source = source  # Composition
        self.init_ui()

    def load_data(self):
        # UI ไม่รู้ว่าข้อมูลมาจากไหน รู้แค่ get_logs()
        logs = self.source.get_logs()
        self.list_widget.addItems(logs)

Step 4: Factory Pattern

สร้าง Simple Factory เพื่อเลือกแหล่งข้อมูลตาม Configuration

class SourceFactory:
    @staticmethod
    def create_source(source_type: str) -> ILogSource:
        if source_type == "file":
            return FileLogSource("app.log")
        elif source_type == "mock":
            return MockLogSource()
        else:
            raise ValueError("Unknown type")

ประโยชน์

Client code (main.py) ไม่จำเป็นต้องรู้ว่าต้อง import คลาสไหน หรือต้องกำหนดค่าเริ่มต้น (filepath) อย่างไร Factory จัดการให้หมด

Step 5: Strategy Pattern

เราต้องการฟีเจอร์ "กรองข้อมูล" (Filter) แทนที่จะเขียน if-else ใน UI ให้เราสร้าง Strategy

# Strategy Interface
class IFilterStrategy(ABC):
    @abstractmethod
    def filter(self, logs: List[str]) -> List[str]:
        pass

# Concrete Strategies
class ErrorOnlyFilter(IFilterStrategy):
    def filter(self, logs):
        return [l for l in logs if "ERROR" in l]

class NoFilter(IFilterStrategy):
    def filter(self, logs):
        return logs

การใช้งานใน MainWindow

เพิ่ม method set_filter_strategy ให้ UI

self.filter_strategy = NoFilter() logs = self.source.get_logs()
filtered = self.filter_strategy.filter(logs)

Step 6: รวมร่าง (Composition Root)

ใน main.py เราจะเชื่อมต่อทุกส่วนเข้าด้วยกัน

import sys
from PySide6.QtWidgets import QApplication
from ui.main_window import MainWindow
from services.factory import SourceFactory

if __name__ == "__main__":
    app = QApplication(sys.argv)

    # 1. เลือก Source (เปลี่ยนแค่ตรงนี้ก็เปลี่ยนทั้งแอปได้)
    # ลองเปลี่ยนเป็น "file" หรือ "mock"
    source = SourceFactory.create_source("mock")

    # 2. Inject เข้าไปใน UI (Dependency Injection)
    window = MainWindow(source)
    window.show()

    sys.exit(app.exec())

ภารกิจท้าทาย (Challenge)

ทดสอบความเข้าใจเรื่อง OCP (Open/Closed Principle)

โจทย์: เพิ่ม CSV Source

จงเพิ่มความสามารถให้แอปอ่านไฟล์ .csv โดยที่:

  • ห้ามแก้ไข ไฟล์ main_window.py (UI)
  • ห้ามแก้ไข คลาส FileLogSource เดิม
  • สร้างคลาสใหม่ CsvLogSource ที่สืบทอดจาก ILogSource
  • แก้ไข Factory หรือ Main เพื่อเรียกใช้คลาสใหม่

สรุปการเรียนรู้

DIP

Dependency Inversion

UI ไม่ขึ้นกับคลาสอ่านไฟล์โดยตรง แต่ขึ้นกับ Interface ILogSource ทำให้สลับเปลี่ยนแหล่งข้อมูลได้ง่าย

SRP

Single Responsibility

แยกการอ่านข้อมูล (Service) ออกจากการแสดงผล (View) อย่างชัดเจน

OCP

Open/Closed

เพิ่มฟีเจอร์ใหม่ (เช่น CSV Source หรือ Filter แบบใหม่) ได้โดยไม่ต้องแก้โค้ดเก่า

"Good architecture pays off in the long run."