المشروع الشامل (الجزء 1)

المشروع الشامل (الجزء 1)

بناء مشروع Go كامل من الصفر — نظام Blog API.

في هذا الدرس سنبني نظام إدارة تدوين المدونة (Blog API)، يغطي تحليل المتطلبات، تصميم الدليل، نماذج البيانات، منطق الأعمال، معالجة الأخطاء، واختبارات الوحدة. ستكمل الدرس التالي جزء HTTP والمسارات والوسائط.


متطلبات المشروع

قائمة الميزات

الوحدة الميزات الوصف
المقالات إنشاء، استعلام، قائمة، تحديث، حذف تغיפוי CRUD كامل
التصنيفات إنشاء، استعلام، قائمة المقالات تنتمي إلى تصنيفات
العلامات إنشاء، استعلام، قائمة يمكن ربط المقالات بعدة علامات
المستخدمين تسجيل، استعلام نسخة مبسطة، بدون مصادقة دخول

المتطلبات غير الوظيفية


تصميم بنية النظام

يتبنى البنية الطبقية الكلاسيكية:

TEXT
┌─────────────────────────────────────────────┐
│              Handler (طبقة HTTP)            │
│   استقبال الطلب → تحليل المعلمات → استدعاء الخدمة → الإرجاع │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│             Service (طبقة الأعمال)          │
│   منطق الأعمال → التحقق من المعلمات → استدعاء المستودع │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│           Repository (طبقة البيانات)         │
│   الوصول إلى البيانات → التخزين في الذاكرة / قاعدة البيانات │
└─────────────────────────────────────────────┘

تعتمد كل طبقة فقط على الطبقة التي تحتها، منفصلة عبر الواجهات لسهولة الاختبار واستبدال التنفيذ.


هيكل دليل المشروع

TEXT
blog-api/
├── cmd/
│   └── server/
│       └── main.go            # نقطة دخول البرنامج
├── internal/
│   ├── handler/               # معالجات HTTP (يُنفذ في الدرس 30)
│   │   ├── article.go
│   │   ├── category.go
│   │   └── response.go
│   ├── service/               # طبقة منطق الأعمال
│   │   ├── article.go
│   │   ├── category.go
│   │   └── service.go
│   ├── repository/            # طبقة الوصول إلى البيانات
│   │   ├── article.go
│   │   ├── category.go
│   │   └── store.go
│   └── model/                 # نماذج البيانات
│       ├── article.go
│       ├── category.go
│       └── user.go
├── pkg/
│   └── errcode/               # أكواد أخطاء موحدة
│       └── errcode.go
├── go.mod
└── README.md

مسؤوليات الأدلة:

الدليل المسؤولية الرؤية
cmd/ نقطة دخول البرنامج، كل دليل فرعي يقابل ملفًا تنفيذيًا عام
internal/ كود المشروع الأساسي، لا يمكن للحزم الخارجية استيراده خاص
pkg/ حزم أدوات قابلة لإعادة الاستخدام من مشاريع خارجية عام

الخطوة 1: تهيئة المشروع

BASH
mkdir -p blog-api && cd blog-api
go mod init blog-api

إنشاء هيكل الدليل:

BASH
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode

الخطوة 2: تعريف نماذج البيانات

نموذج المستخدم

GO
// internal/model/user.go
package model

import "time"

// User نموذج المستخدم
type User struct {
	ID        int64     `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

نموذج التصنيف

GO
// internal/model/category.go
package model

import "time"

// Category تصنيف المقالات
type Category struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

نموذج المقالة

GO
// internal/model/article.go
package model

import "time"

// Article نموذج المقالة
type Article struct {
	ID         int64     `json:"id"`
	Title      string    `json:"title"`
	Content    string    `json:"content"`
	AuthorID   int64     `json:"author_id"`
	CategoryID int64     `json:"category_id"`
	Tags       []string  `json:"tags"`
	Status     int8      `json:"status"` // 0=مسودة 1=منشور
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

// CreateArticleRequest طلب إنشاء مقالة
type CreateArticleRequest struct {
	Title      string   `json:"title"`
	Content    string   `json:"content"`
	AuthorID   int64    `json:"author_id"`
	CategoryID int64    `json:"category_id"`
	Tags       []string `json:"tags"`
}

// UpdateArticleRequest طلب تحديث مقالة
type UpdateArticleRequest struct {
	Title      *string  `json:"title,omitempty"`
	Content    *string  `json:"content,omitempty"`
	CategoryID *int64   `json:"category_id,omitempty"`
	Tags       []string `json:"tags,omitempty"`
	Status     *int8    `json:"status,omitempty"`
}

// ListRequest طلب استعلام مصفح
type ListRequest struct {
	Page     int `json:"page"`
	PageSize int `json:"page_size"`
}

// ListResponse استجابة استعلام مصفح
type ListResponse struct {
	Items      interface{} `json:"items"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int         `json:"total_pages"`
}

