JDBC ونمط DAO

المعاملات في تطبيقات الويب

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

المعاملات في تطبيقات الويب

معاملة قاعدة البيانات (Transaction) هي سلسلة من جمل SQL يُعاملها محرك قاعدة البيانات باعتبارها وحدة عمل واحدة غير قابلة للتجزئة. إما أن تنجح كل جملة في المجموعة وتُسجَّل بصفة دائمة — وهو ما يُعرف بـ الالتزام (commit) — أو لا يسري أيٌّ منها وتُستعاد قاعدة البيانات إلى حالتها قبل تنفيذ أول جملة — وهو ما يُعرف بـ التراجع (rollback). هذا الضمان الذي تُقنّنه خصائص ACID هو ما يميّز تطبيق ويب موثوقًا عن تطبيق يترك البيانات في حالة غير متسقة عند حدوث أخطاء.

لماذا تحتاج كل عملية كتابة غير تافهة إلى معاملة

فكّر في عملية تقديم طلب في متجر إلكتروني: تحتاج إلى إدراج صف في جدول orders، وإدراج عدة صفوف في order_items، وتخفيض المخزون في products. إن فشل تحديث المخزون بعد كتابة الطلب بالفعل، ستحتوي قاعدة بياناتك على طلب وهمي لم يُخصم من المخزون. بدون معاملة، تُنتج عمليات الكتابة الجزئية بيانات تجارية فاسدة لا تُكتشف في الغالب إلا عند التدقيق.

ACID في فقرة واحدة: الذرية (Atomicity) — الكل أو لا شيء. الاتساق (Consistency) — القيود (المفاتيح الخارجية وقيود الفحص) مستوفاة قبل وبعد المعاملة. العزل (Isolation) — لا ترى المعاملات المتزامنة تغييرات بعضها غير الملتزمة (على مستوى العزل الافتراضي). المتانة (Durability) — بمجرد الالتزام، تصمد البيانات أمام الأعطال. يمنحك JDBC الذرية والمتانة مباشرةً؛ أما العزل فهو قابل للتهيئة لكل اتصال.

الإعداد الافتراضي في JDBC: وضع الالتزام التلقائي

افتراضيًا، يبدأ كل Connection في JDBC في وضع الالتزام التلقائي (auto-commit): كل جملة هي معاملة صغيرة مستقلة تُلتزم فور تنفيذها. هذا مقبول للقراءة، لكنه يعني أن عمليات الكتابة متعددة الخطوات لا تحظى بأي حماية. أول شيء تفعله حين تحتاج معاملة حقيقية هو إيقاف الالتزام التلقائي:

connection.setAutoCommit(false); // ابدأ معاملة

من تلك النقطة، تتراكم الجمل في معاملة مفتوحة حتى تستدعي صراحةً commit() أو rollback(). استدعاء close() على اتصال لا يزال يحوي معاملة مفتوحة غير ملتزمة سيجعل المشغّل يتراجع تلقائيًا — لكن الاعتماد على ذلك خطأ لا ميزة.

النمط القياسي للمعاملة في طريقة Servlet أو Service

الهيكل الأمثل لعملية معاملة باستخدام JDBC الصرف يبدو على النحو التالي:

