سياق المستخدم دون الإخلال بتصميمك

باختصار: تمرير معرّفات المستخدمين عبر الكود الخاص بك يعمل – لكن MDC يسمح لسجلاتك بحمل سياق المستخدم دون تلويث منطق الأعمال.

في الجزء الأول من هذه السلسلة (→ لماذا تتوقف السجلات عن كونها مفيدة في الأنظمة الحقيقية)، رأينا كيف تنهار أنماط التسجيل الأساسية بمجرد دخول التزامن والتأخيرات والمستخدمين المتعددين إلى الصورة.

في تلك المرحلة، كانت السجلات لا تزال صحيحة تقنيًا، لكنها لم تعد تسمح لنا بإعادة بناء ما حدث لمستخدم واحد أو طلب واحد. لننتقل إلى الخطوة التالية.

معرّف المستخدم كسياق تشخيصي

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

  • اسم مستخدم،
  • رقم النظام،
  • أو عنوان بريد إلكتروني.

في مثالنا، إنه عنوان بريد إلكتروني: mamian@zoo.gov.eu

للاستفادة من هذه المعلومات، نحتاج إلى طريقة لتصفية السجلات حسب معرّف المستخدم.

إذا كان الكود الإشكالي لا يمكن تفعيله إلا بواسطة مستخدم مُصادق عليه، فإننا بالفعل أقرب إلى الهدف. منذ لحظة تسجيل المستخدم لدخوله والمصادقة عليه، يتم تنفيذ كل ما يحدث في سياقه الخاص.

لذا، دعونا نحاول استخدام ذلك.

❌ المشكلة: تمرير userId في كل مكان

النهج الأكثر مباشرة هو تمرير userId بشكل صريح وقم بتضمينه في كل عبارة سجل:

