أول خدمة مصغّرة لك
الخدمة المصغّرة (Microservice) هي عملية صغيرة قابلة للنشر بشكل مستقل، تملك وظيفة تجارية واحدة بالضبط، وتعرض هذه الوظيفة عبر حدود شبكية، ويمكن بناؤها واختبارها وإصدارها دون التنسيق مع الخدمات الأخرى. يبدو هذا التعريف مجرّدًا حتى تكتب خدمة فعلية — وهذا بالضبط ما يفعله هذا الدرس.
أنت تعرف Spring Boot بالفعل. الخدمة المصغّرة المبنية بـ Spring Boot هي في جوهرها تطبيق Spring Boot عادي تمامًا. ما يجعلها خدمة مصغّرة هو مجموعة من قرارات التصميم، وليس إطار عمل مختلف.
التحوّل الذهني: من المونوليث إلى الخدمة ذات المسؤولية الواحدة
في المونوليث، تحتوي قطعة نشر واحدة على منطق الطلبات ومنطق المستخدمين ومنطق المخزون وكل شيء بينهما. في معمارية الخدمات المصغّرة، كل واحدة من تلك الوحدات هي قطعة نشر مستقلة. يبني هذا الدرس خدمة كتالوج المنتجات — وهي خدمة تفعل شيئًا واحدًا بالضبط: إدارة بيانات المنتجات. لا تتولى الطلبات ولا المستخدمين ولا أي شيء آخر.
لماذا مسؤولية واحدة لكل خدمة؟ عندما يكون لكل خدمة سياق محدود واحد (bounded context)، تستطيع الفرق نشر التغييرات بشكل مستقل، وتوسيع نطاق الخدمات الفردية تحت الحمل، وعزل الأعطال. عطل في خدمة كتالوج المنتجات لا ينتشر إلى خدمة الطلبات. هذا العزل هو الهدف الكامل.
إعداد المشروع بـ Spring Initializr
اذهب إلى start.spring.io (أو استخدم تكامل Spring Initializr في بيئة التطوير الخاصة بك) وأنشئ مشروعًا بهذه الإحداثيات:
- Group:
com.example
- Artifact:
product-service
- Java: 21
- Spring Boot: 3.3.x
- Dependencies: Spring Web, Spring Data JPA, H2 Database, Spring Boot Actuator, Validation
كل واحد من تلك الاختيارات مقصود. Spring Web يمنحنا Tomcat المدمج وطبقة REST. Spring Data JPA وH2 يتيحان لنا استمرارية البيانات دون الحاجة إلى إنشاء قاعدة بيانات خارجية في هذا المثال الأول. Actuator يوفر نقاط نهاية فحص الصحة التي تحتاجها منصات التنسيق مثل Kubernetes وECS. Validation يتيح لنا تطبيق عقود الواجهة البرمجية عند الحدود.
application.yml — هوية الخدمة أولًا
أعد تسمية application.properties إلى application.yml وامنح الخدمة هوية واضحة:
spring:
application:
name: product-service
datasource:
url: jdbc:h2:mem:products
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: false
server:
port: 8081
management:
endpoints:
web:
exposure:
include: health, info, metrics
الخاصية spring.application.name ليست تجميلية. سجلات اكتشاف الخدمات (Eureka وConsul) وأنظمة التتبع الموزع (Micrometer Tracing / Zipkin) وخطوط تجميع السجلات كلها تستخدم هذا الاسم لتحديد الخدمة التي أنتجت سجلًا معينًا. اضبطه بشكل صحيح منذ اليوم الأول.
اضبط دائمًا منفذًا غير افتراضي. تشغيل جميع خدماتك على المنفذ 8080 محليًا يعني أن واحدة فقط يمكنها البدء في وقت واحد. تعيين منافذ مميزة (8081 و8082 و...) أثناء التطوير يتجنب التعارضات ويعكس الفصل الذي ستملكه في الإنتاج.
نموذج النطاق (Domain Model)
أبقِه بسيطًا — كيان Product بمعرّف واسم وسعر وعدد المخزون:
package com.example.productservice.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 120)
@Column(nullable = false, length = 120)
private String name;
@NotNull
@DecimalMin("0.00")
@Digits(integer = 10, fraction = 2)
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Min(0)
@Column(nullable = false)
private int stockCount;
// constructors, getters, setters omitted for brevity
}
لاحظ أن BigDecimal تُستخدم للسعر وليس double. أنواع الفاصلة العائمة لا تستطيع تمثيل الكسور العشرية بدقة، مما ينتج عنه أخطاء تقريب عند جمع الأسعار — وهو خطأ جسيم في أي سياق مالي.
المستودع (Repository)
يُزيل Spring Data JPA الكود المتكرر. أعلن عن واجهة؛ وسيُولّد Spring التنفيذ عند بدء التشغيل:
package com.example.productservice.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByStockCountGreaterThan(int threshold);
}
طبقة الخدمة (Service Layer)
أبقِ منطق الأعمال خارج المتحكمات. طبقة الخدمة تطبّق القواعد وتنسّق المعاملات، وهي الحدود التي تستهدفها اختبارات الوحدة:
package com.example.productservice.service;
import com.example.productservice.domain.Product;
import com.example.productservice.domain.ProductRepository;
import jakarta.transaction.Transactional;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
public List<Product> listAll() {
return repo.findAll();
}
public Product findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
return repo.save(product);
}
@Transactional
public void delete(Long id) {
findById(id); // يرمي استثناء 404 إن لم يُوجد
repo.deleteById(id);
}
}
متحكم REST
المتحكم متعمّد في رفاهته. يترجم HTTP إلى استدعاءات خدمة ونتائج الخدمة إلى استجابات HTTP:
package com.example.productservice.web;
import com.example.productservice.domain.Product;
import com.example.productservice.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<Product> list() {
return service.listAll();
}
@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@Valid @RequestBody Product product) {
return service.create(product);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
معالجة الأخطاء عند حدود الخدمة
الخدمة المصغّرة مواطن شبكي. عندما يطلب مستدعٍ المنتج رقم 999 وهو غير موجود، إعادة صفحة Spring 500 الخام أمر خاطئ. عيّن استثناءات النطاق إلى رموز حالة HTTP المناسبة باستخدام @ControllerAdvice:
package com.example.productservice.web;
import com.example.productservice.service.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public Map<String, Object> handleNotFound(ProductNotFoundException ex) {
return Map.of(
"status", 404,
"error", "Not Found",
"message", ex.getMessage(),
"timestamp", Instant.now().toString()
);
}
}
لا تُكشف آثار المكدس (stack traces) أو رسائل الاستثناء الداخلية عبر حدود الخدمة أبدًا. تكشف تفاصيل التنفيذ (أسماء الفئات وإصدارات المكتبات وSQL) التي يستخدمها المهاجمون لتحديد بصمة مكدسك. أعد هيكل خطأ منظمًا ومقروءًا للإنسان، وسجّل آثار المكدس الكاملة على جانب الخادم فقط.
التحقق من أن الخدمة تعمل
شغّل الخدمة بـ ./mvnw spring-boot:run. يعرض Spring Boot Actuator نقطة النهاية GET /actuator/health تلقائيًا. في الإنتاج، يستدعي منسّق الحاويات هذه النقطة كل بضع ثوانٍ؛ إذا أعادت أي شيء غير {"status":"UP"}، تُخرج النسخة من موزع الحمل وتُستبدل. اعتَد التحقق منها محليًا:
curl http://localhost:8081/actuator/health
# {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
يُضمَّن مكوّن قاعدة البيانات لأن Actuator يكتشف مصدر بيانات H2 ويفحصه تلقائيًا.
ما الذي يجعل هذا خدمة مصغّرة وليس مجرد تطبيق REST
عند هذه النقطة لديك خدمة REST تعمل. ما يمنحها لقب "الخدمة المصغّرة" هو كل شيء تعمّدت عدم وضعه فيها:
- لا منطق مصادقة للمستخدمين — ذلك ينتمي إلى خدمة الهوية (Identity Service).
- لا معالجة للطلبات — ذلك ينتمي إلى خدمة الطلبات (Order Service).
- مخطط قاعدة بيانات خاص بها، تملكه هذه الخدمة حصرًا.
- نقطة نهاية صحة جاهزة لمنسّق الحاويات.
- اسم تطبيق سيظهر في السجلات والتتبعات.
الدروس القادمة ستربط هذه الخدمة بأخريات، وتضيف أنماط المرونة، وتنشرها في حاوية. الأساس الذي بنيته هنا — مسؤولية واحدة، واجهة برمجية واضحة، أخطاء منظمة، نقطة نهاية صحة — هو ما يُبنى عليه كل شيء آخر.