معالجة الأخطاء والتحقق من المدخلات
لا يكتفي التطبيق الجاهز للإنتاج بالعمل الصحيح حين تكون المدخلات مثالية — بل يتحمّل الفشل بأناقة حين لا تكون كذلك، ويعرض رسائل قابلة للتصرّف على المستدعين، ولا يكشف أبدًا عن حالته الداخلية للعالم الخارجي. يبني هذا الدرس منهجيةً منظّمة لمعالجة الأخطاء والتحقق من المدخلات تمتد عبر كل طبقة في تطبيق الكابستون.
لماذا تهم استراتيجية الأخطاء متعددة الطبقات؟
لكل طبقة معمارية مسؤوليات مختلفة وأنماط فشل مختلفة:
- طبقة النطاق — ترفض الحالة غير الصالحة دلاليًا (مثل: رصيد حساب سالب).
- طبقة البيانات — تُغلّف أخطاء JDBC والمثابرة في استثناءات ذات معنى نطاقي.
- طبقة الخدمة/الأعمال — تنسّق قواعد النطاق وتشير إلى انتهاكات الأعمال (مثل: كيان غير موجود، مفتاح مكرّر).
- طبقة الواجهة — تحوّل كل استثناء إلى استجابة مقروءة للإنسان أو الآلة؛ وهي الطبقة الوحيدة التي يُسمح لها بالصيد الشامل.
السماح لـ SQLException بالصعود إلى واجهة المستخدم، أو ابتلاع الاستثناءات بصمت، يكسر هذا العقد ويجعل النظام عسير التصحيح والصيانة.
تصميم تسلسل استثناءات مخصص
ابدأ بتسلسل هرمي خفيف يتجذّر في فئة قاعدة واحدة غير مفحوصة. افضّل الاستثناءات غير المفحوصة لأخطاء التطبيق لأن المستدعين نادرًا ما يستطيعون الاسترداد في موقع الاستدعاء، والاستثناءات المفحوصة تضيف احتكاكًا دون أن تضيف أمانًا في معظم التصميمات الحديثة.
// القاعدة — كل استثناء في الكابستون يمتد من هذه الفئة
public abstract class AppException extends RuntimeException {
private final String errorCode;
protected AppException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
protected AppException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// ---- الاستثناءات الفعلية ----
public class EntityNotFoundException extends AppException {
public EntityNotFoundException(String entity, Object id) {
super("NOT_FOUND", entity + " with id " + id + " does not exist.");
}
}
public class ValidationException extends AppException {
private final List<String> violations;
public ValidationException(List<String> violations) {
super("VALIDATION_ERROR", "Input validation failed.");
this.violations = List.copyOf(violations);
}
public List<String> getViolations() { return violations; }
}
public class DuplicateEntityException extends AppException {
public DuplicateEntityException(String entity, String field, Object value) {
super("DUPLICATE", entity + " with " + field + " '" + value + "' already exists.");
}
}
public class DataAccessException extends AppException {
public DataAccessException(String message, Throwable cause) {
super("DATA_ACCESS_ERROR", message, cause);
}
}
احرص على استقرار errorCode وقابليته للقراءة آليًا. رموز السلاسل مثل "NOT_FOUND" تتيح لعملاء الـ API التفريع على الرمز دون تحليل الرسالة البشرية التي قد تتغير بين الإصدارات.
التحقق من المدخلات: الفشل السريع عند الحدود
لا تدع البيانات غير الصالحة تنتقل إلى أعمق من الطبقة الأولى القادرة على اكتشافها. اكتب أداة تحقق صغيرة تجمع كل الانتهاكات في تمريرة واحدة بدلًا من التوقف عند الأول، حتى يتلقى المستدعون صورة كاملة.
public final class Validator {
private final List<String> violations = new ArrayList<>();
public Validator requireNonBlank(String value, String fieldName) {
if (value == null || value.isBlank()) {
violations.add(fieldName + " must not be blank.");
}
return this;
}
public Validator requirePositive(Number value, String fieldName) {
if (value == null || value.doubleValue() <= 0) {
violations.add(fieldName + " must be a positive number.");
}
return this;
}
public Validator requireMaxLength(String value, int max, String fieldName) {
if (value != null && value.length() > max) {
violations.add(fieldName + " must not exceed " + max + " characters.");
}
return this;
}
/** يرمي ValidationException إذا جُمعت أي انتهاكات. */
public void validate() {
if (!violations.isEmpty()) {
throw new ValidationException(violations);
}
}
}
الاستخدام داخل دالة خدمة:
public Product createProduct(String name, double price, int stock) {
new Validator()
.requireNonBlank(name, "name")
.requirePositive(price, "price")
.requirePositive(stock, "stock")
.requireMaxLength(name, 120, "name")
.validate(); // يرمي إذا كان أي حقل غير صالح
// المنطق التجاري يستمر فقط مع بيانات نظيفة
return productRepository.save(new Product(name, price, stock));
}
اجمع كل الانتهاكات لا الأول فقط. النموذج الذي يعرض خطأً واحدًا في كل مرة يُجبر المستخدم على الإرسال مرارًا. جمع كل شيء في تمريرة واحدة تجربة مستخدم أفضل بكثير.
تغليف استثناءات طبقة البيانات
يرمي JDBC الاستثناء SQLException وهو استثناء مفحوص يحمل تفاصيل منخفضة المستوى (رسائل المشغّل، رموز حالة SQL). يجب ألا تعرف طبقة الخدمة أي محرك قاعدة بيانات تستخدمه. ترجم عند حدود المستودع:
public class ProductRepository {
public void save(Product product) {
String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";
try (var conn = DataSource.getConnection();
var ps = conn.prepareStatement(sql)) {
ps.setString(1, product.getName());
ps.setDouble(2, product.getPrice());
ps.setInt(3, product.getStock());
ps.executeUpdate();
} catch (SQLException e) {
// رمز خطأ MySQL/MariaDB 1062 = إدخال مكرّر (حالة SQL 23000)
if ("23000".equals(e.getSQLState())) {
throw new DuplicateEntityException("Product", "name", product.getName());
}
throw new DataAccessException("Failed to save product.", e);
}
}
public Optional<Product> findById(long id) {
String sql = "SELECT id, name, price, stock FROM products WHERE id = ?";
try (var conn = DataSource.getConnection();
var ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("Failed to fetch product " + id, e);
}
}
private Product mapRow(ResultSet rs) throws SQLException {
return new Product(
rs.getLong("id"),
rs.getString("name"),
rs.getDouble("price"),
rs.getInt("stock")
);
}
}
مركزة معالجة الأخطاء في طبقة الواجهة
بدلًا من إحاطة كل استدعاء خدمة بـ try/catch، أحِل كل الاستثناءات عبر معالج واحد. في واجهة وحدة التحكم أو الـ CLI يكون هذا موزّعًا على المستوى الأعلى؛ في واجهة HTTP API يكون مُعيِّن استثناء عالميًا.
public class ErrorHandler {
private static final System.Logger LOG =
System.getLogger(ErrorHandler.class.getName());
public static void handle(Runnable operation) {
try {
operation.run();
} catch (ValidationException ex) {
System.err.println("[VALIDATION ERROR]");
ex.getViolations().forEach(v -> System.err.println(" - " + v));
} catch (EntityNotFoundException ex) {
System.err.println("[NOT FOUND] " + ex.getMessage());
} catch (DuplicateEntityException ex) {
System.err.println("[DUPLICATE] " + ex.getMessage());
} catch (DataAccessException ex) {
LOG.log(System.Logger.Level.ERROR, "Data access failure", ex);
System.err.println("[SYSTEM ERROR] A database error occurred. Please try again.");
} catch (AppException ex) {
System.err.println("[ERROR " + ex.getErrorCode() + "] " + ex.getMessage());
} catch (Exception ex) {
LOG.log(System.Logger.Level.ERROR, "Unexpected error", ex);
System.err.println("[INTERNAL ERROR] An unexpected error occurred.");
}
}
}
موقع الاستدعاء — تبقى طبقة الواجهة نظيفة:
ErrorHandler.handle(() -> {
var product = productService.findById(42L)
.orElseThrow(() -> new EntityNotFoundException("Product", 42L));
System.out.println(product);
});
لا تكشف أبدًا تتبعات المكدّس أو الرسائل الداخلية للمستخدمين النهائيين. سجّل التفاصيل الكاملة (مع سلسلة الأسباب الأصلية) في سجل آمن، لكن اعرض خارجيًا رسالة عامة منقّحة فحسب. تتبّع المكدّس خارطة طريق للمهاجم.
استخدام Optional للتخلص من فحوص null
يجب أن تعيد دوال المستودع Optional<T> بدلًا من null حين قد لا يوجد الكيان. هذا يجبر المستدعين على التعامل صراحةً مع حالة الغياب:
// تحوّل الخدمة Optional إلى استثناء نطاقي — يتلقى المستدعون إشارة واضحة
public Product requireProduct(long id) {
return productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product", id));
}
استجابات أخطاء منظّمة لواجهات API
إذا كانت طبقة الواجهة عبارة عن HTTP API (مثلًا باستخدام com.sun.net.httpserver أو إطار عمل)، فقيّس شكل استجابة الخطأ حتى يتمكن العملاء من تحليلها بموثوقية:
// سجل بسيط يُستخدم كغلاف JSON للأخطاء
public record ErrorResponse(
String errorCode,
String message,
List<String> details, // null ما لم يكن خطأ تحقق
Instant timestamp
) {}
الخلاصة
تقوم استراتيجية الأخطاء الصلبة على ثلاثة أعمدة: تسلسل استثناءات مكتوبة يحمل رموزًا ثابتة ورسائل ذات معنى؛ تحقق مبكر يجمع كل الانتهاكات قبل تغيير أي حالة؛ ومعالج مركزي عند حدود الواجهة يسجّل التفاصيل الكاملة داخليًا ويكشف للخارج فقط رسائل آمنة وقابلة للتصرّف. تجعل هذه الأعمدة مجتمعةً التطبيق سهل التصحيح والصيانة والتشغيل الآمن في الإنتاج.