public void modifyOrder(String userId) { try { var priceOfItem = getPriceOfItem(userId); var quantity = getQuantity(userId); var totalPrice = calculateTotalPrice(userId, priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure: user (id = {})", userId); } } private int getPriceOfItem(String userId) { var priceOfItem = repository.getPriceOfItem(userId); logger.info("User (id = {}) called: Price of item = {}", userId, priceOfItem); return priceOfItem; } private int getQuantity(String userId) { var quantity = repository.getQuantity(userId); logger.info("User (id = {}) called: Items in order = [{}]", userId, quantity); return quantity; } private int calculateTotalPrice(String userId, int priceOfItem, int quantity) { var totalPrice = calculator.calculate(userId, price, quantity); logger.info("User (id = {}) called: Total price = {}", userId, totalPrice); return totalPrice; }

هذا النهج ناجح، لكنه سرعان ما يصبح مشكلة.

تمرير userId إضافة إلى كل طريقة تصبح تصميماً ضعيفاً:

  • ماذا لو أردنا لاحقاً استبدالسلسلة نصية بريد إلكتروني يحتوي على معرّف فريد لقاعدة البيانات UUID أومعرّف المستخدم كائن القيمة؟
  • ماذا لو نسينا تمريره إلى إحدى الطرق، أو نسينا تسجيله؟
  • ماذا لو بدأت مخاوف التسجيل تتسرب إلى منطق النطاق النظيف؟

عمليًا، يصبح هذا الحل مُرهقًا بسرعة مفاجئة.

✅ الحل: سياق التشخيص المعيّن (MDC)

سياق التشخيص المُعيّن (MDC) هو أداة مساعدة صغيرة لكنها قوية مدمجة في جميع مكتبات تسجيل السجلات الشائعة بلغة Java. فهو يتيح لنا تخزين قيم التشخيص في ذاكرة محلية للخيط، منفصلة لكل خيط تنفيذ.

الفكرة بسيطة:

  • عند بدء التنفيذ، نضع بيانات تشخيصية مفيدة في MDC،
  • عند انتهاء التنفيذ، نقوم بتنظيف MDC.

نظرًا لأن MDC محلي بالنسبة للخيط:

  • لا يمكن أن تتسرب البيانات التشخيصية من تنفيذ إلى آخر،
  • لكن نسيان تنظيفه أمر خطير، لأن الخيوط يُعاد استخدامها.

حيث يتناسب MDC بشكل طبيعي

في التطبيقات القائمة على Spring، يكون حد التنفيذ الأكثر شيوعاً هو طلب HTTP. وهذا يجعل معالجة الطلبات مكاناً مثالياً لتهيئة MDC في البداية وتنظيفه عند إرسال الاستجابة.

ينطبق نمط مشابه على:

  • المهام المجدولة،
  • مستهلكو قوائم الانتظار،
  • أو أي تنفيذ له بداية ونهاية واضحتان.

إليك مثال يتعامل مع دورة حياة MDC بشكل صحيح:

  1. تهيئة الفلتر الذي يُحقن لاحقاً تلقائياً في كل تدفق استدعاء لواجهة REST API.
  2. جمع المعلومات حول المستخدم المسجّل الدخول.
  3. ضع المستخدم المسجّل دخوله في MDC في البداية، ثم نظّف MDC في النهاية.
  4. الـ أخيراً كتلة الكود حاسمة هنا – فهي تضمن تنظيف MDC دائمًا، حتى عند حدوث استثناء.
@Component public class GlobalLoggingRequestFilter extends OncePerRequestFilter { private final AuthenticationService authService; public GlobalLoggingRequestFilter(AuthenticationService authService) { this.authService = authService; } private Optional<String> extractUserIdFrom(HttpServletRequest request) { // In a real application, this service call would parse JWT // or look up session data based on the request. return authService.extractUserId(request); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // init MDC with user ID extractUserIdFrom(request).ifPresent(userId -> MDC.put("userId", userId)); // let the business logic be executed filterChain.doFilter(request, response); } finally { // finish and clean MDC context MDC.clear(); } } }

❌ المشكلة: اقتران MDC بكود الأعمال

في هذه المرحلة، أصبح معرّف المستخدم متاحًا عبر MDC، لذا لم نعد بحاجة إلى تمريره عبر معاملات الدالة.

ومع ذلك، إذا وصلنا إلى MDC يدويًا في كل عبارة سجل، فإننا نقدم شكلاً جديدًا من الاقتران:

public void modifyOrder() { try { var priceOfItem = getPriceOfItem(); var quantity = getQuantity(); var totalPrice = calculateTotalPrice(priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure: user (id = {})", MDC.get("userId")); } } private int getPriceOfItem() { var priceOfItem = repository.getPriceOfItem(); logger.info("User (id = {}) called: Price of item = {}", MDC.get("userId"), priceOfItem); return priceOfItem; } private int getQuantity() { var quantity = repository.getQuantity(); logger.info("User (id = {}) called: Items in order = [{}]", MDC.get("userId"), quantity); return quantity; } private int calculateTotalPrice(int priceOfItem, int quantity) { var totalPrice = calculator.calculate(price, quantity); logger.info("User (id = {}) called: Total price = {}", MDC.get("userId"), totalPrice); return totalPrice; }

لا يزال هذا يلوّث كود التسجيل، ويربط منطق الأعمال بالبنية التحتية للتسجيل، ويتطلب الانضباط في كل مكان.

✅ الحل: دع المسجّل يقرأ MDC نيابة عنا

لحسن الحظ، يمكن لـ Logback قراءة قيم MDC تلقائياً. نحتاج فقط إلى تحديث نمط التسجيل:

%d{ISO8601} %-5level ${PID} [t=%thread] %-48logger{48} %msg : [u=%X{userId}] %n%throwable | | | +--- الوصول إلى المتغير من MDC | …يستخدم محدد التحويل %X | …الوصول إلى userId في حالتنا | +--- فاصل مرئي

هنا، %X{userId} يوجّه Logback لقراءة userId القيمة من MDC وإضافتها إلى كل سطر سجل. لا حاجة لتغيير استدعاءات التسجيل.

كود أعمال أنظف، سجلات أغنى

مع تطبيق هذا الإعداد، يصبح كود التطبيق نظيفًا مرة أخرى:

public void modifyOrder() { try { var priceOfItem = getPriceOfItem(); var quantity = getQuantity(); var totalPrice = calculateTotalPrice(priceOfItem, quantity); updateWith(totalPrice); } catch (OrderException e) { log.error("Failure"); } } private int getPriceOfItem() { var priceOfItem = repository.getPriceOfItem(userId); logger.info("Price of item = {}", priceOfItem); return priceOfItem; } private int getQuantity() { var quantity = repository.getQuantity(userId); logger.info("Items in order = [{}]", quantity); return quantity; } private int calculateTotalPrice(int priceOfItem, int quantity) { var totalPrice = calculator.calculate(userId, price, quantity); logger.info("Total price = {}", totalPrice); return totalPrice; }

وتتضمن السجلات تلقائيًا سياق المستخدم:

2025-10-20 00:29:11,300 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 17 : [u=ferdynand@oo.pl2025-10-20 00:29:11,356 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [3] : [u=ferdynand@oo.pl] 2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl] 2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu] 2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu2025-10-20 00:29:16,884 INFO 214731 [t=Thread-1] demo.PrintingDemo سعر العنصر = 81 : [u=mr.1337@pwnd.it2025-10-20 00:29:16,959 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [1] : [u=mr.1337@pwnd.it] 2025-10-20 00:29:16,979 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 81 : [u=mr.1337@pwnd.it]

تصفية السجلات حسب معرّف المستخدم تكشف فوراً التنفيذ الإشكالي:

2025-10-20 00:29:14,540 INFO 214731 [t=Thread-1] demo.PrintingDemo Price of item = 76 : [u=mamian@zoo.gov.eu2025-10-20 00:29:14,682 INFO 214731 [t=Thread-1] demo.PrintingDemo Items in order = [2] : [u=mamian@zoo.gov.eu] 2025-10-20 00:29:14,810 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = -152 : [u=mamian@zoo.gov.eu]

هناك خطأ ما بوضوح – والآن أصبح من السهل اكتشافه.

معرّف المستخدم مقابل GDPR

اعتمادًا على القطاع، قد يكون تسجيل معرّف المستخدم مقيدًا بموجب GDPR أو لوائح أخرى.

في مثل هذه الحالات، لا يزال بإمكانك تسجيل بيانات وصفية غير محددة للهوية، مثل:

  • دور المستخدم،
  • اللغة المحددة،
  • البلد،
  • وضع واجهة المستخدم،
  • نوع الجهاز.

على سبيل المثال، بدلاً من:

2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [u=ferdynand@oo.pl]

قد تسجّل:

2025-10-20 00:29:11,441 INFO 214731 [t=Thread-1] demo.PrintingDemo Total price = 51 : [role=CUSTOMER,lang=pl_PL,lc=pl,mode=DARK,dvc=MOBILE]

عملياً، يوفر هذا عادةً سياقاً كافياً لفهم المشكلة، دون تحديد هوية شخص معين.

إلى أين يوصلنا هذا – وإلى أين لا يوصلنا

تصفية السجلات حسب معرّف المستخدم يحل العديد من مشاكل الإنتاج الفعلية. يتيح لنا ذلك:

  • ربط الأحداث لمستخدم واحد،
  • تحديد السلوك غير الصحيح بسرعة،
  • تقليل الوقت المستغرق في إعادة بناء مسارات التنفيذ يدوياً.

ومع ذلك، لا يزال هذا النهج محدوداً. ماذا يحدث عندما:

  • المستخدمون غير مصادَق عليهم،
  • يمتد التنفيذ عبر خيوط متعددة،
  • يتم تنفيذ العمل بشكل غير متزامن؟

لنُحسّن حلّنا مرة أخرى، أليس كذلك؟

ما التالي

→ الجزء 3: معرفات الارتباط والتتبع الشامل من البداية إلى النهاية