تحليل الكود:


الخطوة 3: أكواد أخطاء موحدة

GO
// pkg/errcode/errcode.go
package errcode

import "fmt"

// AppError خطأ التطبيق
type AppError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Error تنفيذ واجهة الخطأ
func (e *AppError) Error() string {
	return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

// New يُنشئ خطأ تطبيق
func New(code int, message string) *AppError {
	return &AppError{Code: code, Message: message}
}

// أكواد أخطاء محددة مسبقًا
var (
	ErrNotFound        = New(10001, "المورد غير موجود")
	ErrInvalidParam    = New(10002, "معلمات غير صالحة")
	ErrDuplicate       = New(10003, "المورد موجود بالفعل")
	ErrInternal        = New(10004, "خطأ داخلي")
	ErrArticleNotFound = New(20001, "المقالة غير موجودة")
	ErrCategoryNotFound = New(20002, "التصنيف غير موجود")
	ErrUserNotFound    = New(20003, "المستخدم غير موجود")
	ErrTitleRequired   = New(20004, "العنوان لا يمكن أن يكون فارغًا")
	ErrContentRequired = New(20005, "المحتوى لا يمكن أن يكون فارغًا")
)

تحليل الكود:


الخطوة 4: طبقة الوصول إلى البيانات (Repository)

واجهة التخزين

GO
// internal/repository/store.go
package repository

import "blog-api/internal/model"

// Store واجهة تخزين موحد (البرمجة الموجهة بالواجهات)
type Store interface {
	ArticleRepo() ArticleRepository
	CategoryRepo() CategoryRepository
	UserRepo() UserRepository
}

// ArticleRepository واجهة وصول بيانات المقالات
type ArticleRepository interface {
	Create(article *model.Article) error
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) ([]*model.Article, int64, error)
	Update(article *model.Article) error
	Delete(id int64) error
}

// CategoryRepository واجهة وصول بيانات التصنيفات
type CategoryRepository interface {
	Create(category *model.Category) error
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

// UserRepository واجهة وصول بيانات المستخدمين
type UserRepository interface {
	Create(user *model.User) error
	GetByID(id int64) (*model.User, error)
}

التنفيذ في الذاكرة

GO
// internal/repository/memory_store.go
package repository

import (
	"fmt"
	"sync"
	"sync/atomic"

	"blog-api/internal/model"
)

// MemoryStore تنفيذ التخزين في الذاكرة
type MemoryStore struct {
	articles   map[int64]*model.Article
	categories map[int64]*model.Category
	users      map[int64]*model.User
	mu         sync.RWMutex
	nextID     atomic.Int64
}

// NewMemoryStore يُنشئ تخزينًا في الذاكرة
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		articles:   make(map[int64]*model.Article),
		categories: make(map[int64]*model.Category),
		users:      make(map[int64]*model.User),
	}
}

func (s *MemoryStore) next() int64 {
	return s.nextID.Add(1)
}

// ArticleRepo يُرجع مستودع المقالات
func (s *MemoryStore) ArticleRepo() ArticleRepository {
	return &memoryArticleRepo{store: s}
}

// CategoryRepo يُرجع مستودع التصنيفات
func (s *MemoryStore) CategoryRepo() CategoryRepository {
	return &memoryCategoryRepo{store: s}
}

// UserRepo يُرجع مستودع المستخدمين
func (s *MemoryStore) UserRepo() UserRepository {
	return &memoryUserRepo{store: s}
}

// ---- تنفيذ مستودع المقالات ----

type memoryArticleRepo struct {
	store *MemoryStore
}

func (r *memoryArticleRepo) Create(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	article.ID = r.store.next()
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) GetByID(id int64) (*model.Article, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	article, ok := r.store.articles[id]
	if !ok {
		return nil, fmt.Errorf("المقالة %d غير موجودة", id)
	}
	return article, nil
}

func (r *memoryArticleRepo) List(page, pageSize int) ([]*model.Article, int64, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	// جمع جميع المقالات
	all := make([]*model.Article, 0, len(r.store.articles))
	for _, a := range r.store.articles {
		all = append(all, a)
	}

	total := int64(len(all))
	start := (page - 1) * pageSize
	if start >= len(all) {
		return []*model.Article{}, total, nil
	}
	end := start + pageSize
	if end > len(all) {
		end = len(all)
	}

	return all[start:end], total, nil
}

func (r *memoryArticleRepo) Update(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[article.ID]; !ok {
		return fmt.Errorf("المقالة %d غير موجودة", article.ID)
	}
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) Delete(id int64) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[id]; !ok {
		return fmt.Errorf("المقالة %d غير موجودة", id)
	}
	delete(r.store.articles, id)
	return nil
}

// ---- تنفيذ مستودع التصنيفات ----

