JDBC وقواعد البيانات

مشروع: طبقة DAO

15 دقيقة الدرس 10 من 13

مشروع: طبقة DAO

كل تطبيق في بيئة الإنتاج يحتاج في النهاية إلى حدٍّ فاصل واضح بين منطق الأعمال وقاعدة البيانات. يرسم نمط كائن الوصول إلى البيانات (DAO) هذا الحد: تمتلك كل فئة DAO كامل SQL الخاصة بجدول واحد، وتكشف للبقية دوالَّ Java عادية. طبقة الخدمة لن تكتب PreparedStatement مرة أخرى — بل تستدعي userDao.findById(id) وتتعامل مع كائن User.

في هذا الدرس ستبني طبقة DAO كاملة وجاهزة للإنتاج لجدول users، بدءًا من عقد الواجهة، مرورًا بالتنفيذ بـ JDBC، وانتهاءً بخدمة رفيعة تستخدمها.

لماذا نستخدم DAO؟

  • فصل المسؤوليات — يعيش SQL في مكان واحد فقط؛ يبقى منطق الأعمال خاليًا من SQL.
  • قابلية الاختبار — طبقة الخدمة تعتمد على الواجهة، فتستطيع استبدالها بتنفيذ وهمي في الذاكرة أثناء اختبارات الوحدة دون الحاجة لقاعدة بيانات حقيقية.
  • سهولة الصيانة — أي تغيير في مخطط قاعدة البيانات (إعادة تسمية عمود أو إضافة حقل) ينحصر في فئة واحدة.
  • وضوح القراءةuserDao.findByEmail(email) يخبر القارئ بالضبط ماذا يحدث دون الحاجة إلى قراءة SQL.
DAO مقابل Repository: يؤديان نفس الدور. DAO هو المصطلح الكلاسيكي في Java EE (DAO واحد لكل جدول). Repository هو مصطلح التصميم المبني على المجال (DDD) ويمتد على كامل المجمّع (Aggregate). في مشروع JDBC بسيط، الاسمان قابلان للتبادل — المهم هو المبدأ.

الخطوة 1 — كائن المجال

كائن المجال (يُسمى أحيانًا كيانًا أو نموذجًا) هو فئة Java بسيطة تعكس حقولها أعمدة الجدول. استخدم record من Java 17 للنسخ غير القابلة للتعديل، أو فئة عادية بـ getters حين تحتاج إلى قابلية التعديل.

// User.java public record User(long id, String name, String email, String role) { // مصنع اختصاري — لبناء مستخدم جديد بلا معرّف بعد public static User of(String name, String email, String role) { return new User(0, name, email, role); } }

الخطوة 2 — واجهة DAO

عرّف العقد أولًا. يعتمد باقي التطبيق على هذه الواجهة فقط، لا على فئة JDBC المحددة أبدًا.

import java.util.List; import java.util.Optional; // UserDao.java public interface UserDao { User save(User user); // INSERT؛ يُعيد المستخدم المحفوظ مع معرّفه التلقائي Optional<User> findById(long id); // SELECT بالمفتاح الأساسي Optional<User> findByEmail(String email); List<User> findAll(); List<User> findByRole(String role); boolean update(User user); // UPDATE؛ يُعيد true إذا تغيّر صف boolean deleteById(long id); // DELETE؛ يُعيد true إذا حُذف صف }
أعد Optional لعمليات البحث عن صف واحد التي قد لا تُنتج نتيجة. إعادة null يُلزم كل مستدعٍ بفحصه؛ بينما Optional يجعل احتمال الغياب صريحًا في النوع.

الخطوة 3 — التنفيذ بـ JDBC

تحمل فئة التنفيذ DataSource يُحقن عبر الباني. تفتح اتصالًا جديدًا لكل دالة (تتولى المجمّع إعادة تدويره)، تنفّذ SQL، وتحوّل ResultSet عبر دالة مساعدة خاصة.

import javax.sql.DataSource; import java.sql.*; import java.util.ArrayList; import java.util.List; import java.util.Optional; // JdbcUserDao.java public class JdbcUserDao implements UserDao { private final DataSource dataSource; public JdbcUserDao(DataSource dataSource) { this.dataSource = dataSource; } // ---- دالة مساعدة خاصة ------------------------------------------------- private User mapRow(ResultSet rs) throws SQLException { return new User( rs.getLong("id"), rs.getString("name"), rs.getString("email"), rs.getString("role") ); } // ---- INSERT --------------------------------------------------------- @Override public User save(User user) { String sql = "INSERT INTO users (name, email, role) VALUES (?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, user.name()); ps.setString(2, user.email()); ps.setString(3, user.role()); ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { return new User(keys.getLong(1), user.name(), user.email(), user.role()); } } throw new SQLException("INSERT لم يُعد مفتاحًا مولّدًا"); } catch (SQLException e) { throw new DataAccessException("فشل save", e); } } // ---- SELECT بالمفتاح الأساسي ------------------------------------------- @Override public Optional<User> findById(long id) { String sql = "SELECT id, name, email, role FROM users WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty(); } } catch (SQLException e) { throw new DataAccessException("فشل findById", e); } } // ---- SELECT بالبريد الإلكتروني ------------------------------------------ @Override public Optional<User> findByEmail(String email) { String sql = "SELECT id, name, email, role FROM users WHERE email = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, email); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty(); } } catch (SQLException e) { throw new DataAccessException("فشل findByEmail", e); } } // ---- SELECT الكل ------------------------------------------------------- @Override public List<User> findAll() { String sql = "SELECT id, name, email, role FROM users ORDER BY id"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { List<User> users = new ArrayList<>(); while (rs.next()) { users.add(mapRow(rs)); } return users; } catch (SQLException e) { throw new DataAccessException("فشل findAll", e); } } // ---- SELECT بالدور ----------------------------------------------------- @Override public List<User> findByRole(String role) { String sql = "SELECT id, name, email, role FROM users WHERE role = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, role); try (ResultSet rs = ps.executeQuery()) { List<User> users = new ArrayList<>(); while (rs.next()) { users.add(mapRow(rs)); } return users; } } catch (SQLException e) { throw new DataAccessException("فشل findByRole", e); } } // ---- UPDATE ------------------------------------------------------------ @Override public boolean update(User user) { String sql = "UPDATE users SET name = ?, email = ?, role = ? WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, user.name()); ps.setString(2, user.email()); ps.setString(3, user.role()); ps.setLong(4, user.id()); return ps.executeUpdate() > 0; } catch (SQLException e) { throw new DataAccessException("فشل update", e); } } // ---- DELETE ------------------------------------------------------------ @Override public boolean deleteById(long id) { String sql = "DELETE FROM users WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); return ps.executeUpdate() > 0; } catch (SQLException e) { throw new DataAccessException("فشل deleteById", e); } } }

