بناء الخدمات المصغّرة بـ Spring Boot

مشروع: نظام من خدمتين

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

مشروع: نظام من خدمتين

على مدار هذا البرنامج التعليمي أنشأت كل قطعة — REST APIs وDTOs وWebClient وOpenFeign وأنماط المرونة وقواعد البيانات لكل خدمة والتتبع الموزع وحاويات Docker — بشكل منفصل. يجمع هذا الدرس الختامي كل شيء في نظام من خدمتين يعمل فعلًا يمكنك تشغيله محليًا ومراقبته من البداية حتى النهاية والتفكير فيه كنظام موزع لا كتطبيق متكامل (monolith).

حالة الاستخدام: خدمة الطلبات + خدمة المخزون

نُنمذج واجهة خلفية صغيرة للتجارة الإلكترونية. تتعاون خدمتان مستقلتان لإتمام طلب شراء:

  • خدمة المخزون (Inventory Service) — المنفذ 8081 — تمتلك جدول products. تُعرض واجهة REST API للاستعلام عن مستويات المخزون وتخفيض الكمية المحجوزة.
  • خدمة الطلبات (Order Service) — المنفذ 8080 — تمتلك جدول orders. تستقبل طلب تقديم طلب شراء، وتستدعي خدمة المخزون لحجز المخزون، وتحفظ الطلب فقط إذا نجح الحجز.

هذا هو نمط التنسيق (Orchestration) الكلاسيكي: خدمة الطلبات هي المُنسِّق وخدمة المخزون هي المشارك. الحد الفاصل بينهما مقصود — لا تلمس أي خدمة قاعدة بيانات الأخرى.

لماذا قاعدتا بيانات منفصلتان؟ قواعد البيانات المشتركة هي أكثر الطرق شيوعًا لتتزاوج الخدمات المصغّرة بصمت مع مرور الوقت. تمتلك كل خدمة مخطط بياناتها، وتطوّره بشكل مستقل، وتُنشر أو تُوسَّع أو تُستبدل دون التأثير على الأخرى. الثمن هو أنك لا تستطيع استخدام معاملة ACID واحدة عبر الخدمتين — يجب أن تُصمّم للاتساق التدريجي أو الإجراءات التعويضية بدلًا من ذلك.

هيكل المشروع

أنشئ مشروعَي Spring Boot مستقلَّين جنبًا إلى جنب. كل منهما وحدة Maven/Gradle مستقلة بـ application.yml خاص بها ومجموعة كيانات JPA خاصة بها وصورة Docker خاصة بها.

order-system/ ├── inventory-service/ # Spring Boot 3, port 8081 │ ├── src/main/java/com/example/inventory/ │ │ ├── InventoryServiceApplication.java │ │ ├── product/ │ │ │ ├── Product.java (JPA entity) │ │ │ ├── ProductRepository.java │ │ │ ├── InventoryController.java │ │ │ └── ReserveRequest.java (DTO) │ └── src/main/resources/application.yml └── order-service/ # Spring Boot 3, port 8080 ├── src/main/java/com/example/order/ │ ├── OrderServiceApplication.java │ ├── order/ │ │ ├── Order.java (JPA entity) │ │ ├── OrderRepository.java │ │ ├── OrderController.java │ │ ├── OrderService.java │ │ └── PlaceOrderRequest.java (DTO) │ └── client/ │ └── InventoryClient.java (OpenFeign or WebClient) └── src/main/resources/application.yml

خدمة المخزون — الكود الأساسي

الكيان ونقطة نهاية الحجز واضحان. الخيار التصميمي الرئيسي هو أن reserve عبارة عن POST غير قابل للإلغاء وذو تأثير جانبي: يُرسل المُستدعون عدد الوحدات التي يريدونها؛ تتحقق الخدمة ذريًا وتُنقص الكمية باستخدام قفل تشاؤمي @Transactional.

