آموزش اصول سالید (SOLID) در گولنگ

سالید (SOLID) یک واژه‌ی اختصاری (mnemonic acronym) است که توسط Robert C. Martin (معروف به Uncle Bob) معرفی شده و بیانگر پنج اصل طراحی شی‌ءگرا (Object-Oriented Design) است:

  1. Single Responsibility Principle (SRP) — اصل تک مسئولیتی

  2. Open/Closed Principle (OCP) — اصل باز/بسته

  3. Liskov Substitution Principle (LSP) — اصل جایگزینی لیسکوف

  4. Interface Segregation Principle (ISP) — اصل تفکیک واسط

  5. Dependency Inversion Principle (DIP) — اصل وارونگی وابستگی

گرچه این اصول از دنیای برنامه‌نویسی شی‌ءگرا منشأ گرفته‌اند، اما به‌طور گسترده در بسیاری از زبان‌ها و الگوهای برنامه‌نویسی — از جمله زبان Go — قابل اجرا هستند.


Single Responsibility Principle (SRP)

تعریف: «یک کلاس (یا در Go، یک struct یا component) باید تنها یک و فقط یک دلیل برای تغییر داشته باشد.»

اصل تک مسئولیت‌پذیری (Single Responsibility Principle یا SRP) تأکید می‌کند که هر جزء از نرم‌افزار شما باید تنها مسئول یک بخش خاص از عملکرد باشد. تغییرات باید به یک جزء مرتبط با آن تغییر مربوط شوند.

در زبان گو، اغلب با structها یا پکیج‌های بزرگی مواجه می‌شویم که بیش‌ازحد کار انجام می‌دهند — از جمله مدیریت HTTP، کوئری‌های دیتابیس، Business Logic و..، همگی در یک مکان هستند. این کار با اصل SRP در تضاد است و می‌تواند باعث ایجاد کدی شود که نگهداری یا تست آن دشوار است.

اصل SRP همچنین به این معناست که ماژول‌ها، پکیج‌ها یا structها باید هدفی مشخص و تعریف‌شده داشته باشند. اگر هنگام توصیف وظایف یک struct از واژه «و» استفاده می‌کنید (مثلاً «این struct هم فلان کار را انجام می‌دهد و هم بهمان کار را») شاید وقت آن رسیده که آن را به اجزای کوچکتر و متمرکزتر تقسیم کنید.

مثال بدون استفاده از SRP:

package main

import (
    "fmt"
    "os"
    "time"
)

type UserService struct {
    // Potentially various fields related to user operations
}

