JDBC ونمط DAO

معالجة استثناءات SQL وإدارة الموارد

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

معالجة استثناءات SQL وإدارة الموارد

من أكثر مصادر الأخطاء شيوعًا في كود JDBC اثنان: الموارد غير المغلقة والاستثناءات المُبتلَعة صامتًا. فكائن Connection أو PreparedStatement أو ResultSet الذي لا يُغلَق أبدًا يُسرّب واصفات الملفات (file descriptors) ومؤشرات جانب قاعدة البيانات، مما يُدهور التطبيق أو يُعطّله تحت الحمل. أما الاستثناء الذي يُمسَك ثم يُتجاهل بصمت فيُحوّل خطأً واضحًا إلى لغزٍ محير. يغطي هذا الدرس الأنماط الصحيحة — وحقيقة المقايضات — حتى تكون طبقة DAO لديك متينة في بيئة الإنتاج.

ما تحمله SQLException

java.sql.SQLException استثناء محقَّق (checked) يلفّ ثلاث قطع تشخيصية مميّزة تتجاوز نص الرسالة:

  • SQL State — كود من خمسة أحرف يحدّده معيار SQL (X/Open أو ISO). يُعرّف الحرفان الأولان فئة الخطأ: 08 = فشل الاتصال، 22 = استثناء بيانات، 23 = انتهاك قيد تكاملية، 42 = خطأ صياغة أو انتهاك صلاحية.
  • Error Code — رقم صحيح خاص بالمورّد (مثلًا MySQL 1062 = إدخال مكرر، PostgreSQL 0 إن كان مجهولًا). مفيد حين تحتاج إلى استرداد خاص بقاعدة بيانات معينة.
  • الاستثناءات المسلسلة — تعيد getNextException() استثناءات SQLException إضافية سلسلها المشغّل على الأول. كرّر المشي في السلسلة دائمًا عند التسجيل.