import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; import javax.sql.DataSource; public class OrderService { private final DataSource dataSource; public OrderService(DataSource dataSource) { this.dataSource = dataSource; } public void placeOrder(int customerId, int productId, int quantity, double price) throws SQLException { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); // 1. افتح المعاملة try { // 2. أدرج رأس الطلب long orderId; try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO orders (customer_id, total) VALUES (?, ?)", PreparedStatement.RETURN_GENERATED_KEYS)) { ps.setInt(1, customerId); ps.setDouble(2, price * quantity); ps.executeUpdate(); try (var keys = ps.getGeneratedKeys()) { keys.next(); orderId = keys.getLong(1); } } // 3. أدرج بند الطلب try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO order_items (order_id, product_id, qty, unit_price) VALUES (?, ?, ?, ?)")) { ps.setLong(1, orderId); ps.setInt(2, productId); ps.setInt(3, quantity); ps.setDouble(4, price); ps.executeUpdate(); } // 4. خفّض المخزون try (PreparedStatement ps = conn.prepareStatement( "UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?")) { ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); int rows = ps.executeUpdate(); if (rows == 0) { throw new IllegalStateException("مخزون غير كافٍ للمنتج " + productId); } } conn.commit(); // 5. نجح كل شيء — احفظ التغييرات } catch (Exception e) { conn.rollback(); // 6. فشل شيء — تراجع عن كل شيء throw e; } } // conn.close() تُعيد الاتصال إلى التجمّع } }

الهيكل متعمَّد: يُستدعى setAutoCommit(false) أولًا، ثم تُنفَّذ كل الجمل، ويستدعي catch على المستوى ذاته rollback() قبل إعادة الرمي. يُغلق try-with-resources الخارجي الاتصال بغض النظر عن النجاح أو الفشل.

أعد الرمي دائمًا بعد التراجع. إخفاء الاستثناء بعد التراجع يُخفي الأخطاء عن المُستدعين وعن بنية التسجيل. التقط، تراجع، ثم دع الاستثناء يُتشر حتى تتمكن الـ servlet أو وحدة التحكم من إرجاع استجابة خطأ مناسبة.

معالجة طلب Servlet باعتباره وحدة عمل

في تطبيق ويب، الحد الطبيعي للمعاملة هو عادةً طلب HTTP واحد. النمط هو: افتح اتصالًا، ابدأ معاملة، أنجز كل عمل قاعدة البيانات الذي يتطلبه الطلب، التزم إن نجح كل شيء، تراجع إن رمى شيء استثناءً، ثم أعد الاتصال إلى التجمّع. يُسمى هذا أحيانًا نمط وحدة العمل (Unit of Work).

طريقة أنيقة لتطبيق ذلك دون تشتيت استدعاءات setAutoCommit / commit / rollback في طبقة DAO هي دفع حدود المعاملة إلى طريقة مساعدة تقبل lambda:

import java.sql.Connection; import java.sql.SQLException; import java.util.function.Consumer; import javax.sql.DataSource; public final class Tx { private Tx() {} @FunctionalInterface public interface TxWork<T> { T execute(Connection conn) throws SQLException; } public static <T> T run(DataSource ds, TxWork<T> work) throws SQLException { try (Connection conn = ds.getConnection()) { conn.setAutoCommit(false); try { T result = work.execute(conn); conn.commit(); return result; } catch (Exception e) { conn.rollback(); throw e; } } } }

يمرّر المُستدعون lambda تستقبل اتصالًا مفتوحًا وجاهزًا للمعاملة بالفعل. منطق التراجع موجود في مكان واحد فقط:

// في doPost() الخاصة بـ servlet: long orderId = Tx.run(dataSource, conn -> { return orderDao.insert(conn, customerId, cart); // يمكن وضع itemDao.insertAll و productDao.decrementStock هنا أيضًا });

نقاط الحفظ (Savepoints) — التراجع الجزئي

أحيانًا تريد التراجع عن جزء فقط من المعاملة — مثلًا، لإعادة محاولة عملية فرعية فاشلة دون فقدان الكتابات الناجحة السابقة. يدعم JDBC نقاط الحفظ (savepoints):

conn.setAutoCommit(false); // العملية الأولى — مطلوبة دائمًا insertAuditLog(conn, "محاولة طلب"); java.sql.Savepoint sp = conn.setSavepoint("before_payment"); try { chargePaymentGateway(conn, amount); } catch (SQLException e) { conn.rollback(sp); // تراجع عن خطوة الدفع فقط insertAuditLog(conn, "فشل الدفع، جارٍ إعادة المحاولة"); chargeWithFallbackMethod(conn, amount); } conn.commit();

لا يدعم كل قاعدة بيانات أو مشغّل نقاط الحفظ بالتساوي؛ تحقق من وثائق قاعدة البيانات المستهدفة قبل الاعتماد عليها في المسارات الحرجة.

مستويات العزل — خريطة موجزة

يتيح لك JDBC ضبط مستوى العزل لكل اتصال عبر conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ). المستويات الأربعة القياسية من الأضعف إلى الأقوى هي:

  • READ_UNCOMMITTED — يمكن قراءة بيانات معاملة أخرى غير ملتزمة (بيانات قذرة). نادرًا ما يكون مناسبًا.
  • READ_COMMITTED — يقرأ البيانات الملتزمة فقط؛ الافتراضي في PostgreSQL وSQL Server. يمنع القراءة القذرة.
  • REPEATABLE_READ — إعادة قراءة صف في نفس المعاملة تُعطي دائمًا النتيجة ذاتها؛ الافتراضي في MySQL/InnoDB. يمنع القراءة غير القابلة للتكرار.
  • SERIALIZABLE — دلالات التنفيذ المتسلسل الكاملة؛ يمنع القراءات الوهمية لكنه قد يكون بطيئًا في ظل التزامن.
