مشروع: تعيين نموذج النطاق
يجمع هذا الدرس كل ما تناوله البرنامج التعليمي من خلال تعيين نطاق تجارة إلكترونية واقعي من الصفر. ستُلاحظ كيف يعمل كل مفهوم من مفاهيم JPA/Hibernate — الكيانات، والمفاتيح الأساسية، وتعيينات الأعمدة، والعناصر القابلة للتضمين، والوراثة، وسياق الاستمرارية — معًا في نموذج متماسك وذي جودة إنتاجية. انتبه ليس فقط إلى التعليقات التوضيحية، بل أيضًا إلى قرارات التصميم والمفاضلات في الأداء التي سيتم شرحها على طول الطريق.
النطاق
يُعيّن النظام متجرًا إلكترونيًا يشمل المفاهيم التجارية التالية:
- Customer (العميل) — مستخدم مسجّل يملك عنوان مراسلة وعنوان فوترة.
- Product (المنتج) — عنصر في الكتالوج له سعر ومستوى مخزون؛ يتمايز إلى النوعين الفرعيين PhysicalProduct وDigitalProduct عبر الوراثة.
- Order (الطلب) — يُقدّمه عميل ويتكوّن من بند أو أكثر.
- OrderLine (بند الطلب) — الرابط بين الطلب والمنتج، ويحمل الكمية والسعر الوحدوي وقت الشراء.
هذه شريحة صغيرة لكنها مكتملة من نطاق حقيقي. تستعرض كل تقنية تعيين رئيسية دون أن تصبح مُرهقة.
الكيان الأساسي المشترك
بدلًا من تكرار أعمدة التدقيق في كل جدول، استخرجها إلى فئة عليا مُعيَّنة. هذه ليست كيانًا بذاتها — فليس لها جدول — لكن JPA يرث تعييناتها في كل فئة فرعية.
package com.example.shop.domain;
import jakarta.persistence.*;
import java.time.Instant;
@MappedSuperclass
public abstract class BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@PrePersist
void onPersist() {
createdAt = updatedAt = Instant.now();
}
@PreUpdate
void onUpdate() {
updatedAt = Instant.now();
}
// الـ getters محذوفة للاختصار
}
لماذا @MappedSuperclass بدلًا من كيان محدد؟ تُسهم الفئة العليا المُعيَّنة بالأعمدة في جداول الفئات الفرعية لكن لا يُجرى عليها استعلام مباشر. لو استخدمت كيانًا محددًا بوراثة TABLE_PER_CLASS بدلًا من ذلك، كان على Hibernate أن يجمع عدة جداول في استعلامات متعددة الأشكال — مكلف وغير ضروري في الغالب لأعمدة التدقيق وحدها.
العنوان القابل للتضمين
العنوان هو كائن قيمة — ليس له هوية مستقلة؛ فهو ينتمي كليًا إلى العميل. عرّفه بـ @Embeddable حتى تُخزَّن أعمدته في جدول customers دون حاجة إلى ربط منفصل.
package com.example.shop.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
@Embeddable
public class Address {
@Column(name = "street", length = 200)
private String street;
@Column(name = "city", length = 100)
private String city;
@Column(name = "country", length = 2) // ISO 3166-1 alpha-2
private String country;
@Column(name = "postal_code", length = 20)
private String postalCode;
// المنشئ بدون معاملات مطلوب من قِبل JPA
protected Address() {}
public Address(String street, String city, String country, String postalCode) {
this.street = street;
this.city = city;
this.country = country;
this.postalCode = postalCode;
}
// getters وequals وhashCode محذوفة للاختصار
}
كيان Customer (العميل)
يُضمّن كيان Customer نسختين من Address. نظرًا لأن كلتيهما تُعيَّن إلى نفس الفئة القابلة للتضمين، يجب عليك استخدام @AttributeOverrides لإعطاء أعمدتهما أسماء مميزة.
package com.example.shop.domain;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "customers",
uniqueConstraints = @UniqueConstraint(name = "uq_customer_email",
columnNames = "email"))
public class Customer extends BaseEntity {
@Column(name = "email", nullable = false, length = 254)
private String email;
@Column(name = "display_name", nullable = false, length = 100)
private String displayName;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "mail_street")),
@AttributeOverride(name = "city", column = @Column(name = "mail_city")),
@AttributeOverride(name = "country", column = @Column(name = "mail_country")),
@AttributeOverride(name = "postalCode", column = @Column(name = "mail_postal_code"))
})
private Address mailingAddress;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "bill_street")),
@AttributeOverride(name = "city", column = @Column(name = "bill_city")),
@AttributeOverride(name = "country", column = @Column(name = "bill_country")),
@AttributeOverride(name = "postalCode", column = @Column(name = "bill_postal_code"))
})
private Address billingAddress;
@OneToMany(mappedBy = "customer",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<Order> orders = new ArrayList<>();
protected Customer() {}
public Customer(String email, String displayName,
Address mailingAddress, Address billingAddress) {
this.email = email;
this.displayName = displayName;
this.mailingAddress = mailingAddress;
this.billingAddress = billingAddress;
}
public void placeOrder(Order order) {
orders.add(order);
order.setCustomer(this);
}
}
العلاقات ثنائية الاتجاه: زامن الجانبين دائمًا. تحافظ طريقة المساعدة placeOrder على اتساق كل من orders وorder.customer. إذا ضبطت جانبًا واحدًا فقط، فسيحتفظ سياق الاستمرارية — وهو مصدر الحقيقة حتى التفريغ — بمخطط كائن غير متسق وستواجه سلوكًا محيّرًا.
تسلسل المنتج — وراثة JOINED
تتشارك المنتجات الفيزيائية والرقمية سمات الكتالوج لكنها تحتاج أعمدة منفصلة. تضع وراثة JOINED الأعمدة المشتركة في products والأعمدة الخاصة بكل نوع فرعي في physical_products / digital_products. تظل الاستعلامات متعددة الأشكال نظيفة؛ وتكلف القراءات ارتباطًا إضافيًا واحدًا لكل نوع فرعي.
// --- Product.java ---
@Entity
@Table(name = "products")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "product_type", discriminatorType = DiscriminatorType.STRING)
public abstract class Product extends BaseEntity {
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "price_cents", nullable = false)
private long priceCents; // خزّن المبلغ المالي كأعداد صحيحة (سنتات) — لا تستخدم double أبدًا
@Column(name = "active", nullable = false)
private boolean active = true;
protected Product() {}
public Product(String name, long priceCents) {
this.name = name;
this.priceCents = priceCents;
}
}
// --- PhysicalProduct.java ---
@Entity
@Table(name = "physical_products")
@DiscriminatorValue("PHYSICAL")
public class PhysicalProduct extends Product {
@Column(name = "weight_grams", nullable = false)
private int weightGrams;
@Column(name = "stock_qty", nullable = false)
private int stockQty;
protected PhysicalProduct() {}
public PhysicalProduct(String name, long priceCents,
int weightGrams, int stockQty) {
super(name, priceCents);
this.weightGrams = weightGrams;
this.stockQty = stockQty;
}
}
// --- DigitalProduct.java ---
@Entity
@Table(name = "digital_products")
@DiscriminatorValue("DIGITAL")
public class DigitalProduct extends Product {
@Column(name = "download_url", nullable = false, length = 500)
private String downloadUrl;
@Column(name = "file_size_mb")
private int fileSizeMb;
protected DigitalProduct() {}
public DigitalProduct(String name, long priceCents,
String downloadUrl, int fileSizeMb) {
super(name, priceCents);
this.downloadUrl = downloadUrl;
this.fileSizeMb = fileSizeMb;
}
}
المبالغ المالية كـ long (سنتات)، وليس double أبدًا. الحساب بالفاصلة العائمة غير آمن للحسابات المالية. تخزين السعر كأعداد صحيحة بالسنتات (أو باستخدام BigDecimal) يمنع أخطاء التقريب من إفساد الإجماليات بصمت.
Order و OrderLine (الطلب وبنوده)
يمتلك Order عناصر OrderLine الخاصة به. البند هو كائن تابع — لا معنى له خارج طلبه الأصلي — لذا عرّفه باستخدام CascadeType.ALL وorphanRemoval = true.
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_orders_customer"))
private Customer customer;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private OrderStatus status = OrderStatus.PENDING;
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {}
public Order(Customer customer) {
this.customer = customer;
}
public void addLine(Product product, int qty) {
lines.add(new OrderLine(this, product, qty, product.getPriceCents()));
}
void setCustomer(Customer customer) { this.customer = customer; }
}
// Enum (خزّنه كنص حتى لا تُفسد تغييرات المخطط الصفوف الموجودة)
public enum OrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED }
// --- OrderLine.java ---
@Entity
@Table(name = "order_lines")
public class OrderLine extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_order_lines_order"))
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false,
foreignKey = @ForeignKey(name = "fk_order_lines_product"))
private Product product;
@Column(name = "quantity", nullable = false)
private int quantity;
@Column(name = "unit_price_cents", nullable = false)
private long unitPriceCents; // لقطة وقت الشراء
protected OrderLine() {}
OrderLine(Order order, Product product, int quantity, long unitPriceCents) {
this.order = order;
this.product = product;
this.quantity = quantity;
this.unitPriceCents = unitPriceCents;
}
}
لقطة السعر في OrderLine. يُنسخ unitPriceCents من المنتج في لحظة إنشاء البند، ولا يُخزَّن كمفتاح خارجي لجدول أسعار. هذا نمط أساسي في التجارة الإلكترونية: يجب أن تعكس إيصال العميل دائمًا ما دفعه فعلًا، بصرف النظر عن أي تغييرات مستقبلية في السعر.
التوصيل في خدمة Spring Boot
مع وجود النموذج في مكانه، يمكن لطريقة الخدمة إنشاء طلب كامل في معاملة واحدة. يتولى Hibernate التتالي — إذ يُرسّخ الكيانات OrderLine الفرعية تلقائيًا عند إرساء Order.
@Service
@Transactional
public class OrderService {
private final EntityManager em;
public OrderService(EntityManager em) { this.em = em; }
public Long placeOrder(Long customerId,
Map<Long, Integer> productQtyMap) {
Customer customer = em.find(Customer.class, customerId);
if (customer == null) {
throw new EntityNotFoundException("Customer " + customerId);
}
Order order = new Order(customer);
customer.placeOrder(order); // يحافظ على اتساق كلا جانبي العلاقة
productQtyMap.forEach((productId, qty) -> {
Product product = em.find(Product.class, productId);
if (product == null) {
throw new EntityNotFoundException("Product " + productId);
}
order.addLine(product, qty);
});
em.persist(order); // يتتالى تلقائيًا إلى جميع OrderLines
return order.getId();
}
}
التحقق من توليد المخطط
اضبط spring.jpa.hibernate.ddl-auto=create-drop في ملف application.properties الخاص بالاختبار وشغّل التطبيق مقابل قاعدة بيانات H2 في الذاكرة. افحص DDL المُولَّد — يجب أن ترى جدول customers مع ثمانية أعمدة للعناوين، والربط بين products / physical_products / digital_products، وأسماء قيود المفاتيح الخارجية الصريحة المطابقة لما أعلنته. تحقق من صحة DDL قبل كتابة أي استعلام.
القيود ذات الأسماء تُكافئك في الساعات الصعبة. يعني إعلان @ForeignKey(name = "fk_orders_customer") أن رسائل خطأ قاعدة البيانات وسكريبتات الترحيل تستخدم أسماء قابلة للقراءة بدلًا من أسماء مولَّدة تلقائيًا كـ FK3j2jx.... إنها تعليق توضيحي بسيط ذو قيمة تشغيلية كبيرة.
الخلاصة
نموذج النطاق جيد التعيين هو الأساس الذي يُبنى عليه كل تطبيق JPA. الأنماط الموضَّحة هنا — @MappedSuperclass لأعمدة التدقيق، و@Embeddable لكائنات القيمة، ووراثة JOINED لأنواع المنتجات الفرعية، و@OneToMany/@ManyToOne ثنائية الاتجاه مع تتالٍ صريح، ولقطات الأسعار في بنود الطلب — كلها أساليب معيارية ستصادفها وتطبّقها في قواعد إنتاج حقيقية. مع اكتمال هذا البرنامج التعليمي، أصبح لديك المفردات اللازمة لتعيين أي نطاق أعمال بثقة.