// Product.java @Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int stockQuantity; // getters, setters } // ReserveRequest.java — DTO (record) public record ReserveRequest(Long productId, int quantity) {} // InventoryController.java @RestController @RequestMapping("/inventory") public class InventoryController { private final ProductRepository repo; public InventoryController(ProductRepository repo) { this.repo = repo; } @GetMapping("/{id}/stock") public ResponseEntity<Integer> getStock(@PathVariable Long id) { return repo.findById(id) .map(p -> ResponseEntity.ok(p.getStockQuantity())) .orElse(ResponseEntity.notFound().build()); } @PostMapping("/reserve") @Transactional public ResponseEntity<String> reserve(@RequestBody ReserveRequest req) { Product p = repo.findByIdWithLock(req.productId()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); if (p.getStockQuantity() < req.quantity()) { return ResponseEntity.status(HttpStatus.CONFLICT).body("Insufficient stock"); } p.setStockQuantity(p.getStockQuantity() - req.quantity()); repo.save(p); return ResponseEntity.ok("Reserved"); } }
// ProductRepository.java — قفل تشاؤمي لمنع البيع الزائد public interface ProductRepository extends JpaRepository<Product, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdWithLock(@Param("id") Long id); }
القفل التشاؤمي مقابل التفاؤلي للمخزون: المخزون هو نقطة ازدحام كلاسيكية — يمكن لطلبات متزامنة كثيرة أن تستهدف نفس المنتج. القفل التشاؤمي (استعلام SELECT ... FOR UPDATE على مستوى قاعدة البيانات) أسهل في التفكير هنا. للبيانات ذات الازدحام المنخفض، يمنح القفل التفاؤلي مع @Version إنتاجية أفضل.

خدمة الطلبات — عميل Feign والتنسيق

تُعلن خدمة الطلبات عن استدعاء المخزون كواجهة Feign. هذا يُبقي مخاوف HTTP خارج منطق الأعمال، ويتعامل Spring Cloud OpenFeign مع إعادة المحاولات والمهلات وفك تشفير الأخطاء في مكان واحد.

// InventoryClient.java @FeignClient(name = "inventory-service", url = "${inventory.service.url}") public interface InventoryClient { @PostMapping("/inventory/reserve") ResponseEntity<String> reserve(@RequestBody ReserveRequest req); }
// OrderService.java — ينسّق حالة الاستخدام @Service public class OrderService { private final OrderRepository orderRepo; private final InventoryClient inventoryClient; public OrderService(OrderRepository orderRepo, InventoryClient inventoryClient) { this.orderRepo = orderRepo; this.inventoryClient = inventoryClient; } @Transactional public Order placeOrder(PlaceOrderRequest req) { // 1. حجز المخزون — استدعاء خدمة المخزون ResponseEntity<String> reservationResponse = inventoryClient.reserve(new ReserveRequest(req.productId(), req.quantity())); if (!reservationResponse.getStatusCode().is2xxSuccessful()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "فشل حجز المخزون: " + reservationResponse.getBody()); } // 2. حفظ الطلب فقط بعد نجاح الحجز Order order = new Order(); order.setProductId(req.productId()); order.setQuantity(req.quantity()); order.setStatus("CONFIRMED"); order.setCreatedAt(Instant.now()); return orderRepo.save(order); } }
مشكلة الكتابة المزدوجة. ثمة نافذة زمنية بين نجاح الحجز واستدعاء orderRepo.save() يُترك فيها المخزون منقوصًا دون تسجيل أي طلب إذا حدث عطل. الحل الكامل يتطلب إما نمط Saga (معاملة تعويضية تُلغي حجز المخزون عند الفشل) أو نمط Outbox (كتابة الطلب وحدث معلّق في معاملة محلية واحدة ثم النشر بشكل غير متزامن). لهذا المشروع، وثّق هذا القيد وأضف نقطة نهاية تعويضية إلى خدمة المخزون — هذا أكثر صدقًا من التظاهر بأن المشكلة غير موجودة.

نقل معرف الارتباط

مع خدمتين، ينبثق طلب مستخدم واحد إلى تيارَي سجلات. يربطهما معرف الارتباط (Correlation ID). تُنشئ خدمة الطلبات معرفًا إذا لم يكن موجودًا، تخزّنه في MDC، وتمرّره كرأس HTTP إلى خدمة المخزون.