type memoryCategoryRepo struct {
	store *MemoryStore
}

func (r *memoryCategoryRepo) Create(category *model.Category) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	category.ID = r.store.next()
	r.store.categories[category.ID] = category
	return nil
}

func (r *memoryCategoryRepo) GetByID(id int64) (*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	cat, ok := r.store.categories[id]
	if !ok {
		return nil, fmt.Errorf("التصنيف %d غير موجود", id)
	}
	return cat, nil
}

func (r *memoryCategoryRepo) List() ([]*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	all := make([]*model.Category, 0, len(r.store.categories))
	for _, c := range r.store.categories {
		all = append(all, c)
	}
	return all, nil
}

// ---- تنفيذ مستودع المستخدمين ----

type memoryUserRepo struct {
	store *MemoryStore
}

func (r *memoryUserRepo) Create(user *model.User) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	user.ID = r.store.next()
	r.store.users[user.ID] = user
	return nil
}

func (r *memoryUserRepo) GetByID(id int64) (*model.User, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	user, ok := r.store.users[id]
	if !ok {
		return nil, fmt.Errorf("المستخدم %d غير موجود", id)
	}
	return user, nil
}

تحليل الكود:


الخطوة 5: طبقة منطق الأعمال (Service)

واجهة الخدمة

GO
// internal/service/service.go
package service

import "blog-api/internal/model"

// ArticleService واجهة أعمال المقالات
type ArticleService interface {
	Create(req *model.CreateArticleRequest) (*model.Article, error)
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) (*model.ListResponse, error)
	Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error)
	Delete(id int64) error
}

// CategoryService واجهة أعمال التصنيفات
type CategoryService interface {
	Create(name string) (*model.Category, error)
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

تنفيذ خدمة المقالات

GO
// internal/service/article.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type articleService struct {
	store repository.Store
}

// NewArticleService يُنشئ خدمة مقالات
func NewArticleService(store repository.Store) ArticleService {
	return &articleService{store: store}
}

// Create يُنشئ مقالة
func (s *articleService) Create(req *model.CreateArticleRequest) (*model.Article, error) {
	// التحقق من المعلمات
	if strings.TrimSpace(req.Title) == "" {
		return nil, errcode.ErrTitleRequired
	}
	if strings.TrimSpace(req.Content) == "" {
		return nil, errcode.ErrContentRequired
	}

	// التحقق من وجود المؤلف
	_, err := s.store.UserRepo().GetByID(req.AuthorID)
	if err != nil {
		return nil, errcode.ErrUserNotFound
	}

	// التحقق من وجود التصنيف
	_, err = s.store.CategoryRepo().GetByID(req.CategoryID)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}

	// بناء كائن المقالة
	now := time.Now()
	article := &model.Article{
		Title:      req.Title,
		Content:    req.Content,
		AuthorID:   req.AuthorID,
		CategoryID: req.CategoryID,
		Tags:       req.Tags,
		Status:     0, // مسودة افتراضية
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	// الاستمرار
	if err := s.store.ArticleRepo().Create(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// GetByID يُستعلم عن مقالة بالمعرّف
func (s *articleService) GetByID(id int64) (*model.Article, error) {
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}
	return article, nil
}

// List يُرجع قائمة مقالات مصفحة
func (s *articleService) List(page, pageSize int) (*model.ListResponse, error) {
	// قيم المعلمات الافتراضية
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 || pageSize > 100 {
		pageSize = 10
	}

	articles, total, err := s.store.ArticleRepo().List(page, pageSize)
	if err != nil {
		return nil, errcode.ErrInternal
	}

	// حساب عدد الصفحات
	totalPages := int(total) / pageSize
	if int(total)%pageSize > 0 {
		totalPages++
	}

	return &model.ListResponse{
		Items:      articles,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	}, nil
}

// Update يُحدّث مقالة
func (s *articleService) Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error) {
	// استعلام المقالة الموجودة
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}

	// تحديث الحقول حسب الحاجة (فقط الحقول غير الفارغة التي أُرسلت)
	if req.Title != nil {
		if strings.TrimSpace(*req.Title) == "" {
			return nil, errcode.ErrTitleRequired
		}
		article.Title = *req.Title
	}
	if req.Content != nil {
		if strings.TrimSpace(*req.Content) == "" {
			return nil, errcode.ErrContentRequired
		}
		article.Content = *req.Content
	}
	if req.CategoryID != nil {
		// التحقق من وجود التصنيف
		_, err := s.store.CategoryRepo().GetByID(*req.CategoryID)
		if err != nil {
			return nil, errcode.ErrCategoryNotFound
		}
		article.CategoryID = *req.CategoryID
	}
	if req.Tags != nil {
		article.Tags = req.Tags
	}
	if req.Status != nil {
		article.Status = *req.Status
	}
	article.UpdatedAt = time.Now()

	// الاستمرار
	if err := s.store.ArticleRepo().Update(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// Delete يحذف مقالة
func (s *articleService) Delete(id int64) error {
	_, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return errcode.ErrArticleNotFound
	}

	if err := s.store.ArticleRepo().Delete(id); err != nil {
		return errcode.ErrInternal
	}

	return nil
}

