بناء طبقة البيانات
طبقة البيانات هي الفاصل بين منطق أعمال تطبيقك وآلية الحفظ التي تختارها — قاعدة بيانات علائقية اليوم، وربما مخزن وثائق غدًا. بناء هذه الطبقة بشكل صحيح يعني أن بقية التطبيق لن تحتاج أبدًا إلى الاهتمام بـSQL أو كود JDBC المتكرر أو إدارة الاتصالات. في هذا الدرس ستصمم وتنفّذ طبقة مستودعات/DAO نظيفة وقابلة للاختبار ومنفصلة فعلًا عن بقية الطبقات.
Repository مقابل DAO: اختيار النمط المناسب
يجرّد كلا النمطين طبقة الحفظ، لكنّهما يعملان على مستويين مختلفين من نموذج المجال:
- DAO (كائن الوصول إلى البيانات) — يتمحور حول الجدول. DAO واحد لكل جدول في قاعدة البيانات، مع توابع مثل
findById وsave وdeleteById. يفهم الصفوف والأعمدة.
- Repository (المستودع) — يتمحور حول المجال. مستودع واحد لكل جذر تجميع في نموذجك (مثل
OrderRepository وليس OrderLineItemDAO). يتحدث بلغة كائنات المجال لا بنتائج SQL.
في تطبيق إدارة المهام الخاص بنا، تتطابق كائنات المجال مع الجداول بشكل وثيق، لذا الفرق صغير. سنستخدم اسم المستودع ومنطقه: المستدعي يطلب Task لا ResultSet.
تعريف واجهات المستودع
برمج دائمًا نحو واجهة. الواجهة توجد في حزمة المجال أو المنافذ؛ تنفيذ JDBC يوجد في حزمة البنية التحتية أو الحفظ. هذا الحاجز يسمح لك بتبديل SQLite بـPostgreSQL — أو تبديل الطبقة كاملها بكائن وهمي في الذاكرة أثناء الاختبار — دون المساس بمنطق الأعمال.
// domain/repository/TaskRepository.java
package com.example.taskapp.domain.repository;
import com.example.taskapp.domain.model.Task;
import java.util.List;
import java.util.Optional;
public interface TaskRepository {
Task save(Task task); // إدراج أو تحديث
Optional<Task> findById(long id);
List<Task> findAll();
List<Task> findByStatus(String status);
void deleteById(long id);
}
Optional لا null. أعِد Optional<Task> من توابع البحث عوضًا عن إعادة null. يضطر المستدعون للتعامل صراحةً مع حالة الغياب، مما يقضي على NullPointerExceptions الصامتة في طبقة الخدمات.
بنية تحتية JDBC: مصدر الاتصال
بدلًا من فتح اتصال جديد في كل استدعاء (مكلف) أو تمرير Connection في كل مكان (هشّ)، استخدم غلافًا صغيرًا لـDataSource. لتطبيق مكتفٍ بذاته، يكفي SQLite مع تجمّع بسيط؛ النمط يتوسّع مباشرة إلى HikariCP مع PostgreSQL في الإنتاج.
// infrastructure/persistence/Database.java
package com.example.taskapp.infrastructure.persistence;
import org.sqlite.SQLiteDataSource;
import javax.sql.DataSource;
public final class Database {
private static final DataSource DATA_SOURCE;
static {
SQLiteDataSource ds = new SQLiteDataSource();
ds.setUrl("jdbc:sqlite:taskapp.db");
DATA_SOURCE = ds;
}
private Database() {}
public static DataSource get() {
return DATA_SOURCE;
}
}
استبدل الـsingleton الثابت بحقن التبعيات في الإنتاج. مرّر DataSource في مُنشئ كل مستودع بدلًا من استدعاء Database.get() داخل توابع المستودع. هذا يجعل كل مستودع قابلًا للاختبار بشكل مستقل باستخدام H2 في الذاكرة أو Testcontainers.
تهيئة المخطط
احتفظ بتعريفات DDL داخل التطبيق، لا في سكربت إداري منفصل قد ينسى أحد تشغيله. يعمل SchemaInitializer عند الإقلاع ويُنشئ الجداول إن لم تكن موجودة.
// infrastructure/persistence/SchemaInitializer.java
package com.example.taskapp.infrastructure.persistence;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
public final class SchemaInitializer {
public static void run() {
String ddl = """
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'TODO',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
""";
try (Connection conn = Database.get().getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute(ddl);
} catch (SQLException e) {
throw new RuntimeException("فشل تهيئة المخطط", e);
}
}
private SchemaInitializer() {}
}
تنفيذ المستودع
يترجم تنفيذ JDBC بين صفوف ResultSet وكائنات المجال. مساعدَان خاصان — مُخطِّط ومُنشئ PreparedStatement — يُبقيان التوابع العامة قابلة للقراءة.
// infrastructure/persistence/JdbcTaskRepository.java
package com.example.taskapp.infrastructure.persistence;
import com.example.taskapp.domain.model.Task;
import com.example.taskapp.domain.repository.TaskRepository;
import java.sql.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcTaskRepository implements TaskRepository {
private static final String INSERT =
"INSERT INTO tasks (title, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)";
private static final String UPDATE =
"UPDATE tasks SET title=?, description=?, status=?, updated_at=? WHERE id=?";
private static final String FIND_BY_ID = "SELECT * FROM tasks WHERE id = ?";
private static final String FIND_ALL = "SELECT * FROM tasks ORDER BY created_at DESC";
private static final String FIND_STATUS = "SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC";
private static final String DELETE = "DELETE FROM tasks WHERE id = ?";
@Override
public Task save(Task task) {
if (task.getId() == 0) {
return insert(task);
} else {
return update(task);
}
}
private Task insert(Task task) {
String now = LocalDateTime.now().toString();
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(INSERT, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, task.getTitle());
ps.setString(2, task.getDescription());
ps.setString(3, task.getStatus());
ps.setString(4, now);
ps.setString(5, now);
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
return task.withId(keys.getLong(1));
}
}
throw new RuntimeException("الإدراج لم يُعِد مفتاحًا مولَّدًا");
} catch (SQLException e) {
throw new DataAccessException("insert task", e);
}
}
private Task update(Task task) {
String now = LocalDateTime.now().toString();
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(UPDATE)) {
ps.setString(1, task.getTitle());
ps.setString(2, task.getDescription());
ps.setString(3, task.getStatus());
ps.setString(4, now);
ps.setLong(5, task.getId());
ps.executeUpdate();
return task;
} catch (SQLException e) {
throw new DataAccessException("update task", e);
}
}
@Override
public Optional<Task> findById(long id) {
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(FIND_BY_ID)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(map(rs));
}
}
return Optional.empty();
} catch (SQLException e) {
throw new DataAccessException("findById " + id, e);
}
}
@Override
public List<Task> findAll() {
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(FIND_ALL);
ResultSet rs = ps.executeQuery()) {
List<Task> result = new ArrayList<>();
while (rs.next()) result.add(map(rs));
return result;
} catch (SQLException e) {
throw new DataAccessException("findAll", e);
}
}
@Override
public List<Task> findByStatus(String status) {
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(FIND_STATUS)) {
ps.setString(1, status);
try (ResultSet rs = ps.executeQuery()) {
List<Task> result = new ArrayList<>();
while (rs.next()) result.add(map(rs));
return result;
}
} catch (SQLException e) {
throw new DataAccessException("findByStatus " + status, e);
}
}
@Override
public void deleteById(long id) {
try (Connection conn = Database.get().getConnection();
PreparedStatement ps = conn.prepareStatement(DELETE)) {
ps.setLong(1, id);
ps.executeUpdate();
} catch (SQLException e) {
throw new DataAccessException("deleteById " + id, e);
}
}
// ---------- توابع مساعدة خاصة ----------
private Task map(ResultSet rs) throws SQLException {
return new Task(
rs.getLong("id"),
rs.getString("title"),
rs.getString("description"),
rs.getString("status"),
LocalDateTime.parse(rs.getString("created_at")),
LocalDateTime.parse(rs.getString("updated_at"))
);
}
}
استثناء مجال لأخطاء البيانات
تغليف SQLException في استثناء محدد يُلزم المستدعين بالتعامل صراحةً مع أعطال قاعدة البيانات — وهو بالضبط العقد الخاطئ على مستوى منطق الأعمال. عوضًا عن ذلك، حوّل SQLException إلى استثناء غير محدد يوصل النية دون كشف تفاصيل البنية التحتية.
// infrastructure/persistence/DataAccessException.java
package com.example.taskapp.infrastructure.persistence;
public class DataAccessException extends RuntimeException {
public DataAccessException(String operation, Throwable cause) {
super("فشل الوصول إلى البيانات أثناء: " + operation, cause);
}
}
لا تدع SQLException تتسرب فوق طبقة البيانات. تكشف SQLException تفاصيل JDBC الداخلية — رموز الأخطاء، وحالات SQL، والرسائل الخاصة بالمورّد — التي لا معنى لها في صنف الخدمة أو المتحكّم. غلّفها في كل مرة، سجّلها عند حدود المستودع، وأعِد رمي تجريدك الخاص.
ربط كل شيء معًا
في Main، استدعِ SchemaInitializer.run() مرة واحدة قبل إنشاء أي مستودع. مرّر المستودع الملموس إلى الخدمة عبر نوع الواجهة:
// Main.java (مقتطف)
SchemaInitializer.run();
TaskRepository repository = new JdbcTaskRepository();
TaskService service = new TaskService(repository);
// ... سلّم الخدمة إلى طبقة CLI أو HTTP
لأن TaskService تعتمد فقط على واجهة TaskRepository، يمكنك تزويدها بكائن وهمي مكتوب يدويًا أو Mockito mock في اختبارات الوحدة، دون أي قاعدة بيانات على الإطلاق.
الخلاصة
تُخفي طبقة البيانات المصمَّمة جيدًا كل تفاصيل JDBC خلف واجهة تواجه المجال. يتولى المستودع الملموس الحصول على الاتصال وإدارة PreparedStatement وتخطيط الصفوف وترجمة الاستثناءات. يرى بقية التطبيق — الخدمات ومعالجات CLI وحتى متحكمات HTTP المستقبلية — كائنات المجال ونتائج Optional فقط. في الدرس القادم ستبني طبقة الخدمات التي تنسّق هذه المستودعات لتنفيذ قواعد الأعمال الحقيقية.