// CorrelationFilter.java (خدمة الطلبات — أضفه إلى خدمة المخزون أيضًا) @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class CorrelationFilter implements Filter { private static final String HEADER = "X-Correlation-Id"; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; String correlationId = httpReq.getHeader(HEADER); if (correlationId == null || correlationId.isBlank()) { correlationId = UUID.randomUUID().toString(); } MDC.put("correlationId", correlationId); HttpServletResponse httpRes = (HttpServletResponse) res; httpRes.setHeader(HEADER, correlationId); try { chain.doFilter(req, res); } finally { MDC.clear(); } } }

أضف معترض طلبات Feign في خدمة الطلبات لتمرير الرأس في كل استدعاء صادر:

@Bean public RequestInterceptor correlationInterceptor() { return template -> { String id = MDC.get("correlationId"); if (id != null) { template.header("X-Correlation-Id", id); } }; }

تشغيل كلتا الخدمتين باستخدام Docker Compose

تمتلك كل خدمة ملف Dockerfile (متعدد المراحل، JDK 21 slim). يُشغّل docker-compose.yml في الجذر كل شيء بأمر واحد:

# docker-compose.yml version: "3.9" services: inventory-db: image: postgres:16-alpine environment: POSTGRES_DB: inventory POSTGRES_USER: inv_user POSTGRES_PASSWORD: inv_pass ports: ["5433:5432"] order-db: image: postgres:16-alpine environment: POSTGRES_DB: orders POSTGRES_USER: ord_user POSTGRES_PASSWORD: ord_pass ports: ["5434:5432"] inventory-service: build: ./inventory-service ports: ["8081:8081"] environment: SPRING_DATASOURCE_URL: jdbc:postgresql://inventory-db:5432/inventory SPRING_DATASOURCE_USERNAME: inv_user SPRING_DATASOURCE_PASSWORD: inv_pass depends_on: [inventory-db] order-service: build: ./order-service ports: ["8080:8080"] environment: SPRING_DATASOURCE_URL: jdbc:postgresql://order-db:5432/orders SPRING_DATASOURCE_USERNAME: ord_user SPRING_DATASOURCE_PASSWORD: ord_pass INVENTORY_SERVICE_URL: http://inventory-service:8081 depends_on: [order-db, inventory-service]

التحقق الشامل

بعد docker compose up --build، تحقق من المسار السعيد ومسار الفشل:

# تقديم طلب (المنتج 1، وحدتان) curl -s -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":2}' | jq . # محاولة طلب زائد (يجب أن يُعيد 409 Conflict) curl -s -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":9999}' | jq . # تحقق من تدفق معرف الارتباط عبر الخدمتين curl -v -X POST http://localhost:8080/orders \ -H "Content-Type: application/json" \ -d '{"productId":1,"quantity":1}' 2>&1 | grep X-Correlation

ابحث في تيارَي سجلات الخدمتين عن نفس قيمة معرف الارتباط للتأكد من عمل التتبع الشامل.

ما يمكن توسيعه لاحقًا

نظام الخدمتين هذا أساس وليس غاية. الخطوات الطبيعية التالية تشمل: إضافة منسّق Saga أو تنسيق قائم على الأحداث لمعالجة مشكلة الكتابة المزدوجة؛ تقديم Spring Cloud Gateway كنقطة دخول واحدة؛ إضافة Micrometer + Prometheus + Grafana للمقاييس؛ وتوصيل Config Server حتى لا تُضمِّن أي خدمة عنوان قاعدة بياناتها أو عنوان الخدمة المقابلة.

الخلاصة

بنيت نظامًا من خدمتين تُنسّق فيه خدمة الطلبات خدمة المخزون عبر HTTP، وتمتلك كل خدمة قاعدة بياناتها المستقلة، ويتدفق معرف الارتباط عبر حد الشبكة، وتعمل كلتا الخدمتين معًا في Docker Compose. والأهم من ذلك، جرّبت المقايضات بنفسك: نافذة الكتابة المزدوجة، والحاجة إلى المنطق التعويضي، والانضباط التشغيلي المطلوب عندما تمتد معاملة أعمال واحدة عبر عمليتين مستقلتين. تلك المقايضات هي ما تدور حوله معمارية الخدمات المصغّرة في الواقع.