func (us *UserService) CreateUser(name string, email string) error {
    // 1. Validate the user data
    if name == "" || email == "" {
       return fmt.Errorf("name and email cannot be empty")
    }

    // 2. Business logic: storing user in DB (simulated)
    fmt.Printf("User %s with email %s created.
", name, email)

    // 3. Logging to a file
    file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
       return fmt.Errorf("failed to open log file: %v", err)
    }
    defer file.Close()

    logEntry := fmt.Sprintf("%s: Created user %s with email %s
", time.Now().Format(time.RFC3339), name, email)
    if _, err = file.WriteString(logEntry); err != nil {
       return fmt.Errorf("failed to write to log file: %v", err)
    }

    return nil
}

func main() {
    us := &UserService{}
    err := us.CreateUser("Alice", "alice@example.com")
    if err != nil {
       fmt.Println("Error:", err)
    }
}

مشکلات مثال بالا:

  1. سرویس UserService مسئول ایجاد کاربر و همچنین لاگ‌گیری است.
  2. اگر نیازمندی‌های مربوط به لاگ‌گیری تغییر کنند (مثلاً استفاده از یک سرویس لاگ ابری)، باید UserService را تغییر دهیم.
  3. این باعث می‌شود کد سخت‌تر قابل نگهداری و تست باشد — چون منطق تجاری (business logic) با لاگ‌گیری درهم آمیخته شده و cohesion کد کاهش یافته است.

با SRP:

package main

import (
    "fmt"
    "os"
    "time"
)

// Logger defines the behavior of a logger.
type Logger interface {
    Log(message string) error
}

// FileLogger implements Logger for logging to a file.
type FileLogger struct {
    filePath string
}

func (fl *FileLogger) Log(message string) error {
    file, err := os.OpenFile(fl.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
       return fmt.Errorf("failed to open log file: %v", err)
    }
    defer file.Close()

    logEntry := fmt.Sprintf("%s: %s
", time.Now().Format(time.RFC3339), message)
    if _, err := file.WriteString(logEntry); err != nil {
       return fmt.Errorf("failed to write to log file: %v", err)
    }

    return nil
}

// UserService handles user-related logic.
type UserService struct {
    logger Logger
}

func NewUserService(logger Logger) *UserService {
    return &UserService{logger: logger}
}

func (us *UserService) CreateUser(name string, email string) error {
    // Business logic only
    if name == "" || email == "" {
       return fmt.Errorf("name and email cannot be empty")
    }

    // Simulate user creation
    fmt.Printf("User %s with email %s created.
", name, email)

    // Delegating logging responsibility to the Logger
    return us.logger.Log(fmt.Sprintf("Created user %s with email %s", name, email))
}

func main() {
    fileLogger := &FileLogger{filePath: "app.log"}
    userService := NewUserService(fileLogger)

    if err := userService.CreateUser("Alice", "alice@example.com"); err != nil {
       fmt.Println("Error:", err)
    }
}

مزایا:

  • اکنون UserService فقط مسئول ایجاد کاربر و منطق تجاری مرتبط با آن است.
  • FileLogger مسئولیت لاگ‌گیری را بر عهده دارد.

  • این تفکیک مسئولیت باعث افزایش انسجام، سهولت نگهداری و تست‌پذیری کد می‌شود.



Open/Closed Principle (OCP)

تعریف: «ماژول های نرم‌افزاری باید برای گسترش باز و برای تغییر بسته باشند.»

اصل باز/بسته (Open/Closed Principle) بیان می‌کند که باید بتوانید قابلیت‌های جدید را بدون تغییر کدهای موجود و تست‌شده اضافه کنید.

به جای تغییر مستقیم در کدهای قبلی، شما باید آن‌ها را گسترش دهید — معمولاً با استفاده از interfaceها، composition یا سایر روش‌های انتزاع (abstraction).

این رویکرد باعث افزایش قابلیت نگهداری، تست‌پذیری و انعطاف‌پذیری نرم‌افزار می‌شود، زیرا از بروز خطا در کدهای پایدار جلوگیری می‌کند.


مثال: اشکال هندسی

فرض کنیم یک اپلیکیشن داریم که مساحت اشکال مختلف را محاسبه می‌کند.
در ابتدا فقط دایره (Circle) و مربع (Square) را داریم. سپس کسب‌وکار می‌خواهد مستطیل (Rectangle) را هم اضافه کند.

بر اساس اصل باز/بسته (OCP)، نباید مجبور باشیم تابع (یا ساختار) محاسبه مساحت را هر بار با افزودن یک شکل جدید تغییر دهیم.

بدون استفاده از OCP:

package main

import (
    "fmt"
    "math"
)

type ShapeType int

const (
    Circle ShapeType = iota
    Square
    Rectangle
)

type Shape struct {
    Type   ShapeType
    Radius float64
    Side   float64
    Width  float64
    Height float64
}

func Area(shape Shape) float64 {
    switch shape.Type {
    case Circle:
       return math.Pi * shape.Radius * shape.Radius
    case Square:
       return shape.Side * shape.Side
    case Rectangle:
       return shape.Width * shape.Height
    default:
       return 0
    }
}

func main() {
    circle := Shape{Type: Circle, Radius: 5}
    square := Shape{Type: Square, Side: 4}
    rectangle := Shape{Type: Rectangle, Width: 3, Height: 6}

    fmt.Println("Circle area:", Area(circle))
    fmt.Println("Square area:", Area(square))
    fmt.Println("Rectangle area:", Area(rectangle))
}

مشکلات مثال بالا:

  • هر بار که یک شکل جدید اضافه می‌کنیم، باید تابع Area را تغییر دهیم — که این کار اصل باز/بسته (OCP) را نقض می‌کند.

  • ساختار Shape شامل فیلدهایی است که برای برخی اشکال بی‌ربط هستند (مثلاً فیلد Side برای یک دایره کاربردی ندارد).


با استفاده از OCP:

package main

import (
    "fmt"
    "math"
)

// Shape interface is open for extension.
type Shape interface {
    Area() float64
}

// Circle struct
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

// Square struct
type Square struct {
    Side float64
}

func (s Square) Area() float64 {
    return s.Side * s.Side
}

// Rectangle struct
type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Now the area calculation function is closed for modification.
func PrintArea(shape Shape) {
    fmt.Println("Shape Area:", shape.Area())
}

func main() {
    circle := Circle{Radius: 5}
    square := Square{Side: 4}
    rectangle := Rectangle{Width: 3, Height: 6}

    PrintArea(circle)
    PrintArea(square)
    PrintArea(rectangle)

    // Suppose we add a new shape, Triangle:
    // Just implement Shape and use PrintArea without changing existing code.
    type Triangle struct {
       Base   float64
       Height float64
    }

    func (t Triangle) Area() float64 {
       return 0.5 * t.Base * t.Height
    }

    triangle := Triangle{Base: 5, Height: 10}
    PrintArea(triangle)
}

مزایا استفاده از OCP در مثال بالا:

  • اینترفیس Shape برای تغییر بسته است (نیازی به تغییر آن نداریم).

  • می‌توانیم با ایجاد structهای جدیدی که اینترفیس Shape را پیاده‌سازی می‌کنند، قابلیت‌ها را گسترش دهیم.

  • تابع PrintArea بدون تغییر باقی می‌ماند — حتی اگر شکل‌های جدیدی مثل مثلث (Triangle)، پنج‌ضلعی (Pentagon) یا هر شکل دیگری در آینده اضافه کنیم.


Liskov Substitution Principle (LSP)

تعریف: «اگر کلاس B زیرکلاس یا فرزند کلاس A باشد، آنگاه اشیاء کلاس B باید بتوانند جایگزین اشیاء کلاس A شوند، بدون اینکه رفتار برنامه خراب شود یا برنامه نیاز به تغییر داشته باشد.»

یعنی وقتی شما یک کلاس پایه (Base Class) دارید و از آن ارث‌بری می‌کنید، زیرکلاس شما باید طوری طراحی شود که بتواند به جای کلاس پایه استفاده شود، بدون اینکه نیازی به تغییر در کدی که از کلاس پایه استفاده می‌کند باشد.

در زبان گو، اینترفیس‌ها قراردادهایی را تعریف می‌کنند. یک struct که ادعا می‌کند یک اینترفیس را پیاده‌سازی می‌کند، باید آن قرارداد را به درستی و با رفتار مورد انتظار رعایت کند.
نقض این اصل زمانی رخ می‌دهد که یک نوع اینترفیس را پیاده‌سازی کند اما قواعد یا شرایط پس از اجرا (post-conditions) آن قرارداد را رعایت نکند.


مثال: روش‌های مختلف پرداخت
تصور کنید یک اینترفیس برای روش‌های مختلف پرداخت دارید: کارت اعتباری، پی‌پال، رمزارز و غیره.
هر روش باید پرداخت را پردازش کند. اگر پیاده‌سازی یکی از این روش‌ها قرارداد را نقض کند (مثلاً پول را دریافت کند اما هرگز وضعیت تراکنش را به‌روزرسانی نکند)، این باعث نقض اصل LSP می‌شود.


بدون استفاده از LSP:

package main

import "fmt"

// اینترفیس پرداخت
type PaymentMethod interface {
    ProcessPayment(amount float64) bool
    UpdateTransactionStatus()
}

// کارت اعتباری
type CreditCard struct{}

func (c CreditCard) ProcessPayment(amount float64) bool {
    fmt.Println("پرداخت با کارت اعتباری:", amount)
    return true
}

func (c CreditCard) UpdateTransactionStatus() {
    fmt.Println("آپدیت وضعیت تراکنش کارت اعتباری")
}

// پی‌پال که وضعیت تراکنش را آپدیت نمی‌کند => نقض LSP
type PayPal struct{}

func (p PayPal) ProcessPayment(amount float64) bool {
    fmt.Println("پرداخت با پی‌پال:", amount)
    return true
}

func (p PayPal) UpdateTransactionStatus() {
    // این متد هیچ کاری انجام نمی‌دهد
}

func MakePayment(pm PaymentMethod, amount float64) {
    if pm.ProcessPayment(amount) {
       pm.UpdateTransactionStatus()
       fmt.Println("پرداخت موفق")
    } else {
       fmt.Println("پرداخت ناموفق")
    }
}

func main() {
    cc := CreditCard{}
    pp := PayPal{}

    MakePayment(cc, 100) // درست کار می‌کند
    MakePayment(pp, 200) // وضعیت تراکنش به‌روزرسانی نمی‌شود => نقض LSP
}

با استفاده از LSP:

package main

import "fmt"

// اینترفیس پایه پرداخت فقط پردازش پرداخت
type PaymentMethod interface {
    ProcessPayment(amount float64) bool
}

// اینترفیس برای پرداخت‌هایی که وضعیت تراکنش را آپدیت می‌کنند
type TransactionUpdater interface {
    UpdateTransactionStatus()
}

// کارت اعتباری که هر دو اینترفیس را پیاده‌سازی می‌کند
type CreditCard struct{}

func (c CreditCard) ProcessPayment(amount float64) bool {
    fmt.Println("پرداخت با کارت اعتباری:", amount)
    return true
}

func (c CreditCard) UpdateTransactionStatus() {
    fmt.Println("آپدیت وضعیت تراکنش کارت اعتباری")
}

// پی‌پال فقط پردازش پرداخت را پیاده‌سازی می‌کند
type PayPal struct{}

func (p PayPal) ProcessPayment(amount float64) bool {
    fmt.Println("پرداخت با پی‌پال:", amount)
    return true
}

// تابعی که فقط پرداخت را انجام می‌دهد
func MakePayment(pm PaymentMethod, amount float64) {
    if pm.ProcessPayment(amount) {
       // اگر امکان آپدیت تراکنش بود، انجام بده
       if updater, ok := pm.(TransactionUpdater); ok {
          updater.UpdateTransactionStatus()
       }
       fmt.Println("پرداخت موفق")
    } else {
       fmt.Println("پرداخت ناموفق")
    }
}

func main() {
    cc := CreditCard{}
    pp := PayPal{}

    MakePayment(cc, 100) // آپدیت وضعیت تراکنش هم انجام می‌شود
    MakePayment(pp, 200) // فقط پرداخت انجام می‌شود، آپدیت نیست اما نقض LSP نداریم
}

مزایا استفاده از LSP:

  • افزایش انعطاف‌پذیری کد
  • کاهش خطا و باگ‌های منطقی
  • کد قابل فهم‌تر و قابل نگهداری‌تر


Interface Segregation Principle (ISP)

تعریف: «کلاس‌ها نباید به اینترفیس هایی وابسته باشند که از آن‌ها استفاده نمی‌کنند.»

به عبارت ساده‌تر، به جای اینکه یک اینترفیس بزرگ و جامع داشته باشیم که شامل همه متدهای ممکن است، بهتر است آن را به چندین رابط کوچک‌تر و تخصصی‌تر تقسیم کنیم. این کار باعث می‌شود هر کلاس فقط آن متدهایی که واقعا نیاز دارد را پیاده‌سازی کند و وابستگی‌های اضافه کاهش پیدا کند.


مثال: تقسیم اینترفیس‌ها برای انواع مختلف اسناد

بدون استفاده از ISP:

type MultifunctionDevice interface {
    Print(doc string) error
    Scan(doc string) error
    Fax(doc string) error
    Email(doc string) error
}

type BasicPrinter struct{}

func (bp *BasicPrinter) Print(doc string) error {
    fmt.Println("Printing doc:", doc)
    return nil
}

// We must implement all methods, even if we don't use them!
func (bp *BasicPrinter) Scan(doc string) error {
    return fmt.Errorf("not implemented")
}
func (bp *BasicPrinter) Fax(doc string) error {
    return fmt.Errorf("not implemented")
}
func (bp *BasicPrinter) Email(doc string) error {
    return fmt.Errorf("not implemented")
}

مشکلات مثال بالا:

  • BasicPrinter نیازی به متدهای Scan، Fax یا Email ندارد، اما مجبور است آن‌ها را پیاده‌سازی کند.
  • این باعث نقض اصل ISP (Interface Segregation Principle) می‌شود، چون BasicPrinter به متدهایی وابسته است که اصلاً استفاده نمی‌کند.

با استفاده از ISP:

type Printer interface {
    Print(doc string) error
}

type Scanner interface {
    Scan(doc string) error
}

type Faxer interface {
    Fax(doc string) error
}

type Emailer interface {
    Email(doc string) error
}

// Compose smaller interfaces to make a bigger one if needed
type MultifunctionDevice interface {
    Printer
    Scanner
    Faxer
    Emailer
}

type BasicPrinter struct{}

func (bp *BasicPrinter) Print(doc string) error {
    fmt.Println("Printing doc:", doc)
    return nil
}

// Now BasicPrinter doesn't implement any irrelevant methods!

type AllInOnePrinter struct{}

func (aio *AllInOnePrinter) Print(doc string) error {
    fmt.Println("Printing:", doc)
    return nil
}

func (aio *AllInOnePrinter) Scan(doc string) error {
    fmt.Println("Scanning:", doc)
    return nil
}

func (aio *AllInOnePrinter) Fax(doc string) error {
    fmt.Println("Faxing:", doc)
    return nil
}

func (aio *AllInOnePrinter) Email(doc string) error {
    fmt.Println("Emailing:", doc)
    return nil
}

مزایا استفاده از ISP:

  • اینترفیس‌های کوچک‌تر و منسجم‌تر (مثل Printer، Scanner، Faxer، Emailer)
  • BasicPrinter فقط نیاز دارد اینترفیس Printer را پیاده‌سازی کند.
  • اما AllInOnePrinter می‌تواند کل دستگاه چندکاره را با ترکیب (embedding) این اینترفیس‌های کوچک‌تر پیاده‌سازی کند.


Dependency Inversion Principle (DIP)

تعریف: «ماژول‌های سطح بالا نباید به ماژول‌های سطح پایین وابسته باشند. هر دو باید به انتزاعات (abstractions) وابسته باشند.»

اصل Dependency Inversion Principle (DIP) می‌گوید که باید به انتزاعات (interfaces) وابسته باشید، نه به پیاده‌سازی‌های مشخص و جزئی.

این کار باعث می‌شود سیاست‌ها و قوانین کسب‌وکار در ماژول‌های سطح بالا مستقل از جزئیات اجرای آن‌ها باشند. در نتیجه کد شما ماژولارتر و قابل تست‌تر خواهد شد.

ماژول‌های سطح بالا در مقابل ماژول‌های سطح پایین

  • ماژول‌های سطح بالا:
    شامل منطق تجاری (Business Logic)، موارد کاربرد (Use Cases)، و بخش‌هایی هستند که رفتار کلی برنامه را تعریف و مدیریت می‌کنند.
    مثال: سرویس مدیریت کاربران، منطق سفارش‌دهی.

  • ماژول‌های سطح پایین:
    شامل جزئیات فنی و پیاده‌سازی هستند، مانند پایگاه داده، سیستم فایل، یا سرویس‌های خارجی.
    مثال: کتابخانه ارتباط با دیتابیس، API برای ارسال ایمیل، ذخیره فایل در دیسک.

اگر کد سطح بالا مستقیماً یک کتابخانه پایگاه داده را ایمپورت یا نمونه‌سازی (instantiate) کند، کد شما به آن کتابخانه وابسته و قفل می‌شود.

اصل DIP پیشنهاد می‌کند که یک اینترفیس تعریف کنید که عملیات ذخیره‌سازی را نمایش دهد.
کتابخانه واقعی پایگاه داده به عنوان یک ماژول سطح پایین، این اینترفیس را پیاده‌سازی می‌کند.

بدین ترتیب، کد سطح بالا فقط به اینترفیس وابسته است و می‌توان به راحتی پیاده‌سازی‌های مختلف را جایگزین کرد بدون تغییر کد اصلی.

مثال: ذخیره‌سازی پیام در دیتابیس

بدون استفاده از DIP:

package main

import "fmt"

// Low-level module
type MySQLStorage struct{}

func (s MySQLStorage) SaveMessage(msg string) {
    fmt.Println("Saving message to MySQL:", msg)
}

// High-level module
type MessageService struct {
    storage MySQLStorage
}

func (ms MessageService) Send(msg string) {
    ms.storage.SaveMessage(msg)
}

func main() {
    service := MessageService{storage: MySQLStorage{}}
    service.Send("Hello, DIP!")
}

مشکلات مثال بالا:

  • MessageService مستقیم به MySQLStorage وابسته است.

  • اگر بخواهیم PostgreSQLStorage استفاده کنیم، باید کد را تغییر دهیم → نقض DIP.

با استفاده از DIP:

package main

import "fmt"

// Abstraction
type Storage interface {
    SaveMessage(msg string)
}

// Low-level module
type MySQLStorage struct{}

func (s MySQLStorage) SaveMessage(msg string) {
    fmt.Println("Saving message to MySQL:", msg)
}

// High-level module
type MessageService struct {
    storage Storage // وابستگی به اینترفیس
}

func (ms MessageService) Send(msg string) {
    ms.storage.SaveMessage(msg)
}

func main() {
    var storage Storage = MySQLStorage{}
    service := MessageService{storage: storage}
    service.Send("Hello, DIP with interface!")
}

مزایا استفاده از DIP:

  • MessageService فقط Storage (interface) را می‌شناسد.
  • می‌توانیم به راحتی PostgreSQLStorage, MemoryStorage یا هر نوع دیگری را بدون تغییر MessageService اضافه کنیم.

0 🔥
0 🎉
0 😮
0 👍
0 💜
0 👏
میلاد خسروی
نویسنده کد نیوز

برنامه نویس فان | Fun Developer یک آدم ساده که عاشق برنامه نویسی و کد زدنه :) تلاش میکنه تا به بقیه کمک کنه. توسعه دهنده هسته لاراول و فضای اوپن سورس. فاندر پرانتز و کد نیوز.

0+ نظر

برای ثبت نظر ابتدا ورود کنید.

0 نظر

    اولین نفر باش که نظر ثبت میکنی :) یعنی یه کامنت به ما نمیرسه 😁