انتقل إلى المحتوى الرئيسي

TP10.2 - نظام تسجيل الدخول الاحترافي

أهداف التمرين​

في هذا التمرين، سوف تقوم بـ:

  1. ✅ تكوين Logback باستخدام logback-spring.xml
  2. ✅ إنشاء تكوينات لكل بيئة
  3. ✅ تنفيذ MDC للتتبع
  4. ✅ تكوين تدوير ملفات السجلات
  5. ✅ إضافة سجلات منظمة في التطبيق

المدة المقدرة: 1 ساعة و30 دقيقة


السياق​

تقوم بإعادة استخدام تطبيق Bookstore من TP10.1 وتضيف نظام تسجيل دخول احترافي للمراقبة وتصحيح الأخطاء في الإنتاج.


الجزء 1: تكوين Logback​

إنشاء logback-spring.xml​

قم بإنشاء src/main/resources/logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

<!-- استيراد الافتراضيات من Spring Boot -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<!-- الخصائص -->
<springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="bookstore"/>
<property name="LOG_PATH" value="${LOG_PATH:-logs}"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{requestId:-N/A}] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n"/>

<!-- ===== CONSOLE APPENDER ===== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%X{requestId:-}] %highlight(%-5level) %cyan(%logger{36}) - %msg%n</pattern>
</encoder>
</appender>

<!-- ===== FILE APPENDER ===== -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>

<!-- ===== ERROR FILE APPENDER ===== -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>ERROR</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>

<!-- TODO: إضافة ملفات التعريف dev، prod، test -->

</configuration>

الجزء 2: ملفات تعريف تسجيل الدخول​

إضافة ملفات التعريف في logback-spring.xml​

    <!-- ===== PROFIL DEV ===== -->
<springProfile name="dev,local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<!-- التطبيق في وضع DEBUG -->
<logger name="com.bookstore" level="DEBUG"/>

<!-- استعلامات SQL -->
<logger name="org.hibernate.SQL" level="DEBUG"/>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE"/>

<!-- Spring Web -->
<logger name="org.springframework.web" level="DEBUG"/>
</springProfile>

<!-- ===== PROFIL PROD ===== -->
<springProfile name="prod">
<root level="WARN">
<appender-ref ref="FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>

<logger name="com.bookstore" level="INFO"/>
</springProfile>

<!-- ===== PROFIL TEST ===== -->
<springProfile name="test">
<root level="WARN">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.bookstore" level="INFO"/>
</springProfile>

<!-- ===== DEFAULT ===== -->
<springProfile name="default">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<logger name="com.bookstore" level="DEBUG"/>
</springProfile>

الجزء 3: فلتر MDC​

MdcLoggingFilter​

package com.bookstore.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class MdcLoggingFilter extends OncePerRequestFilter {

private static final String REQUEST_ID = "requestId";
private static final String USER_ID = "userId";
private static final String PATH = "path";
private static final String METHOD = "method";

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {

long startTime = System.currentTimeMillis();

try {
// توليد أو استرجاع معرف الطلب
String requestId = request.getHeader("X-Request-ID");
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString().substring(0, 8);
}

// إضافة إلى MDC
MDC.put(REQUEST_ID, requestId);
MDC.put(PATH, request.getRequestURI());
MDC.put(METHOD, request.getMethod());

// إضافة معرف المستخدم إذا كان مصادقًا
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
MDC.put(USER_ID, auth.getName());
}

// إضافة معرف الطلب في الاستجابة
response.setHeader("X-Request-ID", requestId);

// متابعة السلسلة
filterChain.doFilter(request, response);

} finally {
// تسجيل الطلب المكتمل
long duration = System.currentTimeMillis() - startTime;
logger.info("Request completed: {} {} - Status: {} - Duration: {}ms",
MDC.get(METHOD), MDC.get(PATH), response.getStatus(), duration);

// مهم: تنظيف MDC دائمًا!
MDC.clear();
}
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// عدم تصفية الموارد الثابتة
String path = request.getRequestURI();
return path.startsWith("/css/")
|| path.startsWith("/js/")
|| path.startsWith("/images/")
|| path.equals("/favicon.ico");
}
}

الجزء 4: إضافة سجلات في الكود​

BookService مع تسجيل الدخول​

package com.bookstore.service;

