Spring Data JPA

مشروع: طبقة Spring Data JPA كاملة

18 دقيقة الدرس 10 من 13

مشروع: طبقة Spring Data JPA كاملة

قضيتَ تسعة دروس تتعلّم القطع الفردية من Spring Data JPA — الكيانات والمستودعات والاستعلامات المشتقة وJPQL والإسقاطات والترقيم الصفحي والتدقيق والمعاملات. هذا الدرس الأخير يجمع كل هذه القطع في طبقة مستودعات متماسكة وعالية الجودة الإنتاجية لنطاق تجارة إلكترونية صغير. بنهاية هذا الدرس سيكون لديك بنية مرجعية يمكنك تكييفها مع أي مشروع حقيقي.

النطاق: إدارة الطلبات

يتكوّن النطاق من أربعة كيانات: Customer وProduct وOrder وOrderItem. العلاقات بينها واضحة لكنها تلمس كل مفهوم من مفاهيم JPA التي تعلّمتها.

// Customer.java package com.example.shop.domain; import jakarta.persistence.*; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.Instant; import java.util.ArrayList; import java.util.List; @Entity @Table(name = "customers") @EntityListeners(AuditingEntityListener.class) public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 100) private String name; @Column(nullable = false, unique = true, length = 150) private String email; @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true) private List<Order> orders = new ArrayList<>(); @CreatedDate @Column(updatable = false) private Instant createdAt; @LastModifiedDate private Instant updatedAt; // الـ getters/setters المعيارية محذوفة للإيجاز }
// Order.java @Entity @Table(name = "orders") @EntityListeners(AuditingEntityListener.class) public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id") private Customer customer; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private OrderStatus status; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>(); @CreatedDate @Column(updatable = false) private Instant placedAt; @LastModifiedDate private Instant updatedAt; }
لماذا FetchType.LAZY على كل @ManyToOne؟ الإعداد الافتراضي لـ @ManyToOne هو EAGER، وهو ما يحمّل الكيان الأب بصمت في كل مرة تحمّل فيها كيانًا فرعيًا — حتى حين لا تستخدمه قط. التبديل إلى LAZY على جميع العلاقات يعني أن Hibernate لن يضرب قاعدة البيانات إلا عندما تتتبّع العلاقة فعليًا. هذا التغيير وحده يستطيع خفض عدد الاستعلامات إلى النصف في التطبيقات المُجهَدة.

واجهات المستودعات

كل جذر تجميع يحصل على مستودعه الخاص. لاحظ كيف تجمع الواجهات بين تقنيات مختلفة تعلّمتها عبر هذا البرنامج التعليمي: الاستعلامات المشتقة و@Query والإسقاطات وPageable.

// CustomerRepository.java package com.example.shop.repository; import com.example.shop.domain.Customer; import com.example.shop.dto.CustomerSummary; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.Optional; public interface CustomerRepository extends JpaRepository<Customer, Long> { Optional<Customer> findByEmail(String email); boolean existsByEmail(String email); // استعلام مبني على إسقاط — يجلب id + name + email فقط من قاعدة البيانات Page<CustomerSummary> findBy(Pageable pageable); // جلب العميل مع جميع طلباته في استعلام واحد (يتجنّب مشكلة N+1) @Query("SELECT c FROM Customer c LEFT JOIN FETCH c.orders WHERE c.id = :id") Optional<Customer> findByIdWithOrders(@Param("id") Long id); }
// CustomerSummary.java (واجهة إسقاط مغلقة) package com.example.shop.dto; public interface CustomerSummary { Long getId(); String getName(); String getEmail(); }
// OrderRepository.java package com.example.shop.repository; import com.example.shop.domain.Order; import com.example.shop.domain.OrderStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.Instant; import java.util.List; public interface OrderRepository extends JpaRepository<Order, Long> { // مشتق: جميع طلبات عميل مُرتَّبة من الأحدث للأقدم List<Order> findByCustomerIdOrderByPlacedAtDesc(Long customerId); // طلبات مُرقَّمة حسب الحالة Page<Order> findByStatus(OrderStatus status, Pageable pageable); // استعلام JPQL تجميعي @Query("SELECT COUNT(o) FROM Order o WHERE o.customer.id = :customerId AND o.status = :status") long countByCustomerAndStatus(@Param("customerId") Long customerId, @Param("status") OrderStatus status); // تحديث جماعي للحالة باستخدام استعلام تعديلي — يتجنّب تحميل الكيانات في الذاكرة @Modifying @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus AND o.placedAt < :before") int cancelStaleDraftOrders(@Param("oldStatus") OrderStatus oldStatus, @Param("newStatus") OrderStatus newStatus, @Param("before") Instant before); }
استعلامات @Modifying تتجاوز ذاكرة التخزين المؤقت للمستوى الأول. بعد استدعاء cancelStaleDraftOrders، ستظل أي كيانات Order محمّلة مسبقًا في جلسة EntityManager نفسها تُظهر الحالة القديمة. إما امسح الذاكرة المؤقتة (@Modifying(clearAutomatically = true)) أو أعد تحميل الكيانات من قاعدة البيانات قبل الاعتماد على القيم المُحدَّثة.

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