تحليل الكود:

تنفيذ خدمة التصنيفات

GO
// internal/service/category.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type categoryService struct {
	store repository.Store
}

// NewCategoryService يُنشئ خدمة تصنيفات
func NewCategoryService(store repository.Store) CategoryService {
	return &categoryService{store: store}
}

// Create يُنشئ تصنيفًا
func (s *categoryService) Create(name string) (*model.Category, error) {
	if strings.TrimSpace(name) == "" {
		return nil, errcode.New(10002, "اسم التصنيف لا يمكن أن يكون فارغًا")
	}

	category := &model.Category{
		Name:      name,
		CreatedAt: time.Now(),
	}

	if err := s.store.CategoryRepo().Create(category); err != nil {
		return nil, errcode.ErrInternal
	}

	return category, nil
}

// GetByID يُستعلم عن تصنيف بالمعرّف
func (s *categoryService) GetByID(id int64) (*model.Category, error) {
	cat, err := s.store.CategoryRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}
	return cat, nil
}

// List يُرجع جميع التصنيفات
func (s *categoryService) List() ([]*model.Category, error) {
	cats, err := s.store.CategoryRepo().List()
	if err != nil {
		return nil, errcode.ErrInternal
	}
	return cats, nil
}

الخطوة 6: نقطة دخول البرنامج

GO
// cmd/server/main.go
package main

import (
	"fmt"
	"log"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/internal/service"
)

func main() {
	// تهيئة التخزين
	store := repository.NewMemoryStore()

	// تهيئة الخدمات
	articleSvc := service.NewArticleService(store)
	categorySvc := service.NewCategoryService(store)

	// زراعة بيانات اختبار
	seedData(store, articleSvc, categorySvc)

	// التحقق من منطق الأعمال
	runDemo(articleSvc, categorySvc)
}

// seedData تزرع بيانات اختبار
func seedData(store *repository.MemoryStore, articleSvc service.ArticleService, categorySvc service.CategoryService) {
	// إنشاء مستخدم
	user := &model.User{
		Username: "alice",
		Email:    "alice@example.com",
	}
	if err := store.UserRepo().Create(user); err != nil {
		log.Fatalf("فشل إنشاء المستخدم: %v", err)
	}
	fmt.Printf("✓ تم إنشاء المستخدم: المعرّف=%d، الاسم=%s\n", user.ID, user.Username)

	// إنشاء تصنيفات
	goCategory, _ := categorySvc.Create("لغة Go")
	pythonCategory, _ := categorySvc.Create("Python")
	fmt.Printf("✓ تم إنشاء التصنيف: المعرّف=%d، الاسم=%s\n", goCategory.ID, goCategory.Name)
	fmt.Printf("✓ تم إنشاء التصنيف: المعرّف=%d، الاسم=%s\n", pythonCategory.ID, pythonCategory.Name)

	// إنشاء مقالات
	article1, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "مقدمة في تزامن Go",
		Content:    "هذه المقالة تقدم الاستخدام الأساسي لـ goroutines وchannels...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "تزامن", "goroutine"},
	})
	if err != nil {
		log.Fatalf("فشل إنشاء المقالة: %v", err)
	}
	fmt.Printf("✓ تم إنشاء المقالة: المعرّف=%d، العنوان=%s\n", article1.ID, article1.Title)

	article2, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "أنماط تصميم واجهات Go",
		Content:    "تصميم الواجهات الجيدة هو مفتاح برامج Go...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "واجهة", "أنماط-تصميم"},
	})
	if err != nil {
		log.Fatalf("فشل إنشاء المقالة: %v", err)
	}
	fmt.Printf("✓ تم إنشاء المقالة: المعرّف=%d، العنوان=%s\n", article2.ID, article2.Title)
}

