برای سالها، توسعهدهندگان Go به سادگی و کد صریح خود افتخار میکردند. با این حال، با افزایش پیچیدگی برنامهها، کدهای تکراری و اضافی برای عملیات پایگاه داده اغلب کدها را شلوغ میکنند. قبل از Go 1.18، پیادهسازی یک لایه مخزن عمومی نیازمند ترفندهای پیچیده اینترفیس یا کپی-پیست منطق در موجودیتهای مختلف بود. با معرفی ژنریکها (Generics)، Go به زبانی تکامل یافته است که قادر به پشتیبانی از الگوهای طراحی قوی بدون قربانی کردن فلسفه اصلی خود یعنی سادگی است.
در این پست، ما بررسی خواهیم کرد که چگونه میتوان الگوی مخزن عمومی (Generic Repository) را در Go مدرن پیادهسازی کرد. ما یک انتزاع قابل استفاده مجدد ایجاد خواهیم کرد که به شما امکان میدهد عملیات رایج CRUD (ایجاد، خواندن، بهروزرسانی، حذف) را روی هر موجودیتی انجام دهید و بدین ترتیب کدهای تکراری را به شدت کاهش دهید در حالی که ایمنی نوع را حفظ میکنید.
چرا از مخزن عمومی استفاده کنیم؟
الگوی مخزن به عنوان یک میانجی بین لایه دامنه و لایه نگاشت داده عمل میکند. هدف اصلی آن جداسازی منطق کسبوکار از منطق دسترسی به داده است. در یک برنامه Go معمولی، ممکن است متوجه شوید که ساختارهای کوئری تقریباً یکسانی را برای مدلهای User، Post و Comment مینویسید.
بدون ژنریکها، دستیابی به این قابلیت استفاده مجدد اغلب شامل پاس دادن اینترفیسها یا استفاده از بازتاب (Reflection) است که هر دو میتوانند منجر به خطاهای زمان اجرا یا سربار عملکرد شوند. ژنریکها به ما اجازه میدهند یک اینترفیس یا ساختار واحد را تعریف کنیم که با هر نوع T کار میکند و تضمین میکند که لایه پایگاه داده دقیقاً میداند چه فیلدهایی در زمان کامپایل وجود دارند.
تعریف اینترفیس عمومی
بیایید با تعریف یک اینترفیس عمومی که عملیات پایه را مشخص میکند شروع کنیم. این اینترفیس توسط یک نوع T پارامتریزه خواهد شد. برای این مثال، فرض میکنیم که از یک ORM یا درایور SQL فرضی استفاده میکنیم، اما الگو صرفنظر از کتابخانه داده زیرین یکسان باقی میماند.
package repository
import (
"context"
"errors"
)
// Entity represents a generic database entity with an ID field.
// In a real application, you might use a more complex interface or require
// specific tags for ORM mapping.
type Entity interface {
GetID() any
}
// ErrNotFound is returned when an entity is not found.
var ErrNotFound = errors.New("entity not found")
// GenericRepository defines standard CRUD operations for any entity type T.
type GenericRepository[T Entity] interface {
Create(ctx context.Context, entity T) error
GetByID(ctx context.Context, id any) (T, error)
Update(ctx context.Context, entity T) error
Delete(ctx context.Context, id any) error
List(ctx context.Context) ([]T, error)
}
توجه داشته باشید که T باید قید Entity را برآورده کند. این تضمین میکند که هر نوعی که به مخزن ما پاس داده میشود، یک روش GetID() دارد که برای عملیات بهروزرسانی و حذف حیاتی است.
پیادهسازی مخزن عینی
حال، بیایید یک نوع عینی را پیادهسازی کنیم که این اینترفیس را برآورده میکند. اگرچه ما در اینجا یک ORM کامل نمینویسیم، اما میتوانیم ساختار پیادهسازی را نشان دهیم. ما از یک نقشه به عنوان یک مخزن داده تقلیدی (Mock) استفاده میکنیم تا مثال بر الگو متمرکز باشد و نه بر اتصال به پایگاه داده.
package repository
import (
"context"
"fmt"
"sync"
)
// MockRepository is a generic implementation for demonstration purposes.
type MockRepository[T Entity] struct {
mu sync.Mutex
Store map[any]T
}
// NewMockRepository creates a new instance of the mock repository.
func NewMockRepository[T Entity]() *MockRepository[T] {
return &MockRepository[T]{
Store: make(map[any]T),
}
}
// Create adds a new entity to the store.
func (r *MockRepository[T]) Create(ctx context.Context, entity T) error {
r.mu.Lock()
defer r.mu.Unlock()
id := entity.GetID()
if _, exists := r.Store[id]; exists {
return fmt.Errorf("entity with ID %v already exists", id)
}
r.Store[id] = entity
return nil
}
// GetByID retrieves an entity by its ID.
func (r *MockRepository[T]) GetByID(ctx context.Context, id any) (T, error) {
r.mu.Lock()
defer r.mu.Unlock()
entity, exists := r.Store[id]
if !exists {
var zero T
return zero, ErrNotFound
}
return entity, nil
}
// List returns all entities in the store.
func (r *MockRepository[T]) List(ctx context.Context) ([]T, error) {
r.mu.Lock()
defer r.mu.Unlock()
entities := make([]T, 0, len(r.Store))
for _, v := range r.Store {
entities = append(entities, v)
}
return entities, nil
}
با استفاده از MockRepository[T Entity]، ما تضمین میکنیم که کامپایلر فقط به انواعی که Entity را پیادهسازی میکنند اجازه استفاده با این مخزن را میدهد. اگر سعی کنید مخزنی برای ساختاری که فاقد روش GetID() است ایجاد کنید، کد با خطای کامپایل مواجه خواهد شد.
مثال استفاده عملی
بیایید ببینیم چگونه میتوان از این الگو در لایه سرویس استفاده کرد. یک ساختار کاربر ساده تعریف کنید:
type User struct {
ID int64
Name string
}
func (u User) GetID() any {
return u.ID
}
اکنون میتوانید یک مخزن را به طور خاص برای کاربران ایجاد کنید:
userRepo := repository.NewMockRepository[User]()
err := userRepo.Create(context.Background(), User{ID: 1, Name: "Alice"})
if err != nil {
log.Fatal(err)
}
user, err := userRepo.GetByID(context.Background(), 1)
if err == repository.ErrNotFound {
log.Println("User not found")
} else if err != nil {
log.Fatal(err)
}
fmt.Printf("Retrieved user: %+v\n", user)
نتیجهگیری
پیادهسازی الگوهای مخزن عمومی در Go راهی قدرتمند برای انتزاع منطق پایگاه داده در حالی که از ایمنی نوعی که توسعهدهندگان Go دوست دارند بهره میبرد، ارائه میدهد. این کار کدهای تکراری را حذف میکند، تستپذیری را با امکان پیادهسازیهای تقلیدی ساده آسانتر میسازد و یک قرارداد واضح برای دسترسی به دادهها فراهم میکند.
اگرچه این مثال از یک مخزن تقلیدی استفاده میکند، همان اصول هنگام ادغام با کتابخانههایی مانند GORM، sqlx یا ent نیز صدق میکند. با پذیرش ژنریکها، میتوانید برنامههای Go بیانگر، قابل نگهداری و مقیاسپذیرتری بنویسید.