مشروع: معالجة قضية عرضية باستخدام AOP
يتجمّع في هذا المشروع الختامي كل ما تناوله البرنامج التعليمي. ستبني نظام سجل التدقيق (Audit Trail) الجاهز للإنتاج — وهو نموذج كلاسيكي للمخاوف العرضية — وتربطه بالكامل عبر AOP، بحيث لا يحتاج أي فئة خدمة إلى أن تعلم بوجوده. في النهاية ستمتلك جانبًا (aspect) قابلًا لإعادة الاستخدام والتهيئة يسجّل من استدعى ماذا، ومتى، وما كانت النتيجة، ويحفظ تلك البيانات في جدول قاعدة بيانات — دون أن تلمس منطق الأعمال.
ما الذي تبنيه
الهدف هو توفير تعليق توضيحي @Audited يمكن للمطورين وضعه على أي تابع خدمة. عند تشغيل ذلك التابع يعترض الجانب الاستدعاء ويسجّل هوية المستدعي وتوقيع التابع والمعاملات وقيمة الإرجاع أو الاستثناء وزمن التنفيذ، ثم يحفظ كائن AuditLog. تتغير فئة الخدمة بسطر واحد فقط: التعليق التوضيحي.
الخطوة 1 — النطاق والمثابرة
ابدأ بنموذج البيانات. أنشئ كيانًا JPA باسم AuditLog ومستودعه.
// AuditLog.java
package com.example.audit.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String principal; // المستخدم الحالي أو "anonymous"
private String className;
private String methodName;
@Column(length = 2000)
private String arguments; // المعاملات بصيغة JSON
@Column(length = 2000)
private String result; // قيمة الإرجاع أو رسالة الاستثناء بصيغة JSON
private boolean success;
private long durationMs;
private Instant timestamp;
// --- getters / setters محذوفة للإيجاز ---
}
// AuditLogRepository.java
package com.example.audit.repository;
import com.example.audit.model.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {}
الخطوة 2 — التعليق التوضيحي المخصص
يجعل التعليق التوضيحي المخصص النية واضحة ويمنحك هدفًا نقيًا للـ pointcut. التعليقان التعريفيان @Retention(RUNTIME) و@Target(METHOD) إلزاميان حتى تتمكن Spring AOP من رؤيته.
// Audited.java
package com.example.audit.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Audited {
String action() default ""; // تسمية بشرية اختيارية مثل "CREATE_ORDER"
}
الخطوة 3 — الجانب (Aspect)
الجانب هو المكان الذي يجري فيه كل العمل. استخدم @Around حتى تتمكن من التقاط قيمة الإرجاع وأي استثناء مرمي وقياس وقت التنفيذ في مكان واحد.
// AuditAspect.java
package com.example.audit.aspect;
import com.example.audit.annotation.Audited;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Arrays;
@Aspect
@Component
public class AuditAspect {
private final AuditLogRepository repo;
private final ObjectMapper mapper;
public AuditAspect(AuditLogRepository repo, ObjectMapper mapper) {
this.repo = repo;
this.mapper = mapper;
}
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
long start = System.currentTimeMillis();
MethodSignature sig = (MethodSignature) pjp.getSignature();
AuditLog log = new AuditLog();
log.setPrincipal(resolvePrincipal());
log.setClassName(sig.getDeclaringTypeName());
log.setMethodName(sig.getName());
log.setArguments(toJson(pjp.getArgs()));
log.setTimestamp(Instant.now());
try {
Object result = pjp.proceed();
log.setResult(toJson(result));
log.setSuccess(true);
return result;
} catch (Throwable ex) {
log.setResult(ex.getClass().getSimpleName() + ": " + ex.getMessage());
log.setSuccess(false);
throw ex; // أعد الرمي دائمًا — لا يجوز للجانب ابتلاع الاستثناءات
} finally {
log.setDurationMs(System.currentTimeMillis() - start);
repo.save(log); // يعمل ضمن نفس معاملة تابع الخدمة
}
}
private String resolvePrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (auth != null && auth.isAuthenticated()) ? auth.getName() : "anonymous";
}
private String toJson(Object value) {
try {
return mapper.writeValueAsString(value);
} catch (Exception e) {
return "[unserializable]";
}
}
}
لماذا @Around("@annotation(audited)") وليس pointcut مسمى؟ ربط معامل التعليق التوضيحي مباشرةً في توقيع النصيحة يمنحك الوصول إلى نسخة التعليق التوضيحي في وقت التشغيل (لتتمكن من قراءة audited.action()). إنه أكثر إيجازًا من زوج @Pointcut + @Around منفصلَين عند استخدام الـ pointcut مرة واحدة فقط.
الخطوة 4 — تطبيق التعليق التوضيحي على الخدمة
هذا هو التغيير الوحيد الذي تحتاجه أي فئة خدمة. البنية التحتية للتدقيق غير مرئية تمامًا لمنطق الأعمال.
// OrderService.java
package com.example.order;
import com.example.audit.annotation.Audited;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Audited(action = "CREATE_ORDER")
public Order createOrder(CreateOrderRequest request) {
// ... منطق أعمال بحت ...
return savedOrder;
}
@Audited(action = "CANCEL_ORDER")
public void cancelOrder(Long orderId) {
// ... منطق أعمال ...
}
}
الخطوة 5 — المثابرة غير المتزامنة (تحسين للإنتاج)
الكتابة إلى قاعدة البيانات داخل الاستدعاء المعترَض تضيف زمن استجابة لكل تابع مُدقَّق عليه. في الخدمات ذات الإنتاجية العالية انقل الكتابة إلى خيط منفصل باستخدام @Async.
// AuditLogService.java (فئة جديدة — تُبقي @Async خارج الجانب)
package com.example.audit.service;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditLogService {
private final AuditLogRepository repo;
public AuditLogService(AuditLogRepository repo) {
this.repo = repo;
}
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(AuditLog log) {
repo.save(log);
}
}
استخدم Propagation.REQUIRES_NEW حتى تُعالَج عملية كتابة التدقيق باستقلالية — حتى لو تراجعت المعاملة الرئيسية ستظل تملك سجلًا بأن الاستدعاء جرى ومحاولته باءت بالفشل.
لا تستدع توابع @Async من نفس الفئة. يعمل @Async في Spring عبر الوكلاء — الاستدعاء من تابع داخل نفس الكائن يتجاوز الوكيل. أدخل AuditLogService في الجانب واستدعِها من هناك وليس من تابع في نفس الكائن.
الخطوة 6 — اختبار الجانب
اختبر التكامل عبر المكدس الكامل مع سياق Spring حقيقي وقاعدة بيانات H2 في الذاكرة. تحقق من أن استدعاء التابع ينتج صفًا واحدًا بالضبط في AuditLog بالقيم المتوقعة.
// AuditAspectIT.java
package com.example.audit;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import com.example.order.OrderService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class AuditAspectIT {
@Autowired OrderService orderService;
@Autowired AuditLogRepository repo;
@Test
void createOrder_shouldWriteAuditLog() {
long before = repo.count();
orderService.createOrder(new CreateOrderRequest(...));
List<AuditLog> logs = repo.findAll();
assertThat(logs).hasSize((int) before + 1);
AuditLog log = logs.get(logs.size() - 1);
assertThat(log.getMethodName()).isEqualTo("createOrder");
assertThat(log.isSuccess()).isTrue();
assertThat(log.getDurationMs()).isGreaterThanOrEqualTo(0);
}
}
مقايضات التصميم الواجب معرفتها
- الكتابة ضمن نفس المعاملة مقابل الكتابة المستقلة: استدعاء
repo.save() المتزامن في النصيحة يربط مثابرة التدقيق بمعاملة الأعمال. REQUIRES_NEW + @Async يفصل بينهما على حساب اتساق نهائي طفيف.
- تسلسل المعاملات: تحويل جميع المعاملات إلى JSON قد يُسرّب بيانات حساسة (كلمات المرور، PII). افضّل نهج القائمة البيضاء أو
toString() مخصصًا لكائنات الطلب مقترنًا بمكتبة إخفاء.
- الاستدعاء الذاتي (Self-invocation): تعتمد Spring AOP على الوكلاء — استدعاء تابع
@Audited من تابع آخر في نفس الفئة يتجاوز الجانب. أعد الهيكلة بحقن الكائن في نفسه، أو انتقل إلى النسج الكامل بـ AspectJ للاستدعاءات الداخلية.
- تكلفة الأداء: كل استدعاء وكيل يضيف نانوثوانٍ من الحمل. قِس التكلفة للمسارات الحارة جدًا (ملايين الاستدعاءات في الثانية)؛ أما لاستدعاءات طبقة الخدمة النمطية فهي لا تُذكر.
ابنِ الجانب مرة واحدة واستخدمه في كل مكان. بمجرد وضع AuditAspect في وحدة مكتبة مشتركة، يمكن لأي فريق في مؤسستك إضافة مسارات تدقيق إلى خدمته بتعليق @Audited واحد ودون أي نمط مكرر. هذا هو العائد الحقيقي لمعالجة المخاوف العرضية بشكل صحيح.
الخلاصة
لقد بنيت نظام سجل تدقيق متكاملًا وجاهزًا للإنتاج باستخدام Spring AOP. يعمل التعليق التوضيحي @Audited كعقد تصريحي؛ ويطبّق AuditAspect هذا العقد بشكل موحد على كل تابع مُعلَّق. تبقى طبقة الخدمة نظيفة ومركّزة على منطق الأعمال. المثابرة غير المتزامنة الاختيارية مع REQUIRES_NEW تجعل كتابات التدقيق دائمة حتى عند تراجع معاملات الأعمال. هذا النمط — تعليق توضيحي مخصص، جانب @Around واحد، مثابرة مستقلة — قابل للتطبيق مباشرةً على التسجيل وتطبيق الأمان وتحديد المعدل والتخزين المؤقت ومنطق إعادة المحاولة وأي مخاوف تنتمي إلى البنية التحتية لا إلى النطاق.