حقن التبعيات ودورة حياة الـ Bean

مشروع: طبقة خدمات مفصولة الاقتران

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

مشروع: طبقة خدمات مفصولة الاقتران

منحتك الدروس التسعة السابقة كل أداة من أدوات Spring DI بشكل منفرد: الحقن عبر المنشئ، والنطاقات، وخطافات دورة الحياة، و@Qualifier، والبينات الكسولة، وربط الخصائص. يجمع هذا الدرس الختامي كل ذلك في تطبيق مصغّر واقعي واحد — خلفية معالجة الطلبات — ويُظهر مبادئ التصميم التي تجعل النتيجة سهلة الاختبار، وسهلة التوسيع، وآمنة لإعادة الهيكلة.

الهدف: البرمجة نحو الواجهات لا التطبيقات

المبدأ الأهم في قاعدة كود تعتمد على DI هو الاعتماد على التجريدات. تُعلن كل طبقة عمّا تحتاجه من خلال واجهة (Interface)؛ ويصل Spring في وقت التشغيل بالفئة الملموسة. هذا يعني أنك تستطيع استبدال StripePaymentGateway بـ PayPalPaymentGateway دون لمس OrderService، ويمكنك حقن FakePaymentGateway في الاختبارات دون تشغيل خادم.

هيكل المشروع

يمتلك التطبيق المصغّر أربع طبقات. تشير سهام الاعتمادية للأسفل فقط — لا توجد طبقة تعرف الطبقة التي تعلوها.

  • طبقة التحكم — تستقبل طلبات HTTP (محاكاة هنا بدالة main).
  • طبقة الخدمات — منطق الأعمال: التحقق من الطلب، ومحاسبة العميل، وإرسال إشعار.
  • طبقة المستودع — الثبات: حفظ الطلبات واسترجاعها من قاعدة البيانات.
  • طبقة البنية التحتية — التكاملات الخارجية: بوابة الدفع، ومرسل البريد الإلكتروني.

الخطوة 1 — تحديد العقود (الواجهات)

// repository/OrderRepository.java package com.example.orders.repository; import com.example.orders.model.Order; import java.util.Optional; public interface OrderRepository { Order save(Order order); Optional<Order> findById(long id); } // service/PaymentGateway.java package com.example.orders.service; import java.math.BigDecimal; public interface PaymentGateway { String charge(String customerId, BigDecimal amount); // تُرجع معرّف المعاملة } // service/NotificationService.java package com.example.orders.service; public interface NotificationService { void sendOrderConfirmation(String email, long orderId); }
الواجهات تنتمي إلى طبقة النطاق، لا طبقة البنية التحتية. تعيش PaymentGateway في حزمة service — يمتلكها منطق الأعمال — لا في حزمة stripe التي تُنفّذها. هذا هو مبدأ انعكاس الاعتمادية (الحرف D في SOLID): الوحدات عالية المستوى تُعرّف المنفذ؛ أما الوحدات منخفضة المستوى فتُقدّم المحوّل.

الخطوة 2 — كتابة التطبيقات

// repository/JdbcOrderRepository.java package com.example.orders.repository; import com.example.orders.model.Order; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public class JdbcOrderRepository implements OrderRepository { private final JdbcTemplate jdbc; public JdbcOrderRepository(JdbcTemplate jdbc) { // حقن عبر المنشئ this.jdbc = jdbc; } @Override public Order save(Order order) { jdbc.update( "INSERT INTO orders (customer_id, amount, status) VALUES (?, ?, ?)", order.customerId(), order.amount(), "PENDING" ); return order; } @Override public Optional<Order> findById(long id) { return jdbc.query( "SELECT * FROM orders WHERE id = ?", (rs, n) -> new Order(rs.getLong("id"), rs.getString("customer_id"), rs.getBigDecimal("amount")), id ).stream().findFirst(); } } // infrastructure/StripePaymentGateway.java package com.example.orders.infrastructure; import com.example.orders.service.PaymentGateway; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.math.BigDecimal; @Component("stripeGateway") public class StripePaymentGateway implements PaymentGateway { @Value("${stripe.api-key}") private String apiKey; @Override public String charge(String customerId, BigDecimal amount) { // استدعاء Stripe SDK الحقيقي يكون هنا return "txn_" + System.currentTimeMillis(); } } // infrastructure/SmtpNotificationService.java package com.example.orders.infrastructure; import com.example.orders.service.NotificationService; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Component; @Component public class SmtpNotificationService implements NotificationService { private final JavaMailSender mailer; public SmtpNotificationService(JavaMailSender mailer) { this.mailer = mailer; } @Override public void sendOrderConfirmation(String email, long orderId) { SimpleMailMessage msg = new SimpleMailMessage(); msg.setTo(email); msg.setSubject("Order #" + orderId + " confirmed"); msg.setText("Thank you for your order."); mailer.send(msg); } }

الخطوة 3 — طبقة الخدمات تجمع الكل معًا

// service/OrderService.java package com.example.orders.service; import com.example.orders.model.Order; import com.example.orders.repository.OrderRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @Service public class OrderService { private final OrderRepository orderRepository; private final PaymentGateway paymentGateway; private final NotificationService notificationService; // كل التبعيات محقونة عبر المنشئ — لا حقن في الحقول مطلقًا public OrderService( OrderRepository orderRepository, @Qualifier("stripeGateway") PaymentGateway paymentGateway, NotificationService notificationService) { this.orderRepository = orderRepository; this.paymentGateway = paymentGateway; this.notificationService = notificationService; } @Transactional public Order placeOrder(String customerId, String email, BigDecimal amount) { if (amount.signum() <= 0) { throw new IllegalArgumentException("Amount must be positive"); } Order order = orderRepository.save(new Order(null, customerId, amount)); String txnId = paymentGateway.charge(customerId, amount); // حفظ txnId وتحديث حالة الطلب... (محذوف للاختصار) notificationService.sendOrderConfirmation(email, order.id()); return order; } }
ابقِ الخدمات رشيقة من تفاصيل البنية التحتية. لا تعرف OrderService أن الدفع يُعالَج بـ Stripe أو أن البريد الإلكتروني يمر عبر SMTP. تعرف فقط العقد. هذا الحاجز هو ما يجعل اختبار منطق الأعمال بالوحدة أمرًا تافهًا.

