JDBC ونمط DAO

مشروع: تطبيق ويب لإدارة البيانات بنمط DAO

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

مشروع: تطبيق ويب لإدارة البيانات بنمط DAO

تجمع هذه الدرس الختامي كل ما تناوله البرنامج التعليمي: DataSource بالتجميع (pooling)، وواجهة DAO مبنية على PreparedStatement، وطبقة خدمة (service) خفيفة تتملّك المعاملات (transactions)، وعروض JSP تحرّك دورة إنشاء/قراءة/تحديث/حذف CRUD كاملة. ستبني تطبيق كتالوج منتجات مصغّر — واقعي بما يكفي لإظهار كل المكوّنات، وصغير بما يكفي ليناسب درسًا واحدًا.

هيكل المشروع

يتبع مشروع Maven WAR بنية طبقية نظيفة. لكل طبقة مسؤولية واحدة وتعتمد فقط على الطبقة التي تليها.

product-catalogue/ ├── pom.xml └── src/main/ ├── java/com/example/catalogue/ │ ├── db/ DataSourceProvider.java │ ├── model/ Product.java │ ├── dao/ ProductDao.java (interface) │ │ ProductDaoImpl.java │ ├── service/ ProductService.java │ └── servlet/ ProductServlet.java └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ │ ├── list.jsp │ ├── form.jsp │ └── error.jsp └── index.jsp (يُعيد التوجيه إلى /products)

الخطوة الأولى — النموذج (Model)

Product كائن Java عادي. احتفظ به خاليًا من أي منطق استمرارية.