تغيير مستويات العزل في بيئة ذات تجمّع اتصالات. حين تستعير اتصالًا من التجمّع، يكون مستوى العزل فيه ما آخر ما ضُبط عليه. إن غيّرت مستوى العزل لطلب واحد، أعد ضبطه قبل إعادة الاتصال إلى التجمّع وإلا سيرث كل مستعير لاحق تغييرك. يتيح لك HikariCP تحديد إعداد عالمي افتراضي عبر cfg.setTransactionIsolation("TRANSACTION_READ_COMMITTED") — اضبطه مرة واحدة هناك بدلًا من ضبطه لكل اتصال.

تمرير الاتصالات عبر طبقة DAO

خطأ شائع لدى المبتدئين هو جعل كل طريقة DAO تستدعي dataSource.getConnection() باستقلالية. حين تفعل ذلك، تعمل كل استدعاء DAO على اتصاله الخاص المنفصل — وبالتالي في معاملته الخاصة المنفصلة. لا يمكن أبدًا لاستدعاءات متعددة عبر DAOs مختلفة أن تكون ذرية. الحل هو تمرير الاتصال إلى طرق DAO من طبقة الخدمة حيث تُفتح المعاملة:

// خطأ — كل DAO تفتح اتصالها الخاص؛ لا معاملة مشتركة public void transfer(int from, int to, double amount) throws SQLException { accountDao.debit(from, amount); // تفتح conn A، تلتزم، تغلق accountDao.credit(to, amount); // تفتح conn B، تلتزم، تغلق // إن فشل credit()، فإن debit() ملتزمة بالفعل — المال ضاع } // صحيح — اتصال واحد، معاملة واحدة public void transfer(int from, int to, double amount) throws SQLException { Tx.run(dataSource, conn -> { accountDao.debit(conn, from, amount); accountDao.credit(conn, to, amount); return null; }); }

الخلاصة

كل عملية كتابة متعددة الخطوات في تطبيق ويب تنتمي إلى معاملة. النمط في JDBC هو: استدع setAutoCommit(false)، نفّذ جملك، استدع commit() عند النجاح، وrollback() في كتلة catch قبل إعادة الرمي. أغلّف هذا في أداة قابلة لإعادة الاستخدام (مساعد Tx.run() أو لاحقًا التوصيف @Transactional في Spring) حتى تعيش الميكانيكية في مكان واحد. مرّر الاتصال المفتوح إلى طرق DAO بدلًا من أن تفتح اتصالاتها الخاصة — هذه هي الطريقة الوحيدة التي تجعل استدعاءات DAO متعددة تشارك في نفس المعاملة. في الدرس القادم ستتعلم كيفية معالجة SQLException وتسريبات الموارد بأناقة عبر مكدس استدعاء JDBC كاملًا.