از زمان معرفی Go 1.18، اکوسیستم Go تحولات چشمگیری داشته است. افزودن جنریکها یکی از رایجترین شکایات در توسعه Go، یعنی نیاز به کدهای تکراری (boilerplate) برای مدیریت انواع مختلف را برطرف کرده است. برای توسعهدهندگان متوسط و پیشرفته، استفاده از جنریکها دیگر اختیاری نیست اگر بخواهید کدی تمیز، DRY (خودت را تکرار نکن) و قابل نگهداری بنویسید.
در این پست، بررسی میکنیم که چگونه میتوان یک الگوی Repository قوی و ایمن از نظر نوع را با استفاده از جنریکهای Go پیادهسازی کرد. این الگو برای انتزاع پایگاه داده حیاتی است و به منطق کسبوکار شما اجازه میدهد از پیادهسازیهای خاص پایگاه داده جدا بماند، در حالی که ایمنی نوع را در زمان کامپایل تضمین میکند.
چرا الگوی Repository در Go؟
الگوی Repository به عنوان یک میانجی بین لایه دامنه و لایه نگاشت داده عمل میکند و یک رابط شبیه به مجموعه (collection-like) برای دسترسی به اشیاء دامنه فراهم میآورد. در توسعه سنتی Go بدون جنریکها، پیادهسازی یک Repository برای هر موجودیت (کاربر، محصول، سفارش) نیازمند کدهای تکراری برای هر عملیات پایگاه داده (ایجاد، خواندن، بهروزرسانی، حذف) بود.
با استفاده از جنریکها، میتوانیم یک رابط Repository و پیادهسازی واحد تعریف کنیم که در انواع مختلف موجودیتها کار میکند، که این امر کد تکراری را کاهش داده و قابلیت نگهداری را بهبود میبخشد.
تعریف رابط Repository جنریک
هسته راهحل ما در تعریف یک رابط جنریک نهفته است. ما از یک پارامتر نوع T برای نمایش نوع موجودیت استفاده میکنیم. علاوه بر این، یک رابط محدودیت Entity معرفی میکنیم تا تضمین کنیم که فقط ساختارهایی (structs) با فیلدهای خاص (مانند ID) میتوانند به عنوان موجودیت استفاده شوند.
package repository
import "context"
// EntityConstraint ensures that only structs with an ID field can be used as entities.
type EntityConstraint interface {
~struct{
ID int64
}
}
// Repository defines the generic interface for database operations.
type Repository[T any] interface {
Create(ctx context.Context, entity *T) error
Read(ctx context.Context, id int64) (*T, error)
Update(ctx context.Context, entity *T) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context) ([]*T, error)
}
در اینجا، any اجازه میدهد هر نوعی استفاده شود، اما در عمل، ممکن است T را محدود کنید تا رابط EntityConstraint را پیادهسازی کند اگر نیاز به اعمال قوانین ساختاری دارید. متدها *T را میپذیرند، که تضمین میکند فراخواننده اشارهگرهایی به نوع خاص موجودیت ارائه دهد.
پیادهسازی Repository جنریک
بیایید یک پیادهسازی ملموس با استفاده از sqlx ایجاد کنیم، یک کتابخانه SQL محبوب برای Go که از اسکن به داخل ساختارها پشتیبانی میکند. این پیادهسازی نشان میدهد که چگونه جنریکها به ما اجازه میدهند یک پردازشگر واحد برای انواع جدولهای مختلف بنویسیم.
package repository
import (
"context"
"database/sql"
"fmt"
)
type GenericRepository[T any] struct {
db *sql.DB
tableName string
}
func NewGenericRepository[T any](db *sql.DB, tableName string) *GenericRepository[T] {
return &GenericRepository[T]{
db: db,
tableName: tableName,
}
}
func (r *GenericRepository[T]) Create(ctx context.Context, entity *T) error {
// In a real-world scenario, you would use reflection or a macro-generated query
// to dynamically build the INSERT statement based on the struct fields.
// For brevity, we assume a helper function exists that generates the query.
query := fmt.Sprintf("INSERT INTO %s VALUES (?, ?, ?)", r.tableName)
_, err := r.db.ExecContext(ctx, query, /* fields from entity */ )
return err
}
func (r *GenericRepository[T]) Read(ctx context.Context, id int64) (*T, error) {
var entity T
query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", r.tableName)
err := r.db.QueryRowContext(ctx, query, id).Scan(&entity)
if err != nil {
return nil, err
}
return &entity, nil
}
توجه کنید که متد Read مقدار *T را برمیگرداند. کامپایلر تضمین میکند که اگر این متد را فراخوانی کنید، متغیر برگشتی با نوعی که Repository را با آن ایجاد کردهاید مطابقت دارد. این امر نیاز به تبدیل نوع (type assertions) مانند entity.(*User) را حذف میکند که هم مستعد خطا هستند و هم کند.
استفاده از Repository در لایه سرویس
قدرت واقعی این الگو زمانی آشکار میشود که از آن در لایه سرویس خود استفاده کنید. در اینجا، میتوانیم یک Repository جنریک تزریق کنیم و عملیات را بدون دانستن جزئیات SQL زیرین انجام دهیم.
type UserService struct {
userRepo repository.Repository[*User]
}
func NewUserService(userRepo repository.Repository[*User]) *UserService {
return &UserService{userRepo: userRepo}
}
func (s *UserService) GetUserByID(ctx context.Context, id int64) (*User, error) {
return s.userRepo.Read(ctx, id)
}
در این مثال، userRepo به عنوان Repository[*User] تایپ شده است. این تضمینهای قوی در زمان کامپایل فراهم میکند. اگر به اشتباه تلاش کنید یک *Product را به این سرویس خاص کاربر پاس دهید، کامپایلر Go خطایی ایجاد میکند و از باگهای زمان اجرا جلوگیری میکند.
بهترین شیوهها و ملاحظات
- از بیشازحد انتزاعی کردن پرهیز کنید: جنریکها پیچیدگی اضافه میکنند. تنها زمانی از آنها استفاده کنید که واقعاً به نیاز به بازنویسی منطق در انواع مختلف دارید. برای برنامههای ساده، پیادهسازیهای ملموس ممکن است واضحتر باشند.
- عملکرد: اگرچه جنریکها کد تکراری را کاهش میدهند، مطمئن شوید که توابع جنریک شما توسط کامپایلر درونخطی (inlined) میشوند. از استفاده بیشازحد از بازتاب (reflection) درون متدهای جنریک خودداری کنید، زیرا میتواند بر عملکرد تأثیر بگذارد.
- مدیریت خطا: اطمینان حاصل کنید که مدیریت خطا در توابع جنریک قوی باشد. از آنجا که نوع
Tدر زمان کامپایل ناشناخته است، نمیتوانید بر روی انواع خطای خاص نوع تکیه کنید.
نتیجهگیری
تسلط بر جنریکهای Go امکانات جدیدی برای ساخت برنامههای مقیاسپذیر و قابل نگهداری باز میکند. با پیادهسازی یک الگوی Repository جنریک، میتوانید به میزان بالایی از بازنویسی کد دست یابید، در حالی که ایمنی نوع که Go به آن معروف است را حفظ میکنید. همانطور که به توسعه در Go ادامه میدهید، جنریکها را نه فقط به عنوان یک ویژگی، بلکه به عنوان یک ابزار اساسی برای طراحی معماریهای تمیز بپذیرید.
امروزه شروع به بازنگری در پردازشگرهای تکراری پایگاه داده خود کنید و از مزایای کد ایمن از نظر نوع و جنریک لذت ببرید.