package com.example.catalogue.model; public class Product { private int id; private String name; private String sku; private double price; private int stock; public Product() {} public Product(int id, String name, String sku, double price, int stock) { this.id = id; this.name = name; this.sku = sku; this.price = price; this.stock = stock; } // الـ getters والـ setters المعتادة محذوفة للإيجاز public int getId() { return id; } public void setId(int id) { this.id = id; } public String getName() { return name; } public void setName(String n) { this.name = n; } public String getSku() { return sku; } public void setSku(String s) { this.sku = s; } public double getPrice() { return price; } public void setPrice(double p) { this.price = p; } public int getStock() { return stock; } public void setStock(int s) { this.stock = s; } }

الخطوة الثانية — واجهة DAO والتنفيذ

حدّد العقد أولًا؛ التنفيذ هو الفئة الوحيدة التي تلمس SQL.

package com.example.catalogue.dao; import com.example.catalogue.model.Product; import java.sql.SQLException; import java.util.List; public interface ProductDao { List<Product> findAll() throws SQLException; Product findById(int id) throws SQLException; void insert(Product p) throws SQLException; void update(Product p) throws SQLException; void delete(int id) throws SQLException; }

يحقن التنفيذ DataSource عبر منشئه — مما يجعله قابلًا للاختبار بسهولة.

package com.example.catalogue.dao; import com.example.catalogue.model.Product; import javax.sql.DataSource; import java.sql.*; import java.util.ArrayList; import java.util.List; public class ProductDaoImpl implements ProductDao { private final DataSource ds; public ProductDaoImpl(DataSource ds) { this.ds = ds; } // ---- مساعدات ---------------------------------------- private Product map(ResultSet rs) throws SQLException { return new Product( rs.getInt("id"), rs.getString("name"), rs.getString("sku"), rs.getDouble("price"), rs.getInt("stock") ); } // ---- CRUD ------------------------------------------- @Override public List<Product> findAll() throws SQLException { String sql = "SELECT id, name, sku, price, stock FROM products ORDER BY name"; List<Product> list = new ArrayList<>(); try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) list.add(map(rs)); } return list; } @Override public Product findById(int id) throws SQLException { String sql = "SELECT id, name, sku, price, stock FROM products WHERE id = ?"; try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? map(rs) : null; } } } @Override public void insert(Product p) throws SQLException { String sql = "INSERT INTO products (name, sku, price, stock) VALUES (?, ?, ?, ?)"; try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, p.getName()); ps.setString(2, p.getSku()); 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)); } } } @Override public void update(Product p) throws SQLException { String sql = "UPDATE products SET name=?, sku=?, price=?, stock=? WHERE id=?"; try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, p.getName()); ps.setString(2, p.getSku()); ps.setDouble(3, p.getPrice()); ps.setInt(4, p.getStock()); ps.setInt(5, p.getId()); ps.executeUpdate(); } } @Override public void delete(int id) throws SQLException { String sql = "DELETE FROM products WHERE id = ?"; try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setInt(1, id); ps.executeUpdate(); } } }
لماذا يُمرَّر DataSource عبر المنشئ بدلًا من البحث عنه؟ يُبقي حقن المنشئ DAO منفصلًا عن JNDI وعن أي مكتبة تجميع محددة. في اختبار الوحدة يمكنك تمرير DataSource H2 في الذاكرة؛ وفي الإنتاج يوفّر حاوية servlet الـ HikariCP. نفس DAO، لا تغييرات.

الخطوة الثالثة — طبقة الخدمة

تتملّك الخدمة التحقق وحدود المعاملات. لعمليات CRUD البسيطة على جدول واحد تكون الخدمة خفيفة، لكنها المكان الصحيح لإضافة قواعد العمل لاحقًا (مثلًا: "لا تحذف منتجًا لديه طلبات مفتوحة").

package com.example.catalogue.service; import com.example.catalogue.dao.ProductDao; import com.example.catalogue.model.Product; import java.sql.SQLException; import java.util.List; public class ProductService { private final ProductDao dao; public ProductService(ProductDao dao) { this.dao = dao; } public List<Product> listAll() throws SQLException { return dao.findAll(); } public Product getById(int id) throws SQLException { Product p = dao.findById(id); if (p == null) throw new IllegalArgumentException("Product not found: " + id); return p; } public void create(Product p) throws SQLException { validate(p); dao.insert(p); } public void edit(Product p) throws SQLException { validate(p); dao.update(p); } public void remove(int id) throws SQLException { dao.delete(id); } private void validate(Product p) { if (p.getName() == null || p.getName().isBlank()) throw new IllegalArgumentException("الاسم مطلوب."); if (p.getSku() == null || p.getSku().isBlank()) throw new IllegalArgumentException("رقم SKU مطلوب."); if (p.getPrice() < 0) throw new IllegalArgumentException("لا يمكن أن يكون السعر سالبًا."); } }

الخطوة الرابعة — الـ Servlet (المتحكم الأمامي)

يُعيَّن ProductServlet الواحد إلى /products ويُوزّع الطلبات بناءً على معامل action. يُهيّئ DAO والخدمة مرة واحدة عند بدء التشغيل باستخدام @WebServlet مع loadOnStartup = 1.

package com.example.catalogue.servlet; import com.example.catalogue.dao.ProductDaoImpl; import com.example.catalogue.db.DataSourceProvider; import com.example.catalogue.model.Product; import com.example.catalogue.service.ProductService; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.sql.SQLException; @WebServlet(urlPatterns = "/products", loadOnStartup = 1) public class ProductServlet extends HttpServlet { private ProductService service; @Override public void init() throws ServletException { try { service = new ProductService( new ProductDaoImpl(DataSourceProvider.get()) ); } catch (Exception e) { throw new ServletException("فشل تهيئة الخدمة", e); } } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null) action = "list"; try { switch (action) { case "new" -> showForm(req, resp, new Product()); case "edit" -> showEditForm(req, resp); case "delete" -> handleDelete(req, resp); default -> showList(req, resp); } } catch (SQLException | IllegalArgumentException e) { req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/WEB-INF/views/error.jsp").forward(req, resp); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); try { if ("create".equals(action)) handleCreate(req, resp); else if ("update".equals(action)) handleUpdate(req, resp); } catch (SQLException | IllegalArgumentException e) { req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/WEB-INF/views/error.jsp").forward(req, resp); } } // ---- مساعدات خاصة -------------------------------- private void showList(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException, SQLException { req.setAttribute("products", service.listAll()); req.getRequestDispatcher("/WEB-INF/views/list.jsp").forward(req, resp); } private void showForm(HttpServletRequest req, HttpServletResponse resp, Product p) throws ServletException, IOException { req.setAttribute("product", p); req.getRequestDispatcher("/WEB-INF/views/form.jsp").forward(req, resp); } private void showEditForm(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException, SQLException { int id = Integer.parseInt(req.getParameter("id")); showForm(req, resp, service.getById(id)); } private void handleDelete(HttpServletRequest req, HttpServletResponse resp) throws IOException, SQLException { service.remove(Integer.parseInt(req.getParameter("id"))); resp.sendRedirect(req.getContextPath() + "/products"); } private void handleCreate(HttpServletRequest req, HttpServletResponse resp) throws IOException, SQLException { service.create(buildFromRequest(req, 0)); resp.sendRedirect(req.getContextPath() + "/products"); } private void handleUpdate(HttpServletRequest req, HttpServletResponse resp) throws IOException, SQLException { int id = Integer.parseInt(req.getParameter("id")); service.edit(buildFromRequest(req, id)); resp.sendRedirect(req.getContextPath() + "/products"); } private Product buildFromRequest(HttpServletRequest req, int id) { Product p = new Product(); p.setId(id); p.setName(req.getParameter("name")); p.setSku(req.getParameter("sku")); p.setPrice(Double.parseDouble(req.getParameter("price"))); p.setStock(Integer.parseInt(req.getParameter("stock"))); return p; } }
نمط Post-Redirect-Get (PRG): بعد كل POST ناجح (إنشاء أو تحديث)، يُصدر الـ servlet أمر sendRedirect بدلًا من forward. يمنع هذا المتصفح من إعادة إرسال النموذج عند تحديث الصفحة، مما يمنع إنشاء سجلات مكررة.