// runDemo يُظهر الوظائف الأساسية
func runDemo(articleSvc service.ArticleService, categorySvc service.CategoryService) {
	fmt.Println("\n========== عرض الميزات ==========")

	// استعلام مقالة واحدة
	article, err := articleSvc.GetByID(3)
	if err != nil {
		log.Printf("فشل استعلام المقالة: %v", err)
	} else {
		fmt.Printf("\n📄 استعلام المقالة: %s\n", article.Title)
		fmt.Printf("   المحتوى: %s\n", article.Content)
		fmt.Printf("   العلامات: %v\n", article.Tags)
	}

	// استعلام مصفح
	fmt.Println("\n📋 قائمة المقالات (الصفحة 1، 10 لكل صفحة):")
	list, err := articleSvc.List(1, 10)
	if err != nil {
		log.Printf("فشل استعلام القائمة: %v", err)
	} else {
		articles := list.Items.([]*model.Article)
		for _, a := range articles {
			fmt.Printf("   [%d] %s (علامات: %v)\n", a.ID, a.Title, a.Tags)
		}
		fmt.Printf("   المجموع %d مقالة، %d صفحة\n", list.Total, list.TotalPages)
	}

	// تحديث مقالة
	newTitle := "تزامن Go المتقدم"
	newStatus := int8(1)
	updated, err := articleSvc.Update(3, &model.UpdateArticleRequest{
		Title:  &newTitle,
		Status: &newStatus,
	})
	if err != nil {
		log.Printf("فشل تحديث المقالة: %v", err)
	} else {
		fmt.Printf("\n✏️  تم تحديث المقالة: %s (الحالة: %d)\n", updated.Title, updated.Status)
	}

	// حذف مقالة
	err = articleSvc.Delete(4)
	if err != nil {
		log.Printf("فشل حذف المقالة: %v", err)
	} else {
		fmt.Println("\n🗑️  تم حذف المقالة بالمعرّف=4")
	}

	// التحقق من الاستعلام بعد الحذف
	_, err = articleSvc.GetByID(4)
	if err != nil {
		fmt.Printf("   استعلام المقالة المحذوفة: %v ✓\n", err)
	}

	// قائمة التصنيفات
	categories, _ := categorySvc.List()
	fmt.Println("\n📂 قائمة التصنيفات:")
	for _, c := range categories {
		fmt.Printf("   [%d] %s\n", c.ID, c.Name)
	}

	// عرض معالجة الأخطاء
	fmt.Println("\n❌ عرض معالجة الأخطاء:")

	// عنوان فارغ
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "اختبار",
	})
	fmt.Printf("   عنوان فارغ: %v\n", err)

	// مؤلف غير موجود
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:      "اختبار",
		Content:    "اختبار",
		AuthorID:   999,
		CategoryID: 2,
	})
	fmt.Printf("   مؤلف غير موجود: %v\n", err)

	// استعلام مقالة غير موجودة
	_, err = articleSvc.GetByID(999)
	fmt.Printf("   مقالة غير موجودة: %v\n", err)
}

تشغيل العرض

BASH
go run cmd/server/main.go

الإخراج المتوقع:

TEXT
✓ تم إنشاء المستخدم: المعرّف=1، الاسم=alice
✓ تم إنشاء التصنيف: المعرّف=2، الاسم=لغة Go
✓ تم إنشاء التصنيف: المعرّف=3، الاسم=Python
✓ تم إنشاء المقالة: المعرّف=4، العنوان=مقدمة في تزامن Go
✓ تم إنشاء المقالة: المعرّف=5، العنوان=أنماط تصميم واجهات Go

========== عرض الميزات ==========

📄 استعلام المقالة: مقدمة في تزامن Go
   المحتوى: هذه المقالة تقدم الاستخدام الأساسي لـ goroutines وchannels...
   العلامات: [go تزامن goroutine]

📋 قائمة المقالات (الصفحة 1، 10 لكل صفحة):
   [4] مقدمة في تزامن Go (علامات: [go تزامن goroutine])
   [5] أنماط تصميم واجهات Go (علامات: [go واجهة أنماط-تصميم])
   المجموع 2 مقالة، 1 صفحة

✏️  تم تحديث المقالة: تزامن Go المتقدم (الحالة: 1)

🗑️  تم حذف المقالة بالمعرّف=5
   استعلام المقالة المحذوفة: code=20001, message=المقالة غير موجودة ✓

📂 قائمة التصنيفات:
   [2] لغة Go
   [3] Python

❌ عرض معالجة الأخطاء:
   عنوان فارغ: code=20004, message=العنوان لا يمكن أن يكون فارغًا
   مؤلف غير موجود: code=20003, message=المستخدم غير موجود
   مقالة غير موجودة: code=20001, message=المقالة غير موجودة

الخطوة 7: اختبارات الوحدة

اختبارات طبقة المستودع

GO
// internal/repository/memory_store_test.go
package repository

import (
	"testing"
	"time"

	"blog-api/internal/model"
)

// دالة مساعدة: إنشاء تخزين اختبار
func newTestStore() *MemoryStore {
	return NewMemoryStore()
}

func TestArticleRepository_Create(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:     "مقالة اختبار",
		Content:   "محتوى اختبار",
		AuthorID:  1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	err := repo.Create(article)
	if err != nil {
		t.Fatalf("فشل إنشاء المقالة: %v", err)
	}

	// يجب أن يُعيّن المعرّف تلقائيًا
	if article.ID == 0 {
		t.Fatal("معرّف المقالة يجب ألا يكون 0")
	}
}