الخطوة 4 — مُغلّف DataAccessException

لا ينبغي للمستدعين أن يُصرّحوا بـ throws SQLException في كل مكان. ألفّ الاستثناء المتحقَّق في استثناء وقت التشغيل ليتنشر بشكل طبيعي.

// DataAccessException.java public class DataAccessException extends RuntimeException { public DataAccessException(String message, Throwable cause) { super(message, cause); } }

الخطوة 5 — خدمة تستخدم DAO

تحمل الخدمة قواعد الأعمال. تعتمد فقط على UserDao (الواجهة)، لا على JdbcUserDao مباشرة.

// UserService.java public class UserService { private final UserDao userDao; public UserService(UserDao userDao) { this.userDao = userDao; } public User registerUser(String name, String email, String role) { userDao.findByEmail(email).ifPresent(existing -> { throw new IllegalStateException("البريد مسجّل مسبقًا: " + email); }); return userDao.save(User.of(name, email, role)); } public User getOrThrow(long id) { return userDao.findById(id) .orElseThrow(() -> new IllegalArgumentException("لا يوجد مستخدم بالمعرّف " + id)); } public List<User> admins() { return userDao.findByRole("ADMIN"); } }
حقن الباني (Constructor Injection) يجعل الاعتمادية صريحة ويُسهّل الاختبار. لاختبار UserService في اختبار وحدة، مرّر تنفيذًا بسيطًا للـ UserDao في الذاكرة — لا تحتاج قاعدة بيانات.

الخطوة 6 — ربط كل شيء معًا

في تطبيق حقيقي يتولّى إطار الحقن (Spring أو Guice) الربط. في مشروع مستقل، أجرِ الربط يدويًا في main:

import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; public class Main { public static void main(String[] args) { // 1. مجمّع الاتصالات (HikariCP) HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb"); config.setUsername("root"); config.setPassword("secret"); config.setMaximumPoolSize(10); var dataSource = new HikariDataSource(config); // 2. ربط الطبقات UserDao userDao = new JdbcUserDao(dataSource); UserService service = new UserService(userDao); // 3. استخدام الخدمة User alice = service.registerUser("Alice", "alice@example.com", "ADMIN"); System.out.println("تم الحفظ: " + alice); service.admins().forEach(System.out::println); dataSource.close(); } }

المفاضلات الرئيسية في التصميم

  • اتصال واحد لكل استدعاء دالة — بسيط وصحيح مع مجمّع الاتصالات، لكن العملية متعددة الخطوات التي تحتاج ذرية (Atomicity) يجب أن تمرّر Connection صراحةً.
  • لا مُنشئ استعلامات — نصوص SQL الخام عُرضة للمشاكل عند إعادة التسمية. للمشاريع الصغيرة مقبول؛ الكبيرة تستفيد من jOOQ أو QueryDSL لـ SQL آمن النوع.
  • mapRow غير مُعاد استخدامه عبر DAOs — لجداول كثيرة، واجهة دالية RowMapper<T> تجعل منطق التحويل قابلًا للتركيب والاختبار المنفصل.
لا تضع SQL أبدًا داخل خدمة أو متحكّم. حين يتسرّب SQL خارج طبقة DAO يتكاثر: تنتهي بنفس الاستعلام في ثلاثة أماكن، وأي تغيير في المخطط يستلزم مطاردة كل نسخة.

الخلاصة

تتكوّن طبقة DAO من: سجل/فئة المجال الذي يمثّل صفًا واحدًا، واجهة DAO التي تُعلن كل عملية قاعدة بيانات، تنفيذ JDBC الذي يملك كل SQL ويحوّل مجموعات النتائج، مُغلّف استثناءات وقت التشغيل للتخلص من ضجيج الاستثناءات المتحقَّقة، وخدمة تعتمد فقط على الواجهة. هذه البنية هي أساس كل طبقة ثبات في Java — سواء استبدلت تنفيذ JDBC لاحقًا بـ JPA أو jOOQ أو MyBatis، يبقى كود الواجهة والخدمة دون تغيير.