import com.bookstore.entity.Book;
import com.bookstore.exception.*;
import com.bookstore.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class BookService {

private final BookRepository bookRepository;

public List<Book> findAll() {
log.debug("Fetching all books");
List<Book> books = bookRepository.findAll();
log.debug("Found {} books", books.size());
return books;
}

public Book findById(Long id) {
log.debug("Finding book with ID: {}", id);
return bookRepository.findById(id)
.orElseThrow(() -> {
log.warn("Book not found with ID: {}", id);
return new ResourceNotFoundException("Book", id);
});
}

@Transactional
public Book create(Book book) {
log.info("Creating new book: title='{}', author='{}', isbn='{}'",
book.getTitle(), book.getAuthor(), book.getIsbn());

if (bookRepository.existsByIsbn(book.getIsbn())) {
log.warn("Attempt to create book with duplicate ISBN: {}", book.getIsbn());
throw new ConflictException("ISBN already exists: " + book.getIsbn());
}

Book saved = bookRepository.save(book);
log.info("Book created successfully with ID: {}", saved.getId());
return saved;
}

@Transactional
public Book update(Long id, Book updated) {
log.info("Updating book with ID: {}", id);
log.debug("New values: title='{}', price={}", updated.getTitle(), updated.getPrice());

Book existing = findById(id);

if (!existing.getIsbn().equals(updated.getIsbn())
&& bookRepository.existsByIsbn(updated.getIsbn())) {
log.warn("Attempt to change ISBN to existing value: {}", updated.getIsbn());
throw new ConflictException("ISBN already exists: " + updated.getIsbn());
}

existing.setTitle(updated.getTitle());
existing.setAuthor(updated.getAuthor());
existing.setIsbn(updated.getIsbn());
existing.setPrice(updated.getPrice());
existing.setStock(updated.getStock());

Book saved = bookRepository.save(existing);
log.info("Book {} updated successfully", id);
return saved;
}

@Transactional
public void delete(Long id) {
log.info("Deleting book with ID: {}", id);
Book book = findById(id);
bookRepository.delete(book);
log.info("Book {} deleted successfully", id);
}

@Transactional
public void decreaseStock(Long id, int quantity) {
log.info("Decreasing stock for book {}: quantity={}", id, quantity);

Book book = findById(id);
log.debug("Current stock: {}", book.getStock());

if (book.getStock() < quantity) {
log.warn("Insufficient stock for book {}: available={}, requested={}",
id, book.getStock(), quantity);
throw new BusinessRuleException(
"INSUFFICIENT_STOCK",
String.format("Not enough stock. Available: %d, Requested: %d",
book.getStock(), quantity)
);
}

book.setStock(book.getStock() - quantity);
bookRepository.save(book);
log.info("Stock decreased for book {}: new stock={}", id, book.getStock());
}
}

GlobalExceptionHandler مع تسجيل الدخول​

قم بتحديث المعالج ليشمل السجلات المناسبة:

@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex, HttpServletRequest request) {
// WARN لأنها خطأ من العميل، وليس من الخادم
log.warn("Resource not found: {} at {}", ex.getMessage(), request.getRequestURI());
return ErrorResponse.of(ex.getErrorCode(), ex.getMessage(), request.getRequestURI());
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneric(Exception ex, HttpServletRequest request) {
// ERROR مع تتبع المكدس للأخطاء غير المتوقعة
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
return ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred", request.getRequestURI());
}

الجزء 5: اختبار تسجيل الدخول​

البدء مع ملف تعريف dev​

mvn spring-boot:run -Dspring-boot.run.profiles=dev

اختبار 1: إنشاء كتاب​

curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-H "X-Request-ID: TEST-001" \
-d '{
"title": "Effective Java",
"author": "Joshua Bloch",
"isbn": "9780134685991",
"price": 45.00,
"stock": 15
}'

السجلات المتوقعة:

12:00:00.123 [TEST-001] INFO  BookController - Received create book request
12:00:00.125 [TEST-001] INFO BookService - Creating new book: title='Effective Java', author='Joshua Bloch', isbn='9780134685991'
12:00:00.150 [TEST-001] INFO BookService - Book created successfully with ID: 1
12:00:00.155 [TEST-001] INFO MdcLoggingFilter - Request completed: POST /api/books - Status: 201 - Duration: 32ms

اختبار 2: كتاب غير موجود​

curl http://localhost:8080/api/books/999 \
-H "X-Request-ID: TEST-002"

السجلات المتوقعة:

12:01:00.100 [TEST-002] DEBUG BookService - Finding book with ID: 999
12:01:00.102 [TEST-002] WARN BookService - Book not found with ID: 999
12:01:00.103 [TEST-002] WARN GlobalExceptionHandler - Resource not found: Book with ID '999' not found at /api/books/999
12:01:00.105 [TEST-002] INFO MdcLoggingFilter - Request completed: GET /api/books/999 - Status: 404 - Duration: 5ms

اختبار 3: التحقق من ملفات السجل​

# التحقق من محتوى ملف السجل
cat logs/bookstore.log

# التحقق من ملف الأخطاء
cat logs/bookstore-error.log

الجزء 6: تكوين الإنتاج (إضافي)​

سجلات JSON مع Logstash Encoder​

أضف التبعية:

<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>

أضف appender في logback-spring.xml:

<!-- ===== JSON FILE APPENDER (لـ ELK/Datadog) ===== -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.json</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/archived/${APP_NAME}.%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
<maxFileSize>50MB</maxFileSize>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>path</includeMdcKeyName>
<customFields>{"app":"bookstore","env":"prod"}</customFields>
</encoder>
</appender>

<springProfile name="prod">
<root level="INFO">
<appender-ref ref="JSON_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
<logger name="com.bookstore" level="INFO"/>
</springProfile>

قائمة التحقق​

  • logback-spring.xml تم إنشاؤه ويعمل
  • ملفات تعريف dev/prod/test تم تكوينها
  • MdcLoggingFilter ينفذ requestId
  • سجلات منظمة في الخدمة
  • ملفات السجل تم إنشاؤها في /logs
  • تدوير الملفات تم تكوينه
  • X-Request-ID تم إرجاعه في رؤوس الاستجابة

🎯 الوحدة مكتملة!​

تهانينا! لقد أكملت الوحدة 10 - إدارة الاستثناءات وتسجيل الدخول.

ملخص:

  • الفصل 1: استثناءات مخصصة
  • الفصل 2: @ControllerAdvice
  • الفصل 3: SLF4J
  • الفصل 4: Logback
  • TP10.1: إدارة الأخطاء المركزية
  • TP10.2: نظام تسجيل الدخول

➡️ العودة إلى الوحدة 10


⬅️ العودة إلى خطة الدورة