func TestArticleRepository_GetByID(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// إنشاء مقالة
	article := &model.Article{
		Title:   "مقالة اختبار",
		Content: "محتوى اختبار",
	}
	_ = repo.Create(article)

	// الاستعلام
	got, err := repo.GetByID(article.ID)
	if err != nil {
		t.Fatalf("فشل استعلام المقالة: %v", err)
	}
	if got.Title != "مقالة اختبار" {
		t.Errorf("العنوان المتوقع %q، الحاصل %q", "مقالة اختبار", got.Title)
	}

	// استعلام مقالة غير موجودة
	_, err = repo.GetByID(999)
	if err == nil {
		t.Error("استعلام مقالة غير موجودة يجب أن يُرجع خطأ")
	}
}

func TestArticleRepository_List(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// إنشاء 15 مقالة
	for i := 0; i < 15; i++ {
		_ = repo.Create(&model.Article{
			Title:   "مقالة",
			Content: "محتوى",
		})
	}

	// الصفحة الأولى
	articles, total, err := repo.List(1, 10)
	if err != nil {
		t.Fatalf("فشل استعلام القائمة: %v", err)
	}
	if total != 15 {
		t.Errorf("المجموع المتوقع 15، الحاصل %d", total)
	}
	if len(articles) != 10 {
		t.Errorf("العناصر المتوقعة 10، الحاصل %d", len(articles))
	}

	// الصفحة الثانية
	articles, _, _ = repo.List(2, 10)
	if len(articles) != 5 {
		t.Errorf("العناصر المتوقعة 5، الحاصل %d", len(articles))
	}

	// صفحة خارج النطاق
	articles, _, _ = repo.List(100, 10)
	if len(articles) != 0 {
		t.Errorf("خارج النطاق يجب أن يُرجع فارغًا، الحاصل %d عنصر", len(articles))
	}
}

func TestArticleRepository_Update(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "العنوان الأصلي",
		Content: "المحتوى الأصلي",
	}
	_ = repo.Create(article)

	// التحديث
	article.Title = "عنوان جديد"
	err := repo.Update(article)
	if err != nil {
		t.Fatalf("فشل التحديث: %v", err)
	}

	// التحقق
	got, _ := repo.GetByID(article.ID)
	if got.Title != "عنوان جديد" {
		t.Errorf("المتوقع %q، الحاصل %q", "عنوان جديد", got.Title)
	}
}

func TestArticleRepository_Delete(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "للحذف",
		Content: "محتوى",
	}
	_ = repo.Create(article)

	// الحذف
	err := repo.Delete(article.ID)
	if err != nil {
		t.Fatalf("فشل الحذف: %v", err)
	}

	// التحقق من الحذف
	_, err = repo.GetByID(article.ID)
	if err == nil {
		t.Error("استعلام مقالة محذوفة يجب أن يُرجع خطأ")
	}
}

اختبارات طبقة الخدمة

GO
// internal/service/article_test.go
package service

import (
	"testing"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

// دالة مساعدة: إنشاء خدمة مع بيانات اختبار
func newTestArticleService() (ArticleService, repository.Store) {
	store := repository.NewMemoryStore()

	// إنشاء مستخدم اختبار
	_ = store.UserRepo().Create(&model.User{
		Username: "testuser",
		Email:    "test@example.com",
	})

	// إنشاء تصنيف اختبار
	_ = store.CategoryRepo().Create(&model.Category{
		Name: "Go",
	})

	return NewArticleService(store), store
}

func TestArticleService_Create_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, err := svc.Create(&model.CreateArticleRequest{
		Title:      "مقالة اختبار",
		Content:    "محتوى اختبار",
		AuthorID:   1,
		CategoryID: 2,
		Tags:       []string{"اختبار"},
	})
	if err != nil {
		t.Fatalf("فشل إنشاء المقالة: %v", err)
	}
	if article.Title != "مقالة اختبار" {
		t.Errorf("المتوقع %q، الحاصل %q", "مقالة اختبار", article.Title)
	}
	if article.Status != 0 {
		t.Errorf("الحالة الافتراضية يجب أن تكون 0، الحاصل %d", article.Status)
	}
}

