بناء واجهة REST لعمليات CRUD
علّمتك الدروس السابقة اللبنات الأساسية الفردية — @RestController ومتغيرات المسار و@RequestBody وResponseEntity. الآن نجمعها جميعًا ونبني واجهة برمجية كاملة لمورد بالكامل من الصفر. بنهاية هذا الدرس ستمتلك واجهة Product تعمل بالكامل وتدعم الإنشاء والقراءة (للعنصر الواحد والقائمة) والتحديث والحذف، مع الدلالات الصحيحة لـ HTTP وفصل نظيف بين الطبقات.
نهج الطبقات الثلاث
حتى في واجهة برمجية متواضعة، يُفيد فصل المسؤوليات:
- المتحكم (Controller) — يتعامل مع HTTP: يُحلّل الطلبات ويُفوّض إلى الخدمة ويُشكّل الاستجابة.
- الخدمة (Service) — تمتلك منطق الأعمال. لا ينبغي للمتحكم أن يصل إلى المستودع مباشرةً أبدًا.
- المستودع (Repository) — الوصول إلى البيانات. نستخدم Spring Data JPA هنا، لكن النمط يعمل مع JDBC أو أي طبقة استمرارية أخرى.
لماذا يهمّ هذا: إذا وضعت منطق الأعمال في المتحكم، فإن اختباره يعني تشغيل خادم HTTP. أما إذا كان في حبة @Service عادية، فيمكنك اختبارها باختبار وحدة بسيط دون أي طبقة ويب.
الكيان والمستودع
ابدأ بكيان JPA ومستودع Spring Data:
// Product.java
package com.example.shop.product;
import jakarta.persistence.*;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private double price;
// المنشئات والـ getters والـ setters محذوفة للإيجاز
}
// ProductRepository.java
package com.example.shop.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> { }
يوفّر JpaRepository بالفعل findAll() وfindById() وsave() وdeleteById() — تحصل على CRUD مجانًا.
طبقة الخدمة
تُترجم الخدمة نية الأعمال إلى استدعاءات المستودع وترمي استثناءات النطاق عند حدوث خطأ:
// ProductService.java
package com.example.shop.product;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
@Transactional(readOnly = true)
public List<Product> findAll() {
return repo.findAll();
}
@Transactional(readOnly = true)
public Product findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
public Product create(Product product) {
product.setId(null); // منع المعرّفات التي يرسلها العميل
return repo.save(product);
}
public Product update(Long id, Product incoming) {
Product existing = findById(id);
existing.setName(incoming.getName());
existing.setDescription(incoming.getDescription());
existing.setPrice(incoming.getPrice());
return repo.save(existing);
}
public void delete(Long id) {
Product existing = findById(id); // يؤكد الوجود أولًا
repo.delete(existing);
}
}
علّم العمليات القراءة فقط بـ @Transactional(readOnly = true). يُخبر هذا مزوّد JPA بأنه يستطيع تخطّي التحقق من التغييرات (dirty-checking)، مما يُقلّل الضغط على الذاكرة مع مجموعات النتائج الكبيرة. قد يختار مشغّل قاعدة البيانات أيضًا مسار تنفيذ أسرع.
المتحكم
يُعيّن المتحكم خمس عمليات HTTP إلى الخدمة. لاحظ كم يبقى نحيفًا — لا منطق أعمال، فقط ترجمة HTTP:
// ProductController.java
package com.example.shop.product;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
// GET /api/products
@GetMapping
public List<Product> list() {
return service.findAll();
}
// GET /api/products/{id}
@GetMapping("/{id}")
public ResponseEntity<Product> get(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
// POST /api/products
@PostMapping
public ResponseEntity<Product> create(@RequestBody Product product) {
Product saved = service.create(product);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.getId())
.toUri();
return ResponseEntity.created(location).body(saved);
}
// PUT /api/products/{id}
@PutMapping("/{id}")
public ResponseEntity<Product> update(
@PathVariable Long id,
@RequestBody Product product) {
return ResponseEntity.ok(service.update(id, product));
}
// DELETE /api/products/{id}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
بناء ترويسة Location عند POST
عندما يُرسل العميل POST لمورد جديد، ينبغي أن تتضمن الاستجابة ترويسة Location تشير إلى المورد المُنشأ حديثًا. يلتقط ServletUriComponentsBuilder.fromCurrentRequest() عنوان URL للطلب الحالي (مثل /api/products) ويُلحق المعرّف الجديد لينتج /api/products/42. حالة الاستجابة 201 Created مع هذه الترويسة هي ما تتوقعه عملاء REST وبوابات API.
التعامل مع "غير موجود" باستثناء مخصص
ترمي الخدمة ProductNotFoundException عندما لا يوجد المنتج. ربطه باستجابة 404 يتم عبر معالج استثناء صغير:
// ProductNotFoundException.java
package com.example.shop.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Product not found: " + id);
}
}
// GlobalExceptionHandler.java
package com.example.shop;
import com.example.shop.product.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(
"timestamp", Instant.now().toString(),
"status", 404,
"error", "Not Found",
"message", ex.getMessage()
);
}
}
لا تدع صفحة الخطأ الافتراضية لـ Spring تُسرّب تتبعات المكدس. نقطة النهاية الافتراضية /error تتضمن أسماء فئات الاستثناءات والمسارات الداخلية. تمنحك فئة @RestControllerAdvice تحكمًا كاملًا فيما يراه العميل. في الإنتاج، سجّل تتبع المكدس على جانب الخادم وأعد فقط رسالة آمنة للمُستدعي.
التحقق من واجهتك البرمجية باستخدام curl
مع تشغيل التطبيق على المنفذ 8080 يمكنك تجربة نقاط النهاية الخمس:
# إنشاء
curl -s -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Keyboard","description":"Mechanical","price":129.99}'
# قائمة الكل
curl -s http://localhost:8080/api/products
# الحصول على واحد
curl -s http://localhost:8080/api/products/1
# تحديث
curl -s -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name":"Keyboard","description":"Wireless Mechanical","price":149.99}'
# حذف
curl -s -X DELETE http://localhost:8080/api/products/1
الخلاصة
واجهة CRUD نظيفة في Spring Boot هي مجموع أربعة أجزاء: كيان JPA ومستودع للاستمرارية، وحبة @Service لمنطق الأعمال، و@RestController لترجمة HTTP، و@RestControllerAdvice لمعالجة الأخطاء. يبقى المتحكم نحيفًا، والخدمة قابلة للاختبار، وكل عملية تُعيد رمز حالة HTTP الذي يتوقعه عملاء REST فعلًا — 200 للقراءة والتحديث، و201 للإنشاء، و204 للحذف، و404 عندما لا يُوجد المورد. هذا الهيكل يتوسّع مباشرةً إلى خدمة مصغّرة في بيئة الإنتاج.