مشروع: نموذج نطاق متصل
كل ما درسته حتى الآن — @OneToOne و @OneToMany و @ManyToMany وأنواع الجلب ومشكلة N+1 و Join Fetch وEntity Graphs والتتالي وحذف الأيتام — موجود لحل مشاكل تصميم حقيقية. في هذا الدرس الختامي ستربط كل ذلك معًا في نموذج نطاق متماسك وجاهز للإنتاج لتطبيق تجارة إلكترونية صغير، وستستعرض مقايضات التصميم في كل خطوة.
النطاق
يدير التطبيق عملاء يضعون طلبات. كل طلب يحتوي على سطر طلب واحد أو أكثر يشير إلى منتج. كل عميل يمتلك سلة تسوق واحدة بالضبط. تنتمي المنتجات إلى فئة واحدة أو أكثر. تغطي هذه الكيانات الخمسة كل نوع علاقة تمت دراسته في هذا البرنامج التعليمي.
تصميم الكيانات
ابدأ بكيان Customer الجذر. يمتلك السلة عبر علاقة واحد-لواحد ويحمل مجموعة من الطلبات.
@Entity
@Table(name = "customers")
public class Customer {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String fullName;
@Column(nullable = false, unique = true)
private String email;
// الجانب المالك: العميل يحمل المفتاح الأجنبي للسلة
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id")
private ShoppingCart cart;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
// دالة مساعدة تحافظ على تزامن الجانبين
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this);
}
public void removeOrder(Order order) {
orders.remove(order);
order.setCustomer(null);
}
// getters / setters محذوفة للإيجاز
}
لاحظ ثلاثة خيارات متعمدة: FetchType.LAZY على كلتا العلاقتين (نجلب بشكل متحمس فقط عند الحاجة)، و CascadeType.ALL حتى تقود دورة حياة العميل سلته وطلباته، و orphanRemoval = true حتى يحذف إزالة طلب من المجموعة صفه — لا تحتاج إلى استدعاء مستودع منفصل.
@Entity
@Table(name = "shopping_carts")
public class ShoppingCart {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id") // أحادي الاتجاه؛ لا حاجة لمرجع عكسي
private List<CartItem> items = new ArrayList<>();
}
كيان Order يقع على الجانب الكثير من علاقة العميل ويمتلك مجموعة من عناصر OrderLine.
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.PENDING;
@Column(nullable = false)
private LocalDateTime placedAt = LocalDateTime.now();
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<OrderLine> lines = new ArrayList<>();
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
}
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
}
أخيرًا، تشارك Product في علاقة متعدد-لمتعدد مع Category. جانب المنتج يمتلك جدول الربط.
@Entity
@Table(name = "products")
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories = new HashSet<>();
}
@Entity
@Table(name = "categories")
public class Category {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@ManyToMany(mappedBy = "categories", fetch = FetchType.LAZY)
private Set<Product> products = new HashSet<>();
}
لماذا Set لمجموعة متعدد-لمتعدد؟ استخدام List على كلا جانبي علاقة متعدد-لمتعدد يجعل Hibernate يُطلق عملية حذف الكل / إدراج الكل عند كل تعديل (مشكلة "الحقيبة"). يتجنب Set على الجانب المالك على الأقل هذا الأمر بتمكين Hibernate من إصدار عمليات INSERT و DELETE محددة.
الاستعلام بدون N+1
حالة استخدام شائعة: عرض سجل طلبات العميل مع اسم منتج كل سطر. استدعاء مستودع ساذج يُحمّل الطلبات كسولًا ثم السطور ثم المنتجات ينتج استعلامات N+1. استخدم رسمًا بيانيًا للكيان أو JPQL مع JOIN FETCH للقضاء عليها.
// المستودع
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"lines", "lines.product"})
List<Order> findByCustomerId(Long customerId);
}
يخبر الرسم البياني للكيان Hibernate بإجراء left-join-fetch لـ lines ثم lines.product في استعلام SQL واحد — بغض النظر عن الإعدادات الافتراضية LAZY على تلك العلاقات. تحافظ على التحميل الكسول لكل نمط وصول آخر بينما تختار التحميل المتحمس بالضبط حيث تتطلبه حالة الاستخدام.
استعلام واحد لكل حالة استخدام. حدد دالة مستودع منفصلة (أو رسمًا بيانيًا للكيان مُسمى) لكل متطلب جلب مميز. لا ترقّ علاقة إلى EAGER عالميًا فقط لأن شاشة واحدة تحتاجها — هذا يُعاقب كل المستدعين الآخرين.
المعاملات في طبقة الخدمة
تنتمي جميع العمليات متعددة الخطوات داخل حدود @Transactional واحدة حتى تُعاد فشلها الجزئي بنظافة.
@Service
@Transactional
public class OrderService {
private final CustomerRepository customerRepo;
private final ProductRepository productRepo;
private final OrderRepository orderRepo;
public Order placeOrder(Long customerId, List<Long> productIds, Map<Long, Integer> quantities) {
Customer customer = customerRepo.findById(customerId)
.orElseThrow(() -> new EntityNotFoundException("Customer not found"));
Order order = new Order();
for (Long productId : productIds) {
Product product = productRepo.findById(productId)
.orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found"));
OrderLine line = new OrderLine();
line.setProduct(product);
line.setQuantity(quantities.get(productId));
line.setUnitPrice(product.getPrice());
order.addLine(line);
}
customer.addOrder(order);
// لا حاجة لاستدعاء save() صريح — يتولى التتالي نشر الحفظ من العميل إلى الطلب إلى السطور
customerRepo.save(customer);
return order;
}
}
لا تستدع save() على الكيانات الفرعية بشكل منفصل عند تهيئة التتالي بالفعل. استدعاء orderRepo.save(order) بعد customerRepo.save(customer) زائد عن الحاجة ويمكن أن يُربك آلية التحقق من الأوساخ (dirty-checking) في Hibernate، مما يتسبب أحيانًا في إدراجات مكررة أو استثناءات كيان قديم.
تشغيل الحزمة الكاملة محليًا
اربط الكيانات بـ application.properties بسيط يستهدف قاعدة بيانات H2 في الذاكرة حتى تتمكن من التكرار بسرعة دون تشغيل نسخة MySQL:
spring.datasource.url=jdbc:h2:mem:shop;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.h2.console.enabled=true
مع ddl-auto=create-drop يُنشئ Hibernate جميع الجداول من بيانات تعريف كياناتك عند بدء التشغيل ويُسقطها عند الإيقاف. جنبًا إلى جنب مع show-sql=true يمكنك فحص كل جملة مُنشأة والتحقق من أن جداول الربط والمفاتيح الأجنبية والفهارس تطابق نموذجك الذهني قبل كتابة ملف هجرة واحد.
الخلاصة
يُطبّق نموذج النطاق المتصل المُصمَّم جيدًا كل أداة علاقة في سياقها: @OneToOne مع التحميل الكسول والتتالي الكامل للتجمعات المملوكة بإحكام؛ @OneToMany / @ManyToOne ثنائي الاتجاه مع حذف الأيتام لمجموعات الأصل-الفرع؛ @ManyToMany مع Set على الجانب المالك لتجنب مشكلة الحقيبة؛ ورسوم بيانية للكيانات أو استعلامات JOIN FETCH مُحددة النطاق لحالات استخدام فردية للقضاء على N+1 دون التحميل المتحمس العالمي. تلف طبقة الخدمة التحولات في حدود @Transactional واحدة وتترك التتالي يحمل الاستمرارية عبر الرسم البياني للكائن. تُنتج هذه الأنماط معًا قاعدة كود قابلة للقراءة والاختبار والكفاءة تحت أعباء العمل الحقيقية.