func TestArticleService_Create_EmptyTitle(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "محتوى",
	})

	// التحقق من النوع لفحص كود الخطأ
	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("متوقع AppError، الحاصل %T", err)
	}
	if appErr.Code != errcode.ErrTitleRequired.Code {
		t.Errorf("كود الخطأ المتوقع %d، الحاصل %d", errcode.ErrTitleRequired.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidAuthor(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "مقالة",
		Content:    "محتوى",
		AuthorID:   999,
		CategoryID: 2,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("متوقع AppError، الحاصل %T", err)
	}
	if appErr.Code != errcode.ErrUserNotFound.Code {
		t.Errorf("كود الخطأ المتوقع %d، الحاصل %d", errcode.ErrUserNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidCategory(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "مقالة",
		Content:    "محتوى",
		AuthorID:   1,
		CategoryID: 999,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("متوقع AppError، الحاصل %T", err)
	}
	if appErr.Code != errcode.ErrCategoryNotFound.Code {
		t.Errorf("كود الخطأ المتوقع %d، الحاصل %d", errcode.ErrCategoryNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Update_PartialUpdate(t *testing.T) {
	svc, _ := newTestArticleService()

	// إنشاء مقالة أولاً
	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "العنوان الأصلي",
		Content:    "المحتوى الأصلي",
		AuthorID:   1,
		CategoryID: 2,
	})

	// تحديث العنوان فقط
	newTitle := "عنوان جديد"
	updated, err := svc.Update(article.ID, &model.UpdateArticleRequest{
		Title: &newTitle,
	})
	if err != nil {
		t.Fatalf("فشل التحديث: %v", err)
	}
	if updated.Title != "عنوان جديد" {
		t.Errorf("المتوقع %q، الحاصل %q", "عنوان جديد", updated.Title)
	}
	if updated.Content != "المحتوى الأصلي" {
		t.Errorf("المحتوى يجب ألا يُعدّل، الحاصل %q", updated.Content)
	}
}

func TestArticleService_Update_NotFound(t *testing.T) {
	svc, _ := newTestArticleService()

	newTitle := "اختبار"
	_, err := svc.Update(999, &model.UpdateArticleRequest{
		Title: &newTitle,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("متوقع AppError، الحاصل %T", err)
	}
	if appErr.Code != errcode.ErrArticleNotFound.Code {
		t.Errorf("كود الخطأ المتوقع %d، الحاصل %d", errcode.ErrArticleNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Delete_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "للحذف",
		Content:    "محتوى",
		AuthorID:   1,
		CategoryID: 2,
	})

	err := svc.Delete(article.ID)
	if err != nil {
		t.Fatalf("فشل الحذف: %v", err)
	}

	// التحقق من الحذف
	_, err = svc.GetByID(article.ID)
	if err == nil {
		t.Error("استعلام مقالة محذوفة يجب أن يُرجع خطأ")
	}
}

func TestArticleService_List_Pagination(t *testing.T) {
	svc, _ := newTestArticleService()

	// إنشاء 25 مقالة
	for i := 0; i < 25; i++ {
		_, _ = svc.Create(&model.CreateArticleRequest{
			Title:      "مقالة",
			Content:    "محتوى",
			AuthorID:   1,
			CategoryID: 2,
		})
	}

	// الصفحة الأولى
	resp, err := svc.List(1, 10)
	if err != nil {
		t.Fatalf("فشل الاستعلام: %v", err)
	}
	if resp.Total != 25 {
		t.Errorf("المجموع المتوقع 25، الحاصل %d", resp.Total)
	}
	if resp.TotalPages != 3 {
		t.Errorf("الصفحات المتوقعة 3، الحاصل %d", resp.TotalPages)
	}

	// معالجة القيم الافتراضية
	resp, _ = svc.List(0, 0)
	if resp.Page != 1 {
		t.Errorf("page=0 يجب أن يعود افتراضيًا إلى 1، الحاصل %d", resp.Page)
	}
	if resp.PageSize != 10 {
		t.Errorf("pageSize=0 يجب أن يعود افتراضيًا إلى 10، الحاصل %d", resp.PageSize)
	}
}

تشغيل الاختبارات

BASH
go test ./internal/... -v -cover

الإخراج المتوقع:

TEXT
=== RUN   TestArticleRepository_Create
--- PASS: TestArticleRepository_Create (0.00s)
=== RUN   TestArticleRepository_GetByID
--- PASS: TestArticleRepository_GetByID (0.00s)
=== RUN   TestArticleRepository_List
--- PASS: TestArticleRepository_List (0.00s)
=== RUN   TestArticleRepository_Update
--- PASS: TestArticleRepository_Update (0.00s)
=== RUN   TestArticleRepository_Delete
--- PASS: TestArticleRepository_Delete (0.00s)
=== RUN   TestArticleService_Create_Success
--- PASS: TestArticleService_Create_Success (0.00s)
=== RUN   TestArticleService_Create_EmptyTitle
--- PASS: TestArticleService_Create_EmptyTitle (0.00s)
=== RUN   TestArticleService_Create_InvalidAuthor
--- PASS: TestArticleService_Create_InvalidAuthor (0.00s)
=== RUN   TestArticleService_Create_InvalidCategory
--- PASS: TestArticleService_Create_InvalidCategory (0.00s)
=== RUN   TestArticleService_Update_PartialUpdate
--- PASS: TestArticleService_Update_PartialUpdate (0.00s)
=== RUN   TestArticleService_Update_NotFound
--- PASS: TestArticleService_Update_NotFound (0.00s)
=== RUN   TestArticleService_Delete_Success
--- PASS: TestArticleService_Delete_Success (0.00s)
=== RUN   TestArticleService_List_Pagination
--- PASS: TestArticleService_List_Pagination (0.00s)
PASS
تغיפוי: 82.4% من العبارات
ok      blog-api/internal/repository    0.002s
ok      blog-api/internal/service       0.002s

ملخص الكود

أكمل هذا الدرس الجزء الأساسي من Blog API:

الطبقة الملف المسؤولية
النموذج internal/model/*.go تعريفات هياكل البيانات، أجسام الطلب/الاستجابة
المستودع internal/repository/*.go واجهات الوصول إلى البيانات + تنفيذ الذاكرة
الخدمة internal/service/*.go منطق الأعمال، التحقق من المعلمات، تحويل الأخطاء
الخطأ pkg/errcode/errcode.go تعريفات أكواد أخطاء موحدة
الدخول cmd/server/main.go نقطة دخول البرنامج، التهيئة، العرض

القرارات التصميمية الرئيسية:

  1. فصل الواجهات: كل من Repository وService تتفاعلان عبر الواجهات، مما يسهل اختبار الوحدة (التقليد) واستبدال التخزين المستقبلي
  2. تحديثات المؤشرات: يستخدم UpdateArticleRequest حقول مؤشرات للتمييز بين القيم الصفرية والقيم غير المعينة
  3. أخطاء موحدة: يُغلّف AppError أكواد الأخطاء والرسائل للمعالجة الموحدة في الواجهة الأمامية
  4. سلامة التزامن: تحمي sync.RWMutex البيانات في الذاكرة، عمليات القراءة تستخدم أقفال قراءة، وعمليات الكتابة تستخدم أقفال كتابة

❓ أسئلة شائعة

س1: لماذا لا تعتمد طبقة Service مباشرة على تنفيذات Repository المحددة؟

تعتمد Service على الواجهات بدلاً من التنفيذات المحددة، لذا يمكن لاختبارات الوحدة حقن تنفيذات مقلدة بدون تخزين حقيقي. أيضًا، إذا أردت لاحقًا استبدال التخزين في الذاكرة بـ MySQL، فقط تحتاج إلى إنشاء mysqlArticleRepo جديدًا ينفذ واجهة ArticleRepository — كود طبقة Service لا يحتاج إلى تغيير.

س2: لماذا يستخدم UpdateArticleRequest حقول مؤشرات؟

في Go، القيمة الصفرية لـ string هي سلسلة فارغة "". مع الحقول العادية، لا يمكنك التمييز بين "المستخدم لم يرسل العنوان" و"المستخدم أرسل عنوانًا فارغًا". مع *string، يعني nil لم يُرسل، وغير nil يعني أن هناك قيمة. هذا نمط شائع في تصميم Go API.

س3: هل ستصبح sync.RWMutex في التخزين الذاكري عنق زجاجة للأداء؟

إنه كافٍ لسيناريو عرض هذا الدرس التعليمي. إذا كانت القراءات تفوق الكتابات بكثير (استعلامات قائمة المقالات تفوق الإنشاءات/التحديثات بكثير)، فإن قفل القراءة في RWMutex مشترك — يمكن لعدة عمليات قراءة التنفيذ المتزامن، وعمليات الكتابة فقط تستبعد بعضها. للإنتاج، استخدم قاعدة بيانات — برك الاتصالات آمنة بطبيعتها للتزامن.

س4: كيف نحسّن تغיפוי الاختبار؟

حاليًا أكثر من 80%. لتغיפוי أعلى، يمكنك إضافة: اختبارات القيم الحدودية (عناوين طويلة جدًا، أحرف خاصة)، اختبارات سلامة التزامن (عدة goroutines تُنشئ مقالات في وقت واحد)، وحالات اختبار كاملة لخدمة التصنيفات.


📖 ملخص

في هذا الدرس بنينا هيكل مشروع Go كامل من الصفر:

الدرس التالي سيكمل طبقة HTTP Handler، ودمج المسارات والوسائط لعرض منطق الأعمال كواجهة RESTful API.


📝 تمارين

التمرين 1: إضافة إدارة علامات مستقلة

نفّذ مستودعًا وخدمة مستقلين للعلامات، يدعمان:

التمرين 2: تنفيذ وظيفة البحث في المقالات

أضف طريقة Search إلى ArticleService:

تلميح: للتنفيذ في الذاكرة، مرر map وقم بالتصفية. مطلوب مطابقة غير حساسة لحالة الأحرف.

التمرين 3: كتابة اختبارات سلامة التزامن

اكتب حالة اختبار تستخدم 100 goroutine لاستدعاء ArticleService.Create في وقت واحد، للتحقق:


الدرس التالي: المشروع الشامل (الجزء 2) — HTTP والمسارات والوسائط وAPI كامل

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%