الخطوة 4 — اختبار الوحدة بدون سياق Spring

لأن كل تبعية محقونة عبر المنشئ، يمكنك إنشاء مثيل لـ OrderService في اختبار JUnit عادي — لا سياق تطبيق، ولا قاعدة بيانات، ولا شبكة:

// test/OrderServiceTest.java package com.example.orders.service; import com.example.orders.model.Order; import com.example.orders.repository.OrderRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.math.BigDecimal; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class OrderServiceTest { @Mock OrderRepository orderRepository; @Mock PaymentGateway paymentGateway; @Mock NotificationService notificationService; @InjectMocks OrderService orderService; // Mockito يستدعي المنشئ @Test void placeOrder_chargesAndNotifies() { Order saved = new Order(1L, "cust-42", new BigDecimal("99.00")); when(orderRepository.save(any())).thenReturn(saved); when(paymentGateway.charge(eq("cust-42"), any())).thenReturn("txn_abc"); orderService.placeOrder("cust-42", "alice@example.com", new BigDecimal("99.00")); verify(paymentGateway).charge(eq("cust-42"), eq(new BigDecimal("99.00"))); verify(notificationService).sendOrderConfirmation("alice@example.com", 1L); } @Test void placeOrder_rejectsZeroAmount() { assertThatThrownBy(() -> orderService.placeOrder("cust-1", "a@b.com", BigDecimal.ZERO)) .isInstanceOf(IllegalArgumentException.class); verifyNoInteractions(paymentGateway, notificationService); } }

لاحظ أن @Mock ينشئ بدائل اختبار في الذاكرة. لا يوجد Spring، ولا مفتاح Stripe API، ولا خادم بريد. تعمل الاختبارات في غضون ميلي ثانية وهي حتمية تمامًا.

الخطوة 5 — اختبار التكامل مع @SpringBootTest

بمجرد تغطية المنطق باختبارات الوحدة، يُنشئ اختبار تكامل منفصل سياقًا حقيقيًا (أو مدمجًا) للتحقق من الربط:

@SpringBootTest @ActiveProfiles("test") // يُحمّل application-test.properties مع H2 وبينات وهمية class OrderServiceIntegrationTest { @Autowired OrderService orderService; @Test void contextLoads_andBeanIsWired() { assertThat(orderService).isNotNull(); } }
# application-test.properties spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 spring.datasource.driver-class-name=org.h2.Driver stripe.api-key=test_dummy_key spring.mail.host=localhost spring.mail.port=3025

المفاضلات والقرارات التي تستحق المعرفة

  • الحقن عبر المنشئ مقابل حقن الحقل — دائمًا افضّل حقن المنشئ للتبعيات الإلزامية. يُخفي حقن الحقل (@Autowired على حقل) الاقتران ويجعل الاختبارات أصعب لأنك لا تستطيع تعيين الحقل دون سياق Spring أو الانعكاس.
  • @Primary مقابل @Qualifier — إذا كان لديك تطبيق واحد مهيمن، عَلِّمه بـ @Primary وطبّق @Qualifier فقط حيث تحتاج البديل. إغراق كل نقطة حقن بـ @Qualifier يُشوّش الكود.
  • نطاق Singleton للخدمات — ينبغي أن تكون بينات الخدمة عديمة الحالة singleton (الوضع الافتراضي). إدخال حالة قابلة للتعديل في بين singleton يُنشئ أخطاء تزامن خفية تحت الحمل.
  • @Transactional على الخدمة لا المستودع — تتحكم الخدمة في حدود المعاملة لأنها تُقرر أي عمليات المستودع يجب أن تنجح أو تفشل معًا. وضع @Transactional فقط على المستودع يمنح كل جملة SQL معاملتها الخاصة، مما يمنع التراجع عبر العمليات.
لا تحقن ApplicationContext نفسه في خدمتك. استدعاء context.getBean(...) داخل خدمة يتجاوز DI، ويُخفي التبعيات، ويجعل الاختبار مؤلمًا. إذا شعرت بالحاجة لفعل ذلك، فهذا علامة تكاد تكون دائمًا على أن الفئة تقوم بالكثير وينبغي تقسيمها.

الخلاصة

طبقة الخدمات المفصولة الاقتران المعتمدة على DI ليست مجرد أناقة معمارية — بل هي مضاعف قوة لسرعة الاختبار، وثقة إعادة الهيكلة، وإنتاجية الفريق. الوصفة متسقة: أعلن الواجهات في طبقة النطاق، وأحقن عبر المنشئات، ودع Spring يصل الفئات الملموسة، واستخدم Mockito لاستبدالها في اختبارات الوحدة. كل مفهوم قُدِّم في هذا البرنامج التعليمي — النطاقات، ودورة الحياة، والمُحدِّدات، وحقن القيم — موجود في خدمة هذا الهدف الواحد: إبقاء كل فئة مركّزة، وقابلة للاستبدال، وقابلة للتحقق في عزلة.