المستودعات بنية تحتية؛ منطق الأعمال ينتمي إلى الخدمة. الخدمة هي أيضًا المكان الذي تضبط فيه حدّ المعاملة الصحيح للعمليات متعددة الخطوات.

// OrderService.java package com.example.shop.service; import com.example.shop.domain.*; import com.example.shop.repository.*; import jakarta.persistence.EntityNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; @Service @Transactional(readOnly = true) // افتراضي: كل الطوابع للقراءة فقط public class OrderService { private final OrderRepository orderRepository; private final CustomerRepository customerRepository; private final ProductRepository productRepository; public OrderService(OrderRepository orderRepository, CustomerRepository customerRepository, ProductRepository productRepository) { this.orderRepository = orderRepository; this.customerRepository = customerRepository; this.productRepository = productRepository; } public Order findById(Long id) { return orderRepository.findById(id) .orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found")); } @Transactional // يتجاوز readOnly=true على مستوى الفئة — هذه الطريقة تكتب public Order placeOrder(Long customerId, List<OrderLineRequest> lines) { Customer customer = customerRepository.findById(customerId) .orElseThrow(() -> new EntityNotFoundException("Customer not found")); Order order = new Order(); order.setCustomer(customer); order.setStatus(OrderStatus.PENDING); for (OrderLineRequest line : lines) { Product product = productRepository.findById(line.productId()) .orElseThrow(() -> new EntityNotFoundException("Product not found")); OrderItem item = new OrderItem(); item.setOrder(order); item.setProduct(product); item.setQuantity(line.quantity()); item.setUnitPrice(product.getPrice()); order.getItems().add(item); } return orderRepository.save(order); // Hibernate يُدرج Order وجميع OrderItems في معاملة واحدة } }
ضع @Transactional(readOnly = true) على الفئة وتجاوزه بـ @Transactional على طوابع الكتابة الفردية. تُخبر التلميح readOnly Hibernate بتخطّي التحقق من التعديلات أثناء مرحلة المزامنة، كما يُخبر كثيرًا من برامج تشغيل JDBC وتجميعات الاتصال بتوجيه الاتصال إلى نسخة قراءة فقط. تحصل على هذه الفائدة الأدائية مجانًا في جميع طوابع الاستعلام دون كتابة أي كود إضافي.

تفعيل التدقيق

تستخدم الكيانات الأربعة @CreatedDate و@LastModifiedDate. تفعيل الآلية يتطلّب تعليقًا توضيحيًا واحدًا فقط على فئة التطبيق الرئيسية:

@SpringBootApplication @EnableJpaAuditing public class ShopApplication { public static void main(String[] args) { SpringApplication.run(ShopApplication.class, args); } }

اختبار تكاملي: التحقق من طبقة المستودعات

طبقة مستودعات بدون اختبارات غير مكتملة. استخدم @DataJpaTest لتشغيل سياق شريحة مع قاعدة بيانات H2 مدمجة — سريع ومعزول ودقيق.

@DataJpaTest class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; @Autowired private CustomerRepository customerRepository; @Test void findByCustomerId_returnsMostRecentFirst() { Customer c = new Customer(); c.setName("Alice"); c.setEmail("alice@example.com"); customerRepository.save(c); Order o1 = new Order(); o1.setCustomer(c); o1.setStatus(OrderStatus.PENDING); orderRepository.save(o1); Order o2 = new Order(); o2.setCustomer(c); o2.setStatus(OrderStatus.COMPLETED); orderRepository.save(o2); List<Order> results = orderRepository .findByCustomerIdOrderByPlacedAtDesc(c.getId()); assertThat(results).hasSize(2); assertThat(results.get(0).getStatus()).isEqualTo(OrderStatus.COMPLETED); } }

ملخص البنية المعمارية

تتبع طبقة المستودعات الكاملة فصلًا واضحًا للمخاوف:

  • الكيانات — كائنات نطاق خالصة مُوسومة بتعليقات JPA التوضيحية والتدقيق. لا تبعيات Spring سوى @EntityListeners.
  • واجهات المستودعات — تمتد JpaRepository؛ تعلن استعلامات مشتقة وطوابع @Query وإسقاطات وتحديثات تعديلية. لا كود تنفيذي تكتبه.
  • فئات الخدمة — تمتلك حدّ المعاملة، وتنسّق مستودعات متعددة، وتُطبّق قواعد الأعمال.
  • كائنات نقل البيانات والإسقاطات — تفصل شكل قاعدة البيانات عن شكل الواجهة البرمجية وتمنع الجلب الزائد.

كل نمط في هذا الدرس — التحميل الكسول وJOIN FETCH لتجنّب N+1 والمعاملات للقراءة فقط وتحديثات @Modifying الجماعية والإسقاطات المغلقة وشرائح @DataJpaTest — هو شيء ستلجأ إليه يوميًا في تطبيقات Spring Boot الإنتاجية. احتفظ بهذا المشروع مرجعًا وكيّف بنيته لأي نطاق تبنيه.