المشروع الشامل (الجزء 1)
المشروع الشامل (الجزء 1)
بناء مشروع Go كامل من الصفر — نظام Blog API.
في هذا الدرس سنبني نظام إدارة تدوين المدونة (Blog API)، يغطي تحليل المتطلبات، تصميم الدليل، نماذج البيانات، منطق الأعمال، معالجة الأخطاء، واختبارات الوحدة. ستكمل الدرس التالي جزء HTTP والمسارات والوسائط.
متطلبات المشروع
قائمة الميزات
| الوحدة | الميزات | الوصف |
|---|---|---|
| المقالات | إنشاء، استعلام، قائمة، تحديث، حذف | تغיפוי CRUD كامل |
| التصنيفات | إنشاء، استعلام، قائمة | المقالات تنتمي إلى تصنيفات |
| العلامات | إنشاء، استعلام، قائمة | يمكن ربط المقالات بعدة علامات |
| المستخدمين | تسجيل، استعلام | نسخة مبسطة، بدون مصادقة دخول |
المتطلبات غير الوظيفية
- التحقق من صحة معلمات الإدخال
- تنسيق استجابة خطأ موحد
- دعم الاستعلام المصفح
- تغיפוי اختبار الوحدة > 70%
تصميم بنية النظام
يتبنى البنية الطبقية الكلاسيكية:
┌─────────────────────────────────────────────┐
│ Handler (طبقة HTTP) │
│ استقبال الطلب → تحليل المعلمات → استدعاء الخدمة → الإرجاع │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ Service (طبقة الأعمال) │
│ منطق الأعمال → التحقق من المعلمات → استدعاء المستودع │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ Repository (طبقة البيانات) │
│ الوصول إلى البيانات → التخزين في الذاكرة / قاعدة البيانات │
└─────────────────────────────────────────────┘
تعتمد كل طبقة فقط على الطبقة التي تحتها، منفصلة عبر الواجهات لسهولة الاختبار واستبدال التنفيذ.
هيكل دليل المشروع
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: تهيئة المشروع
mkdir -p blog-api && cd blog-api
go mod init blog-api
إنشاء هيكل الدليل:
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode
الخطوة 2: تعريف نماذج البيانات
نموذج المستخدم
// 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"`
}
نموذج التصنيف
// 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"`
}
نموذج المقالة
// 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"`
}
تحليل الكود:
CreateArticleRequest: جميع الحقول مطلوبة عند الإنشاء، يستخدم هيكلًا منفصلًاUpdateArticleRequest: يستخدم المؤشرات أثناء التمييز بين "لم يُرسل" و"قيمة فارغة"ListRequest/Response: تنسيق تصفح موحد، يُعاد استخدامه في جميع نقاط نهاية القائمة
الخطوة 3: أكواد أخطاء موحدة
// 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, "المحتوى لا يمكن أن يكون فارغًا")
)
تحليل الكود:
- يحتوي هيكل
AppErrorعلى كود الخطأ والرسالة، سهل للمعالجة الموحدة في الواجهة الأمامية - يستخدم
varلتحديد الأخطاء مسبقًا، متجنبًا ربط السلاسل النصية وقت التشغيل - نطاقات أكواد الأخطاء مقسمة حسب الوحدة: 1xxxx عام، 2xxxx متعلق بالمقالات
الخطوة 4: طبقة الوصول إلى البيانات (Repository)
واجهة التخزين
// 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)
}
التنفيذ في الذاكرة
// 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
}
تحليل الكود:
- تحتوي
MemoryStoreعلى جميع البيانات، تستخدمsync.RWMutexلسلامة التزامن - كل طريقة مستودع تُرجع تنفيذ واجهتها المقابلة
atomic.Int64تُولّد معرّفات زيادة تلقائية بدون قفل- التنفيذ في الذاكرة مناسب لاختبار التطوير؛ يمكن استبداله لاحقًا بتنفيذ قاعدة البيانات دون تعديل كود الطبقة العليا
الخطوة 5: طبقة منطق الأعمال (Service)
واجهة الخدمة
// 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)
}
تنفيذ خدمة المقالات
// 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
}
تحليل الكود:
- طريقة
Createتقوم بالتسلسل: التحقق من المعلمات → التحقق من الموارد المرتبطة → بناء الكائن → الاستمرار Updateيستخدم حقول المؤشرات للتحديث الجزئي: يُعدّل فقط الحقول التي أُرسلت- طريقة
Listتحتوي على حماية قيم افتراضية مدمجة لمنع المعلمات غير الصالحة - جميع الأخطاء في الطبقة السفلية تُحوّل بشكل موحد إلى
AppError، لا تحتاج الطبقات العليا لمعرفة تفاصيل تنفيذ التخزين
تنفيذ خدمة التصنيفات
// 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: نقطة دخول البرنامج
// 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)
}
تشغيل العرض
go run cmd/server/main.go
الإخراج المتوقع:
✓ تم إنشاء المستخدم: المعرّف=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: اختبارات الوحدة
اختبارات طبقة المستودع
// 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("استعلام مقالة محذوفة يجب أن يُرجع خطأ")
}
}
اختبارات طبقة الخدمة
// 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)
}
}
تشغيل الاختبارات
go test ./internal/... -v -cover
الإخراج المتوقع:
=== 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 |
نقطة دخول البرنامج، التهيئة، العرض |
القرارات التصميمية الرئيسية:
- فصل الواجهات: كل من Repository وService تتفاعلان عبر الواجهات، مما يسهل اختبار الوحدة (التقليد) واستبدال التخزين المستقبلي
- تحديثات المؤشرات: يستخدم
UpdateArticleRequestحقول مؤشرات للتمييز بين القيم الصفرية والقيم غير المعينة - أخطاء موحدة: يُغلّف
AppErrorأكواد الأخطاء والرسائل للمعالجة الموحدة في الواجهة الأمامية - سلامة التزامن: تحمي
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 كامل من الصفر:
- تحليل المتطلبات: حددنا الحدود الوظيفية والمتطلبات غير الوظيفية
- هيكل الدليل: اعتمدنا التخطيط القياسي
cmd/internal/pkg - نماذج البيانات: حددنا المستخدم والتصنيف والمقالة وهياكل الطلب/الالاستجابة
- طبقة المستودع: صممنا واجهات + تنفيذ الذاكرة، لضمان سلامة التزامن
- طبقة الخدمة: نفذنا منطق الأعمال، التحقق من المعلمات، معالجة الأخطاء
- أكواد أخطاء موحدة: يُغلّف
AppErrorمعلومات الخطأ للمعالجة الموحدة في الواجهة الأمامية - اختبارات الوحدة: كل من طبقتي المستودع والخدمة لديهما اختبارات تغطي السيناريوهات الطبيعية والخاطئة
الدرس التالي سيكمل طبقة HTTP Handler، ودمج المسارات والوسائط لعرض منطق الأعمال كواجهة RESTful API.
📝 تمارين
التمرين 1: إضافة إدارة علامات مستقلة
نفّذ مستودعًا وخدمة مستقلين للعلامات، يدعمان:
- إنشاء علامة
POST /tags - استعلام قائمة العلامات
GET /tags - حساب عدد المقالات المرتبطة بكل علامة
التمرين 2: تنفيذ وظيفة البحث في المقالات
أضف طريقة Search إلى ArticleService:
- بحث غامض بكلمة العنوان
- تصفية حسب معرّف التصنيف
- تصفية حسب العلامة
- دعم التصفح
تلميح: للتنفيذ في الذاكرة، مرر map وقم بالتصفية. مطلوب مطابقة غير حساسة لحالة الأحرف.
التمرين 3: كتابة اختبارات سلامة التزامن
اكتب حالة اختبار تستخدم 100 goroutine لاستدعاء ArticleService.Create في وقت واحد، للتحقق:
- جميع المقالات تُنشأ بنجاح
- لا توجد معرّفات مكررة
- لا توجد أحداث مسابقة (استخدم
go test -raceللكشف)
الدرس التالي: المشروع الشامل (الجزء 2) — HTTP والمسارات والوسائط وAPI كامل



