البرمجة الجانبية في Spring

AOP التطبيقي: التسجيل والتدقيق

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

AOP التطبيقي: التسجيل والتدقيق

تتجلّى القوة الحقيقية لـ AOP حين تجلس لحلّ مشكلة فعلية. التسجيل والتدقيق مثالان كلاسيكيان — فهما مطلوبان في كل مكان، ومتكرّران، ودمجهما في كود الأعمال يجعل الاختبار والتعديل أصعب في الحالتين. في هذا الدرس ستبني جانبًا (Aspect) للتسجيل والتدقيق بمستوى الإنتاج باستخدام Spring AOP. ستتعلم كيف تستخرج توقيعات الدوال، وتلتقط الوسيطات وقيم الإرجاع، وتعالج الاستثناءات، وتكتب سجلات تدقيق منظّمة — كل ذلك دون سطر واحد من كود التتبّع داخل كلاسات الخدمة.

لماذا يجب أن يكون التسجيل في جانب منفصل

تأمّل كيف تبدو دالة خدمة "مُسجَّلة" نموذجية دون AOP:

public OrderDto createOrder(CreateOrderRequest request) { log.info("createOrder called by user={} with itemCount={}", currentUser(), request.getItems().size()); long start = System.currentTimeMillis(); try { OrderDto result = orderService.create(request); log.info("createOrder succeeded in {}ms, orderId={}", System.currentTimeMillis() - start, result.getId()); return result; } catch (Exception ex) { log.error("createOrder failed for user={}: {}", currentUser(), ex.getMessage(), ex); throw ex; } }

هذا ضوضاء بحتة. منطق الأعمال مدفون وسط استدعاءات التوقيت والتسجيل. اضرب هذا في خمسين دالة خدمة وستحصل على مشكلة صيانة. الجانب يُركّز كل هذا في مكان واحد.

الإعداد: نهج التعليق التوضيحي المخصّص

بدلًا من تسجيل كل دالة في التطبيق، النمط الاحترافي هو استخدام تعليق توضيحي (annotation) كعلامة. الدوال التي تحتاج إلى تسجيل تُعلَّم فحسب؛ والجانب لا يعترض سواها.

أولًا، عرّف التعليق التوضيحي:

