نمط DAO
يواجه كل تطبيق غير تافه في نهاية المطاف السؤال نفسه في التصميم: أين يقع الكود الذي يتحدث إلى قاعدة البيانات؟ بدون إجابة مدروسة، تتسلل جمل SQL إلى الـ Servlets وفئات الخدمة وحتى مساعدات العرض — وهو ما يُعرف بـ تسرب منطق الاستمرارية (persistence logic bleed). يمثل نمط كائن الوصول إلى البيانات (DAO - Data Access Object) العلاج المعياري في هذه الصناعة. فهو يخفي كل تفاصيل استرجاع البيانات وتخزينها خلف واجهة محددة، تاركًا لبقية التطبيق حرية العمل مع كائنات Java العادية بمعزل تام عن SQL.
ما المشكلة التي تحلها DAOs فعلًا
تخيّل servlet يُدرج المنتجات. بدون DAO، سيحمل كائن Connection ويبني جمل SQL ويكرّر على ResultSet ويُنشئ كائنات النطاق — كل هذا بشكل مضمّن. هذا الـ servlet أصبح مستحيل الاختبار دون قاعدة بيانات تعمل، ومستحيل التحويل من MySQL إلى PostgreSQL دون لمس منطق الأعمال، ومستحيل القراءة السريعة من أي شخص جديد على الكود.
يرسم DAO حدًا صارمًا: على جانب ماذا يحتاج التطبيق ("أعطني جميع المنتجات في الفئة X")؛ وعلى الجانب الآخر كيف يحدث ذلك في قاعدة البيانات. كل شيء على الجانب الثاني هو شأن خاص بالـ DAO.
الواجهة — البرمجة نحو التجريد
ابدأ بتعريف واجهة تعبّر عن العمليات التي يحتاجها التطبيق، مستخدمًا أنواع النطاق فقط:
package com.example.dao;
import com.example.model.Product;
import java.util.List;
import java.util.Optional;
public interface ProductDao {
List<Product> findAll();
Optional<Product> findById(int id);
List<Product> findByCategory(String category);
void save(Product product); // INSERT أو UPDATE
void delete(int id);
}
لاحظ ما هو غائب: لا Connection، ولا SQLException، ولا كلمة SQL. يعتمد المستدعون فقط على هذا العقد. تبديل مخزن البيانات — MySQL اليوم، وذاكرة وهمية للاختبارات غدًا — لا يتطلب أي تغيير في أي مكان إلا في التطبيق الذي يُحقن.
لماذا نستخدم Optional لعمليات البحث عن كائن واحد؟ إعادة null من findById تُجبر كل مستدعٍ على إضافة فحص null أو المخاطرة بـ NullPointerException. Optional<Product> يجعل احتمال الغياب صريحًا في النوع، مما يتيح للمترجم إلزام التعامل معه.
نموذج النطاق
الكائنات التي يعمل معها الـ DAO هي كائنات Java بسيطة عادية تُعرف بـ POJOs — يطلق عليها أحيانًا كيانات أو كائنات النطاق. تحمل حالة ولا تحتوي على منطق قاعدة بيانات:
package com.example.model;
public class Product {
private int id;
private String name;
private String category;
private double price;
private int stock;
// المنشئ الأساسي
public Product(int id, String name, String category, double price, int stock) {
this.id = id;
this.name = name;
this.category = category;
this.price = price;
this.stock = stock;
}
// منشئ بدون معاملات لحالة "الإدراج بدون ID"
public Product() {}
// getters and setters مختصرة للإيجاز
public int getId() { return id; }
public String getName() { return name; }
public String getCategory() { return category; }
public double getPrice() { return price; }
public int getStock() { return stock; }
public void setId(int id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setCategory(String cat) { this.category = cat; }
public void setPrice(double price) { this.price = price; }
public void setStock(int stock) { this.stock = stock; }
}
التطبيق بـ JDBC
الفئة الملموسة تُنفّذ الواجهة وهي المكان الوحيد في قاعدة الكود بأكملها الذي يعرف SQL:
package com.example.dao;
import com.example.model.Product;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcProductDao implements ProductDao {
private final DataSource dataSource;
// حقن DataSource — بدون DriverManager، بدون URL مضمّن
public JdbcProductDao(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public List<Product> findAll() {
String sql = "SELECT id, name, category, price, stock FROM products ORDER BY name";
List<Product> results = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
results.add(mapRow(rs));
}
} catch (SQLException e) {
throw new DataAccessException("فشل findAll", e);
}
return results;
}
@Override
public Optional<Product> findById(int id) {
String sql = "SELECT id, name, category, price, stock FROM products WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
} catch (SQLException e) {
throw new DataAccessException("فشل findById للمعرف=" + id, e);
}
}
@Override
public List<Product> findByCategory(String category) {
String sql = "SELECT id, name, category, price, stock FROM products WHERE category = ?";
List<Product> results = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, category);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) results.add(mapRow(rs));
}
} catch (SQLException e) {
throw new DataAccessException("فشل findByCategory", e);
}
return results;
}
@Override
public void save(Product product) {
if (product.getId() == 0) {
insert(product);
} else {
update(product);
}
}
private void insert(Product p) {
String sql = "INSERT INTO products (name, category, price, stock) VALUES (?, ?, ?, ?)";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, p.getName());
ps.setString(2, p.getCategory());
ps.setDouble(3, p.getPrice());
ps.setInt(4, p.getStock());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) p.setId(keys.getInt(1));
}
} catch (SQLException e) {
throw new DataAccessException("فشل الإدراج", e);
}
}
private void update(Product p) {
String sql = "UPDATE products SET name=?, category=?, price=?, stock=? WHERE id=?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, p.getName());
ps.setString(2, p.getCategory());
ps.setDouble(3, p.getPrice());
ps.setInt(4, p.getStock());
ps.setInt(5, p.getId());
ps.executeUpdate();
} catch (SQLException e) {
throw new DataAccessException("فشل التحديث", e);
}
}
@Override
public void delete(int id) {
String sql = "DELETE FROM products WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
ps.executeUpdate();
} catch (SQLException e) {
throw new DataAccessException("فشل الحذف", e);
}
}
// مُعيّن الصف الخاص — المكان الوحيد الذي يعرف أسماء الأعمدة
private Product mapRow(ResultSet rs) throws SQLException {
return new Product(
rs.getInt("id"),
rs.getString("name"),
rs.getString("category"),
rs.getDouble("price"),
rs.getInt("stock")
);
}
}
استثناء وقت التشغيل المخصص
بما أن SQLException استثناء مدقوق (checked exception)، فإنه سيتسرب من كل دالة في الـ DAO إلى مستدعين ليس لديهم علاقة بـ JDBC. الحل المعياري هو تغليفه في استثناء وقت تشغيل وإعادة رميه:
package com.example.dao;
public class DataAccessException extends RuntimeException {
public DataAccessException(String message, Throwable cause) {
super(message, cause);
}
}
يُمسك المستدعون بـ DataAccessException فقط عند الحد الذي يمكنهم فيه فعل شيء مفيد — عادةً doGet أو doPost في الـ servlet الذي يحوّله إلى صفحة خطأ 500.
احتفظ بالدالة الخاصة mapRow. إذا تغيّر اسم عمود في قاعدة البيانات، ستُصلحه في مكان واحد فقط. تضمين rs.getString("name") في كل دالة استعلام يضمن أنك ستفوّت تكرارًا على الأقل.
ربط الأجزاء معًا في Servlet
يصبح الـ servlet منسقًا رفيعًا: يتحقق من المدخلات، ويستدعي الـ DAO، ويفوّض العرض إلى JSP. لا يحتوي على أي SQL:
@WebServlet("/products")
public class ProductServlet extends HttpServlet {
private ProductDao productDao;
@Override
public void init() {
// البحث عن DataSource من JNDI (مُهيأ في خادم التطبيق)
DataSource ds = (DataSource) new InitialContext()
.lookup("java:comp/env/jdbc/shopDB");
productDao = new JdbcProductDao(ds);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String cat = req.getParameter("category");
List<Product> products = (cat != null && !cat.isBlank())
? productDao.findByCategory(cat)
: productDao.findAll();
req.setAttribute("products", products);
req.getRequestDispatcher("/WEB-INF/views/products.jsp").forward(req, resp);
}
}
اختبار الـ DAO بمعزل
بما أن واجهة الـ DAO تقبل DataSource، يمكن للاختبارات حقن قاعدة بيانات H2 في الذاكرة دون لمس كود الإنتاج:
// في اختبار JUnit 5 — بدون Mockito، بدون مقلّدات، فقط H2 حقيقية في الذاكرة
class JdbcProductDaoTest {
private static HikariDataSource ds;
private ProductDao dao;
@BeforeAll
static void setupPool() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1");
cfg.setUsername("sa");
cfg.setPassword("");
ds = new HikariDataSource(cfg);
}
@BeforeEach
void setupSchema() throws Exception {
try (Connection c = ds.getConnection(); Statement s = c.createStatement()) {
s.execute("DROP TABLE IF EXISTS products");
s.execute("""
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
category VARCHAR(50),
price DOUBLE,
stock INT
)""");
}
dao = new JdbcProductDao(ds);
}
@Test
void saveAndFindById() {
Product p = new Product();
p.setName("Widget"); p.setCategory("tools"); p.setPrice(9.99); p.setStock(50);
dao.save(p);
assertTrue(p.getId() > 0, "يجب ضبط المفتاح المُولَّد بعد الإدراج");
Optional<Product> found = dao.findById(p.getId());
assertTrue(found.isPresent());
assertEquals("Widget", found.get().getName());
}
}
لا تختبر منطق الـ DAO عبر ResultSet وهمي (mock). تقليد ResultSet يتحقق من أن كودك يستدعي الدوال الصحيحة بالترتيب الصحيح — لا أنه يُعيد البيانات الصحيحة. استخدم قاعدة بيانات حقيقية خفيفة مثل H2 لتكون الاختبارات ذات معنى.
المقايضات الرئيسية والبدائل
- DAO مقابل Repository: مصطلح "Repository" (من Domain-Driven Design) أغنى مفاهيميًا — يتصرف كمجموعة في الذاكرة من كائنات النطاق. عمليًا، مع JDBC العادي يُستخدم المصطلحان بالتبادل. يصبح الفرق أكثر أهمية عند استخدام JPA/Hibernate.
- DAO واحد لكل جدول مقابل كل تجميع: للمخططات البسيطة، DAO واحد لكل جدول مناسب. حين تمتد كائنات النطاق على جداول متعددة (مثل
Order مع صفوف OrderItem)، فإن OrderDao واحدًا يدير الاثنين أنظف من DAO-ين منفصلين يجب أن يتنسقا.
- DAO أساسي عام: يمكن أن تحمل فئة أب
BaseDao<T, ID> منطق CRUD المشترك، مما يُقلل الكود المتكرر حين يكون لديك أكثر من كيانين أو ثلاثة. أدخلها حين تمتلك ثلاثة DAOs ملموسة أو أكثر بكود متكرر.
الخلاصة
يعزل نمط DAO كل كود JDBC خلف واجهة مكتوبة بأنواع النطاق. الواجهة تعبّر عن نية النطاق؛ والتطبيق يتعامل مع SQL. هذا الفصل يجعل كل مكون قابلًا للاختبار بمعزل، ويتيح تبديل مخزن البيانات دون لمس منطق الأعمال، ويحافظ على قابلية قراءة الـ Servlets وفئات الخدمة. كل ما يأتي في الدروس القادمة — المعاملات، ومعالجة الاستثناءات، وطبقة الخدمة — مبني على هذا الأساس.