catch (SQLException ex) { // المشي في سلسلة الاستثناء كاملةً — كثيرًا ما يُسلسل المشغّل السبب الجذري for (SQLException e = ex; e != null; e = e.getNextException()) { logger.error("SQLState={} errorCode={} message={}", e.getSQLState(), e.getErrorCode(), e.getMessage()); } }
SQLState أكثر قابليةً للنقل من أكواد الخطأ. إن أردت اكتشاف انتهاك مفتاح مكرر عبر MySQL وPostgreSQL، فافحص SQLState 23000 أو 23505 بدلًا من أكواد الخطأ الخاصة بالمورّد التي تختلف بين قواعد البيانات.

فئات فرعية من SQLException تستحق المعرفة

منذ Java 6، تُعرّف JDBC عدة فئات فرعية مُكتَّبة تتيح لك التقاط فئات محددة دون فحص سلاسل SQLState:

  • SQLTransientException — قد تنجح العملية عند إعادة المحاولة (مثل SQLTransientConnectionException لخلل شبكة عابر).
  • SQLNonTransientException — إعادة المحاولة دون إصلاح السبب الجذري لن تُجدي (مثل خطأ في صياغة SQL أو انتهاك قيد).
  • SQLIntegrityConstraintViolationException — فئة فرعية من SQLNonTransientException؛ تُرمى عند انتهاك قيود الفريد أو المفتاح الأجنبي أو Check.
  • SQLTimeoutException — تجاوز الاستعلام المهلة المحددة عبر setQueryTimeout().
  • BatchUpdateException — تُرمى حين يفشل أحد العبارات في استدعاء executeBatch()؛ تكشف getUpdateCounts() أي الصفوف نجحت.
try { userDao.insert(user); } catch (SQLIntegrityConstraintViolationException ex) { // بريد إلكتروني مكرر — هذا خطأ منطق أعمال لا خطأ برمجي throw new DuplicateEmailException("Email already registered: " + user.getEmail(), ex); } catch (SQLTransientConnectionException ex) { // اضطراب شبكي عابر — آمن إعادة المحاولة بعد توقف قصير retryPolicy.execute(() -> userDao.insert(user)); } catch (SQLException ex) { // كل شيء آخر — رفع الاستثناء throw new DataAccessException("Unexpected database error", ex); }

try-with-resources: النمط الوحيد المقبول

قبل Java 7، كان كود JDBC يتطلب كتل finally متداخلة لضمان إغلاق كل مورد حتى عند رمي استثناء في منتصف العملية. كان ذلك الكود مطوّلًا بشكل ملحوظ وسهل الخطأ — فمن الأخطاء الشائعة إغلاق ResultSet فقط مع نسيان Statement. تُزيل try-with-resources هذه المشكلة كليًا.

أي كائن تُنفّذ فئته java.lang.AutoCloseable يمكن إدراجه في قائمة موارد عبارة try. كلٌّ من Connection وStatement وPreparedStatement وResultSet تُنفّذ AutoCloseable. تُغلَق الموارد بترتيب عكسي لترتيب التصريحResultSet أولًا ثم PreparedStatement ثم Connection — بصرف النظر عمّا إذا أُلقي استثناء.

// تُغلق الموارد الثلاثة تلقائيًا بالترتيب العكسي try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT id, email FROM users WHERE active = ?"); ) { ps.setBoolean(1, true); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { System.out.println(rs.getLong("id") + " " + rs.getString("email")); } } // يُغلق rs هنا } // يُغلق ps و conn هنا
افتح ResultSet في كتلة try متداخلة خاصة به. يُنشأ ResultSet بعد إعداد العبارة وربط المعاملات، لذا لا يمكنه مشاركة قائمة الموارد الخارجية دون تهيئة مزعجة بقيمة null. الحل الأنظف هو try (ResultSet rs = ps.executeQuery()) المتداخلة التي تجعل دورة حياة كل كائن واضحة تمامًا.

الاستثناءات المكبوتة (Suppressed Exceptions)

تعالج try-with-resources حالةً خفيةً كان نمط finally القديم يُخطئ فيها: إذا رمى جسم try استثناءً وأيضًا رمت close() استثناءً، فإن Java تُرفق استثناء الإغلاق كـاستثناء مكبوت على الأصلي عوضًا عن استبداله. يحفظ هذا الخطأ الأصلي — الشيء الذي تحتاج فعلًا لتشخيصه — مع تسجيل فشل الإغلاق أيضًا.

catch (SQLException ex) { logger.error("Primary exception: {}", ex.getMessage()); // سجّل أي استثناءات وقعت أثناء تنظيف الموارد for (Throwable suppressed : ex.getSuppressed()) { logger.warn("Suppressed during close: {}", suppressed.getMessage()); } throw new DataAccessException("Query failed", ex); }

النمط المضاد لما قبل Java 7 (ولماذا فشل)

للسياق التاريخي، إليك النمط الهشّ الذي حلّت try-with-resources محلّه. لاحظ كيف يمكن لفحص null منسي واحد أو استثناء داخل finally أن يُخفي الخطأ الأصلي أو يترك موردًا مفتوحًا:

Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = dataSource.getConnection(); ps = conn.prepareStatement("SELECT ..."); rs = ps.executeQuery(); // معالجة rs ... } catch (SQLException ex) { throw new DataAccessException(ex); } finally { // إذا رمت rs.close() استثناءً فلن تُستدعى ps.close() أبدًا — خطأ برمجي if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { conn.close(); } catch (SQLException ignored) {} }
لا تُصمت الاستثناءات أبدًا بكتلة catch فارغة أو متغير ignored مجرّد. سجّلها على الأقل بمستوى WARN. الاستثناءات المُصمَتة تجعل حوادث الإنتاج أصعب تشخيصًا بكثير وقد تشير إلى مشكلات خطيرة كتسريب في التجمّع أو معاملة فاسدة.

تغليف SQLException في استثناء وقت تشغيل

لأن SQLException استثناء محقَّق، فإنه يُسرّب تفاصيل قاعدة البيانات إلى كل طبقة في تطبيقك إن سمحت له بالانتشار بلا غطاء. يُغلّفه نمط DAO المعياري في استثناء غير محقَّق خاص بالمجال (domain-specific) قبل عبوره حدود DAO:

// غلاف غير محقَّق مخصص — يعيش في حزمة persistence لديك public class DataAccessException extends RuntimeException { public DataAccessException(String message, Throwable cause) { super(message, cause); } } // داخل أسلوب DAO public User findById(long id) { String sql = "SELECT id, email, name FROM users WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return mapRow(rs); } return null; } } catch (SQLException ex) { throw new DataAccessException("findById(" + id + ") failed", ex); } }

تلتقط طبقة الخدمة DataAccessException (أو تتركها تنتشر إلى Servlet أو Controller) دون أن تعتمد على java.sql إطلاقًا. هذا يُبقي اختيار تقنية قاعدة البيانات مخفيًا خلف واجهة DAO — وهو هدف محوري للنمط.

ضبط مهل العبارات والاستعلامات

استعلام جامح يمكنه الإمساك باتصال من التجمّع لدقائق، مما يُجفّف الطلبات الأخرى. اضبط دائمًا queryTimeout على أي Statement يُنفّذ منطقًا مدفوعًا بالمستخدم:

try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT * FROM reports WHERE created_by = ?")) { ps.setQueryTimeout(5); // 5 ثوانٍ — يرمي SQLTimeoutException عند التجاوز ps.setLong(1, userId); try (ResultSet rs = ps.executeQuery()) { // المعالجة ... } } catch (SQLTimeoutException ex) { logger.warn("Query timed out for user {}", userId); throw new ServiceException("استغرق استعلام التقرير وقتًا طويلًا. حاول اختيار نطاق تاريخ أصغر."); } catch (SQLException ex) { throw new DataAccessException("Report query failed", ex); }

الخلاصة

تقوم إدارة موارد JDBC المتينة على ثلاث عادات: استخدم try-with-resources دائمًا لكل Connection وStatement وResultSet؛ وسجّل سلسلة الاستثناء الكاملة بما فيها SQLState وكود الخطأ والاستثناءات المكبوتة؛ وغلّف SQLException دائمًا في استثناء غير محقَّق خاص بالمجال قبل خروجه من طبقة DAO. طبّق الفئات الفرعية المُكتَّبة (SQLIntegrityConstraintViolationException، SQLTransientConnectionException) لمعالجة أوضاع الفشل المحددة بأناقة دون تحليل السلاسل النصية. في الدرس القادم ستُضيف طبقة خدمة فوق DAO تُطبّق قواعد الأعمال وحدود المعاملات.