إضافة طبقة الخدمات
بحلول الدرس الخامس أصبح لديك DAO يعمل بكفاءة ويُحوّل الصفوف إلى كائنات، وبحلول الدرس السادس أمكنك تغليف استدعاءات متعددة من DAO داخل معاملة (transaction) واحدة لقاعدة البيانات. لكن ما لا تزال تفتقر إليه هو مكان نظيف يُجيب على السؤال: ما القواعد التي يجب أن يُطبّقها التطبيق، ومن المسؤول عن تنسيق DAO متعددة حين تتطلّب عملية أعمال ذلك؟ هذا المكان هو طبقة الخدمات (Service Layer).
لماذا توجد طبقة الخدمات
للـ DAO نطاق ضيّق ومقصود: فهو يعرف كيف يتحدث إلى جدول واحد (أو مجموعة صغيرة من الجداول المترابطة). لكنه لا يعرف — ولا يجب أن يكترث — بأن إنشاء طلب جديد يستلزم أيضًا خصم المخزون وتسجيل إدخال تدقيق وإرسال بريد تأكيد. دفع منطق التنسيق هذا إلى داخل Servlet أو JSP يُنتج متحكّمات يستحيل اختبارها وقواعد أعمال مبعثرة في أنحاء قاعدة الكود.
تجلس طبقة الخدمات بين طبقة الويب (Servlets ونقاط نهاية REST ومتحكمات MVC) وطبقة البيانات (DAOs). مسؤولياتها هي:
- قواعد الأعمال — التحقق من أن عنوان البريد الإلكتروني غير مسجّل مسبقًا قبل إنشاء مستخدم.
- التنسيق بين DAOs متعددة — استدعاء
UserDAO ثم WalletDAO ثم AuditDAO كلها داخل معاملة واحدة.
- حدود المعاملات — تمتلك الخدمة
Connection (أو وحدة العمل) وتُنهي أو تُلغي المعاملة بناءً على النجاح الكلي.
- التحويل بين الطبقات — تحويل كيان نطاق خام إلى DTO (كائن نقل البيانات) تستطيع طبقة الويب كشفه بأمان، أو العكس.
القاعدة الإرشادية المعيارية: إذا كانت Servlet ستحتاج إلى معرفة كلمة "و" — إنشاء مستخدم وإرسال بريد ترحيب وتسجيل العملية — فهذا الـ "و" ينتمي إلى خدمة لا إلى المتحكم.
هيكلة الخدمة
الخدمة عادةً فئة Java عادية (أو واجهة وتنفيذها). البرمجة للواجهة تجعل طبقة الويب تعتمد على التجريد فحسب مما يجعل اختبارات الوحدة بالغة البساطة.
// UserService.java (الواجهة)
public interface UserService {
User register(String email, String fullName, String rawPassword)
throws EmailAlreadyExistsException, ServiceException;
User findById(long id) throws ServiceException;
void changeEmail(long userId, String newEmail)
throws EmailAlreadyExistsException, ServiceException;
}
// UserServiceImpl.java (التنفيذ)
import java.sql.Connection;
import java.sql.SQLException;
public class UserServiceImpl implements UserService {
private final UserDAO userDao;
private final AuditDAO auditDao;
// الاعتماديات تُحقن عبر المُنشئ
public UserServiceImpl(UserDAO userDao, AuditDAO auditDao) {
this.userDao = userDao;
this.auditDao = auditDao;
}
@Override
public User register(String email, String fullName, String rawPassword)
throws EmailAlreadyExistsException, ServiceException {
// --- قاعدة أعمال ---
if (userDao.existsByEmail(email)) {
throw new EmailAlreadyExistsException(email);
}
String hashed = PasswordUtil.bcrypt(rawPassword);
User newUser = new User(email, fullName, hashed);
// --- عمل عبر DAOs متعددة داخل معاملة واحدة ---
try (Connection conn = DataSourceFactory.get().getConnection()) {
conn.setAutoCommit(false);
try {
userDao.insert(conn, newUser); // يعيد newUser مع id المُولَّد
auditDao.log(conn, "USER_CREATED", newUser.getId());
conn.commit();
return newUser;
} catch (SQLException ex) {
conn.rollback();
throw new ServiceException("فشل التسجيل", ex);
}
} catch (SQLException ex) {
throw new ServiceException("تعذّر فتح الاتصال", ex);
}
}
}
لاحظ أن DAOs تستقبل Connection كمعامل بدلًا من جلبها بأنفسها. هذا هو المفتاح للإبقاء على استدعاءات DAO متعددة داخل معاملة واحدة — فالخدمة تمتلك Connection وتمرّره إلى الأسفل.
تمرير الاتصال إلى DAOs
تعرض DAOs المُصمَّمة لدعم هذا النمط نوعين من الأساليب: أساليب مستقلة تفتح اتصالها الخاص (مفيدة لاستدعاءات قراءة معزولة) وأساليب تعاونية تقبل اتصالًا مُدارًا خارجيًا:
public class UserDAO {
// مستقل — مناسب للقراءات المعزولة
public Optional<User> findById(long id) throws SQLException {
try (Connection conn = DataSourceFactory.get().getConnection()) {
return findById(conn, id); // تفويض إلى التنفيذ المشترك
}
}
// تعاوني — تستدعيه الخدمة حين تمتلك المعاملة
public Optional<User> findById(Connection conn, long id) throws SQLException {
String sql = "SELECT id, email, full_name FROM users WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
}
}
public void insert(Connection conn, User user) throws SQLException {
String sql = "INSERT INTO users (email, full_name, password_hash) VALUES (?, ?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql,
PreparedStatement.RETURN_GENERATED_KEYS)) {
ps.setString(1, user.getEmail());
ps.setString(2, user.getFullName());
ps.setString(3, user.getPasswordHash());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) user.setId(keys.getLong(1));
}
}
}
private User mapRow(ResultSet rs) throws SQLException {
User u = new User();
u.setId(rs.getLong("id"));
u.setEmail(rs.getString("email"));
u.setFullName(rs.getString("full_name"));
return u;
}
}
فضّل التحميل الزائد على تسميات أساليب منفصلة. findById(long) وfindById(Connection, long) أسلوب Java محاكي. الشكل ذو الحجة الواحدة غلاف مريح للحالة المستقلة الشائعة؛ أما الشكل ذو الحجتين فهو الذي تستخدمه الخدمة حين تحتاج إلى مشاركة اتصال عبر استدعاءات DAO متعددة.
الخدمة بوصفها حدًا للمعاملة
قرار تصميمي حاسم: طبقة الخدمات لا طبقة DAO هي التي تمتلك حدود المعاملة. إذا بدأت DAOs معاملاتها الخاصة داخليًا، فسيعمل DAO-ان يستدعيهما نفس أسلوب الخدمة في معاملتين مختلفتين — أي استثناء يرميه الـ DAO الثاني لن يستطيع التراجع عن العمل الذي أنهاه الـ DAO الأول. إن مركزة المعاملة في الخدمة تمنحك ضمانة الكل أو لا شيء عبر العملية بأكملها.
// نمط مضاد: DAO يُنهي المعاملة داخليًا
public void insert(User user) throws SQLException {
try (Connection conn = DataSourceFactory.get().getConnection()) {
conn.setAutoCommit(false);
// ... إدراج ...
conn.commit(); // ❌ أُنهيت قبل أن تتمكن الخدمة من التراجع!
}
}
// الصحيح: DAO يقبل Connection مُدارًا خارجيًا
public void insert(Connection conn, User user) throws SQLException {
// ... إدراج — لا commit هنا ... ✅
}
استثناءات الخدمة: محددة أم غير محددة
لا يجب أن تكشف الخدمات عن SQLException خام لطبقة الويب — فهذا تفصيل تنفيذي. عرّف تسلسلًا صغيرًا من الاستثناءات لنطاق عملك:
ServiceException (محددة أو غير محددة) — تُغلّف أعطال البنية التحتية.
ValidationException — انتهاكات قواعد الأعمال (أخطاء حقول، سجلات مكررة).
NotFoundException — الكيان المطلوب غير موجود.
تلتقط Servlet استثناءات طبقة الخدمات وتحوّلها إلى استجابات HTTP (400، 404، 500) دون أن ترى SQLException قط:
// داخل Jakarta Servlet
try {
User user = userService.register(email, fullName, password);
resp.sendRedirect(req.getContextPath() + "/dashboard");
} catch (EmailAlreadyExistsException e) {
req.setAttribute("error", "هذا البريد الإلكتروني مسجّل بالفعل.");
req.getRequestDispatcher("/WEB-INF/views/register.jsp").forward(req, resp);
} catch (ServiceException e) {
log.error("فشل التسجيل", e);
resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
ربط الخدمات في تطبيق Servlet
دون حاوية حقن التبعيات، النهج الشائع هو تهيئة الخدمات في ServletContextListener وتخزينها في نطاق التطبيق حيث يمكن لأي Servlet استرجاعها:
@WebListener
public class AppBootstrap implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent sce) {
UserDAO userDao = new UserDAO();
AuditDAO auditDao = new AuditDAO();
UserService userService = new UserServiceImpl(userDao, auditDao);
ServletContext ctx = sce.getServletContext();
ctx.setAttribute("userService", userService);
}
}
// داخل أي Servlet
UserService userService =
(UserService) getServletContext().getAttribute("userService");
يجب أن تكون الخدمات عديمة الحالة بالنسبة للطلبات الفردية. خدمة مخزّنة في نطاق التطبيق تُشارَك عبر جميع الخيوط المتزامنة. لا تخزّن أبدًا بيانات خاصة بطلب معيّن (المستخدم الحالي، قيم النموذج، Connection) في حقول كائن الخدمة — فهذا خطأ أمان الخيوط يصعب إعادة إنتاجه أثناء التطوير لكنه يُفسد البيانات تحت الحمل.
الخلاصة
طبقة الخدمات هي المكان الذي تعيش فيه قواعد تطبيقك الفعلية. فهي تفصل طبقة الويب عن تفاصيل الاستمرارية، وتُنسّق العمليات متعددة الـ DAO ضمن حد معاملة واحد، وتُترجم استثناءات البنية التحتية إلى استثناءات ذات معنى في نطاق العمل. حالما تُفوّض Servlets إلى خدمات وخدمات إلى DAOs، تُصبح لكل طبقة وظيفة واحدة بالضبط — ويصبح كامل المكدّس قابلًا للاختبار والصيانة وسهل الفهم.