package com.example.aop; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Audited { String action() default ""; // مثل "CREATE_ORDER"، "DELETE_USER" }

RetentionPolicy.RUNTIME إلزامي — يقرأ Spring AOP التعليقات التوضيحية في وقت التشغيل عبر الانعكاس (reflection). أما ElementType.METHOD فيقصر التعليق على الدوال فقط.

بناء جانب التسجيل

يستخدم الجانب نصيحة @Around لالتقاط وقت البداية والنتيجة (نجاح أو استثناء) في دالة واحدة:

package com.example.aop; import lombok.extern.slf4j.Slf4j; 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.stereotype.Component; import java.util.Arrays; @Aspect @Component @Slf4j public class AuditLoggingAspect { @Around("@annotation(audited)") public Object auditMethod(ProceedingJoinPoint pjp, Audited audited) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); String cls = sig.getDeclaringType().getSimpleName(); String method = sig.getName(); String action = audited.action().isEmpty() ? method : audited.action(); Object[] args = pjp.getArgs(); log.info("[AUDIT] action={} class={} method={} args={}", action, cls, method, Arrays.toString(args)); long start = System.nanoTime(); try { Object result = pjp.proceed(); long elapsedMs = (System.nanoTime() - start) / 1_000_000; log.info("[AUDIT] action={} SUCCESS durationMs={} result={}", action, elapsedMs, summarise(result)); return result; } catch (Throwable ex) { long elapsedMs = (System.nanoTime() - start) / 1_000_000; log.error("[AUDIT] action={} FAILURE durationMs={} error={}", action, elapsedMs, ex.getMessage(), ex); throw ex; // أعد رميها حتى يتمكّن المستدعي من معالجتها } } private String summarise(Object obj) { if (obj == null) return "null"; String s = obj.toString(); return s.length() > 120 ? s.substring(0, 120) + "..." : s; } }
ربط التعليق التوضيحي بمعامل النصيحة: اسم المعامل audited في auditMethod(ProceedingJoinPoint pjp, Audited audited) يجب أن يطابق الاسم في تعبير pointcut وهو @annotation(audited). يستخدم Spring AOP هذا الربط لحقن نسخة التعليق التوضيحي الفعلية، مما يمنحك الوصول إلى خصائصه مثل audited.action() دون استدعاءات انعكاس إضافية.

تطبيق التعليق التوضيحي على دوال الخدمة

الآن علّم الدوال التي تحتاج إلى مسارات تدقيق:

@Service public class OrderService { @Audited(action = "CREATE_ORDER") public OrderDto createOrder(CreateOrderRequest request) { // منطق أعمال خالص، لا كود تسجيل return orderRepository.save(mapper.toEntity(request)); } @Audited(action = "CANCEL_ORDER") public void cancelOrder(Long orderId) { orderRepository.cancelById(orderId); } }

كلاس الخدمة نظيف الآن. كل سلوك المراقبة يعيش في الجانب.

حفظ سجلات التدقيق في قاعدة البيانات

لسيناريوهات الامتثال والأمان، سطور السجل وحدها لا تكفي — تحتاج إلى سجلات تدقيق دائمة وقابلة للاستعلام. وسّع الجانب ليكتب في جدول AuditLog:

// كيان AuditLog (Jakarta Persistence / Spring Data JPA) @Entity @Table(name = "audit_logs") public class AuditLog { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String action; private String className; private String methodName; private String args; // JSON أو toString مقتطع private String outcome; // SUCCESS | FAILURE private Long durationMs; private String errorMessage; private Instant performedAt; // getters/setters أو استخدم Lombok @Data } // Repository public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {}

احقن المستودع في الجانب واحفظ بعد كل استدعاء:

@Aspect @Component @Slf4j @RequiredArgsConstructor public class AuditLoggingAspect { private final AuditLogRepository auditLogRepo; @Around("@annotation(audited)") public Object auditMethod(ProceedingJoinPoint pjp, Audited audited) throws Throwable { MethodSignature sig = (MethodSignature) pjp.getSignature(); String action = audited.action().isEmpty() ? sig.getName() : audited.action(); long start = System.nanoTime(); String outcome; String errorMsg = null; try { Object result = pjp.proceed(); outcome = "SUCCESS"; return result; } catch (Throwable ex) { outcome = "FAILURE"; errorMsg = ex.getMessage(); throw ex; } finally { long elapsedMs = (System.nanoTime() - start) / 1_000_000; persist(sig, action, Arrays.toString(pjp.getArgs()), outcome, elapsedMs, errorMsg); } } private void persist(MethodSignature sig, String action, String args, String outcome, long durationMs, String errorMsg) { try { AuditLog entry = new AuditLog(); entry.setAction(action); entry.setClassName(sig.getDeclaringType().getSimpleName()); entry.setMethodName(sig.getName()); entry.setArgs(args.length() > 500 ? args.substring(0, 500) : args); entry.setOutcome(outcome); entry.setDurationMs(durationMs); entry.setErrorMessage(errorMsg); entry.setPerformedAt(Instant.now()); auditLogRepo.save(entry); } catch (Exception e) { // لا تسمح لآلية التدقيق أن تُسقط الخيط المُستدعي أبدًا log.warn("Failed to persist audit record for action={}: {}", action, e.getMessage()); } } }
غلّف الحفظ بـ try-catch خاص به. إذا رمى auditLogRepo.save() استثناءً (مثلًا إذا كانت قاعدة البيانات معطّلة)، يجب ألّا تُشيع هذا الاستثناء — فعلك سيحوّل عملية أعمال ناجحة إلى فشل ظاهر. التدقيق بنية تحتية للمراقبة؛ يجب ألّا يكسر أبدًا التدفقات الأساسية.

إضافة المستخدم الحالي إلى سجلات التدقيق

سجلات التدقيق الأكثر فائدة هي تلك التي تلتقط من نفّذ الإجراء. في تطبيقات Spring Security، يتوفّر المستخدم الحالي عبر SecurityContextHolder:

private String currentUser() { var auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) return "anonymous"; return auth.getName(); }

أضف عمودًا performedBy إلى AuditLog واملأه من currentUser() داخل الجانب. هذا يمنحك مسار تدقيق كاملًا: من فعل ماذا، متى، كم استغرق، وهل نجح.

تسجيل دخول الدالة وخروجها بـ @Before / @AfterReturning

أحيانًا تريد تسجيلًا أخفّ وزنًا على مستوى الطبقة — سجلات دخول/خروج بمستوى trace للتصحيح — دون تكلفة @Around. يمكنك الجمع بين @Before و@AfterReturning على pointcut محدّد الحزمة:

@Aspect @Component @Slf4j public class TraceLoggingAspect { @Pointcut("execution(* com.example.service..*(..))") private void serviceLayer() {} @Before("serviceLayer()") public void logEntry(JoinPoint jp) { if (log.isDebugEnabled()) { log.debug("--> {}.{}({})", jp.getTarget().getClass().getSimpleName(), jp.getSignature().getName(), Arrays.toString(jp.getArgs())); } } @AfterReturning(pointcut = "serviceLayer()", returning = "result") public void logExit(JoinPoint jp, Object result) { if (log.isDebugEnabled()) { log.debug("<-- {}.{} returned {}", jp.getTarget().getClass().getSimpleName(), jp.getSignature().getName(), result); } } }
احمِ تسجيل debug بـ isDebugEnabled(). بناء سلسلة الوسيطات (خاصةً Arrays.toString(jp.getArgs())) له تكلفة صغيرة لكنها حقيقية. تغليفها بفحص المستوى يعني صفر تكلفة حين يكون مستوى السجل INFO أو أعلى في الإنتاج.

مقارنة بين الخيارات التصميمية

  • قائم على التعليقات التوضيحية مقابل نطاق الحزمة: التعليقات التوضيحية صريحة — يختار المطوّر وعيًا الانضمام، لذا لا يُدقَّق في دوال بالخطأ. نطاقات الحزمة ضمنية لكن شاملة — مفيدة للتتبّع، لكنها محفوفة بالمخاطر لمسارات التدقيق التي تحتاج سيطرة دقيقة.
  • الوسيطات الحسّاسة: لا تسجّل كلمات المرور أو الرموز أو المعلومات الشخصية خامًا أبدًا. طبّق أداة إخفاء قبل تسجيل الوسيطات، أو استخدم تعليقًا توضيحيًا منفصلًا مثل @Masked على المعاملات.
  • الحفظ غير المتزامن: في الخدمات ذات الإنتاجية العالية، الكتابة المتزامنة في جدول التدقيق تضيف زمن استجابة لكل طلب. فكّر في نشر AuditEvent في قائمة أحداث أو قائمة رسائل والحفظ خارج المسار الحرج.
  • حدود المعاملات: يعمل save() للتدقيق داخل معاملة الدالة المستدعِية افتراضيًا. إذا تراجعت معاملة الأعمال فسيتراجع معها سجل التدقيق. استخدم @Transactional(propagation = Propagation.REQUIRES_NEW) على دالة الحفظ إذا أردت بقاء سجلات التدقيق حتى عند التراجع.

الخلاصة

جانب التسجيل والتدقيق هو أحد أعلى الاهتمامات المشتركة قيمةً يمكنك إدخاله إلى تطبيق Spring Boot. بدمج تعليق توضيحي @Audited مخصّص مع نصيحة @Around، تلتقط نقطة الدخول والنتيجة والمدة وهوية المستدعي في مكان واحد قابل لإعادة الاستخدام. تبقى كلاسات الخدمة نظيفة، والاختبارات تتمحور حول منطق الأعمال، ويمكن أن تتطوّر سياسة التدقيق — مزيد من الأعمدة، حفظ غير متزامن، مصبّات خارجية — دون لمس خدمة واحدة. في الدرس القادم ستطبّق النمط ذاته على مراقبة الأداء والأمان على مستوى الدالة.