الخطوة الخامسة — عروض JSP

list.jsp — يتكرر على قائمة المنتجات ويوفر روابط للإجراءات.

<%@ taglib prefix="c" uri="jakarta.tags.core" %> <html><body> <h1>المنتجات</h1> <a href="${pageContext.request.contextPath}/products?action=new">+ منتج جديد</a> <table> <tr><th>الاسم</th><th>SKU</th><th>السعر</th><th>المخزون</th><th>الإجراءات</th></tr> <c:forEach var="p" items="${products}"> <tr> <td><c:out value="${p.name}"/></td> <td><c:out value="${p.sku}"/></td> <td><c:out value="${p.price}"/></td> <td><c:out value="${p.stock}"/></td> <td> <a href="products?action=edit&amp;id=${p.id}">تعديل</a> <a href="products?action=delete&amp;id=${p.id}" onclick="return confirm('حذف هذا المنتج؟')">حذف</a> </td> </tr> </c:forEach> </table> </body></html>

form.jsp — مشترك بين الإنشاء والتعديل. عندما يكون product.id غير صفر يكون النموذج في وضع التعديل.

<%@ taglib prefix="c" uri="jakarta.tags.core" %> <html><body> <h1><c:choose> <c:when test="${product.id == 0}">منتج جديد</c:when> <c:otherwise>تعديل المنتج</c:otherwise> </c:choose></h1> <form method="post" action="${pageContext.request.contextPath}/products"> <input type="hidden" name="action" value="${product.id == 0 ? 'create' : 'update'}"/> <c:if test="${product.id != 0}"> <input type="hidden" name="id" value="${product.id}"/> </c:if> <label>الاسم: <input name="name" value="<c:out value='${product.name}'/>" required/></label><br/> <label>SKU: <input name="sku" value="<c:out value='${product.sku}'/>" required/></label><br/> <label>السعر:<input name="price" type="number" step="0.01" value="${product.price}" required/></label><br/> <label>المخزون:<input name="stock" type="number" value="${product.stock}" required/></label><br/> <button type="submit">حفظ</button> <a href="${pageContext.request.contextPath}/products">إلغاء</a> </form> </body></html>
استخدم دائمًا <c:out> عند عرض نص أدخله المستخدم في JSP. بدونها، سيُنفَّذ اسم منتج كـ <script>alert(1)</script> في المتصفح. يُهرّب <c:out> القيمة إلى HTML تلقائيًا، ما يحجب XSS المنعكس.

الخطوة السادسة — توصيل كل شيء

يُهيّئ DataSourceProvider HikariCP مرة واحدة ويمرّر نفس التجمّع لكل DAO. قراءة بيانات الاعتماد من متغيرات البيئة تُبقي الأسرار خارج التحكم في الإصدارات.

package com.example.catalogue.db; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import javax.sql.DataSource; public class DataSourceProvider { private static final HikariDataSource DS; static { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(System.getenv("DB_URL")); // مثال: jdbc:mysql://localhost/catalogue cfg.setUsername(System.getenv("DB_USER")); cfg.setPassword(System.getenv("DB_PASS")); cfg.setMaximumPoolSize(10); DS = new HikariDataSource(cfg); } public static DataSource get() { return DS; } }

DDL جدول المنتجات بسيط:

CREATE TABLE products ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(120) NOT NULL, sku VARCHAR(60) NOT NULL UNIQUE, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0 );

قرارات التصميم الرئيسية

  • servlet واحد مع توزيع الإجراءات: يبسّط تعيين URL. للتطبيقات الأكبر انتقل إلى إطار عمل (Spring MVC) أو فئة موارد JAX-RS.
  • DAO لكل كيان: ProductDao يمتلك كل SQL الخاصة بـ products. لا تتسرّب SQL إلى servlet أو خدمة.
  • الخدمة تمتلك التحقق: DAO يثق بالبيانات؛ الخدمة تُطبّق القواعد. هذا الفصل يجعل اختبار منطق الأعمال دون قاعدة بيانات أمرًا بسيطًا.
  • PRG بعد POST: يمنع الإرسال المكرر عند التحديث.
  • حقن المنشئ في كل مكان: كل فئة تستقبل تبعياتها؛ لا بحث ثابت في منطق الأعمال. يُجمع الرسم البياني للكائنات في init().

الخلاصة

لديك الآن تطبيق ويب CRUD متكامل ومتعدد الطبقات: DataSource (التجميع) → DAO (SQL، تعيين الصفوف) → Service (التحقق، قواعد الأعمال) → Servlet (توزيع HTTP، PRG) → JSP (العرض، الإخراج الآمن من XSS). كل طبقة قابلة للاختبار والاستبدال بشكل مستقل. تتوسع هذه البنية بنظافة — استبدل تنفيذ DAO بآخر مدعوم من Spring Data JPA غدًا، ولن تتغير الخدمة ولا الـ servlet على الإطلاق.