8. دراسة حالة
8.1. Introduction
نعتزم كتابة تطبيق ويب لحجز المواعيد لعيادة طبية. وقد تم تناول هذه المشكلة في الوثيقة «الدرس التعليمي AngularJS / Spring 4» في URL [http://tahe.developpez.com/angularjs-spring4/]. وكانت بنية هذا التطبيق كما يلي:
![]() |
- في [1]، يقوم خادم الويب بتقديم صفحات ثابتة إلى متصفح. تحتوي هذه الصفحات على تطبيق AngularJS مبني على نموذج MVC (النموذج – العرض – وحدة التحكم). النموذج هنا هو في الوقت نفسه نموذج العروض ونموذج المجال الذي يمثله هنا الطبقة [Services]؛
- سيتفاعل المستخدم مع العروض المعروضة له في المتصفح. وستتطلب إجراءاته أحيانًا استعلام خادم Spring 4 [2]. وسيقوم هذا الخادم بمعالجة الطلب وإرجاع استجابة jSON (JavaScript Object Notation) [3]. وستُستخدم هذه الاستجابة لتحديث العرض المقدم للمستخدم.
نقترح استئناف هذا التطبيق وتنفيذه من البداية إلى النهاية باستخدام Spring MVC. وبذلك تصبح البنية كما يلي:
![]() |
سيتصل المتصفح بتطبيق [Web 1] الذي تم تنفيذه بواسطة Spring MVC، والذي سيقوم باسترداد بياناته من خدمة ويب [Web 2] التي تم تنفيذها هي الأخرى باستخدام Spring MVC.
8.2. ميزات التطبيق
ندعو القارئ لاكتشاف ميزات التطبيق من خلال اختباره. نقوم بتحميل مشاريع Maven الموجودة في المجلد [etude-de-cas] إلى STS:
![]() | ![]() |
أولاً، سنقوم بإنشاء قاعدة البيانات MySQL 5 [dbrdvmedecins] باستخدام الأداة [Wamp Server] (انظر الفقرة 9.5):
![]() |
- في [1]، نختار الأداة [phpMyAdmin] من WampServer؛
- في [2]، يتم اختيار الخيار [Importer]؛
![]() |
- في [3]، نختار الملف [database/dbrdvmedecins.sql]؛
- في [4]، نقوم بتنفيذه؛
- في [5]، يتم إنشاء قاعدة البيانات.
بعد ذلك، يتعين علينا تشغيل الخادم المتصل بقاعدة البيانات. وهو المشروع [rdvmedecins-webjson-server]
![]() |
سيكون الخادم متاحًا في URL و[http://localhost:8080]. يمكن تغيير ذلك في ملف [application.properties] الخاص بالمشروع:
![]() |
server.port=8080
يتم تسجيل خصائص الوصول إلى قاعدة البيانات في الفئة [DomainAndPersistenceConfig] التابعة للمشروع [rdvmedecins-metier-dao]:
![]() |
// مصدر البيانات MySQL
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
return dataSource;
}
إذا قمت بالوصول إلى SGBD MySQL باستخدام معرّفات أخرى، فهذا هو المكان الذي يحدث فيه ذلك.
ثم يتم تشغيل الخادم [rdvmedecins-springthymeleaf-server] بنفس الطريقة التي تم بها تشغيل الخادم السابق:
![]() | ![]() |
هذا الخادم متاح افتراضيًا على URL [http://localhost:8081]. ومرة أخرى، يمكن تكوين ذلك في ملف [application.properties] الخاص بالمشروع:
server.port=8081
بالإضافة إلى ذلك، يجب أن يعرف هذا الخادم URL الخاص بالخادم المتصل بقاعدة البيانات. توجد هذه الإعدادات في الفئة [AppConfig] المذكورة أعلاه:
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// جذر خدمة الويب / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// مهلة الانتظار بالمللي ثانية
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
إذا تم تشغيل الخادم الأول على منفذ آخر غير 8080، فيجب تعديل السطر 5.
بعد ذلك، نطلب ملف URL و [http://localhost:8081/boot.html] باستخدام متصفح:
![]() |
- إلى [1]، وهي صفحة تسجيل الدخول إلى التطبيق؛
- إلى [2] و [3]، وهما اسم المستخدم وكلمة المرور لمن يرغب في استخدام التطبيق. يوجد مستخدمان: admin/admin (اسم المستخدم/كلمة المرور) مع دور (ADMIN) و user/user مع دور (USER). الدور ADMIN هو الوحيد الذي يمتلك حق استخدام التطبيق. أما الدور USER فهو موجود فقط لإظهار الرد الذي يقدمه الخادم في حالة الاستخدام هذه؛
- في [4]، الزر الذي يتيح الاتصال بالخادم؛
- في [5]، لغة التطبيق. هناك لغتان: الفرنسية كإعداد افتراضي والإنجليزية؛
- في [6]، URL الخاص بالخادم [rdvmedecins-springthymeleaf-server]؛
![]() |
- في [1]، يتم تسجيل الدخول؛
![]() |
- بمجرد تسجيل الدخول، يمكن اختيار الطبيب الذي نريد حجز موعد معه [2] ويوم الموعد [3]. بمجرد تحديد الطبيب واليوم، يتم عرض الجدول تلقائيًا:
![]() |
- بمجرد الحصول على جدول مواعيد الطبيب، يمكن حجز موعد [5]؛
![]() |
- في [6]، يتم اختيار المريض للموعد وتأكيد هذا الاختيار في [7]؛
![]() |
بمجرد تأكيد الموعد، يتم إرجاعنا تلقائيًا إلى التقويم حيث يتم الآن تسجيل الموعد الجديد. ويمكن حذف هذا الموعد لاحقًا [8].
لقد تم وصف الميزات الرئيسية. وهي بسيطة. لنختتم بإدارة اللغة:
1

- في [1]، ننتقل من اللغة الفرنسية إلى اللغة الإنجليزية؛
![]() |
- في [2]، يتم تحويل العرض إلى اللغة الإنجليزية، بما في ذلك التقويم؛
8.3. قاعدة البيانات
![]() |
قاعدة البيانات التي سيُشار إليها فيما بعد بـ [dbrdvmedecins] هي قاعدة بيانات MySQL5 تحتوي على الجداول التالية:
![]() |
تتم إدارة المواعيد من خلال الجداول التالية:
- [medecins]: تحتوي على قائمة الأطباء في العيادة؛
- [clients]: تحتوي على قائمة المرضى في العيادة؛
- [creneaux]: تحتوي على الفترات الزمنية المتاحة لكل طبيب؛
- [rv]: تحتوي على قائمة مواعيد الأطباء.
الجداول [roles] و [users] و [users_roles] هي جداول مرتبطة بعملية المصادقة. في البداية، لن نتطرق إليها. والعلاقات بين الجداول التي تدير المواعيد هي كما يلي:
![]() |
- كل فترة زمنية تخص طبيبًا واحدًا – ويكون لكل طبيب فترة زمنية واحدة أو أكثر؛
- يجمع الموعد بين عميل وطبيب عبر فترة زمنية مخصصة لهذا الأخير؛
- كل عميل لديه صفر أو أكثر من المواعيد؛
- يُربط بكل فترة زمنية موعد واحد أو أكثر (في أيام مختلفة).
8.3.1. الجدول [MEDECINS]
تحتوي على معلومات عن الأطباء الذين تديرهم التطبيق [RdvMedecins].
![]() | ![]() |
- ID: الرقم الذي يحدد هوية الطبيب - المفتاح الأساسي للجدول
- VERSION: الرقم الذي يحدد إصدار السطر في الجدول. يتم زيادة هذا الرقم بمقدار 1 في كل مرة يتم فيها إجراء تعديل على السطر.
- NOM: اسم الطبيب
- PRENOM: اسمه الأول
- TITRE: لقبه (الآنسة، السيدة، السيد)
8.3.2. الجدول [CLIENTS]
يتم تسجيل مرضى الأطباء المختلفين في الجدول [CLIENTS]:
![]() | ![]() |
- ID: الرقم التعريفي للعميل - المفتاح الأساسي للجدول
- VERSION: الرقم الذي يحدد إصدار السطر في الجدول. يتم زيادة هذا الرقم بمقدار 1 في كل مرة يتم فيها إجراء تعديل على السطر.
- NOM: اسم العميل
- PRENOM: اسمه الأول
- TITRE: لقبه (الآنسة، السيدة، السيد)
8.3.3. الجدول [CRENEAUX]
تسرد هذه الجدول الفترات الزمنية التي يمكن فيها استخدام RV:
![]() |
![]() |
- ID: الرقم الذي يحدد الفترة الزمنية - المفتاح الأساسي للجدول (السطر 8)
- VERSION: الرقم الذي يحدد إصدار السطر في الجدول. يتم زيادة هذا الرقم بمقدار 1 في كل مرة يتم فيها إجراء تعديل على السطر.
- ID_MEDECIN: الرقم الذي يحدد الطبيب الذي ينتمي إليه هذا الموعد – مفتاح خارجي في العمود MEDECINS (ID).
- HDEBUT: وقت بدء الفترة الزمنية
- MDEBUT: الدقائق الأولى للفترة
- HFIN: ساعة انتهاء الفترة الزمنية
- MFIN: الدقائق التي تنتهي فيها الفترة الزمنية
يشير السطر الثاني من الجدول [CRENEAUX] (انظر [1] أعلاه)، على سبيل المثال، إلى أن الفترة رقم 2 تبدأ في الساعة 8:20 وتنتهي في الساعة 8:40، وتخص الطبيبة رقم 1 (السيدة ماري PELISSIER).
8.3.4. الجدول [RV]
تسرد المواعيد المحددة لكل طبيب:
![]() |
- ID: الرقم الذي يحدد RV بشكل فريد – المفتاح الأساسي
- JOUR: يوم RV
- ID_CRENEAU: الفترة الزمنية لـ RV – مفتاح خارجي في الحقل [ID] في الجدول [CRENEAUX] – يحدد في آن واحد الفترة الزمنية والطبيب المعني.
- ID_CLIENT: رقم العميل الذي تم الحجز لصالحه – مفتاح خارجي في الحقل [ID] في الجدول [CLIENTS]
تخضع هذه الجدولة لقيود التفرد على قيم الأعمدة المرتبطة (JOUR، ID_CRENEAU):
إذا كان أحد صفوف الجدول [RV] يحتوي على القيمة (JOUR1، ID_CRENEAU1) في الأعمدة (JOUR، ID_CRENEAU)، فلا يمكن أن تظهر هذه القيمة في أي مكان آخر. وإلا، فهذا يعني أنه تم تسجيل قيمتين لـ RV في نفس الوقت لنفس الطبيب. من منظور برمجة Java، يقوم برنامج التشغيل JDBC الخاص بقاعدة البيانات بتشغيل برنامج SQLException عند حدوث هذه الحالة.
السطر الذي يحتوي على القيمة 3 في id (انظر [1] أعلاه) يعني أنه تم حجز موعد RV للفترة رقم 20 والعميل رقم 4 في 23/08/2006. توضح لنا الجدولة [CRENEAUX] أن الموعد رقم 20 يتوافق مع الفترة الزمنية من الساعة 16:20 إلى 16:40، وهو مخصص للطبيبة رقم 1 (السيدة ماري PELISSIER). توضح لنا الجدولة [CLIENTS] أن العميل رقم 4 هي الآنسة بريجيت BISTROU.
8.3.5. إنشاء قاعدة البيانات
لإنشاء قاعدة البيانات [dbrdvmedecins]، يتم توفير برنامج نصي [dbrdvmedecins.sql] مع الأمثلة الواردة في هذا المستند [1-3]:
![]() |
نستخدم الأداة [PhpMyAdmin] من WampServer:
![]() |
- في [1]، نختار الأداة [phpMyAdmin] من WampServer؛
- في [2]، نختار الخيار [Importer]؛
![]() |
- في [3]، نختار الملف [database/dbrdvmedecins.sql]؛
- في [4]، نقوم بتنفيذه؛
- في [5]، يتم إنشاء قاعدة البيانات.
8.4. خدمة الويب / jSON
![]() |
في البنية المذكورة أعلاه، ننتقل الآن إلى إنشاء خدمة الويب / jSON التي تم إنشاؤها باستخدام إطار عمل Spring MVC. سنقوم بكتابتها على عدة مراحل:
- أولاً الطبقات [métier] و [DAO] (كائنات الوصول إلى البيانات). سنستخدم هنا Spring Data؛
- ثم خدمة الويب jSON بدون مصادقة. سنستخدم هنا Spring MVC؛
- ثم سنضيف جزء المصادقة باستخدام Spring Security.
ما يلي هو نسخة من المستند [http://tahe.developpez.com/angularjs-spring4/] مع بعض التعديلات.
8.4.1. مقدمة إلى Spring Data
سنقوم بتنفيذ الطبقة [DAO] للمشروع باستخدام Spring Data، وهو فرع من نظام Spring البيئي.
![]() |
يوجد على موقع Spring العديد من الدروس التعليمية للبدء في استخدام Spring [http://spring.io/guides]. سنستخدم إحداها لتقديم Spring Data. ونستخدم في ذلك Spring Tool Suite (STS).
![]() |
- في [1]، نقوم باستيراد أحد الدروس التعليمية من [spring.io/guides]؛
![]() |
- في [2]، نختار البرنامج التعليمي [Accessing Data Jpa] الذي يوضح كيفية الوصول إلى قاعدة البيانات باستخدام Spring Data؛
- في [3]، نختار مشروعًا تم تكوينه بواسطة Maven؛
- في [4]، يمكن تقديم البرنامج التعليمي في شكلين: [initial] وهي نسخة فارغة يتم ملؤها باتباع البرنامج التعليمي، أو [complete] وهي النسخة النهائية من البرنامج التعليمي. نختار هذه الأخيرة؛
- في [5]، يمكننا اختيار عرض البرنامج التعليمي في متصفح؛
- في [6]، المشروع النهائي.
8.4.1.1. تكوين Maven للمشروع
يتم تكوين تبعيات Maven للمشروع في الملف [pom.xml]:
<groupId>org.springframework</groupId>
<artifactId>gs-accessing-data-jpa</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<properties>
<!-- استخدام UTF-8 لكل شيء -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>hello.Application</start-class>
</properties>
- الأسطر 5-9: تحدد مشروع Maven أم. وهو الذي يحدد الجزء الأساسي من تبعيات المشروع. قد تكون هذه التبعيات كافية، وفي هذه الحالة لا نضيف المزيد، أو قد لا تكون كافية، وفي هذه الحالة نضيف التبعيات الناقصة؛
- الأسطر 12-15: تحدد تبعية لـ [spring-boot-starter-data-jpa]. يحتوي هذا الأرتيفاكت على فئات Spring Data؛
- الأسطر 16-19: تحدد تبعية لـ SGBD و H2 التي تسمح بإنشاء وإدارة قواعد البيانات في الذاكرة.
لنلقِ نظرة على الفئات التي توفرها هذه التبعيات:
![]() | ![]() | ![]() |
وهي عديدة جدًا:
- بعضها ينتمي إلى منظومة Spring (تلك التي تبدأ بـ spring)؛
- وبعضها الآخر ينتمي إلى نظام Hibernate (hibernate، jboss) الذي نستخدم هنا تنفيذه JPA؛
- وبعضها الآخر عبارة عن مكتبات اختبار (junit، hamcrest)؛
- وأخرى عبارة عن مكتبات سجلات (log4j، logback، slf4j)؛
سنحتفظ بها جميعًا. أما بالنسبة لتطبيق قيد التشغيل، فيجب الاحتفاظ فقط بالمكتبات الضرورية.
في السطر 26 من الملف [pom.xml]، نجد السطر التالي:
<start-class>hello.Application</start-class>
يرتبط هذا السطر بالسطور التالية:
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
الأسطر 6-9، يتيح المكون الإضافي [spring-boot-maven-plugin] إنشاء ملف jar القابل للتنفيذ للتطبيق. ويشير السطر 26 من الملف [pom.xml] إلى الفئة القابلة للتنفيذ لهذا الملف jar.
8.4.1.2. الطبقة [JPA]
يتم الوصول إلى قاعدة البيانات من خلال طبقة [JPA]، Java Persistence API:
![]() |
![]() |
التطبيق بسيط ويقوم بإدارة العملاء [Customer]. الفئة [Customer] هي جزء من الطبقة [JPA] وهي كما يلي:
package hello;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String firstName;
private String lastName;
protected Customer() {
}
public Customer(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public String toString() {
return String.format("Customer[id=%d, firstName='%s', lastName='%s']", id, firstName, lastName);
}
}
يحتوي العميل على معرّف [id]، واسم أول [firstName]، واسم عائلة [lastName]. تمثل كل مثيل [Customer] سطرًا في جدول قاعدة البيانات.
- السطر 8: تعليق توضيحي JPA الذي يجعل استمرارية مثيلات [Customer] (إنشاء، قراءة، تحديث، حذف) تُدار بواسطة تنفيذ JPA. وبناءً على تبعيات Maven، نلاحظ أن التنفيذ JPA / Hibernate هو الذي يتم استخدامه؛
- السطران 11-12: تعليقات توضيحية JPA تربط الحقل [id] بالمفتاح الأساسي لجدول [Customer]. يشير السطر 12 إلى أن التنفيذ JPA سيستخدم طريقة إنشاء المفتاح الأساسي الخاصة بـ SGBD المستخدم، وهو هنا H2؛
لا توجد تعليقات توضيحية أخرى لـ JPA. وبالتالي، سيتم استخدام القيم الافتراضية:
- ستحمل الجدولة الخاصة بـ [Customer] اسم الفئة، أي [Customer]؛
- وستحمل أعمدة هذا الجدول أسماء حقول الفئة: [id, firstName, lastName] مع العلم أن الأحرف الكبيرة والصغيرة لا تؤخذ في الاعتبار في اسم عمود الجدول؛
تجدر الإشارة إلى أنه لم يتم ذكر اسم التنفيذ المستخدم JPA في أي مرحلة.
8.4.1.3. الطبقة [DAO]
![]() |
![]() |
تقوم الفئة [CustomerRepository] بتنفيذ الطبقة [DAO]. وفيما يلي شفرة البرمجة الخاصة بها:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
وبالتالي فهي واجهة وليست فئة (السطر 7). وهي تمتد من واجهة [CrudRepository]، وهي واجهة تابعة لـ Spring Data (السطر 5). يتم تعريف هذه الواجهة بواسطة نوعين: الأول هو نوع العناصر المدارة، وهو هنا النوع [Customer]، والثاني هو نوع المفتاح الأساسي للعناصر المدارة، وهو هنا النوع [Long]. واجهة [CrudRepository] هي كما يلي:
package org.springframework.data.repository;
import java.io.Serializable;
@NoRepositoryBean
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> save(Iterable<S> entities);
T findOne(ID id);
boolean exists(ID id);
Iterable<T> findAll();
Iterable<T> findAll(Iterable<ID> ids);
long count();
void delete(ID id);
void delete(T entity);
void delete(Iterable<? extends T> entities);
void deleteAll();
}
تحدد هذه الواجهة العمليات CRUD (إنشاء – قراءة – تحديث – حذف) التي يمكن إجراؤها على النوع JPA T:
- السطر 8: تتيح طريقة save حفظ كيان T في قاعدة البيانات. وهي تحفظ الكيان باستخدام المفتاح الأساسي الذي منحته إياه SGBD. كما تتيح تحديث كيان T المحدد بمفتاحه الأساسي id. ويتم اختيار إحدى العمليتين وفقًا لقيمة المفتاح الأساسي id: إذا كانت قيمته null، تتم عملية التخزين الدائم، وإلا تتم عملية التحديث؛
- السطر 10: نفس الأمر ولكن بالنسبة لقائمة من الكيانات؛
- السطر 12: تتيح الطريقة findOne استرجاع كيان T محدد بواسطة مفتاحه الأساسي id؛
- السطر 22: تسمح الطريقة delete بحذف كيان T المحدد بمفتاحه الأساسي id؛
- الأسطر 24-28: أشكال مختلفة من الطريقة [delete]؛
- السطر 16: تسمح الطريقة [findAll] بالبحث عن جميع الكيانات T المخزنة؛
- السطر 18: نفس الشيء، لكن يقتصر على الكيانات التي تم تمرير قائمة معرّفاتها؛
لنعد إلى الواجهة [CustomerRepository]:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
- السطر 9 يسمح بالبحث عن [Customer] باستخدام اسمه [lastName]؛
وهذا كل شيء بخصوص الطبقة [DAO]. لا توجد فئة لتنفيذ الواجهة السابقة. يتم إنشاء هذه الفئة عند التنفيذ بواسطة [Spring Data]. يتم تنفيذ أساليب واجهة [CrudRepository] تلقائيًا. أما بالنسبة للأساليب المضافة إلى واجهة [CustomerRepository]، فالأمر يختلف. لنعد إلى تعريف [Customer]:
private long id;
private String firstName;
private String lastName;
يتم تنفيذ الطريقة الموجودة في السطر 9 تلقائيًا بواسطة [Spring Data] لأنها تشير إلى الحقل [lastName] (السطر 3) في [Customer]. عندما يصادف Spring Data طريقة [findBySomething] في الواجهة المطلوب تنفيذها، فإنه ينفذها باستخدام الاستعلام التالي JPQL (لغة استعلامات Java Persistence):
لذلك يجب أن يحتوي النوع T على حقل باسم [something]. وبذلك فإن الطريقة
سيتم تنفيذها بواسطة كود مشابه لما يلي:
return [em].createQuery("select c from Customer c where c.lastName=:value").setParameter("value",lastName).getResultList()
حيث يشير [em] إلى سياق الاستمرارية JPA. ولا يكون ذلك ممكنًا إلا إذا كانت الفئة [Customer] تحتوي على حقل باسم [lastName]، وهو ما ينطبق هنا.
في الختام، في الحالات البسيطة، تتيح لنا Spring Data تنفيذ الطبقة [DAO] باستخدام واجهة بسيطة.
8.4.1.4. الطبقة [console]
![]() |
![]() |
الفئة [Application] هي كما يلي:
package hello;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
// حفظ عدد قليل من العملاء
repository.save(new Customer("Jack", "Bauer"));
repository.save(new Customer("Chloe", "O'Brian"));
repository.save(new Customer("Kim", "Bauer"));
repository.save(new Customer("David", "Palmer"));
repository.save(new Customer("Michelle", "Dessler"));
// استدعاء جميع العملاء
Iterable<Customer> customers = repository.findAll();
System.out.println("Customers found with findAll():");
System.out.println("-------------------------------");
for (Customer customer : customers) {
System.out.println(customer);
}
System.out.println();
// استدعاء عميل واحد باستخدام ID
Customer customer = repository.findOne(1L);
System.out.println("Customer found with findOne(1L):");
System.out.println("--------------------------------");
System.out.println(customer);
System.out.println();
// استرجاع العملاء حسب الاسم الأخير
List<Customer> bauers = repository.findByLastName("Bauer");
System.out.println("Customer found with findByLastName('Bauer'):");
System.out.println("--------------------------------------------");
for (Customer bauer : bauers) {
System.out.println(bauer);
}
context.close();
}
}
- السطر 10: يشير إلى أن الفئة تُستخدم لتكوين Spring. يمكن بالفعل تكوين الإصدارات الحديثة من Spring بلغة Java بدلاً من XML. يمكن استخدام الطريقتين في آن واحد. في كود الفئة التي تحمل التعليق التوضيحي [Configuration]، نجد عادةً حبات Spring، أي تعريفات للفئات التي سيتم إنشاء مثيلات لها. هنا لم يتم تعريف أي حبة. تجدر الإشارة هنا إلى أنه عند العمل مع SGBD، يجب تعريف حبات Spring متنوعة:
- ملف [EntityManagerFactory] الذي يحدد التنفيذ JPA المطلوب استخدامه،
- ملف [DataSource] الذي يحدد مصدر البيانات المطلوب استخدامه،
- [TransactionManager] الذي يحدد مدير المعاملات المطلوب استخدامه؛
هنا لم يتم تعريف أي من هذه الفاصوليا.
- السطر 11: التعليق التوضيحي [EnableAutoConfiguration] هو تعليق توضيحي مستمد من المشروع [Spring Boot] (السطران 5-6). يطلب هذا التعليق التوضيحي من Spring Boot عبر الفئة [SpringApplication] (السطر 16) تكوين التطبيق وفقًا للمكتبات الموجودة في مسار الفئات (Classpath) الخاص به. ونظرًا لوجود مكتبات Hibernate في مسار الفئات، سيتم تنفيذ المكون [entityManagerFactory] باستخدام Hibernate. ونظرًا لوجود مكتبة SGBD وH2 في مسار الفئات (Classpath)، فسيتم تنفيذ الكائن [dataSource] باستخدام H2. في الكائن [dataSource]، يجب أيضًا تعريف المستخدم وكلمة المرور الخاصة به. هنا سيستخدم Spring Boot المسؤول الافتراضي لـ H2، وهو بدون كلمة مرور. ونظرًا لوجود المكتبة [spring-tx] في مسار الفئات (Classpath)، فسيتم استخدام مدير المعاملات الخاص بـ Spring.
بالإضافة إلى ذلك، سيتم فحص المجلد الذي توجد فيه الفئة [Application] بحثًا عن «بيانات» (beans) يتعرف عليها Spring ضمناً أو يتم تعريفها صراحةً بواسطة تعليقات Spring. وبالتالي، سيتم فحص الفئتين [Customer] و [CustomerRepository]. ونظرًا لأن الفئة الأولى تحمل التعليق التوضيحي [@Entity]، فسيتم تصنيفها ككيان يجب أن يديره Hibernate. ونظرًا لأن الفئة الثانية تمتد واجهة [CrudRepository]، فسيتم تسجيلها كـ bean في Spring.
لنلقِ نظرة على السطرين 16 و17 من الكود:
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
- السطر 16: يتم تنفيذ الطريقة الثابتة [run] الخاصة بالفئة [SpringApplication] في مشروع Spring Boot. معلمتها هي الفئة التي تحمل تعليقًا [Configuration] أو [EnableAutoConfiguration]. وعندئذٍ سيحدث كل ما تم شرحه سابقًا. والنتيجة هي سياق تطبيق Spring، أي مجموعة من الحبوب (beans) التي يديرها Spring؛
- السطر 17: نطلب من سياق Spring هذا حبة تُنفذ الواجهة [CustomerRepository]. ونحصل هنا على الفئة التي أنشأتها Spring Data لتنفيذ هذه الواجهة.
العمليات التالية لا تفعل سوى استخدام أساليب المكون الذي ينفذ الواجهة [CustomerRepository]. تجدر الإشارة في السطر 50 إلى أن السياق قد أُغلق. نتائج وحدة التحكم هي كما يلي:
- الأسطر 1-8: شعار مشروع Spring Boot؛
- السطر 9: يتم تنفيذ الفئة [hello.Application]؛
- السطر 10: [AnnotationConfigApplicationContext] هي فئة تُنفذ واجهة [ApplicationContext] الخاصة بـ Spring. وهي عبارة عن حاوية للفاصوليا (beans)؛
- السطر 11: يتم تنفيذ «البيان» [entityManagerFactory] باستخدام الفئة [LocalContainerEntityManagerFactory]، وهي فئة تابعة لـ Spring؛
- السطر 15: يظهر [Hibernate]. وقد تم اختيار هذا التنفيذ JPA؛
- السطر 19: لهجة Hibernate هي المتغير SQL الذي يجب استخدامه مع SGBD. وهنا تُظهر اللهجة [H2Dialect] أن Hibernate سيعمل مع SGBD وH2؛
- السطران 21-22: تم إنشاء قاعدة البيانات. يتم إنشاء الجدول [CUSTOMER]. وهذا يعني أن Hibernate قد تم تكوينه لإنشاء الجداول استنادًا إلى التعريفات JPA، وهنا التعريف JPA للفئة [Customer]؛
- الأسطر 27-31: تم إدراج العملاء الخمسة؛
- الأسطر 336-35: نتيجة الطريقة [findOne] الخاصة بالواجهة؛
- الأسطر 37-40: نتائج الطريقة [findByLastName]؛
- السطر 41 وما يليه: سجلات إغلاق سياق Spring.
8.4.1.5. التكوين اليدوي لمشروع Spring Data
نقوم بنسخ المشروع السابق إلى المشروع [gs-accessing-data-jpa-2]:
![]() |
في هذا المشروع الجديد، لن نعتمد على التكوين التلقائي الذي يقوم به Spring Boot. سنقوم به يدويًا. قد يكون ذلك مفيدًا إذا كانت الإعدادات الافتراضية لا تناسبنا.
أولاً، سنقوم بتحديد التبعيات الضرورية في الملف [pom.xml]:
...
<dependencies>
<!-- Spring Core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- معاملات Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring ORM -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.7.1.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.1.10.RELEASE</version>
</dependency>
<!-- Hibernate -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>4.3.4.Final</version>
</dependency>
<!-- قاعدة بيانات H2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.178</version>
</dependency>
<!-- Commons DBCP -->
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
<version>1.6</version>
</dependency>
</dependencies>
...
</project>
- الأسطر 2-18: مكتبات Spring الأساسية؛
- الأسطر 19-29: مكتبات Spring لإدارة المعاملات مع قاعدة البيانات؛
- الأسطر 30-35: مكتبة Spring للعمل مع ORM (مُخطِط العلاقات بين الكائنات وقواعد البيانات)؛
- الأسطر 36-41: Spring Data المستخدم للوصول إلى قاعدة البيانات؛
- الأسطر 42-47: Spring Boot لتشغيل التطبيق؛
- الأسطر 54-59: SGBD و H2؛
- الأسطر 60-70: غالبًا ما تُستخدم قواعد البيانات مع مجموعات من الاتصالات المفتوحة التي تتجنب فتح وإغلاق الاتصالات بشكل متكرر. هنا، يتم استخدام تطبيق [commons-dbcp]؛
وفي [pom.xml] أيضًا، يتم تعديل اسم الفئة القابلة للتنفيذ:
<properties>
...
<start-class>demo.console.Main</start-class>
</properties>
في المشروع الجديد، لا تتغير الكيان [Customer] والواجهة [CustomerRepository]. سنقوم بتعديل الفئة [Application] التي سيتم تقسيمها إلى فئتين:
- [Config] التي ستكون فئة التكوين:
- [Main] التي ستكون الفئة القابلة للتنفيذ؛
![]() |
الفئة القابلة للتنفيذ [Main] هي نفسها كما في السابق بدون تعليقات التكوين:
package demo.console;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
...
context.close();
}
}
- السطر 12: لم تعد الفئة [Main] تحتوي على تعليقات توضيحية للتكوين؛
- السطر 16: يتم تشغيل التطبيق باستخدام Spring Boot. المعلمة [Config.class] هي الفئة الجديدة لتكوين المشروع؛
الفئة [Config] التي تقوم بتكوين المشروع هي كما يلي:
package demo.config;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
//@ComponentScan(basePackages = { "demo" })
//@EntityScan(basePackages = { "demo.entities" })
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = { "demo.repositories" })
@Configuration
public class Config {
// مصدر البيانات H2
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:./demo");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
// المزود JPA
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(true);
hibernateJpaVendorAdapter.setDatabase(Database.H2);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan("demo.entities");
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// مدير المعاملات
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- السطر 22: تعمل العلامة التوضيحية [@Configuration] على تحويل الفئة [Config] إلى فئة تكوين Spring؛
- السطر 21: يسمح التعليق التوضيحي [@EnableJpaRepositories] بتحديد المجلدات التي توجد فيها واجهات Spring Data [CrudRepository]. ستصبح هذه الواجهات مكونات Spring وستكون متاحة في سياقها؛
- السطر 20: تشير العلامة التوضيحية [@EnableTransactionManagement] إلى أن أساليب واجهات [CrudRepository] يجب أن تُنفَّذ داخل معاملة؛
- السطر 19: تسمح العلامة التوضيحية [@EntityScan] بتحديد المجلدات التي يجب البحث فيها عن الكيانات JPA. وقد تم تعليقها هنا، لأن هذه المعلومة قد ذُكرت صراحةً في السطر 50. يجب أن يكون هذا التعليق موجودًا في حالة استخدام الوضع [@EnableAutoConfiguration]، وإذا لم تكن الكيانات JPA موجودة في نفس المجلد الذي توجد فيه فئة التكوين؛
- السطر 18: يسمح التعليق التوضيحي [@ComponentScan] بإدراج المجلدات التي يجب البحث فيها عن مكونات Spring. مكونات Spring هي فئات مُعلَّمة بعلامات Spring مثل @Service و@Component و@Controller، ... ولا توجد هنا أي مكونات أخرى غير تلك المُعرَّفة داخل الفئة [Config]، ولذلك تم تعليق العلامة؛
- الأسطر 25-33: تحدد مصدر البيانات، وهو قاعدة البيانات H2. إن التعليق التوضيحي @Bean في السطر 25 هو الذي يجعل الكائن الذي تم إنشاؤه بواسطة هذه الطريقة مكونًا يديره Spring. يمكن أن يكون اسم الطريقة هنا أي اسم. ومع ذلك، يجب أن يُسمى [dataSource] إذا كانت الطريقة EntityManagerFactory في السطر 47 غير موجودة وتم تعريفها عن طريق التكوين التلقائي؛
- السطر 29: ستسمى قاعدة البيانات [demo] وسيتم إنشاؤها في مجلد المشروع؛
- الأسطر 36-43: تحدد التنفيذ JPA المستخدم، وهو هنا تنفيذ Hibernate. يمكن أن يكون اسم الأسلوب هنا أي اسم؛
- السطر 39: لا توجد سجلات لـ SQL؛
- السطر 30: سيتم إنشاء قاعدة البيانات إذا لم تكن موجودة؛
- الأسطر 46-54: تحدد EntityManagerFactory التي ستدير استمرارية JPA. يجب أن يُسمى الأسلوب بالضرورة [entityManagerFactory]؛
- السطر 47: تتلقى الطريقة معلمتين من النوع الخاص بالفاصوليتين المحددتين سابقًا. سيتم بعد ذلك إنشاء هاتين الفاصوليتين ثم حقنهما بواسطة Spring كمعلمات للطريقة؛
- السطر 49: يحدد التنفيذ JPA المستخدم؛
- السطر 50: يحدد المجلدات التي توجد فيها الكيانات JPA؛
- السطر 51: يحدد مصدر البيانات المراد إدارته؛
- الأسطر 57-62: مدير المعاملات. يجب أن يُسمى الأسلوب بالضرورة [transactionManager]. ويتلقى كمعلمة «البيان» الموجود في الأسطر 46-54؛
- السطر 60: يتم ربط مدير المعاملات بـ EntityManagerFactory؛
يمكن تعريف الطرق السابقة بأي ترتيب.
يؤدي تنفيذ المشروع إلى نفس النتائج. يظهر ملف جديد في مجلد المشروع، وهو ملف قاعدة البيانات H2:
![]() |
وأخيرًا، يمكن الاستغناء عن Spring Boot. نقوم بإنشاء فئة قابلة للتنفيذ ثانيةً باسم [Main2]:
![]() |
تحتوي الفئة [Main2] على الكود التالي:
package demo.console;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main2 {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
....
context.close();
}
}
- السطر 15: أصبحت فئة التكوين [Config] تُستخدم الآن بواسطة فئة Spring [AnnotationConfigApplicationContext]. ويمكن ملاحظة في السطر 5 أنه لم تعد هناك أي تبعيات لـ Spring Boot.
يؤدي التنفيذ إلى نفس النتائج التي تم الحصول عليها سابقًا.
8.4.1.6. إنشاء ملف أرشيف قابل للتنفيذ
لإنشاء ملف أرشيف قابل للتنفيذ للمشروع، يمكن اتباع الخطوات التالية:
![]() |
- في [1]: يتم إنشاء تكوين للتنفيذ؛
- في [2]: من النوع [Java Application]
- في [3]: يحدد المشروع المراد تنفيذه (استخدم الزر Browse)؛
- في [4]: يشير إلى الفئة المراد تنفيذها؛
- في [5]: اسم تكوين التنفيذ – يمكن أن يكون أي اسم؛
![]() |
- في [6]: يتم تصدير المشروع؛
- في [7]: في شكل أرشيف JAR قابل للتنفيذ؛
- في [8]: يحدد المسار واسم الملف القابل للتنفيذ المراد إنشاؤه؛
- في [9]: اسم إعدادات التشغيل التي تم إنشاؤها في [5]؛
بعد ذلك، نفتح نافذة الأوامر في المجلد الذي يحتوي على الأرشيف القابل للتنفيذ:
يتم تشغيل الملف المضغوط بالطريقة التالية:
.....\dist>java -jar gs-accessing-data-jpa-2.jar
والنتائج التي تظهر في نافذة الأوامر هي كما يلي:
8.4.1.7. إنشاء مشروع Spring Data جديد
لإنشاء هيكل مشروع Spring Data، يمكن اتباع الخطوات التالية:
![]() |
- في [1]، نقوم بإنشاء مشروع جديد؛
- في [2]: من النوع [Spring Starter Project]؛
- سيكون المشروع الذي تم إنشاؤه مشروعًا من نوع Maven. في [3]، نحدد اسم مجموعة المشروع؛
- في [4]: نحدد اسم الأرتيفاكت (ملف jar هنا) الذي سيتم إنشاؤه عند بناء المشروع؛
- في [5]: يُحدد حزمة الفئة القابلة للتنفيذ التي سيتم إنشاؤها في المشروع؛
- في [6]: اسم المشروع في Eclipse – يمكن أن يكون أي اسم (لا يجب أن يكون مطابقًا لـ [4])؛
- في [7]: يُشار إلى أنه سيتم إنشاء مشروع يحتوي على طبقة [JPA]. وسيتم عندئذٍ تضمين التبعيات اللازمة لمثل هذا المشروع في الملف [pom.xml]؛
![]() |
- في [8]: المشروع الذي تم إنشاؤه؛
يحتوي الملف [pom.xml] على التبعيات اللازمة لمشروع JPA:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath/> <!-- البحث عن العنصر الأصلي من المستودع -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
- الأسطر 9-12: التبعيات اللازمة لـ JPA – ستشمل [Spring Data]؛
- الأسطر 13-17: التبعيات اللازمة لاختبارات JUnit المدمجة مع Spring؛
الفئة القابلة للتنفيذ [Application] لا تقوم بأي شيء ولكنها مُعدة مسبقًا:
package istia.st;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
فئة الاختبارات [ApplicationTests] لا تقوم بأي شيء ولكنها مُهيأة مسبقًا:
package istia.st;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class ApplicationTests {
@Test
public void contextLoads() {
}
}
- السطر 9: يسمح التعليق التوضيحي [@SpringApplicationConfiguration] باستخدام ملف التكوين [Application]. وبذلك ستستفيد فئة الاختبار من جميع الفاصوليا التي سيتم تعريفها بواسطة هذا الملف؛
- السطر 8: تسمح العلامة [@RunWith] بدمج Spring مع JUnit: ستتمكن الفئة من التنفيذ كاختبار JUnit. [@RunWith] هو تعليق توضيحي لـ JUnit (السطر 4) في حين أن الفئة [SpringJUnit4ClassRunner] هي فئة Spring (السطر 6)؛
والآن بعد أن أصبح لدينا هيكل أساسي للتطبيق JPA، يمكننا استكماله لكتابة مشروع طبقة الاستمرارية للخادم في تطبيقنا لإدارة المواعيد.
8.4.2. مشروع Eclipse الخاص بالخادم
![]() |
![]() |
العناصر الرئيسية للمشروع هي كما يلي:
- [pom.xml]: ملف تكوين Maven للمشروع؛
- [rdvmedecins.entities]: كيانات JPA؛
- [rdvmedecins.repositories]: واجهات Spring Data للوصول إلى الكيانات JPA؛
- [rdvmedecins.metier]: الطبقة [métier]؛
- [rdvmedecins.domain]: الكيانات التي تعالجها الطبقة [métier]؛
- [rdvmdecins.config]: فئات التكوين الخاصة بطبقة الاستمرارية؛
- [rdvmedecins.boot]: تطبيق وحدة تحكم بسيط؛
8.4.3. تكوين Maven
![]() | ![]() | ![]() |
ملف [pom.xml] الخاص بالمشروع هو كما يلي:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- اختبار Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- أمان Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- برنامج تجريبي JDBC / MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Tomcat JDBC -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</dependency>
<!-- أداة التعيين jSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Googe Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
</dependencies>
<properties>
<!-- استخدم UTF-8 لكل شيء -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>rdvmedecins.boot.Boot</start-class>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>org.jboss.repository.releases</id>
<name>JBoss Maven Release Repository</name>
<url>https://repository.jboss.org/nexus/content/repositories/releases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
- الأسطر 8-12: يعتمد المشروع على المشروع الأصلي [spring-boot-starter-parent]. بالنسبة للتبعيات الموجودة بالفعل في المشروع الأصلي، لا يتم تحديد إصدار. سيتم استخدام الإصدار المحدد في المشروع الأصلي. أما بالنسبة للتبعيات الأخرى، فيتم الإعلان عنها بشكل عادي؛
- الأسطر 15-18: بالنسبة لـ Spring Data؛
- الأسطر 20-24: للاختبارات JUnit؛
- الأسطر 26-29: لمكتبة Spring Security التي تستخدم طبقة [DAO] إحدى فئات تشفير كلمات المرور؛
- الأسطر 31-34: برنامج التشغيل JDBC الخاص بـ SGBD و MySQL5؛
- الأسطر 36-39: مجموعة اتصالات Tomcat JDBC. مجموعة الاتصالات هي مجموعة من الاتصالات المفتوحة إلى قاعدة البيانات. عندما يرغب الكود في فتح اتصال، يتم طلبه من المجموعة. وعندما يغلق الكود الاتصال، لا يتم إغلاقه بل يتم إرجاعه إلى المجموعة. ويتم كل هذا بشكل شفاف من ناحية الكود. ونحقق مكاسب في الأداء لأن فتح/إغلاق الاتصال بشكل متكرر يستغرق وقتًا. هنا، يقوم مجمع الاتصالات بإنشاء عدد معين من الاتصالات مع قاعدة البيانات فور إنشاء مثيله. بعد ذلك، لا يتم فتح أو إغلاق أي اتصال، إلا إذا تبين أن عدد الاتصالات المخزنة في المجمع غير كافٍ. في هذه الحالة، يقوم المجمع تلقائيًا بإنشاء اتصالات جديدة؛
- الأسطر 41-44: مكتبة Jackson لإدارة jSON؛
- الأسطر 46-50: مكتبة Google لإدارة المجموعات؛
8.4.4. الكيانات JPA
![]() |
الكيانات JPA هي الكائنات التي ستقوم بتغليف صفوف جداول قاعدة البيانات.
![]() |
الفئة [AbstractEntity] هي الفئة الأم للكيانات [Personne, Creneau, Rv]. وتعريفها كما يلي:
package rdvmedecins.entities;
import java.io.Serializable;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import javax.persistence.Version;
@MappedSuperclass
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@Version
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// التهيئة
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1) || entity==null) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id.longValue() == other.id.longValue();
}
// الوصول إلى القيم وتعيينها
..
}
- السطر 11: يشير التعليق التوضيحي [@MappedSuperclass] إلى أن الفئة المُعلَّقة عليها هي الفئة الأم للكيانات JPA و[@Entity]؛
- الأسطر 15-17: تحدد المفتاح الأساسي [id] لكل كيان. التعليق التوضيحي [@Id] هو الذي يجعل الحقل [id] مفتاحًا أساسيًا. تشير التعليقات التوضيحية [@GeneratedValue(strategy = GenerationType.IDENTITY)] إلى أن قيمة هذا المفتاح الأساسي يتم إنشاؤها بواسطة SGBD وأن طريقة الإنشاء [IDENTITY] مفروضة. بالنسبة إلى SGBD وMySQL، هذا يعني أن المفاتيح الأولية سيتم إنشاؤها بواسطة SGBD باستخدام السمة [AUTO_INCREMENT]
- السطران 18-19: يحددان إصدار كل كيان. سيقوم التنفيذ JPA بزيادة رقم الإصدار هذا في كل مرة يتم فيها تعديل الكيان. يُستخدم هذا الرقم لمنع التحديث المتزامن للكيان من قِبل مستخدمين مختلفين: يقوم مستخدمان هما U1 وU2 بقراءة الكيان E الذي يحمل رقم إصدار يساوي V1. يقوم المستخدم U1 بتعديل الكيان E وحفظ هذا التعديل في قاعدة البيانات: فيتغير رقم الإصدار عندئذٍ إلى V1+1. يقوم U2 بدوره بتعديل E وحفظ هذا التعديل في قاعدة البيانات: وسيتلقى استثناءً لأنه يمتلك إصدارًا (V1) يختلف عن الإصدار الموجود في قاعدة البيانات (V1+1)؛
- الأسطر 29-33: تسمح الطريقة [build] بتهيئة الحقلين في [AbstractEntity]. وتؤدي هذه الطريقة إلى تهيئة مرجع مثيل [AbstractEntity]؛
- الأسطر 36-44: يتم إعادة تعريف الطريقة [equals] للفئة: يُعتبر كيانان متساويين إذا كان لهما نفس اسم الفئة ونفس المعرف id؛
- الأسطر 21-26: عند إعادة تعريف الطريقة [equals] لفئة ما، يجب عندئذٍ إعادة تعريف طريقتها [hashCode] (الأسطر 21-26). القاعدة هي أن الكيانين اللذين تعتبرهما الطريقة [equals] متساويين يجب أن يكون لهما نفس [hashCode]. هنا، فإن [hashCode] لأحد الكيانات يساوي مفتاحه الأساسي [id]. يُستخدم الرمز [hashCode] الخاص بفئة ما بشكل خاص في إدارة القواميس التي تكون قيمها مثيلات لتلك الفئة؛
الكيان [Personne] هو الفئة الأم للكيانين [Medecin] و [Client]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.MappedSuperclass;
@MappedSuperclass
public class Personne extends AbstractEntity {
private static final long serialVersionUID = 1L;
// سمات الشخص
@Column(length = 5)
private String titre;
@Column(length = 20)
private String nom;
@Column(length = 20)
private String prenom;
// منشئ افتراضي
public Personne() {
}
// منشئ مع معلمات
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// دالات الاسترجاع والتعيين
...
}
- السطر 6: يشير التعليق التوضيحي [@MappedSuperclass] إلى أن الفئة المُعلَّقة عليها هي الفئة الأم للكيانات JPA و [@Entity]؛
- الأسطر 10-15: شخص ما له لقب (Melle)، واسم أول (Jacqueline)، واسم عائلة (Tatou). لم تُقدم أي معلومات عن أعمدة الجدول. لذا ستحمل هذه الأعمدة افتراضيًا نفس أسماء الحقول؛
الكيان [Medecin] هو كما يلي:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "medecins")
public class Medecin extends Personne {
private static final long serialVersionUID = 1L;
// منشئ افتراضي
public Medecin() {
}
// منشئ مع معلمات
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
- السطر 6: الفئة هي كيان JPA؛
- السطر 7: مرتبطة بالجدول [MEDECINS] في قاعدة البيانات؛
- السطر 8: الكيان [Medecin] مشتق من الكيان [Personne]؛
يمكن تهيئة طبيب بالطريقة التالية:
وإذا أردنا أيضًا تعيين معرّف وإصدار له، فيمكننا كتابة:
حيث إن الطريقة [build] هي تلك المُعرَّفة في [AbstractEntity].
الكيان [Client] هو كما يلي:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "clients")
public class Client extends Personne {
private static final long serialVersionUID = 1L;
// منشئ افتراضي
public Client() {
}
// منشئ مع معلمات
public Client(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
// الهوية
public String toString() {
return String.format("Client[%s]", super.toString());
}
}
- السطر 6: الفئة هي كيان JPA؛
- السطر 7: مرتبطة بالجدول [CLIENTS] في قاعدة البيانات؛
- السطر 8: الكيان [Client] مشتق من الكيان [Personne]؛
الكيان [Creneau] هو كما يلي:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص موعد من نوع RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// الفتحة مرتبطة بطبيب
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// مفتاح خارجي
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
// المُنشئ الافتراضي
public Creneau() {
}
// مُنشئ مع معلمات
public Creneau(Medecin medecin, int hdebut, int mdebut, int hfin, int mfin) {
this.medecin = medecin;
this.hdebut = hdebut;
this.mdebut = mdebut;
this.hfin = hfin;
this.mfin = mfin;
}
// toString
public String toString() {
return String.format("Créneau[%d, %d, %d, %d:%d, %d:%d]", id, version, idMedecin, hdebut, mdebut, hfin, mfin);
}
// مفتاح خارجي
public long getIdMedecin() {
return idMedecin;
}
// مُعيِّنات - مُستخرِجات
...
}
- السطر 10: الفئة هي كيان JPA؛
- السطر 11: مرتبطة بالجدول [CRENEAUX] في قاعدة البيانات؛
- السطر 12: الكيان [Creneau] مشتق من الكيان [AbstractEntity]، وبالتالي يرث المعرف [id] والإصدار [version]؛
- السطر 16: وقت بدء الفترة الزمنية (14)؛
- السطر 17: دقائق بداية الفترة الزمنية (20)؛
- السطر 18: ساعة انتهاء الفترة الزمنية (14)؛
- السطر 19: دقائق انتهاء الفترة الزمنية (40)؛
- الأسطر 22-24: الطبيب المالك للفترة. تحتوي الجدول [CRENEAUX] على مفتاح خارجي مرتبط بالجدول [MEDECINS]. تتجسد هذه العلاقة في الأسطر 22-24؛
- السطر 22: تشير العلامة [@ManyToOne] إلى علاقة «عدة (فترات) إلى واحد (طبيب)». يشير السمة [fetch=FetchType.LAZY] إلى أنه عند طلب كيان [Creneau] من سياق الاستمرارية، وإذا كان لا بد من البحث عنه في قاعدة البيانات، فإن الكيان [Medecin] لا يتم إرجاعه معه. وتكمن فائدة هذا الوضع في أن الكيان [Medecin] لا يتم البحث عنه إلا إذا طلب المطور ذلك. وبذلك يتم توفير الذاكرة وتحسين الأداء؛
- السطر 23: يشير إلى اسم عمود المفتاح الأجنبي في الجدول [CRENEAUX]؛
- السطران 27-28: المفتاح الأجنبي في الجدول [MEDECINS]؛
- السطر 27: تم استخدام العمود [ID_MEDECIN] بالفعل في السطر 23. وهذا يعني أنه يمكن تعديله بطريقتين مختلفتين، وهو ما لا تقبله معيار JPA. لذلك نضيف السمات [insertable = false, updatable = false]، مما يجعل العمود قابلاً للقراءة فقط؛
الكيان [Rv] هو كما يلي:
package rdvmedecins.entities;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص Rv
@Temporal(TemporalType.DATE)
private Date jour;
// يرتبط rv بعميل
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// Rv مرتبط بفترة زمنية
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// المفاتيح الخارجية
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
// الشركة المصنعة الافتراضية
public Rv() {
}
// مع المعلمات
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// مفاتيح خارجية
public long getIdCreneau() {
return idCreneau;
}
public long getIdClient() {
return idClient;
}
// مُستردات وقيم
...
}
- السطر 14: الفئة هي كيان JPA؛
- السطر 15: مرتبطة بالجدول [RV] في قاعدة البيانات؛
- السطر 16: الكيان [Rv] مشتق من الكيان [AbstractEntity]، وبالتالي يرث المعرّف [id] والإصدار [version]؛
- السطر 21: تاريخ الموعد؛
- السطر 20: يحتوي النوع [Date] في لغة Java على كل من التاريخ والوقت. هنا نحدد أنه يتم استخدام التاريخ فقط؛
- الأسطر 24-26: العميل الذي تم تحديد هذا الموعد من أجله. تحتوي الجدولة [RV] على مفتاح خارجي مرتبط بالجدولة [CLIENTS]. وتظهر هذه العلاقة في الأسطر 24-26؛
- الأسطر 29-31: الفترة الزمنية للموعد. تحتوي الجدولة [RV] على مفتاح خارجي مرتبط بالجدولة [CRENEAUX]. وتجسد الأسطر 29-31 هذه العلاقة؛
- السطور 34-35: المفتاح الأجنبي [idClient]؛
- الصفوف 36-37: المفتاح الأجنبي [idCreneau]؛
8.4.5. الطبقة [DAO]
![]() |
سنقوم بتنفيذ الطبقة [DAO] باستخدام Spring Data:
![]() |
يتم تنفيذ الطبقة [DAO] باستخدام أربع واجهات Spring Data:
- [ClientRepository]: تتيح الوصول إلى الكيانات JPA و [Client]؛
- [CreneauRepository]: تتيح الوصول إلى الكيانات JPA و [Creneau]؛
- [MedecinRepository]: يتيح الوصول إلى الكيانات JPA و [Medecin]؛
- [RvRepository]: يتيح الوصول إلى الكيانات JPA و [Rv]؛
الواجهة [MedecinRepository] هي كما يلي:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Medecin;
public interface MedecinRepository extends CrudRepository<Medecin, Long> {
}
- السطر 7: تكتفي الواجهة [MedecinRepository] برث الطرق من الواجهة [CrudRepository] دون إضافة طرق أخرى؛
الواجهة [ClientRepository] هي كما يلي:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Client;
public interface ClientRepository extends CrudRepository<Client, Long> {
}
- السطر 7: تكتفي الواجهة [ClientRepository] بوراثة طرق الواجهة [CrudRepository] دون إضافة طرق أخرى؛
الواجهة [CreneauRepository] هي كما يلي:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Creneau;
public interface CreneauRepository extends CrudRepository<Creneau, Long> {
// قائمة المواعيد المتاحة للطبيب
@Query("select c from Creneau c where c.medecin.id=?1")
Iterable<Creneau> getAllCreneaux(long idMedecin);
}
- السطر 8: ترث الواجهة [CreneauRepository] الطرق الموجودة في الواجهة [CrudRepository]؛
- السطران 10-11: تتيح الطريقة [getAllCreneaux] الحصول على المواعيد المتاحة لطبيب ما؛
- السطر 11: المعلمة هي معرّف الطبيب. والنتيجة هي قائمة بالمواعيد المتاحة في شكل كائن [Iterable<Creneau>]؛
- السطر 10: يسمح التعليق التوضيحي [@Query] بتحديد الاستعلام JPQL (لغة استعلامات الاستمرارية في Java) الذي ينفذ الطريقة. سيتم استبدال المعلمة [?1] بالمعلمة [idMedecin] الخاصة بالطريقة؛
الواجهة [RvRepository] هي كما يلي:
package rdvmedecins.repositories;
import java.util.Date;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Rv;
public interface RvRepository extends CrudRepository<Rv, Long> {
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
Iterable<Rv> getRvMedecinJour(long idMedecin, Date jour);
}
- السطر 10: ترث الواجهة [RvRepository] الطرق من الواجهة [CrudRepository]؛
- السطران 12-13: تتيح الطريقة [getRvMedecinJour] الحصول على مواعيد الطبيب ليوم معين؛
- السطر 13: المعلمات هي معرّف الطبيب واليوم. والنتيجة هي قائمة بالمواعيد في شكل كائن [Iterable<Rv>]؛
- السطر 12: يسمح التعليق التوضيحي [@Query] بتحديد الاستعلام JPQL الذي ينفذ الطريقة. سيتم استبدال المعلمة [?1] بالمعلمة [idMedecin] الخاصة بالطريقة، وسيتم استبدال المعلمة [?2] بالمعلمة [jour] الخاصة بالطريقة. لا يمكن الاكتفاء بالاستعلام JPQL التالي:
لأن حقول الفئة Rv، من الأنواع [Client] و [Creneau]، يتم الحصول عليها في الوضع [FetchType.LAZY]، مما يعني أنه يجب طلبها صراحةً للحصول عليها. ويتم ذلك في الاستعلام JPQL باستخدام صيغة [left join fetch entité] التي تطلب إجراء ربط مع الجدول الذي يشير إليه المفتاح الأجنبي من أجل استرداد الكيان المشار إليه؛
8.4.6. الطبقة [métier]
![]() |
![]() |
- [IMetier] هي واجهة الطبقة [métier]، و [Metier] هي تنفيذها؛
- [AgendaMedecinJour] و [CreneauMedecinJour] هما كيانان من كيانات الأعمال؛
8.4.6.1. الكيانات
تربط الكيان [CreneauMedecinJour] بين فترة زمنية وموعد محتمل تم تحديده خلال تلك الفترة:
package rdvmedecins.domain;
import java.io.Serializable;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// الحقول
private Creneau creneau;
private Rv rv;
// المنشئات
public CreneauMedecinJour() {
}
public CreneauMedecinJour(Creneau creneau, Rv rv) {
this.creneau=creneau;
this.rv=rv;
}
// toString
@Override
public String toString() {
return String.format("[%s %s]", creneau, rv);
}
// أدوات الاسترجاع والتعيين
...
}
- السطر 12: الفترة الزمنية؛
- السطر 13: الموعد المحتمل – null في حالة عدم وجود موعد؛
الكيان [AgendaMedecinJour] هو جدول مواعيد الطبيب ليوم معين، أي قائمة مواعيده:
package rdvmedecins.domain;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
import rdvmedecins.entities.Medecin;
public class AgendaMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// الحقول
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
// منشئات
public AgendaMedecinJour() {
}
public AgendaMedecinJour(Medecin medecin, Date jour, CreneauMedecinJour[] creneauxMedecinJour) {
this.medecin = medecin;
this.jour = jour;
this.creneauxMedecinJour = creneauxMedecinJour;
}
public String toString() {
StringBuffer str = new StringBuffer("");
for (CreneauMedecinJour cr : creneauxMedecinJour) {
str.append(" ");
str.append(cr.toString());
}
return String.format("Agenda[%s,%s,%s]", medecin, new SimpleDateFormat("dd/MM/yyyy").format(jour), str.toString());
}
// دالات الاسترجاع والتعيين
...
}
- السطر 13: الطبيب؛
- السطر 14: اليوم في جدول المواعيد؛
- السطر 15: فتراته الزمنية سواء كانت محجوزة بموعد أم لا؛
8.4.6.2. الخدمة
واجهة الطبقة [métier] هي كما يلي:
package rdvmedecins.metier;
import java.util.Date;
import java.util.List;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
public interface IMetier {
// قائمة العملاء
public List<Client> getAllClients();
// قائمة الأطباء
public List<Medecin> getAllMedecins();
// قائمة المواعيد المتاحة للطبيب
public List<Creneau> getAllCreneaux(long idMedecin);
// قائمة مواعيد الطبيب في يوم معين
public List<Rv> getRvMedecinJour(long idMedecin, Date jour);
// البحث عن عميل باستخدام رقم التعريف الخاص به
public Client getClientById(long id);
// البحث عن عميل باستخدام رقم التعريف الخاص به
public Medecin getMedecinById(long id);
// البحث عن موعد محدد برقمه التعريفي
public Rv getRvById(long id);
// البحث عن فترة زمنية باستخدام معرّفها
public Creneau getCreneauById(long id);
// إضافة RV
public Rv ajouterRv(Date jour, Creneau créneau, Client client);
// حذف RV
public void supprimerRv(Rv rv);
// المهنة
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour);
}
تشرح التعليقات دور كل طريقة.
تنفيذ واجهة [IMetier] هو الفئة [Metier] التالية:
package rdvmedecins.metier;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.domain.CreneauMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.repositories.ClientRepository;
import rdvmedecins.repositories.CreneauRepository;
import rdvmedecins.repositories.MedecinRepository;
import rdvmedecins.repositories.RvRepository;
import com.google.common.collect.Lists;
@Service("métier")
public class Metier implements IMetier {
// مستودعات
@Autowired
private MedecinRepository medecinRepository;
@Autowired
private ClientRepository clientRepository;
@Autowired
private CreneauRepository creneauRepository;
@Autowired
private RvRepository rvRepository;
// تنفيذ الواجهة
@Override
public List<Client> getAllClients() {
return Lists.newArrayList(clientRepository.findAll());
}
@Override
public List<Medecin> getAllMedecins() {
return Lists.newArrayList(medecinRepository.findAll());
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return Lists.newArrayList(creneauRepository.getAllCreneaux(idMedecin));
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return Lists.newArrayList(rvRepository.getRvMedecinJour(idMedecin, jour));
}
@Override
public Client getClientById(long id) {
return clientRepository.findOne(id);
}
@Override
public Medecin getMedecinById(long id) {
return medecinRepository.findOne(id);
}
@Override
public Rv getRvById(long id) {
return rvRepository.findOne(id);
}
@Override
public Creneau getCreneauById(long id) {
return creneauRepository.findOne(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau créneau, Client client) {
return rvRepository.save(new Rv(jour, client, créneau));
}
@Override
public void supprimerRv(Rv rv) {
rvRepository.delete(rv.getId());
}
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
...
}
}
- السطر 24: التعليق التوضيحي [@Service] هو تعليق توضيحي من Spring يجعل الفئة المُعلَّمة مكونًا يديره Spring. يمكن تسمية المكون أو عدم تسميته. وقد سُمي هذا المكون [métier]؛
- السطر 25: الفئة [Metier] تُنفِّذ الواجهة [IMetier]؛
- السطر 28: التعليق التوضيحي [@Autowired] هو تعليق توضيحي من Spring. سيتم تهيئة (حقن) قيمة الحقل المُعلَّم بهذه الطريقة بواسطة Spring باستخدام مرجع مكون Spring من النوع أو الاسم المحدد. هنا، لا تحدد العلامة [@Autowired] أي اسم. وبالتالي، سيتم إجراء حقن حسب النوع؛
- السطر 29: سيتم تهيئة الحقل [medecinRepository] بإشارة إلى مكون Spring من النوع [MedecinRepository]. وستكون هذه الإشارة إلى الفئة التي تم إنشاؤها بواسطة Spring Data لتنفيذ الواجهة [MedecinRepository] التي سبق أن عرضناها؛
- الأسطر 30-35: تتكرر هذه العملية بالنسبة للواجهات الثلاث الأخرى التي تمت دراستها؛
- الأسطر 39-41: تنفيذ الطريقة [getAllClients]؛
- السطر 40: نستخدم الطريقة [findAll] من الواجهة [ClientRepository]. تُرجع هذه الطريقة نوعًا [Iterable<Client>] الذي نقوم بتحويله إلى [List<Client>] باستخدام الطريقة الثابتة [Lists.newArrayList]. تم تعريف الفئة [Lists] في مكتبة Google Guava. وفي [pom.xml]، تم استيراد هذا التبعية:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
- الأسطر 38-86: يتم تنفيذ طرق واجهة [IMetier] بمساعدة فئات الطبقة [DAO]؛
الطريقة الموجودة في السطر 88 هي الوحيدة الخاصة بطبقة [métier]. وقد وُضعت هنا لأنها تقوم بمعالجة متخصصة لا تقتصر على مجرد الوصول إلى البيانات. وبدون هذه الطريقة، لم يكن هناك داعٍ لإنشاء طبقة [métier]. الطريقة [getAgendaMedecinJour] هي كما يلي:
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
// قائمة المواعيد المتاحة للطبيب
List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
// قائمة حجوزات هذا الطبيب نفسه لهذا اليوم نفسه
List<Rv> reservations = getRvMedecinJour(idMedecin, jour);
// يتم إنشاء قاموس بناءً على المواعيد المحجوزة
Map<Long, Rv> hReservations = new Hashtable<Long, Rv>();
for (Rv resa : reservations) {
hReservations.put(resa.getCreneau().getId(), resa);
}
// يتم إنشاء جدول المواعيد لليوم المطلوب
AgendaMedecinJour agenda = new AgendaMedecinJour();
// الطبيب
agenda.setMedecin(getMedecinById(idMedecin));
// اليوم
agenda.setJour(jour);
// فترات الحجز
CreneauMedecinJour[] creneauxMedecinJour = new CreneauMedecinJour[creneauxHoraires.size()];
agenda.setCreneauxMedecinJour(creneauxMedecinJour);
// ملء فترات الحجز
for (int i = 0; i < creneauxHoraires.size(); i++) {
// السطر i في جدول المواعيد
creneauxMedecinJour[i] = new CreneauMedecinJour();
// الفترة الزمنية
Creneau créneau = creneauxHoraires.get(i);
long idCreneau = créneau.getId();
creneauxMedecinJour[i].setCreneau(créneau);
// هل الفترة الزمنية متاحة أم محجوزة؟
if (hReservations.containsKey(idCreneau)) {
// الفترة مشغولة - يتم تسجيل الحجز
Rv resa = hReservations.get(idCreneau);
creneauxMedecinJour[i].setRv(resa);
}
}
// يتم إرجاع النتيجة
return agenda;
}
يُرجى من القارئ الاطلاع على التعليقات. والخوارزمية هي كما يلي:
- يتم استرداد جميع المواعيد المتاحة للطبيب المحدد؛
- نسترد جميع مواعيده في اليوم المحدد؛
- باستخدام هاتين المعلومتين، يمكننا تحديد ما إذا كان الموعد متاحًا أم مشغولًا؛
8.4.7. تكوين مشروع Spring
![]() |
تقوم الفئة [DomainAndPersistenceConfig] بتكوين المشروع بأكمله:
package rdvmedecins.config;
import javax.persistence.EntityManagerFactory;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@ComponentScan(basePackages = { "rdvmedecins" })
public class DomainAndPersistenceConfig {
// حزم الكيانات JPA
public final static String[] ENTITIES_PACKAGES = { "rdvmedecins.entities", "rdvmedecins.security" };
// مصدر البيانات MySQL
@Bean
public DataSource dataSource() {
// مصدر البيانات TomcatJdbc
DataSource dataSource = new DataSource();
// التكوين JDBC
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
// الاتصالات المفتوحة في البداية
dataSource.setInitialSize(5);
// النتيجة
return dataSource;
}
// المزود JPA هو Hibernate
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(ENTITIES_PACKAGES);
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// مدير المعاملات
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- السطر 17: هذه الفئة هي فئة تكوين Spring؛
- السطر 18: الحزم التي توجد بها واجهات [CrudRepository] الخاصة بـ Spring Data. سيتم إضافة هذه الواجهات إلى سياق Spring؛
- السطر 19: يضيف إلى سياق Spring جميع الفئات الموجودة في الحزمة [rdvmedecins] وفئاتها الفرعية التي تحتوي على تعليق Spring. في الحزمة [rdvmdecins.metier]، سيتم العثور على الفئة [Metier] مع تعليقها [@Service] وإضافتها إلى سياق Spring؛
- الأسطر 26-39: تُستخدم لتكوين مجموعة اتصالات Tomcat JDBC (السطر 5)؛
- السطر 36: سيحتوي مجمع الاتصالات بشكل افتراضي على 5 اتصالات مفتوحة. يُعرض هذا السطر على سبيل المثال. في حالتنا، يكفي اتصال واحد. في حالة استخدام الطبقة [DAO] من قبل عدة خيوط (threads)، سيكون هذا السطر ضروريًا. وسيكون هذا هو الحال لاحقًا، عندما تعمل الطبقة [DAO] كدعم لتطبيق ويب يدعم بطبيعته عدة مستخدمين يتم خدمتهم في نفس الوقت؛
- الأسطر 42-49: التنفيذ JPA المستخدم هو تنفيذ Hibernate؛
- السطر 45: لا توجد سجلات لـ SQL؛
- السطر 46: لا توجد عملية إعادة إنشاء الجداول؛
- السطر 47: SGBD المستخدم هو MySQL؛
- الأسطر 53-61: تحدد EntityManagerFactory من الطبقة JPA. من هذا الكائن، نحصل على الكائن [EntityManager] الذي يسمح بإجراء العمليات JPA؛
- السطر 57: يُشار إلى الحزمة أو الحزم التي توجد فيها الكيانات JPA؛
- السطر 58: يُحدد مصدر البيانات المراد ربطه بالطبقة JPA؛
- الأسطر 64-69: مدير المعاملات المرتبط بـ EntityManagerFactory السابق. بشكل افتراضي، تُنفَّذ طرق واجهات Spring Data [CrudRepository] داخل معاملة. تبدأ المعاملة قبل الدخول إلى الطريقة وتنتهي (عن طريق التثبيت أو التراجع) بعد الخروج منها؛
8.4.8. اختبارات طبقة [métier]
الفئة [rdvmedecins.tests.Metier] هي فئة اختبار Spring / JUnit 4:
package rdvmedecins.tests;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Metier {
@Autowired
private IMetier métier;
@Test
public void test1(){
// عرض العملاء
List<Client> clients = métier.getAllClients();
display("Liste des clients :", clients);
// عرض الأطباء
List<Medecin> medecins = métier.getAllMedecins();
display("Liste des médecins :", medecins);
// عرض المواعيد المتاحة لأحد الأطباء
Medecin médecin = medecins.get(0);
List<Creneau> creneaux = métier.getAllCreneaux(médecin.getId());
display(String.format("Liste des créneaux du médecin %s", médecin), creneaux);
// قائمة مواعيد الطبيب في يوم معين
Date jour = new Date();
display(String.format("Liste des rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// إضافة RV
Rv rv = null;
Creneau créneau = creneaux.get(2);
Client client = clients.get(0);
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
rv = métier.ajouterRv(jour, créneau, client);
// التحقق
Rv rv2 = métier.getRvById(rv.getId());
Assert.assertEquals(rv, rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// إضافة RV في نفس الموعد من نفس اليوم
// يجب أن يؤدي إلى حدوث استثناء
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
Boolean erreur = false;
try {
rv = métier.ajouterRv(jour, créneau, client);
System.out.println("Rv ajouté");
} catch (Exception ex) {
Throwable th = ex;
while (th != null) {
System.out.println(ex.getMessage());
th = th.getCause();
}
// يتم تسجيل الخطأ
erreur = true;
}
// نتحقق من وجود خطأ
Assert.assertTrue(erreur);
// قائمة بـ RV
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// عرض التقويم
AgendaMedecinJour agenda = métier.getAgendaMedecinJour(médecin.getId(), jour);
System.out.println(agenda);
Assert.assertEquals(rv, agenda.getCreneauxMedecinJour()[2].getRv());
// حذف RV
System.out.println("Suppression du Rv ajouté");
métier.supprimerRv(rv);
// التحقق
rv2 = métier.getRvById(rv.getId());
Assert.assertNull(rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
}
// طريقة الأداة المساعدة - تعرض عناصر المجموعة
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- السطر 22: تسمح العلامة التوضيحية [@SpringApplicationConfiguration] باستخدام ملف التكوين [DomainAndPersistenceConfig] الذي تمت دراسته سابقًا. وبذلك تستفيد فئة الاختبار من جميع الحبوب (beans) المحددة في هذا الملف؛
- السطر 23: تسمح التعليقة التوضيحية [@RunWith] بدمج Spring مع JUnit: ستتمكن الفئة من التنفيذ كاختبار JUnit. [@RunWith] هو تعليق توضيحي JUnit (السطر 9) في حين أن الفئة [SpringJUnit4ClassRunner] هي فئة Spring (السطر 12)؛
- السطران 26-27: حقن مرجع إلى الطبقة [métier] في فئة الاختبار؛
- العديد من الاختبارات ليست سوى اختبارات بصرية بسيطة:
- السطران 32-33: قائمة العملاء؛
- السطران 35-36: قائمة الأطباء؛
- السطران 39-40: قائمة المواعيد المتاحة لأحد الأطباء؛
- السطر 43: قائمة مواعيد الطبيب؛
- السطر 50: إضافة موعد جديد. تعرض الطريقة [ajouterRv] الموعد مع معلومة إضافية، وهي مفتاحه الأساسي id؛
- السطر 53: يتم استخدام هذا المفتاح الأساسي للبحث عن الموعد في قاعدة البيانات؛
- السطر 54: يتم التحقق من أن الموعد المطلوب والموعد الذي تم العثور عليه هما نفس الموعد. تجدر الإشارة إلى أن الطريقة [equals] الخاصة بالكيان [Rv] قد أُعيد تعريفها: يعتبر الموعدان متطابقين إذا كان لهما نفس المفتاح الأساسي (id). وهذا يوضح لنا هنا أن الموعد المضاف قد أُدرج بالفعل في قاعدة البيانات؛
- الأسطر 61-73: نحاول إضافة الموعد نفسه للمرة الثانية. يجب أن يرفض ذلك بواسطة SGBD نظرًا لوجود قيد التفرد:
CREATE TABLE IF NOT EXISTS `rv` (
`ID` bigint(20) NOT NULL AUTO_INCREMENT,
`JOUR` date NOT NULL,
`ID_CLIENT` bigint(20) NOT NULL,
`ID_CRENEAU` bigint(20) NOT NULL,
`VERSION` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`ID`),
UNIQUE KEY `UNQ1_RV` (`JOUR`,`ID_CRENEAU`),
KEY `FK_RV_ID_CRENEAU` (`ID_CRENEAU`),
KEY `FK_RV_ID_CLIENT` (`ID_CLIENT`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=60 ;
يشير السطر 8 أعلاه إلى أن التركيبة [JOUR, ID_CRENEAU] يجب أن تكون فريدة، مما يمنع إدراج موعدين في نفس اليوم ضمن نفس الفترة الزمنية.
- السطر 73: يتم التحقق من حدوث استثناء بالفعل؛
- السطر 77: نطلب جدول مواعيد الطبيب الذي أضفنا له موعدًا للتو؛
- السطر 79: نتحقق من أن الموعد المضاف موجود بالفعل في جدول مواعيده؛
- السطر 82: يتم حذف الموعد الذي تمت إضافته؛
- السطر 84: يتم البحث في قاعدة البيانات عن الموعد الذي تم حذفه؛
- السطر 85: نتحقق من استرداد مؤشر null، مما يدل على أن الموعد المطلوب غير موجود؛
نجح تنفيذ الاختبار:
![]() |
8.4.9. برنامج وحدة التحكم
![]() |
برنامج وحدة التحكم بسيط. وهو يوضح كيفية استرداد مفتاح خارجي:
package rdvmedecins.boot;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
public class Boot {
// التمهيد
public static void main(String[] args) {
// إعداد التكوين
SpringApplication app = new SpringApplication(DomainAndPersistenceConfig.class);
app.setLogStartupInfo(false);
// تشغيلها
ConfigurableApplicationContext context = app.run(args);
// المهمة
IMetier métier = context.getBean(IMetier.class);
try {
// إضافة RV
Date jour = new Date();
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau 1 pour le client 1", new SimpleDateFormat("dd/MM/yyyy").format(jour)));
Client client = (Client) new Client().build(1L, 1L);
Creneau créneau = (Creneau) new Creneau().build(1L, 1L);
Rv rv = métier.ajouterRv(jour, créneau, client);
System.out.println(String.format("Rv ajouté = %s", rv));
// التحقق
créneau = métier.getCreneauById(1L);
long idMedecin = créneau.getIdMedecin();
display("Liste des rendez-vous", métier.getRvMedecinJour(idMedecin, jour));
} catch (Exception ex) {
System.out.println("Exception : " + ex.getCause());
}
// إغلاق سياق Spring
context.close();
}
// طريقة مساعدة - تعرض عناصر المجموعة
private static <T> void display(String message, Iterable<T> elements) {
System.out.println(message);
for (T element : elements) {
System.out.println(element);
}
}
}
يقوم البرنامج بإضافة موعد ثم يتحقق من أنه تمت إضافته.
- السطر 19: ستستخدم الفئة [SpringApplication] فئة التكوين [DomainAndPersistenceConfig]؛
- السطر 20: حذف سجلات بدء تشغيل التطبيق؛
- السطر 22: يتم تنفيذ الفئة [SpringApplication]. وهي تُرجع سياق Spring، أي قائمة الحبوب المسجلة؛
- السطر 24: يتم الحصول على مرجع إلى الكائن الذي ينفذ واجهة [IMetier]. وبالتالي، فهو مرجع إلى الطبقة [métier]؛
- الأسطر 27-31: إضافة موعد جديد لليوم، للعميل رقم 1 في الفترة الزمنية رقم 1. تم إنشاء العميل والفترة الزمنية من الصفر لإظهار أنه يتم استخدام المعرفات فقط. تم هنا تهيئة «الإصدار»، لكن كان من الممكن وضع أي قيمة. لا يتم استخدامها هنا؛
- السطر 34: نريد معرفة الطبيب الذي لديه الموعد رقم 1. ولذلك نحتاج إلى البحث في قاعدة البيانات عن الموعد رقم 1. ولأننا في الوضع [FetchType.LAZY]، لا يتم إرجاع اسم الطبيب مع الموعد. ومع ذلك، حرصنا على توفير حقل [idMedecin] في الكيان [Creneau] لاسترداد المفتاح الأساسي للطبيب؛
- السطر 35: يتم استرداد المفتاح الأساسي للطبيب؛
- السطر 36: يتم عرض قائمة مواعيد الطبيب؛
نتائج وحدة التحكم هي كما يلي:
8.4.10. إدارة السجلات
يتم تكوين سجلات وحدة التحكم بواسطة ملفين هما [application.properties] و [logback.xml] [1]:
![]() |
يتم استخدام الملف [application.properties] بواسطة إطار العمل Spring Boot. يمكن تحديد العديد من المعلمات فيه لتغيير القيم الافتراضية التي يستخدمها Spring Boot (http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html). وفيما يلي محتواه:
logging.level.org.hibernate=OFF
spring.main.show-banner=false
- السطر 1: يتحكم في مستوى سجلات Hibernate - هنا لا توجد سجلات
- السطر 2: يتحكم في عرض شعار Spring Boot - لا يوجد شعار هنا
الملف [logback.xml] هو ملف تكوين إطار عمل السجلات [logback] [2]:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- يتم تعيين النوع ch.qos.logback.classic.encoder.PatternLayoutEncoder للمشفرات بشكل افتراضي -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- التحكم في مستوى السجلات -->
<root level="info"> <!-- إيقاف، معلومات، تصحيح أخطاء، تحذير -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- يتم التحكم في مستوى السجلات العام من خلال السطر 9 - هنا سجلات بمستوى [info]؛
وهذا يعطي النتيجة التالية:
إذا قمنا بتغيير مستوى سجلات Hibernate إلى [info] (دون تغيير أي شيء آخر):
logging.level.org.hibernate=INFO
spring.main.show-banner=false
فسيعطي ذلك النتيجة التالية:
إذا قمنا بتغيير مستوى السجلات إلى [debug] (دون تغيير أي شيء آخر):
logging.level.org.hibernate=DEBUG
spring.main.show-banner=false
يؤدي ذلك إلى النتيجة التالية:
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Eagerly caching bean 'clientRepository' to allow for resolving potential circular references
10:35:13.522 [main] DEBUG o.s.b.f.annotation.InjectionMetadata - Processing injected element of bean 'clientRepository': PersistenceElement for public void org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.setEntityManager(javax.persistence.EntityManager)
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'entityManagerFactory'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'jpaMappingContext'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name 'clientRepository'
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$ThreadBoundTargetSource@723ed581
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is SingletonTargetSource for target object [org.springframework.data.jpa.repository.support.SimpleJpaRepository@796065aa]
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean 'clientRepository'
10:35:13.522 [main] DEBUG o.s.b.f.a.AutowiredAnnotationBeanPostProcessor - Autowiring by type from bean name 'métier' to bean named 'clientRepository'
...
8.4.11. الطبقة [web / jSON]
![]() |
![]() |
سنقوم بإنشاء الطبقة [web / jSON] على عدة مراحل:
- الخطوة 1: طبقة ويب جاهزة للعمل بدون مصادقة؛
- الخطوة 2: تفعيل المصادقة باستخدام Spring Security؛
- الخطوة 3: تنفيذ CORS و [Cross-Origin Resource Sharing (CORS) is a mechanism that allows many resources (e.g. fonts, JavaScript, etc.) on a web page to be requested from another domain outside the domain the resource originated from. (Wikipedia)]. سيكون عميل خدمة الويب لدينا عميل ويب Angular الذي لن ينتمي بالضرورة إلى نفس المجال الذي تنتمي إليه خدمة الويب لدينا. وبالتالي، لا يمكنه الوصول إليها افتراضيًا ما لم تسمح له خدمة الويب بذلك. سنرى كيف؛
8.4.11.1. تكوين Maven
ملف [pom.xml] الخاص بالمشروع هو كما يلي:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webjson-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- طبقة ويب Spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- طبقة الاختبار -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- طبقة DAO -->
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
...
</project>
- الأسطر 12-15: مشروع Maven الأصلي؛
- الأسطر 19-22: التبعيات لمشروع Spring MVC؛
- الأسطر 24-28: التبعيات الخاصة باختبارات JUnit / Spring؛
- الأسطر 30-34: التبعيات الخاصة بمشروع الطبقات [métier, DAO, JPA]؛
8.4.11.2. واجهة خدمة الويب
![]() |
- في [1]، أعلاه، لا يمكن للمتصفح أن يطلب سوى عدد محدود من URL باستخدام صيغة محددة؛
- في [4]، يتلقى المتصفح استجابة jSON؛
ستكون جميع ردود خدمة الويب الخاصة بنا بنفس الشكل الذي يتوافق مع تحويل jSON لكائن من النوع [Response] التالي:
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- الخصائص
// حالة العملية
private int status;
// رسائل الخطأ (إن وجدت)
private List<String> messages;
// نص الرد
private T body;
// المنشئات
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// أدوات الاسترجاع والتعيين
...
}
- السطر 7: رمز خطأ الاستجابة 0: OK، أي شيء آخر: KO؛
- السطر 11: قائمة برسائل الخطأ، في حالة وجود خطأ؛
- السطر 13: نص الرد؛
نقدم الآن لقطات الشاشة التي توضح واجهة خدمة الويب / jSON:
قائمة بجميع المرضى في العيادة الطبية [/getAllClients]
![]() |
قائمة بجميع الأطباء في العيادة الطبية [/getAllMedecins]
![]() |
قائمة المواعيد المتاحة لأحد الأطباء [/getAllCreneaux/{idMedecin}]
![]() |
قائمة مواعيد طبيب ما [/getRvMedecinJour/{idMedecin}/{aaaa-mm-jj}
![]() |
جدول مواعيد الطبيب [/getAgendaMedecinJour/{idMedecin}/{aaaa-mm-jj}]
![]() |
لإضافة/حذف موعد، نستخدم ملحق Chrome [Advanced Rest Client] لأن هذه العمليات تتم باستخدام POST.
إضافة موعد [/ajouterRv]
![]() |
- في [0]، يتم استخدام URL الخاص بخدمة الويب؛
- إلى [1]، حيث تُستخدم الطريقة POST؛
- في [2]، النص jSON للمعلومات المرسلة إلى خدمة الويب في شكل {اليوم، idClient، idCreneau}؛
- في [3]، يحدد العميل لخدمة الويب أنه يرسل إليها معلومات بتنسيق jSON؛
وتكون الإجابة عندئذٍ كما يلي:
![]() |
- في [4]: يرسل العميل الرأس الذي يشير إلى أن البيانات التي يرسلها بتنسيق jSON؛
- إلى [5]: ترد خدمة الويب بأنها ترسل هي الأخرى بتنسيق jSON؛
- في [6]: الرد jSON من خدمة الويب. يحتوي الحقل [body] على الصيغة jSON للموعد المضاف؛
يمكن التحقق من وجود الموعد الجديد:
![]() |
نلاحظ معرّف الموعد [50]. سنقوم بحذف هذا الموعد.
حذف موعد [/supprimerRv]
![]() |
- في [1]، يتم استخدام الطريقة URL الخاصة بخدمة الويب؛
- إلى [2]، حيث تُستخدم الطريقة POST؛
- في [3]، النص jSON للمعلومات المرسلة إلى خدمة الويب في شكل {idRv}؛
- في [4]، يحدد العميل لخدمة الويب أنه يرسل إليها المعلومات jSON؛
وتكون الإجابة عندئذٍ كما يلي:
![]() |
- في [5]: الحقل [status] يساوي 0، مما يدل على نجاح العملية؛
يمكن التحقق من حذف الموعد:
![]() |
فيما سبق، لم يعد موعد المريض [Mme GERMAIN] موجودًا.
تتيح خدمة الويب أيضًا استرداد الكيانات عبر معرّفاتها:
![]() |
![]() |
![]() |
![]() |
يتم معالجة جميع هذه الكيانات URL بواسطة وحدة التحكم [RdvMedecinsController] التي سنقدمها قريبًا.
8.4.11.3. تكوين خدمة الويب
![]() |
فئة التكوين [AppConfig] هي كما يلي:
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- السطر 12: تقوم فئة [AppConfig] بتكوين التطبيق بالكامل؛
- السطر 9: فئة [AppConfig] هي فئة تكوين Spring؛
- السطر 10: يُطلب البحث عن مكونات Spring في الحزمة [rdvmedecins.web] وفروعها. وبهذه الطريقة سيتم اكتشاف المكونات التالية:
- [@RestController RdvMedecinsController] في الحزمة [rdvmedecins.web.controllers]؛
- [@Component ApplicationModel] في الحزمة [rdvmedecins.web.models]؛
- السطر 11: يتم استيراد الفئة [DomainAndPersistenceConfig] التي تهيئ المشروع [rdvmedecins-metier-dao] من أجل الوصول إلى مكونات هذا المشروع؛
- السطر 11: تقوم الفئة [SecurityConfig] بتكوين أمان تطبيق الويب. سنتجاهلها في الوقت الحالي؛
- السطر 11: تقوم الفئة [WebConfig] بتكوين الطبقة [web / jSON]؛
الفئة [WebConfig] هي كما يلي:
package rdvmedecins.web.config;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@EnableWebMvc
public class WebConfig {
// تكوين servlet الموزع للرؤوس CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8080);
}
// المُخَطِّطون jSON
@Bean
public ObjectMapper jsonMapper() {
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- الأسطر 20-25: تُعرّف البين [dispatcherServlet]. الفئة [DispatcherServlet] هي سيرفلت إطار عمل Spring MVC. وهي تؤدي دور [FrontController]: فهي تعترض الطلبات الموجهة إلى موقع Spring MVC وتقوم بتوجيهها إلى أحد وحدات التحكم (Controller) في الموقع؛
- السطر 22: إنشاء مثيل للفئة؛
- السطر 23: يمكن تجاهل هذا السطر في الوقت الحالي؛
- الأسطر 27-30: تعالج السيرفلت [dispatcherServlet] جميع طلبات URL؛
- الأسطر 27-30: تعمل على تنشيط خادم Tomcat المدمج في تبعيات المشروع. وسيعمل على المنفذ 8080؛
- الأسطر 38-67: أربعة مُخَطِّطين jSON مُهيَّأون بمرشحات jSON مختلفة؛
- الأسطر 38-41: أداة تعيين jSON بدون مرشحات؛
- الأسطر 43-49: يقوم المُحَوِّل jSON [jsonMapperShortCreneau] بتحويل كائن [Creneau] إلى صيغة تسلسلية / استرجاعه من الصيغة التسلسلية مع تجاهل الحقل [Creneau.medecin]؛
- الأسطر 51-59: يقوم المُخطِّط jSON [jsonMapperLongRv] بتسلسل/إلغاء تسلسل كائن [Rv] مع تجاهل الحقل [Rv.creneau.medecin]؛
- الأسطر 61-67: يقوم المُخطِّط jSON [jsonMapperShortRv] بتسلسل / يسترجع كائن [Rv] متجاهلاً الحقلين [Rv.creneau] و [Rv.client]؛
8.4.11.4. الفئة [ApplicationModel]
![]() |
ستفيدنا الفئة [ApplicationModel] في أمرين:
- كذاكرة تخزين مؤقتة لتخزين قوائم الأطباء والمرضى (العملاء)؛
- كواجهة موحدة لوحدات التحكم؛
package rdvmedecins.web.models;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
import rdvmedecins.web.helpers.Static;
@Component
public class ApplicationModel implements IMetier {
// الطبقة [métier]
@Autowired
private IMetier métier;
// البيانات الواردة من الطبقة [métier]
private List<Medecin> médecins;
private List<Client> clients;
private List<String> messages;
// بيانات التكوين
private boolean CORSneeded = false;
private boolean secured = false;
@PostConstruct
public void init() {
// يتم استرداد الأطباء والعملاء
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
// مُستخرج
public List<String> getMessages() {
return messages;
}
// ------------------------- واجهة طبقة [métier]
@Override
public List<Client> getAllClients() {
return clients;
}
@Override
public List<Medecin> getAllMedecins() {
return médecins;
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return métier.getAllCreneaux(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return métier.getRvMedecinJour(idMedecin, jour);
}
@Override
public Client getClientById(long id) {
return métier.getClientById(id);
}
@Override
public Medecin getMedecinById(long id) {
return métier.getMedecinById(id);
}
@Override
public Rv getRvById(long id) {
return métier.getRvById(id);
}
@Override
public Creneau getCreneauById(long id) {
return métier.getCreneauById(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau creneau, Client client) {
return métier.ajouterRv(jour, creneau, client);
}
@Override
public void supprimerRv(long idRv) {
métier.supprimerRv(idRv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
// مُستردات وقيم
public boolean isCORSneeded() {
return CORSneeded;
}
public boolean isSecured() {
return secured;
}
}
- السطر 19: التعليق التوضيحي [@Component] يجعل من الفئة [ApplicationModel] مكونًا من مكونات Spring. وكما هو الحال مع جميع مكونات Spring التي رأيناها حتى الآن (باستثناء @Controller)، سيتم إنشاء مثيل واحد فقط من هذا النوع (singleton)؛
- السطر 20: الفئة [ApplicationModel] تُنفذ الواجهة [IMetier]؛
- السطران 23-24: يتم حقن مرجع على الطبقة [métier] بواسطة Spring؛
- السطر 34: يؤدي التعليق التوضيحي [@PostConstruct] إلى تنفيذ الطريقة [init] مباشرةً بعد إنشاء مثيل للفئة [ApplicationModel]؛
- السطران 38-39: يتم استرداد قوائم الأطباء والعملاء من الطبقة [métier]؛
- السطر 41: في حالة حدوث استثناء، يتم تخزين رسائل مكدس الاستثناءات في الحقل الموجود في السطر 17؛
تتطور بنية طبقة الويب على النحو التالي:
![]() |
- في [2b]، تتواصل أساليب وحدة التحكم (أو وحدات التحكم) مع العنصر الفريد [ApplicationModel]؛
توفر هذه الاستراتيجية مرونة في إدارة ذاكرة التخزين المؤقت. حاليًا، لا يتم تخزين فترات عمل الأطباء في ذاكرة التخزين المؤقت. ولإدراجها فيها، يكفي تعديل الفئة [ApplicationModel]. ولا يؤثر ذلك بأي شكل على وحدة التحكم التي ستستمر في استخدام الطريقة [List<Creneau> getAllCreneaux(long idMedecin)] كما كانت تفعل من قبل. ما سيتم تغييره هو تنفيذ هذه الطريقة في [ApplicationModel].
8.4.11.5. الفئة Static
تضم الفئة [Static] مجموعة من الطرق الثابتة المساعدة التي لا تتعلق بـ«الأعمال» أو «الويب»:
![]() |
وإليك كودها:
package rdvmedecins.web.helpers;
import java.util.ArrayList;
import java.util.List;
public class Static {
public Static() {
}
// قائمة رسائل الخطأ الخاصة باستثناء
public static List<String> getErreursForException(Exception exception) {
// استرداد قائمة رسائل الخطأ الخاصة بالاستثناء
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
erreurs.add(cause.getMessage());
cause = cause.getCause();
}
return erreurs;
}
}
- السطر 12: الأسلوب [Static.getErreursForException] الذي تم استخدامه (السطر 8 أدناه) في الأسلوب [init] الخاص بالفئة [ApplicationModel]:
@PostConstruct
public void init() {
// استرداد الأطباء والعملاء
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
تقوم هذه الطريقة بإنشاء كائن [List<String>] باستخدام رسائل الخطأ [exception.getMessage()] الخاصة باستثناء [exception] وتلك التي يحتوي عليها [exception.getCause()].
8.4.11.6. الهيكل الأساسي لوحدة التحكم [RdvMedecinsController]
![]() |
سنقوم الآن بتفصيل معالجة URL لخدمة الويب. هناك ثلاث فئات رئيسية معنية في هذه المعالجة:
- وحدة التحكم [RdvMedecinsController]؛
- فئة الأساليب المساعدة [Static]؛
- فئة ذاكرة التخزين المؤقت [ApplicationModel]؛
![]() |
وحدة التحكم [RdvMedecinsController] هي كما يلي:
package rdvmedecins.web.controllers;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.web.helpers.Static;
import rdvmedecins.web.models.ApplicationModel;
import rdvmedecins.web.models.PostAjouterRv;
import rdvmedecins.web.models.PostSupprimerRv;
import rdvmedecins.web.models.Response;
@Controller
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
// قائمة الرسائل
private List<String> messages;
// أدوات التعيين jSON
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
@PostConstruct
public void init() {
// رسائل خطأ التطبيق
messages = application.getMessages();
}
// قائمة الأطباء
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {...}
// قائمة العملاء
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {...}
// قائمة المواعيد المتاحة للطبيب
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {...}
// قائمة مواعيد الطبيب
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) String origin) throws JsonProcessingException {...}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {...}
}
- السطر 35: التعليق التوضيحي [@Controller] يجعل من الفئة [RdvMedecinsController] وحدة تحكم Spring، وهي C في MVC؛
- السطران 38-39: سيتم حقن كائن من النوع [ApplicationModel] هنا بواسطة Spring. وقد قمنا بعرضه سابقًا؛
- السطران 41-42: سيتم حقن كائن من النوع [RdvMedecinsCorsController] هنا بواسطة Spring. لن نقدم هذا الكائن إلا لاحقًا؛
- الأسطر 48-58: أدوات التعيين jSON المُعرَّفة في فئة التكوين [WebConfig]؛
- السطر 60: تُشير التعليقة التوضيحية [@PostConstruct] إلى طريقة يجب تنفيذها مباشرة بعد إنشاء مثيل للفئة. وعند تنفيذها، تكون الكائنات التي تم إدخالها بواسطة Spring متاحة؛
- السطر 63: يتم استرداد أي رسائل خطأ من الكائن [ApplicationModel]. تم إنشاء مثيل لهذا الكائن عند بدء تشغيل التطبيق، وقد حاول تخزين الأطباء والعملاء في ذاكرة التخزين المؤقت. إذا فشل في ذلك، فسيظهر [messages!=null]. سيسمح ذلك لأساليب وحدة التحكم بمعرفة ما إذا كان التطبيق قد تم تهيئته بشكل صحيح؛
- الأسطر 67-118: القيم URL التي يعرضها الخدمة [web / jSON]. جميع الطرق تُرجع السلسلة jSON لكائن من النوع [Response<T>] التالي:
![]() |
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- الخصائص
// حالة العملية
private int status;
// رسائل الخطأ (إن وجدت)
private List<String> messages;
// نص الرد
private T body;
// المنشئون
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// دالات الاسترجاع والتعيين
...
}
- السطر 9: رمز الخطأ: 0 يعني عدم وجود خطأ؛
- السطر 11: إذا كان [status!=0]، فإن [messages] يمثل قائمة برسائل الخطأ؛
- السطر 13: كائن T مغلف في الرد. تبلغ قيمة T null في حالة وجود خطأ؛
يتم تسلسل هذا الكائن إلى jSON قبل إرساله إلى متصفح العميل؛
- السطر 67: الكائن URL المعروض هو [/getAllMedecins]. يجب على العميل استخدام طريقة [GET] لتقديم طلبه (method = RequestMethod.GET). إذا تم طلب هذه الطريقة URL بواسطة طريقة POST، فسيتم رفضها وسيقوم Spring بإرسال رمز خطأ HTTP إلى عميل الويب. تقوم الطريقة نفسها بإرجاع الاستجابة إلى العميل (السطر 68). وستكون هذه الاستجابة عبارة عن سلسلة أحرف (السطر 67). سيتم إرسال الرأس HTTP [Content-type : application/json; charset=UTF-8] إلى العميل لإعلامه بأنه سيتلقى سلسلة jSON (السطر 67)؛
- السطر 77: يتم تعيين المعلمة URL بواسطة {idMedecin}. يتم استرداد هذه المعلمة باستخدام التعليق التوضيحي [@PathVariable] في السطر 79؛
- السطر 79: المعلمة [long idMedecin] تستمد قيمتها من المعلمة {idMedecin} الموجودة في URL [@PathVariable("idMedecin")]. قد تحمل المعلمة في URL والمعلمة الخاصة بالطريقة أسماء مختلفة. تجدر الإشارة هنا إلى أن [@PathVariable("idMedecin")] من نوع String (جميع عناصر URL هي من نوع String) في حين أن المعلمة [long idMedecin] من نوع [long]. يتم تغيير النوع تلقائيًا. يتم إرجاع رمز الخطأ HTTP في حالة فشل تغيير النوع هذا؛
- السطر 105: يشير التعليق التوضيحي [@RequestBody] إلى نص الاستعلام. في طلب GET، لا يوجد نص أبدًا تقريبًا (ولكن من الممكن إدراج واحد). أما في طلب POST، فغالبًا ما يكون موجودًا (ولكن من الممكن عدم إدراجه). بالنسبة لـ URL و [ajouterRv]، يرسل عميل الويب في POST الخاص به السلسلة التالية:
تؤدي صيغة [@RequestBody PostAjouterRv post] (السطر 105) بالإضافة إلى أن الطريقة تتوقع jSON [consumes = "application/json; charset=UTF-8"] في السطر 103، سيؤدي ذلك إلى تحويل السلسلة jSON المرسلة من عميل الويب إلى كائن من النوع [PostAjouterRv]. وهذا الكائن هو التالي:
package rdvmedecins.web.models;
public class PostAjouterRv {
// بيانات المنشور
private String jour;
private long idClient;
private long idCreneau;
// دالات الاسترجاع والتعيين
...
}
وهنا أيضًا، ستتم التغييرات اللازمة في النوع تلقائيًا؛
- في الأسطر 107-109، نجد آلية مماثلة لـ URL و [/supprimerRv]. السلسلة jSON المرسلة هي كما يلي:
والنوع [PostSupprimerRv] هو التالي:
package rdvmedecins.web.models;
public class PostSupprimerRv {
// بيانات المنشور
private long idRv;
// دالات الاسترجاع والتعيين
...
}
8.4.11.7. يتم معالجة URL و [/getAllMedecins]
يتم معالجة URL و [/getAllMedecins] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
// قائمة الأطباء
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {
// الاستجابة
Response<List<Medecin>> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// قائمة الأطباء
try {
response = new Response<>(0, null, application.getAllMedecins());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
}
// الرد
return jsonMapper.writeValueAsString(response);
}
- السطران 9-10: يتم التحقق مما إذا كان التطبيق قد تم تهيئته بشكل صحيح (messages==null). إذا لم يكن الأمر كذلك، يتم إرجاع استجابة باستخدام status=-1 و body=messages؛
- السطر 13: وإلا، نطلب قائمة الأطباء من الفئة [ApplicationModel]؛
- السطر 19: يتم إرسال السلسلة jSON من الرد باستخدام المُعَيِّن jSON [jsonMapper] لأن الفئة [Medecin]تحتوي على مرشح jSON. قد تكون الاستجابة خالية من الأخطاء (السطر 14) أو تحتوي على خطأ (السطر 16). لا تطلق الطريقة [application.getAllMedecins()] استثناءً لأنها تكتفي بإرجاع قائمة موجودة في ذاكرة التخزين المؤقت. ومع ذلك، سنحتفظ بإدارة الاستثناءات هذه تحسبًا لحالة عدم تخزين الأطباء في ذاكرة التخزين المؤقت بعد الآن؛
لم نوضح بعد الحالة التي يتم فيها تهيئة التطبيق بشكل خاطئ. دعونا نوقف SGBD و MySQL5، ونشغل خدمة الويب، ثم نطلب URL و [/getAllMedecins]:

نحصل بالفعل على خطأ. في الظروف العادية، نحصل على العرض التالي:
![]() |
8.4.11.8. يتم معالجة URL [/getAllClients]
يتم معالجة URL [/getAllClients] بواسطة الطريقة التالية في وحدة التحكم [RdvMedecinsController]:
// قائمة العملاء
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {
// الرد
Response<List<Client>> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// قائمة العملاء
try {
response = new Response<>(0, null, application.getAllClients());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
// الرد
return jsonMapper.writeValueAsString(response);
}
وهي مشابهة للطريقة [getAllMedecins] التي تمت دراستها سابقًا. والنتائج التي تم الحصول عليها هي كما يلي:
![]() |
8.4.11.9. URL [/getAllCreneaux/{idMedecin}]
يتم معالجة URL و [/getAllCreneaux/{idMedecin}] بواسطة الطريقة التالية الخاصة بوحدة التحكم [RdvMedecinsController]:
// قائمة المواعيد المتاحة للطبيب
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {
// الرد
Response<List<Creneau>> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// استرداد الطبيب
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
Medecin médecin = responseMedecin.getBody();
// مواعيد الطبيب
try {
response = new Response<>(0, null, application.getAllCreneaux(médecin.getId()));
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// الرد
return jsonMapperShortCreneau.writeValueAsString(response);
}
- السطر 12: يُطلب من الطبيب المحدد بواسطة المعلمة [id] استدعاء طريقة محلية:
private Response<Medecin> getMedecin(long id) {
// استرجاع بيانات الطبيب
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// هل الطبيب موجود؟
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// حسناً
return new Response<Medecin>(0, null, médecin);
}
يتم العودة من هذه الطريقة بقيمة status في [0,1,2]. لنعد إلى كود الطريقة [getAllCreneaux]:
- السطران 13-14: إذا كانت النتيجة هي status!=0، يتم إنشاء استجابة تحتوي على خطأ؛
- السطر 16: يتم استرداد بيانات الطبيب؛
- السطر 19: يتم استرداد المواعيد المتاحة لهذا الطبيب؛
- السطر 25: يتم إرسال كائن [List<Creneau>] كاستجابة. دعونا نستذكر تعريف الفئة [Creneau]:
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص موعد من نوع RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// الفترة الزمنية مرتبطة بطبيب
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// مفتاح خارجي
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
...
}
- السطر 13: يتم البحث عن الطبيب في وضع [FetchType.LAZY]؛
لنتذكر الاستعلام JPQL الذي ينفذ الطريقة [getAllCreneaux] في الطبقة [DAO]:
@Query("select c from Creneau c where c.medecin.id=?1")
تفرض صيغة [c.medecin.id] إجراء عملية ربط بين الجدولين [CRENEAUX] و [MEDECINS]. وبالتالي، فإن الاستعلام يعرض جميع المواعيد المتاحة للطبيب مع ذكر اسم الطبيب في كل منها. وعند تسلسل هذه المواعيد في jSON، تظهر السلسلة jSON الخاصة بالطبيب في كل منها. وهذا أمر غير ضروري. للتحكم في عملية التسلسل، نحتاج إلى أمرين:
- الوصول إلى الكائن الذي يتم تسلسله؛
- تكوين الكائن المراد تسلسله؛
يتم التحقق من النقطة 1 عن طريق إدخال المحول jSON المناسب للكائن في وحدة التحكم:
@Autowired
private ObjectMapper jsonMapperShortCreneau;
يتم تحقيق النقطة 2 بإضافة تعليق توضيحي إلى الفئة [Creneau] المُعرَّفة في المشروع [rdvmedecins-metier-dao]:
![]() |
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
...
- السطر 3: تعليق من مكتبة jSON Jackson. وهو ينشئ مرشحًا يُسمى [creneauFilter]. باستخدام هذا المرشح، سنتمكن من تحديد الحقول التي يجب تسلسلها أو عدم تسلسلها برمجيًا؛
يتم تسلسل الكائن [Creneau] في السطر التالي من الأسلوب [getAllCreneaux]:
// الرد
return jsonMapperShortCreneau.writeValueAsString(response);
تم تعريف أداة التعيين jSON [jsonMapperShortCreneau] في الفئة [WebConfig] على النحو التالي:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
- السطر 5: يتم ربط المرشح المسمى [creneauFilter] بالمرشح [creneauFilter] الموجود في السطر 4. يقوم هذا المرشح بتحويل الكائن [Creneau] إلى تسلسل دون حقل [medecin] الخاص به؛
النتيجة التي تُرجعها الطريقة [getAllCreneaux] هي السلسلة jSON من النوع [Response<List<Creneau>].
النتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج في حالة عدم وجود الفترة الزمنية:
![]() |
من هذا المثال، نستخلص القاعدة التالية:
- تُرجع طرق خادم الويب / jSON كائنًا من النوع [Response<T>] الذي يتم تسلسله إلى jSON؛
- إذا كان النوع T يحتوي على مرشح واحد أو أكثر من نوع jSON، فسيتم استخدام مُخَطِّط مع هذه المرشحات نفسها لتسلسله؛
8.4.11.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]
يتم معالجة URL [/getRvMedecinJour/{idMedecin}/{jour}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
// قائمة مواعيد الطبيب
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// الرد
Response<List<Rv>> response=null;
boolean erreur = false;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// التحقق من التاريخ
Date jourAgenda = null;
if (!erreur) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<List<Rv>>(3, messages, null);
erreur = true;
}
}
Response<Medecin> responseMedecin = null;
if (!erreur) {
// استرجاع بيانات الطبيب
responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
Medecin médecin = responseMedecin.getBody();
// قائمة مواعيده
try {
response = new Response<>(0, null, application.getRvMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// الرد
return jsonMapperLongRv.writeValueAsString(response);
}
- يجب تحويل السلسلة jSON إلى نوع [Response<List<Rv>>]. تحتوي الفئة [Rv] على حقل [Rv.creneau]. إذا تم تسلسل هذا الحقل، فسوف نواجه المرشح jSON [creneauFilter]؛
- السطر 47: يتم تسلسل الكائن من النوع [Response<List<Rv>>] الموجود في السطر 7 إلى jSON؛
لندرس الحالة التي تم فيها الحصول على قائمة المواعيد في السطر 42. يتم تعريف الفئة [Rv] في المشروع [rdvmedecins-metier-dao] على النحو التالي:
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص الموعد
@Temporal(TemporalType.DATE)
private Date jour;
// موعد مرتبط بعميل
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// موعد مرتبط بفترة زمنية
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// المفاتيح الخارجية
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
...
}
- السطر 11: يتم البحث عن العميل باستخدام الوضع [FetchType.LAZY]؛
- السطر 18: يتم البحث عن الموعد باستخدام الوضع [FetchType.LAZY]؛
لنتذكر الاستعلام JPQL الذي يبحث عن المواعيد:
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
يتم إجراء عمليات ربط بشكل صريح لاستخراج الحقول [client] و [creneau]. علاوة على ذلك، وبسبب عملية الربط [cr.medecin.id=?1]، سنحصل أيضًا على اسم الطبيب. وبالتالي، سيظهر اسم الطبيب في السلسلة jSON لكل موعد. لكن هذه المعلومات المكررة غير مفيدة. وقد رأينا كيف يمكن حل هذه المشكلة باستخدام مرشح jSON على الكائن [Creneau]. ونظرًا لأوضاع [FetchType.LAZY] لحقول [client] و[creneau] في الفئة [Rv]، سنكتشف قريبًا الحاجة إلى وضع مرشح jSON على الفئة [RV] للمشروع [rdvmedecins-metier-dao]:
@Entity
@Table(name = "rv")
@JsonFilter("rvFilter")
public class Rv extends AbstractEntity {
...
سنقوم بالتحقق من تسلسل الكائن [Rv] باستخدام المرشح [rvFilter]. يبدو هنا أننا لا نحتاج إلى التصفية لأننا نحتاج إلى جميع الحقول الخاصة بالكائن من النوع [Rv]. ومع ذلك، نظرًا لأننا أشرنا إلى أن الفئة تحتوي على مرشح jSON، يجب علينا تعريف هذا المرشح لأي عملية تسلسل لكائن من النوع [Rv]، وإلا فسوف نحصل على استثناء. ولذلك، نستخدم أداة التعيين jSON التالية المُعرَّفة في الفئة [rdvMedecinsController]:
@Autowired
private ObjectMapper jsonMapperLongRv;
يتم تعريف أداة التعيين هذه على النحو التالي في فئة التكوين [WebConfig]:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
- السطر 4: نحدد أن جميع حقول الكائن [Rv] يجب تسلسلها؛
- السطر 5: نحدد أنه في الكائن [Creneau]، لا يجب تسلسل الحقل [medecin]؛
- السطر 6: نضيف المرشحين [rvFilter] و [creneauFilter] إلى المرشح jSON الخاص بالكائن [jsonMapperLongRv]؛
النتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج مع يوم بدون مواعيد:
![]() |
أو هذه النتائج مع يوم غير صحيح:
![]() |
أو هذه التي تحتوي على اسم طبيب غير صحيح:
![]() |
8.4.11.11. يتم معالجة URL و [/getAgendaMedecinJour/{idMedecin}/{jour}]
يتم معالجة URL و [/getAgendaMedecinJour/{idMedecin}/{jour}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// الرد
Response<AgendaMedecinJour> response = null;
boolean erreur = false;
// حالة الطلب
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// يتم التحقق من التاريخ
Date jourAgenda = null;
if (!erreur) {
// يتم التحقق من التاريخ
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
erreur = true;
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(3, messages, null);
}
}
// يتم استدعاء الطبيب
Medecin médecin = null;
if (!erreur) {
// يتم استدعاء الطبيب
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
médecin = responseMedecin.getBody();
}
}
// استرداد جدول مواعيده
if (!erreur) {
try {
response = new Response<>(0, null, application.getAgendaMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// الرد
return jsonMapperLongRv.writeValueAsString(response);
}
- السطران 6 و49: يتم تحويل السلسلة jSON من النوع [AgendaMedecinJour] إلى كائن [Response]؛
النوع [AgendaMedecinJour] هو كما يلي:
public class AgendaMedecinJour implements Serializable {
// الحقول
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
النوع [CreneauMedecinJour] هو كما يلي:
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// الحقول
private Creneau creneau;
private Rv rv;
يحتوي الحقلان [creneau] و [rv] على عوامل تصفية jSON التي يجب تكوينها. وهذا ما تقوم به السطر 49 من الأسلوب [getAgendaMedecinJour] الذي يستخدم أداة التعيين jSON [jsonMapperLongRv] التي سبق ذكرها:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
والنتائج التي تم الحصول عليها هي كما يلي:
![]() |
فيما سبق، نرى أن في 28/01/2015، لدى الدكتور PELISSIER موعد مع السيدة بريجيت BISTROU في الساعة 8:20؛
أو هذه النتائج إذا كان اليوم غير صحيح:
![]() |
أو هذه إذا كان رقم الطبيب غير صالح:
![]() |
8.4.11.12. يتم معالجة URL [/getMedecinById/{id}]
يتم معالجة URL و [/getMedecinById/{id}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) throws JsonProcessingException {
// الرد
Response<Medecin> response;
// حالة التطبيق
if (messages != null) {
response = new Response<Medecin>(-1, messages, null);
} else {
response = getMedecin(id);
}
// الرد
return jsonMapper.writeValueAsString(response);
}
- السطران 5 و13: تقوم الدالة بتحويل السلسلة jSON إلى النوع [Medecin]. ولا يحتوي هذا النوع على تعليق مرشح jSON. لذلك، في السطر 14، يتم استخدام أداة التعيين jSON بدون مرشحات؛
في السطر 10، تكون الطريقة [getMedecin] كما يلي:
private Response<Medecin> getMedecin(long id) {
// يتم استدعاء الطبيب
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// هل الطبيب موجود؟
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// حسنًا
return new Response<Medecin>(0, null, médecin);
}
النتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج إذا كان رقم الطبيب غير صحيح:
![]() |
8.4.11.13. يتم معالجة URL [/getClientById/{id}]
يتم معالجة URL [/getClientById/{id}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {
// الرد
Response<Client> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
response = getClient(id);
}
// الرد
return jsonMapper.writeValueAsString(response);
}
- السطران 5 و13: تقوم الطريقة بتحويل السلسلة jSON إلى نوع [Client]. ولا يحتوي هذا النوع على أي تعليقات توضيحية خاصة بالفلاتر jSON. لذلك، في السطر 13، يتم استخدام أداة التعيين jSON بدون مرشحات؛
في السطر 11، تكون الطريقة [getClient] كما يلي:
private Response<Client> getClient(long id) {
// يتم استرداد العميل
Client client = null;
try {
client = application.getClientById(id);
} catch (RuntimeException e1) {
return new Response<Client>(1, Static.getErreursForException(e1), null);
}
// هل العميل موجود؟
if (client == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le client d'id [%s] n'existe pas", id));
return new Response<Client>(2, messages, null);
}
// حسناً
return new Response<Client>(0, null, client);
}
النتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج إذا كان رقم العميل غير صحيح:
![]() |
8.4.11.14. يتم معالجة URL [/getCreneauById/{id}]
يتم معالجة URL [/getCreneauById/{id}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {
// الرد
Response<Creneau> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// إرجاع الموعد
response = getCreneau(id);
}
// الرد
return jsonMapperShortCreneau.writeValueAsString(response);
}
- السطران 5 و14: تُرجع الطريقة السلسلة jSON من النوع [Response<Creneau>]؛
السطر 8، الطريقة [getCreneau] هي كما يلي:
private Response<Creneau> getCreneau(long id) {
// استرداد الموعد
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (RuntimeException e1) {
return new Response<Creneau>(1, Static.getErreursForException(e1), null);
}
// هل الفترة الزمنية موجودة؟
if (créneau == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le créneau d'id [%s] n'existe pas", id));
return new Response<Creneau>(2, messages, null);
}
// حسناً
return new Response<Creneau>(0, null, créneau);
}
لنتذكر رمز الكيان [Creneau]:
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص موعد من RV
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// الفترة الزمنية مرتبطة بطبيب
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// مفتاح خارجي
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
- الأسطر 14-16: نظرًا لأن الحقل [medecin] يعمل في الوضع [fetch = FetchType.LAZY]، فإنه لا يتم استرجاعه عند البحث عن فترة زمنية عبر [id] الخاص بها. لذلك، من الضروري استبعاده من عملية التسلسل. وبدون هذا الاستبعاد، تحدث استثناء. ويرجع ذلك إلى أن كائن التسلسل [mapper] سيستدعي الطريقة [getMedecin] للحصول على الحقل [medecin]. ولكن، مع تنفيذ JPA / Hibernate، أعاد الوضع [fetch = FetchType.LAZY] للحقل [medecin] كائنًا [Creneau]، حيث تمت برمجة الطريقة [getMedecin] لهذا الكائن لجلب الطبيب من السياق JPA. ويُطلق على هذا الكائن اسم [proxy]. ولنتذكر بنية تطبيق الويب:
![]() |
يوجد وحدة التحكم في الكتلة [Contrôleurs / Actions]. وعندما نكون داخل هذه الكتلة، لا يوجد مفهوم للسياق JPA. حيث يتم إنشاء هذا السياق أثناء عمليات الطبقة [DAO]. ولا يستمر وجوده بعد ذلك. لذلك، عندما يحاول وحدة التحكم الوصول إلى السياق JPA، تحدث استثناء يشير إلى أن هذا السياق مغلق. لتجنب هذا الاستثناء، يجب منع تسلسل الحقل [medecin] التابع للفئة [Rv]. وهذا ما يقوم به المُخطِط jSON [jsonMapperShortCreneau]:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
والنتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج إذا كان رقم الفترة الزمنية غير صحيح:
![]() |
8.4.11.15. يتم معالجة URL [/getRvById/{id}]
يتم معالجة URL [/getRvById/{id}] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {
// الرد
Response<Rv> response;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// يتم استرداد موعد الزيارة
response = getRv(id);
}
// الرد
return jsonMapperShortRv.writeValueAsString(response);
}
- السطران 5 و14: تُرجع الطريقة السلسلة jSON من النوع [Response<Rv>]؛
السطر 11، الطريقة [getRv] هي كما يلي:
private Response<Rv> getRv(long id) {
// استرداد Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (RuntimeException e1) {
return new Response<Rv>(1, Static.getErreursForException(e1), null);
}
// هل Rv موجود؟
if (rv == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le rendez-vous d'id [%s] n'existe pas", id));
return new Response<Rv>(2, messages, null);
}
// حسنًا
return new Response<Rv>(0, null, rv);
}
تحتوي الفئة [Rv] على حقلين مع التعليق التوضيحي [fetch = FetchType.LAZY]، وهما الحقلان [creneau] و [client]. وبالتالي، لا يتم استرجاع هذين الحقلين عند البحث عن [Rv] عبر مفتاحه الأساسي. لذلك، وللأسباب نفسها المذكورة سابقًا، يجب استبعادهما من عملية التسلسل. وهذا ما يقوم به مُعَيِّن التعيين [jsonMapperShortRv] التالي المُعرَّف في الفئة [WebConfig]:
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
والنتائج التي تم الحصول عليها هي كما يلي:
![]() |
أو هذه النتائج إذا كان رقم الموعد غير صحيح:
![]() |
8.4.11.16. يتم معالجة URL [/ajouterRv]
يتم معالجة URL [/ajouterRv] بالطريقة التالية من وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {
// الرد
Response<Rv> response = null;
boolean erreur = false;
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// يتم استرداد القيم المرسلة
String jour;
long idCreneau = -1;
long idClient = -1;
Date jourAgenda = null;
if (!erreur) {
// يتم استرداد القيم التي تم إرسالها
jour = post.getJour();
idCreneau = post.getIdCreneau();
idClient = post.getIdClient();
// التحقق من التاريخ
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(6, messages, null);
erreur = true;
}
}
// استرداد الفترة الزمنية
Response<Creneau> responseCréneau = null;
if (!erreur) {
// يتم استرداد الفترة الزمنية
responseCréneau = getCreneau(idCreneau);
if (responseCréneau.getStatus() != 0) {
erreur = true;
response = new Response<>(responseCréneau.getStatus(), responseCréneau.getMessages(), null);
}
}
// استرداد العميل
Response<Client> responseClient = null;
Creneau créneau = null;
if (!erreur) {
créneau = (Creneau) responseCréneau.getBody();
// يتم استرداد العميل
responseClient = getClient(idClient);
if (responseClient.getStatus() != 0) {
erreur = true;
response = new Response<>(responseClient.getStatus() + 2, responseClient.getMessages(), null);
}
}
if (!erreur) {
Client client = responseClient.getBody();
// يتم إضافة الموعد
try {
response = new Response<>(0, null, application.ajouterRv(jourAgenda, créneau, client));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(5, Static.getErreursForException(e1), null);
}
}
// الرد
return jsonMapperLongRv.writeValueAsString(response);
}
- السطران 5 و67: يجب أن تُرجع الطريقة السلسلة jSON من النوع [Response<Rv>]؛
- السطر 3: تقوم التعليقة [@RequestBody PostAjouterRv post] باسترداد نص POST ووضعه في المعلمة [PostAjouterRv post]. هذا النص الأساسي مأخوذ من jSON و[consumes = "application/json; charset=UTF-8"]، وسيتم تحويله تلقائيًا إلى النوع التالي [PostAjouterRv]:
public class PostAjouterRv {
// بيانات المنشور
private String jour;
private long idClient;
private long idCreneau;
...
- ثم يوجد كود سبق أن تمت مواجهته بشكل أو بآخر؛
- السطر 67: إعداد المرشحات jSON و [creneauFilter] و [rvFilter]. تقوم الطريقة بتحويل السلسلة jSON إلى نوع [Response<Rv>] حيث تم الحصول على Rv في السطر 61. يحتوي الكائن [Rv] على كائن [Creneau] وكائن [Client]. الكائن [Creneau] له تبعية [FetchType.LAZY] على كائن [Medecin] وتم الحصول عليه في الأسطر 36-44. تم البحث عنه في السياق JPA عبر مفتاحه الأساسي وتم الحصول عليه بدون تبعيته [FetchType.LAZY]. وفي النهاية،
- يحتوي الكائن [Rv] على جميع تبعياته. يمكن تسلسلها؛
- الكائن [Creneau] لا يحتوي على التبعية [medecin]. لذا يجب ألا يتم تسلسل هذه التبعية؛
يستوفي المُعَيِّن jSON [jsonMapperLongRv] المُعرَّف في الفئة [WebConfig] هذه القيود:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
تبدو النتائج التي تم الحصول عليها كما يلي باستخدام العميل [Advanced Rest Client]:
![]() |
- في [1]، فإن URL من POST؛
- إلى [2]، من POST؛
- في [3]، القيمة المنشورة؛
- في [4a]، هذه القيمة المنشورة هي من jSON؛
![]() |
- في [4b]، يشير العميل إلى أنه يرسل jSON؛
- في [5]، يشير الخادم إلى أنه يعيد إرسال jSON؛
![]() |
- في [6]، رد الخادم jSON الذي يمثل الموعد المضاف. ونرى فيه معرّف الموعد المضاف [id]؛
ونحصل على ما يلي مع رقم فترة زمنية غير موجود:
![]() |
8.4.11.17. يتم معالجة URL و[/supprimerRv]
يتم معالجة URL و [/supprimerRv] بالطريقة التالية من قبل وحدة التحكم [RdvMedecinsController]:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {
// الرد
Response<Void> response = null;
boolean erreur = false;
// رؤوس CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// حالة التطبيق
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// يتم استرداد القيم التي تم إرسالها
long idRv = post.getIdRv();
// استرداد rv
if (!erreur) {
Response<Rv> responseRv = getRv(idRv);
if (responseRv.getStatus() != 0) {
response = new Response<>(responseRv.getStatus(), responseRv.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
// حذف rv
try {
application.supprimerRv(idRv);
response = new Response<Void>(0, null, null);
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// الرد
return jsonMapper.writeValueAsString(response);
}
- السطر 5: النوع [Void] هو الفئة المطابقة للنوع البدائي [void]؛
- السطران 5 و34: تُرجع الطريقة السلسلة jSON من النوع [Response<Void>] الذي لا يحتوي على مرشحات jSON. لذلك، في السطر 34، يتم استخدام أداة التعيين jSON بدون مرشحات؛
- السطر 3: تتخذ الطريقة نص POST كمعلمة، أي القيمة المرسلة. يتم استلام هذه القيمة في صيغة jSON [consumes = "application/json; charset=UTF-8"] ويتم تحويلها تلقائيًا إلى النوع التالي [PostSupprimerRv]:
public class PostSupprimerRv {
// بيانات الإرسال
private long idRv;
- السطر 28: عند نجاح عملية الحذف، يتم إرسال رد بقيمة [status=0]؛
النتائج التي تم الحصول عليها هي كما يلي:
![]() |
![]() |
- في [5]، يشير الحقل [status=0] إلى نجاح عملية الحذف؛
مع رقم موعد غير موجود، نحصل على ما يلي:
![]() |
لقد انتهينا من وحدة التحكم. سنرى الآن كيفية تنفيذ المشروع.
8.4.11.18. الفئة القابلة للتنفيذ لخدمة الويب
![]() |
الفئة [Boot] [1] هي كما يلي:
package rdvmedecins.web.boot;
import org.springframework.boot.SpringApplication;
import rdvmedecins.web.config.AppConfig;
public class Boot {
public static void main(String[] args) {
SpringApplication.run(AppConfig.class, args);
}
}
في السطر 10، يتم تنفيذ الطريقة الثابتة [SpringApplication.run] مع الفئة [AppConfig] الخاصة بتكوين المشروع كمعلمة أولى. ستقوم هذه الطريقة بالتكوين التلقائي للمشروع، وتشغيل خادم Tomcat المدمج في التبعيات، ونشر وحدة التحكم [RdvMedecinsController] عليه.
يتم التحكم في السجلات من خلال الملفات التالية [2]:
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- يتم تعيين النوع ch.qos.logback.classic.encoder.PatternLayoutEncoder للمشفرات بشكل افتراضي -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- التحكم في مستوى السجلات -->
<root level="info"> <!-- للمشفرات بشكل افتراضي، مع خيارات: إيقاف التشغيل، معلومات، تصحيح الأخطاء، تحذير -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- السطر 9: يتم تعيين مستوى السجلات العام على [info]؛
[application.properties]
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=OFF
spring.main.show-banner=false
تسمح السطران 1 و2 بتعيين مستوى سجلات محدد لبعض عناصر التطبيق:
- السطر 1: نريد سجلات الطبقة [web]؛
- السطر 2: لا نريد سجلات الطبقة [JPA]؛
- السطر 3: لا تظهر شارة Spring Boot؛
سجلات التشغيل هي كما يلي:
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Found resource [logback.xml] at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-metier-dao/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,342 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:06:04,357 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to INFO
11:06:04,404 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:06:04,420 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point
11:06:04.732 [main] INFO rdvmedecins.web.boot.Boot - Starting Boot on Gportpers3 with PID 420 (D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server\target\classes started by usrlocal in D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server)
11:06:04.775 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:05.538 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
11:06:05.688 [main] INFO o.a.catalina.core.StandardService - Starting service Tomcat
11:06:05.689 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet Engine: Apache Tomcat/8.0.26
11:06:05.833 [localhost-startStop-1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
11:06:05.833 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 1061 ms
11:06:06.231 [localhost-startStop-1] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
11:06:09.234 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain - Creating filter chain: org.springframework.security.web.util.matcher.AnyRequestMatcher@1, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@12d14fa, org.springframework.security.web.context.SecurityContextPersistenceFilter@29823fb6, org.springframework.security.web.header.HeaderWriterFilter@662d93b2, org.springframework.security.web.authentication.logout.LogoutFilter@2d81ee0, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@52aa47ad, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@60bd7a74, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5a374232, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7ddb4452, org.springframework.security.web.session.SessionManagementFilter@2cd9855f, org.springframework.security.web.access.ExceptionTranslationFilter@2263f0a2, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@192ce7f6]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Medecin> rdvmedecins.web.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Client> rdvmedecins.web.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.web.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Client>> rdvmedecins.web.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.web.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Medecin>> rdvmedecins.web.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
11:06:09.677 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerAdapter - Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:09.770 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
11:06:09.786 [main] INFO o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
11:06:09.802 [main] INFO o.a.tomcat.util.net.NioSelectorPool - Using a shared selector for servlet write/read
11:06:09.817 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
11:06:09.817 [main] INFO rdvmedecins.web.boot.Boot - Started Boot in 5.319 seconds (JVM running for 6.053)
- السطر 18: خادم Tomcat نشط؛
- السطر 21: يجري تهيئة سياق Spring؛
- الأسطر 27-38: تم اكتشاف URL المعروضة بواسطة خدمة الويب؛
- السطر 44: خادم Tomcat جاهز وينتظر الطلبات على المنفذ 8080؛
إذا قمنا بتعديل الملف [application.properties] على النحو التالي:
logging.level.org.springframework.web: OFF
logging.level.org.hibernate:OFF
spring.main.show-banner=false
نحصل على السجلات التالية:
وإذا قمنا أيضًا بتعديل الملف [logback.xml] على النحو التالي:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- يتم تعيين النوع ch.qos.logback.classic.encoder.PatternLayoutEncoder للمشفرات بشكل افتراضي -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- التحكم في مستوى السجلات -->
<root level="off"> <!-- مغلق، معلومات، تصحيح أخطاء، تحذير -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
نحصل على السجلات التالية:
من الواضح إذن أن لدينا بعض التحكم في السجلات التي تظهر في وحدة التحكم. غالبًا ما يكون المستوى [info] هو المستوى المناسب للسجلات.
لدينا الآن خدمة ويب جاهزة للعمل يمكن الاستعلام عنها باستخدام عميل ويب. ننتقل الآن إلى تأمين هذه الخدمة: نريد أن يقتصر إدارة مواعيد الأطباء على أشخاص معينين فقط. وسنستخدم لهذا الغرض إطار عمل Spring Security، وهو أحد مكونات منظومة Spring.
8.4.12. مقدمة إلى Spring Security
سنقوم مرة أخرى باستيراد دليل Spring باتباع الخطوات من 1 إلى 3 أدناه:
![]() |
![]() |
يتكون المشروع من العناصر التالية:
- في المجلد [templates]، توجد صفحات HTML الخاصة بالمشروع؛
- [Application]: هي الفئة القابلة للتنفيذ للمشروع؛
- [MvcConfig]: هي فئة تكوين Spring MVC؛
- [WebSecurityConfig]: هي فئة تكوين Spring Security؛
8.4.12.1. تكوين Maven
المشروع [3] هو مشروع Maven. دعونا نلقي نظرة على ملفه [pom.xml] لمعرفة تبعياته:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- tag::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- الأسطر 10-14: المشروع هو مشروع Spring Boot؛
- الأسطر 17-20: التبعية لإطار العمل [Thymeleaf]؛
- الأسطر 22-25: التبعية لإطار العمل Spring Security؛
8.4.12.2. طرق عرض Thymeleaf
![]() |
تبدو طريقة العرض [home.html] كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- السطر 12: ستُنشئ السمة [th:href="@{/hello}"] السمة [href] لعلامة <a>. ستؤدي القيمة [@{/hello}] إلى إنشاء المسار [<context>/hello]، حيث يمثل [context] سياق تطبيق الويب؛
الرمز HTML الذي تم إنشاؤه هو التالي:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
الطريقة [hello.html] هي كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- السطر 9: ستقوم السمة [th:inline="text"] بإنشاء نص العلامة <h1>. يحتوي هذا النص على تعبير $ يجب تقييمه. العنصر [[${#httpServletRequest.remoteUser}]] هو قيمة السمة [RemoteUser] للاستعلام HTTP الحالي. وهو اسم المستخدم المسجل الدخول؛
- السطر 10: نموذج HTML. سيؤدي السمة [th:action="@{/logout}"] إلى إنشاء السمة [action] لعلامة [form]. ستؤدي القيمة [@{/logout}] إلى إنشاء المسار [<context>/logout]، حيث يمثل [context] سياق تطبيق الويب؛
الرمز HTML الذي تم إنشاؤه هو التالي:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- السطر 8: ترجمة Hello [[${#httpServletRequest.remoteUser}]]!؛
- السطر 9: ترجمة @{/logout}؛
- السطر 11: حقل مخفي يُسمى (السمة name) _csrf؛
الطريقة الأخيرة [login.html] هي كما يلي:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- السطر 9: السمة [th:if="${param.error}"] تجعل علامة <div> لا تُنشأ إلا إذا كانت السمة URL التي تعرض صفحة تسجيل الدخول تحتوي على المعلمة [error] (http://context/login?error)؛
- السطر 10: السمة [th:if="${param.logout}"] تجعل العلامة <div> لا تُنشأ إلا إذا كانت العلامة URL التي تعرض صفحة تسجيل الدخول تحتوي على المعلمة [logout] (http://context/login?logout)؛
- الأسطر 11-23: نموذج HTML؛
- السطر 11: سيتم إرسال النموذج إلى URL [<context>/login] حيث <context> هو سياق تطبيق الويب؛
- السطر 13: حقل إدخال باسم [username]؛
- السطر 17: حقل إدخال باسم [password]؛
الرمز HTML الذي تم إنشاؤه هو التالي:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
يُلاحظ في السطر 28 أن Thymeleaf أضاف حقلًا مخفيًا باسم [_csrf].
8.4.12.3. تكوين Spring MVC
![]() |
تقوم الفئة [MvcConfig] بتكوين إطار عمل Spring MVC:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- السطر 7: تعمل التعليقة التوضيحية [@Configuration] على تحويل الفئة [MvcConfig] إلى فئة تكوين؛
- السطر 8: الفئة [MvcConfig] تمتد من الفئة [WebMvcConfigurerAdapter] لإعادة تعريف بعض أساليبها؛
- السطر 10: إعادة تعريف إحدى طرق الفئة الأم؛
- الأسطر 11-16: تسمح الطريقة [addViewControllers] بربط URL بعروض HTML. ويتم إجراء الروابط التالية فيها:
URL | عرض |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
اللاحقة [html] والمجلد [templates] هما القيمتان الافتراضيتان اللتان يستخدمهما Thymeleaf. ويمكن تغييرهما من خلال التهيئة. يجب أن يكون المجلد [templates] موجودًا في جذر مسار الفئات (Classpath) للمشروع:
![]() |
في المثال أعلاه [1]، المجلدان [java] و [resources] هما مجلدان مصدران (source folders). وهذا يعني أن محتوياتهما ستكون في جذر مسار الفئات (Classpath) للمشروع. وبالتالي، في المجلد [2]، سيكون المجلدان [hello] و [templates] في جذر مسار الفئات (Classpath).
8.4.12.4. تكوين Spring Security
![]() |
تقوم الفئة [WebSecurityConfig] بتكوين إطار عمل Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- السطر 9: تعمل العلامة التوضيحية [@Configuration] على تحويل الفئة [WebSecurityConfig] إلى فئة تكوين؛
- السطر 10: التعليق التوضيحي [@EnableWebSecurity] يجعل الفئة [WebSecurityConfig] فئة تكوين لـ Spring Security؛
- السطر 11: الفئة [WebSecurity] تمتد من الفئة [WebSecurityConfigurerAdapter] لإعادة تعريف بعض طرقها؛
- السطر 12: إعادة تعريف إحدى طرق الفئة الأم؛
- الأسطر 13-16: يتم إعادة تعريف الطريقة [configure(HttpSecurity http)] لتحديد حقوق الوصول إلى مختلف URL في التطبيق؛
- السطر 14: تتيح الطريقة [http.authorizeRequests()] ربط URL بحقوق الوصول. ويتم إجراء الروابط التالية فيها:
URL | القاعدة | الرمز |
الوصول دون مصادقة | | |
الوصول بعد المصادقة فقط |
- السطر 15: يحدد طريقة المصادقة. تتم المصادقة عبر نموذج URL [/login] متاح للجميع [http.formLogin().loginPage("/login").permitAll()]. كما أن تسجيل الخروج (logout) متاح للجميع؛
- الأسطر 19-21: تعيد تعريف الطريقة [configure(AuthenticationManagerBuilder auth)] التي تدير المستخدمين؛
- السطر 20: تتم المصادقة باستخدام مستخدمين محددين بشكل «ثابت» [auth.inMemoryAuthentication()]. يتم تعريف المستخدم هنا باستخدام اسم المستخدم [user]، وكلمة المرور [password]، والدور [USER]. يمكن منح نفس الحقوق للمستخدمين الذين لديهم نفس الدور؛
8.4.12.5. فئة قابلة للتنفيذ
![]() |
الفئة [Application] هي كما يلي:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- السطر 8: يطلب التعليق التوضيحي [@EnableAutoConfiguration] من Spring Boot (السطر 3) إجراء التكوين الذي لم يقم المطور بإجرائه صراحةً؛
- السطر 9: يجعل من الفئة [Application] فئة تكوين Spring؛
- السطر 10: يطلب فحص مجلد الفئة [Application] للبحث عن مكونات Spring. وبالتالي سيتم اكتشاف الفئتين [MvcConfig] و [WebSecurityConfig] لأنهما تحملان التعليق التوضيحي [@Configuration]؛
- السطر 13: الطريقة [main] للفئة القابلة للتنفيذ؛
- السطر 14: يتم تنفيذ الطريقة الثابتة [SpringApplication.run] مع فئة التكوين [Application] كمعلمة. لقد سبق أن تعرفنا على هذه العملية ونعلم أن خادم Tomcat المضمن في تبعيات Maven الخاصة بالمشروع سيتم تشغيله ونشر المشروع عليه. وقد رأينا أن أربع عمليات URL تُدار بواسطة [/, /home, /login, /hello] وأن بعضها محمي بحقوق الوصول.
8.4.12.6. اختبارات التطبيق
لنبدأ بطلب URL [/]، وهي واحدة من أربع عمليات URL التي تم قبولها. وهي مرتبطة بالعرض [/templates/home.html]:
![]() |
يمكن للجميع الوصول إلى URL المطلوب [/]. ولهذا السبب حصلنا عليه. الرابط [here] هو التالي:
سيتم طلب الرابط URL [/hello] عند النقر على الرابط. وهذا الرابط محمي:
URL | القاعدة | الرمز |
الوصول دون مصادقة | | |
الوصول بعد المصادقة فقط |
يجب المصادقة للحصول عليه. سيقوم Spring Security عندئذ بإعادة توجيه متصفح العميل إلى صفحة المصادقة. وفقًا للتكوين المعروض، فإنها صفحة URL [/login]. هذه الصفحة متاحة للجميع:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
وبالتالي نحصل على [1]:
![]() |
الرمز المصدري للصفحة التي تم الحصول عليها هو التالي:
- في السطر 7، يظهر حقل مخفي غير موجود في الصفحة الأصلية [login.html]. وقد أضافه Thymeleaf. ويهدف هذا الرمز المسمى CSRF (تزوير الطلبات عبر المواقع) إلى إزالة ثغرة أمنية. يجب إعادة إرسال هذا الرمز إلى Spring Security مع المصادقة حتى يتم قبولها؛
ونذكر أن Spring Security لا يتعرف إلا على المستخدم user/password. إذا أدخلنا أي شيء آخر في [2]، فسنحصل على نفس الصفحة مع رسالة خطأ في [3]. قام Spring Security بإعادة توجيه المتصفح إلى URL [http://localhost:8080/login?error]. أدى وجود المعلمة [error] إلى عرض العلامة:
<div th:if="${param.error}">Invalid username and password.</div>
الآن، دعونا ندخل القيم المتوقعة user/password [4]:
![]() |
- في [4]، نقوم بتسجيل الدخول؛
- في [5]، يقوم Spring Security بإعادة توجيهنا إلى URL [/hello] لأن هذا هو URL الذي كنا نطلبه عندما تمت إعادة توجيهنا إلى صفحة تسجيل الدخول. تم عرض هوية المستخدم في السطر التالي من [hello.html]:
تعرض الصفحة [5] النموذج التالي:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
عند النقر على الزر [Sign Out]، سيتم إجراء عملية POST على URL و[/logout]. وهذا الملف، مثل ملف URL و[/login]، متاح للجميع:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
في جمعيتنا URL / views، لم نحدد أي شيء لـ URL و [/logout]. ماذا سيحدث؟ لنجرب:
![]() |
- في [6]، نضغط على الزر [Sign Out]؛
- في [7]، نلاحظ أننا تمت إعادة توجيهنا إلى URL [http://localhost:8080/login?logout]. Spring Security هو الذي طلب إعادة التوجيه هذه. أدى وجود المعلمة [logout] في URL إلى عرض السطر التالي في العرض:
<div th:if="${param.logout}">You have been logged out.</div>
8.4.12.7. Conclusion
في المثال السابق، كان بإمكاننا كتابة تطبيق الويب أولاً ثم تأمينه لاحقًا. Spring Security ليس تدخليًا. يمكننا تطبيق الأمان على تطبيق ويب مكتوب بالفعل. علاوة على ذلك، اكتشفنا النقاط التالية:
- من الممكن تعريف صفحة مصادقة؛
- يجب أن تكون المصادقة مصحوبة برمز CSRF الصادر عن Spring Security؛
- إذا فشلت عملية المصادقة، يتم إعادة توجيه المستخدم إلى صفحة المصادقة مع إضافة معلمة error في الرمز URL؛
- إذا نجحت عملية المصادقة، يتم إعادة توجيه المستخدم إلى الصفحة المطلوبة عند إتمام المصادقة. إذا تم طلب صفحة المصادقة مباشرةً دون المرور بصفحة وسيطة، فإن Spring Security يعيد توجيهنا إلى URL [/] (لم يتم عرض هذه الحالة)؛
- يتم تسجيل الخروج عن طريق طلب الصفحة URL [/logout] باستخدام معلمة POST. ثم يقوم Spring Security بإعادة توجيهنا إلى صفحة المصادقة مع المعلمة logout في URL؛
تستند جميع هذه الاستنتاجات إلى السلوكيات الافتراضية لـ Spring Security. ويمكن تغيير هذه السلوكيات عن طريق التهيئة من خلال إعادة تعريف بعض الطرق في الفئة [WebSecurityConfigurerAdapter].
لن يفيدنا البرنامج التعليمي السابق كثيرًا في الخطوات التالية. سنستخدم بالفعل:
- قاعدة بيانات لتخزين المستخدمين وكلمات مرورهم وأدوارهم؛
- المصادقة عبر الرأس HTTP؛
لا توجد سوى القليل من الدروس التعليمية لما نريد القيام به هنا. الحل الذي سيتم اقتراحه هو تجميع لأكواد تم العثور عليها هنا وهناك.
8.4.13. تفعيل الأمان على خدمة حجز المواعيد عبر الويب
8.4.13.1. قاعدة البيانات
تتطور قاعدة البيانات [rdvmedecins] لتشمل المستخدمين وكلمات مرورهم وأدوارهم. تظهر ثلاث جداول جديدة:

الجدول [USERS]: المستخدمون
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار السطر؛
- IDENTITY: هوية وصفية للمستخدم؛
- LOGIN: اسم تسجيل دخول المستخدم؛
- PASSWORD: كلمة المرور الخاصة به؛
في الجدول USERS، لا يتم تخزين كلمات المرور بشكل عادي:
![]() |
الخوارزمية التي تقوم بتشفير كلمات المرور هي الخوارزمية BCRYPT.
الجدول [ROLES]: الأدوار
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار السطر؛
- NAME: اسم الدور. بشكل افتراضي، يتوقع Spring Security أسماءً بالصيغة ROLE_XX، على سبيل المثال ROLE_ADMIN أو ROLE_GUEST؛
![]() |
الجدول [USERS_ROLES]: جدول الربط بين USERS و ROLES
يمكن أن يكون للمستخدم عدة أدوار، ويمكن أن يضم الدور عدة مستخدمين. لدينا علاقة متعددة إلى متعددة تتجسد في الجدول [USERS_ROLES].
- ID: المفتاح الأساسي؛
- VERSION: عمود إصدار السطر؛
- USER_ID: معرّف المستخدم؛
- ROLE_ID: معرّف الدور؛
![]() |
نظرًا لأننا نقوم بتعديل قاعدة البيانات، يجب تعديل جميع طبقات المشروع [métier, DAO, JPA]:
![]() |
8.4.13.2. المشروع الجديد STS من [métier, DAO, JPA]
يتطور المشروع [rdvmedecins-metier-dao] على النحو التالي:
![]() |
- في [1]: المشروع الجديد؛
- في [2]: تم تجميع التعديلات الناتجة عن مراعاة الأمان في حزمة واحدة هي [rdvmedecins.security]. تنتمي هذه العناصر الجديدة إلى الطبقات [JPA] و [DAO]، ولكن لتسهيل الأمر تم تجميعها في حزمة واحدة.
8.4.13.3. الكيانات الجديدة [JPA]
![]() |
تحدد الطبقة JPA ثلاث كيانات جديدة:
![]() |
الفئة [User] هي صورة للجدول [USERS]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
private static final long serialVersionUID = 1L;
// خصائص
private String identity;
private String login;
private String password;
// الشركة المصنعة
public User() {
}
public User(String identity, String login, String password) {
this.identity = identity;
this.login = login;
this.password = password;
}
// الهوية
@Override
public String toString() {
return String.format("User[%s,%s,%s]", identity, login, password);
}
// أدوات الاسترجاع والتعيين
....
}
- السطر 9: توسع الفئة نطاق الفئة [AbstractEntity] المستخدمة بالفعل للكيانات الأخرى؛
- الأسطر 13-15: لم يتم تحديد أسماء للأعمدة لأنها تحمل نفس أسماء الحقول المرتبطة بها؛
الفئة [Role] هي نسخة طبق الأصل من الجدول [ROLES]:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
private static final long serialVersionUID = 1L;
// الخصائص
private String name;
// منشئات
public Role() {
}
public Role(String name) {
this.name = name;
}
// الهوية
@Override
public String toString() {
return String.format("Role[%s]", name);
}
// دالات الاسترجاع والتعيين
...
}
الفئة [UserRole] هي صورة الجدول [USERS_ROLES]:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
private static final long serialVersionUID = 1L;
// يشير UserRole إلى مستخدم
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// تشير UserRole إلى دور
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// وظائف الحصول والتعيين
...
}
- الأسطر 15-17: تمثل المفتاح الأجنبي من الجدول [USERS_ROLES] إلى الجدول [USERS]؛
- الأسطر 19-21: تُجسد المفتاح الأجنبي للجدول [USERS_ROLES] إلى الجدول [ROLES]؛
8.4.13.4. التعديلات على الطبقة [DAO]
![]() |
تمت إضافة ثلاثة عناصر جديدة من نوع [Repository] إلى الطبقة [DAO]:
![]() |
تدير الواجهة [UserRepository] الوصول إلى الكيانات [User]:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// قائمة الأدوار الخاصة بمستخدم محدد بواسطة معرّفه
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// قائمة الأدوار الخاصة بمستخدم تم تحديده بواسطة اسم تسجيل الدخول وكلمة المرور
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// البحث عن مستخدم باستخدام اسم تسجيل الدخول الخاص به
User findUserByLogin(String login);
}
- السطر 9: واجهة [UserRepository] توسع واجهة [CrudRepository] الخاصة بـ Spring Data (السطر 4)؛
- السطران 12-13: تتيح الطريقة [getRoles(User user)] الحصول على جميع أدوار المستخدم الذي تم تعريفه بواسطة [id]
- السطران 16-17: نفس الشيء ولكن بالنسبة لمستخدم تم تحديد هويته باستخدام اسم تسجيل الدخول وكلمة المرور؛
- السطر 20: للبحث عن مستخدم عبر اسم تسجيل الدخول الخاص به؛
تدير الواجهة [RoleRepository] الوصول إلى الكيانات [Role]:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface RoleRepository extends CrudRepository<Role, Long> {
// البحث عن دور باستخدام اسمه
Role findRoleByName(String name);
}
- السطر 5: واجهة [RoleRepository] توسع واجهة [CrudRepository]؛
- السطر 8: يمكن البحث عن دور باستخدام اسمه؛
تدير الواجهة [userRoleRepository] الوصول إلى الكيانات [UserRole]:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- السطر 5: تكتفي الواجهة [UserRoleRepository] بتوسيع الواجهة [CrudRepository] دون إضافة أساليب جديدة إليها؛
8.4.13.5. فئات إدارة المستخدمين والأدوار
![]() |
يفرض Spring Security إنشاء فئة تنفذ الواجهة التالية [UsersDetail]:
![]() |
يتم تنفيذ هذه الواجهة هنا بواسطة الفئة [AppUserDetails]:
package rdvmedecins.security;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// الخصائص
private User user;
private UserRepository userRepository;
// المنشئون
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------واجهة
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// أدوات الاسترجاع والتعيين
...
}
- السطر 10: الفئة [AppUserDetails] تُنفذ الواجهة [UserDetails]؛
- السطران 15-16: تُغلف الفئة مستخدمًا (السطر 15) والمستودع الذي يتيح الحصول على تفاصيل هذا المستخدم (السطر 16)؛
- الأسطر 22-25: المنشئ الذي يقوم بإنشاء مثيل للفئة باستخدام مستخدم ومستودعه؛
- الأسطر 28-35: تنفيذ الطريقة [getAuthorities] الخاصة بالواجهة [UserDetails]. يجب أن تقوم هذه الطريقة بإنشاء مجموعة من العناصر من النوع [GrantedAuthority] أو أحد مشتقاته. هنا، نستخدم النوع المشتق [SimpleGrantedAuthority] (السطر 32) الذي يغلف اسم أحد أدوار المستخدم المذكور في السطر 15؛
- الأسطر 31-33: يتم تصفح قائمة أدوار المستخدم في السطر 15 لإنشاء قائمة بالعناصر من النوع [SimpleGrantedAuthority]؛
- الأسطر 38-40: تُنفذ الطريقة [getPassword] الخاصة بالواجهة [UserDetails]. يتم إرجاع كلمة مرور المستخدم المذكور في السطر 15؛
- الأسطر 38-40: تُنفذ الطريقة [getUserName] الخاصة بالواجهة [UserDetails]. يتم إرجاع اسم تسجيل الدخول للمستخدم الموجود في السطر 15؛
- الأسطر 47-50: لا تنتهي صلاحية حساب المستخدم أبدًا؛
- الأسطر 52-55: لا يتم حظر حساب المستخدم أبدًا؛
- الأسطر 57-60: لا تنتهي صلاحية بيانات اعتماد المستخدم أبدًا؛
- الأسطر 62-65: يظل حساب المستخدم نشطًا دائمًا؛
يفرض Spring Security أيضًا وجود فئة تُنفِّذ الواجهة [AppUserDetailsService]:
![]() |
يتم تنفيذ هذه الواجهة بواسطة الفئة [AppUserDetailsService] التالية:
package rdvmedecins.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// البحث عن المستخدم عبر اسم تسجيل الدخول
User user = userRepository.findUserByLogin(login);
// هل تم العثور عليه؟
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// عرض تفاصيل المستخدم
return new AppUserDetails(user, userRepository);
}
}
- السطر 9: ستكون الفئة مكونًا من مكونات Spring، وبالتالي ستكون متاحة في سياقها؛
- السطران 12-13: سيتم حقن المكون [UserRepository] هنا؛
- الأسطر 16-25: تنفيذ الطريقة [loadUserByUsername] الخاصة بالواجهة [UserDetailsService] (السطر 10). المعلمة هي اسم تسجيل دخول المستخدم؛
- السطر 18: يتم البحث عن المستخدم باستخدام اسم تسجيل الدخول الخاص به؛
- الأسطر 20-22: إذا لم يتم العثور عليه، يتم إلقاء استثناء؛
- السطر 24: يتم إنشاء كائن [AppUserDetails] وعرضه. وهو بالفعل من النوع [UserDetails] (السطر 16)؛
8.4.13.6. اختبارات الطبقة [DAO]
![]() |
أولاً، نقوم بإنشاء فئة قابلة للتنفيذ [CreateUser] قادرة على إنشاء مستخدم مع دور:
package rdvmedecins.security;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.security.Role;
import rdvmedecins.security.RoleRepository;
import rdvmedecins.security.User;
import rdvmedecins.security.UserRepository;
import rdvmedecins.security.UserRole;
import rdvmedecins.security.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// الصيغة: اسم المستخدم وكلمة المرور roleName
// يلزم وجود ثلاثة معلمات
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// يتم استرداد المعلمات
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// سياق Spring
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DomainAndPersistenceConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// هل الدور موجود بالفعل؟
Role role = roleRepository.findRoleByName(roleName);
// إذا لم يكن موجودًا، يتم إنشاؤه
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// هل المستخدم موجود بالفعل؟
User user = userRepository.findUserByLogin(login);
// إذا لم يكن موجودًا، فسنقوم بإنشائه
if (user == null) {
// نقوم بتشفير كلمة المرور باستخدام bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// نقوم بحفظ المستخدم
user = userRepository.save(new User(login, login, crypt));
// يتم إنشاء الارتباط بالدور
userRoleRepository.save(new UserRole(user, role));
} else {
// المستخدم موجود بالفعل - هل لديه الدور المطلوب؟
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// إذا لم يتم العثور عليه، يتم إنشاء العلاقة مع الدور
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// إغلاق سياق Spring
context.close();
}
}
- السطر 17: تتوقع الفئة ثلاث معلمات تحدد هوية المستخدم: اسم المستخدم، وكلمة المرور، والدور؛
- الأسطر 25-27: يتم استرداد المعلمات الثلاثة؛
- السطر 29: يتم إنشاء سياق Spring استنادًا إلى فئة التكوين [DomainAndPersistenceConfig]. كانت هذه الفئة موجودة بالفعل في المشروع الأولي. ويجب تعديلها على النحو التالي:
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities", "rdvmedecins.security" })
@EnableTransactionManagement
public class DomainAndPersistenceConfig {
....
}
- السطر 1: يجب الإشارة إلى وجود مكونات [Repository] الآن في الحزمة [rdvmedecins.security]؛
- السطر 4: يجب الإشارة إلى أن هناك الآن كيانات JPA في الحزمة [rdvmedecins.security]؛
لنعد إلى كود إنشاء المستخدم:
- الأسطر 30-32: نسترد مراجع الكيانات الثلاثة [Repository] التي قد تكون مفيدة لنا لإنشاء المستخدم؛
- السطر 34: نتحقق مما إذا كان الدور موجودًا بالفعل؛
- الأسطر 36-38: إذا لم يكن موجودًا، نقوم بإنشائه في قاعدة البيانات. وسيكون اسمه من النوع [ROLE_XX]؛
- السطر 40: نتحقق مما إذا كان اسم المستخدم موجودًا بالفعل؛
- الأسطر 42-49: إذا لم يكن اسم المستخدم موجودًا، نقوم بإنشائه في قاعدة البيانات؛
- السطر 44: يتم تشفير كلمة المرور. نستخدم هنا فئة [BCrypt] من Spring Security (السطر 4). لذا نحتاج إلى أرشيفات هذا الإطار. يتضمن الملف [pom.xml] تبعية جديدة:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- السطر 46: يتم حفظ بيانات المستخدم في قاعدة البيانات؛
- السطر 48: وكذلك العلاقة التي تربطه بدوره؛
- الأسطر 51-57: في حالة وجود تسجيل الدخول بالفعل – يتم عندئذٍ التحقق مما إذا كان الدور الذي نريد تعيينه له موجودًا بالفعل ضمن أدواره؛
- السطور 59-61: إذا لم يتم العثور على الدور المطلوب، يتم إنشاء سطر في الجدول [USERS_ROLES] لربط المستخدم بدوره؛
- لم يتم اتخاذ تدابير للحماية من الاستثناءات المحتملة. هذه فئة مساعدة لإنشاء مستخدم مع دور بسرعة.
عند تشغيل الفئة باستخدام الوسيطات [x x guest]، نحصل في قاعدة البيانات على النتائج التالية:
الجدول [USERS]
![]() |
الجدول [ROLES]
![]() |
الجدول [USERS_ROLES]
![]() |
لننظر الآن إلى الفئة الثانية [UsersTest] التي تمثل اختبارًا لـ JUnit:
![]() |
package rdvmedecins.security;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import com.google.common.collect.Lists;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
@Test
public void findAllUsersWithTheirRoles() {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user);
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// يتم استرداد المستخدم [admin]
User user = userRepository.findUserByLogin("admin");
// نتحقق من أن كلمة مروره هي [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// التحقق من دور admin / admin
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// يتم استرداد المستخدم [admin]
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// نتحقق من أن كلمة المرور الخاصة به هي [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// يتم التحقق من دور admin / admin
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// طريقة مساعدة - تعرض عناصر المجموعة
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- الأسطر 27-34: اختبار بصري. يتم عرض جميع المستخدمين مع أدوارهم؛
- الأسطر 36-46: نتحقق من أن المستخدم [admin] لديه كلمة المرور [admin] والدور [ROLE_ADMIN] باستخدام المستودع [UserRepository]؛
- السطر 41: [admin] هي كلمة المرور غير المشفرة. في القاعدة، يتم تشفيرها وفقًا لخوارزمية BCrypt. تسمح الطريقة [BCrypt.checkpw] بالتحقق من أن كلمة المرور غير المشفرة بعد تشفيرها تساوي بالفعل تلك الموجودة في القاعدة؛
- الأسطر 48-59: يتم التحقق من أن المستخدم [admin] لديه كلمة المرور [admin] والدور [ROLE_ADMIN] باستخدام الخدمة [appUserDetailsService]؛
تم تنفيذ الاختبارات بنجاح مع السجلات التالية:
8.4.13.7. استنتاج مؤقت
تمت إضافة الفئات اللازمة لـ Spring Security مع إجراء تعديلات طفيفة على المشروع الأصلي. وللتذكير، كانت هذه التعديلات كما يلي:
- إضافة تبعية لـ Spring Security في الملف [pom.xml]؛
- إنشاء ثلاث جداول إضافية في قاعدة البيانات؛
- إنشاء كيانات JPA ومكونات Spring في الحزمة [rdvmedecins.security]؛
ينبع هذا السيناريو المواتي للغاية من حقيقة أن الجداول الثلاثة المضافة إلى قاعدة البيانات مستقلة عن الجداول الموجودة. بل كان من الممكن وضعها في قاعدة بيانات منفصلة. وقد كان ذلك ممكنًا لأننا قررنا أن المستخدم له وجود مستقل عن الأطباء والعملاء. ولو كان هؤلاء الأطباء والعملاء مستخدمين محتملين، لكان من الضروري إنشاء روابط بين الجدول [USERS] والجدولين [MEDECINS] و[CLIENTS]. وكان ذلك سيؤثر بشكل كبير على المشروع الحالي.
8.4.13.8. المشروع STS التابع للطبقة [web]
![]() |
يتطور المشروع [rdvmedecins-webjson] على النحو التالي من [1]:
![]() |
التعديلات الرئيسية يجب إجراؤها في الحزمة [rdvmedecins.web.config] حيث يجب تكوين Spring Security. وهناك تعديلات أخرى، ثانوية، في الفئتين [AppConfig] و [ApplicationModel]. وقد صادفنا بالفعل فئة تكوين لـ Spring Security:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
وسنتبع نفس الخطوات:
- السطر 11: تعريف فئة تمتد من الفئة [WebSecurityConfigurerAdapter]؛
- السطر 13: تعريف طريقة [configure(HttpSecurity http)] التي تحدد حقوق الوصول إلى مختلف URL في خدمة الويب؛
- السطر 19: تعريف طريقة [configure(AuthenticationManagerBuilder auth)] التي تحدد المستخدمين وأدوارهم؛
يتم تكوين Spring Security بواسطة الفئة [SecurityConfig]:
package rdvmedecins.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
import rdvmedecins.web.models.ApplicationModel;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@Autowired
private ApplicationModel application;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// تتم المصادقة بواسطة الكائن [appUserDetailsService]
// يتم تشفير كلمة المرور بواسطة خوارزمية التجزئة BCrypt
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// تطبيق آمن؟
if (application.isSecured()) {
// يتم إرسال كلمة المرور عبر رأس الرسالة Authorization: Basic xxxx
http.httpBasic();
// يجب السماح باستخدام الطريقة HTTP OPTIONS للجميع
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// لا يمكن استخدام التطبيق إلا من خلال الدور ADMIN
http.authorizeRequests() //
.antMatchers("/", "/**") // جميع URL
.hasRole("ADMIN");
// لا توجد جلسة عمل
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- السطر 15: الفئة [SecurityConfig] هي فئة تكوين Spring؛
- السطر 16: لتنفيذ إجراءات أمان المشروع؛
- السطران 19-20: يتم إدخال الفئة [AppUserDetails] التي تتيح الوصول لمستخدمي التطبيق؛
- السطران 21-22: يتم إدخال الفئة [ApplicationModel] التي تعمل كذاكرة تخزين مؤقتة للتطبيق الويب. ونقرر هنا استخدامها أيضًا، لتكوين تطبيق الويب من مكان واحد. وهي التي تحدد القيمة المنطقية [isSecured] في السطر 36. هذه القيمة المنطقية تؤمن (true) أو لا تؤمن (false) تطبيق الويب؛
- الأسطر 25-29: تحدد الطريقة [configure(HttpSecurity http)] المستخدمين وأدوارهم. وتتلقى كمعلمة نوعًا [AuthenticationManagerBuilder]. ويتم إثراء هذه المعلمة بمعلومتين (السطر 28):
- مرجع إلى الخدمة [appUserDetailsService] في السطر 20 التي تتيح الوصول للمستخدمين المسجلين. وتجدر الإشارة هنا إلى أن حقيقة تسجيلهم في قاعدة بيانات لا تظهر. لذا، قد يكونون مخزَّنين في ذاكرة التخزين المؤقت، أو مقدمين من خلال خدمة ويب، ...
- نوع التشفير المستخدم لكلمة المرور. نذكر هنا أننا استخدمنا الخوارزمية BCrypt؛
- الأسطر 38-47: تحدد الطريقة [configure(HttpSecurity http)] حقوق الوصول إلى URL الخاصة بخدمة الويب؛
- السطر 34: رأينا في المشروع التمهيدي أن Spring Security يدير افتراضيًا رمزًا CSRF (تزوير الطلبات عبر المواقع) يجب على المستخدم الذي يرغب في المصادقة إرساله إلى الخادم. هنا، تم تعطيل هذه الآلية. وهذا، إلى جانب القيمة المنطقية (isSecured=false)، يسمح باستخدام التطبيق الويب دون أمان؛
- السطر 38: يتم تفعيل وضع المصادقة عبر الرأس HTTP. سيتعين على العميل إرسال الرأس HTTP التالي:
حيث «code» هو ترميز السلسلة login:password باستخدام خوارزمية Base64. على سبيل المثال، الترميز Base64 للسلسلة admin:admin هو YWRtaW46YWRtaW4=. وبالتالي، فإن المستخدم الذي يستخدم اسم المستخدم [admin] وكلمة المرور [admin] سيرسل الرأس التالي HTTP لتوثيق هويته:
- الأسطر 40-42: تشير إلى أن جميع URL الخاصة بخدمة الويب متاحة للمستخدمين الذين لديهم الدور [ROLE_ADMIN]. وهذا يعني أن المستخدم الذي لا يمتلك هذا الدور لا يمكنه الوصول إلى خدمة الويب؛
- السطر 47: يمكن تسجيل كلمة مرور المستخدم في جلسة عمل أو عدم تسجيلها. إذا تم تسجيلها، فلن يحتاج المستخدم إلى المصادقة إلا في المرة الأولى. وفي المرات التالية، لن يُطلب منه إدخال بيانات اعتماده. هنا، تم اختيار وضع بدون جلسة عمل. ويجب أن تكون كل طلبات الوصول مصحوبة ببيانات الاعتماد الأمنية؛
تتطور الفئة [AppConfig] التي تهيئ التطبيق بأكمله على النحو التالي:
![]() |
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- يتم التعديل في السطر 11: نضيف فئة التهيئة [SecurityConfig]؛
وأخيرًا، تُضاف قيمة منطقية إلى الفئة [ApplicationModel]:
@Component
public class ApplicationModel implements IMetier {
...
// بيانات التكوين
private boolean secured = false;
public boolean isSecured() {
return secured;
}
- السطر 6: يتم تعيين القيمة المنطقية [secured] إلى [true / false] حسب الرغبة في تفعيل الأمان أم لا.
8.4.13.9. اختبارات خدمة الويب
سنقوم باختبار خدمة الويب باستخدام متصفح Chrome [Advanced Rest Client]. سنحتاج إلى تحديد رأس المصادقة HTTP:
حيث [code] هو رمز Base64 لسلسلة [login:password]. لتوليد هذا الرمز، يمكن استخدام البرنامج التالي:
![]() |
package rdvmedecins.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// يُتوقع إدخال معلمتين: اسم المستخدم وكلمة المرور
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// يتم استرداد المعلمتين
String chaîne = String.format("%s:%s", args[0], args[1]);
// يتم ترميز السلسلة
byte[] data = Base64.encode(chaîne.getBytes());
// يتم عرض الترميز Base64
System.out.println(new String(data));
}
}
إذا قمنا بتشغيل هذا البرنامج مع المعلمتين [admin admin]:
![]() |
نحصل على النتيجة التالية:
والآن بعد أن أصبحنا قادرين على إنشاء رأس المصادقة HTTP، سنقوم بتشغيل خدمة الويب الآمنة:
@Component
public class ApplicationModel implements IMetier {
...
private boolean secured = true;
ثم باستخدام متصفح Chrome [Advanced Rest Client]، نطلب قائمة بجميع الأطباء:
![]() |
- في [1]، نطلب URL الخاص بالأطباء؛
- في [2]، باستخدام طريقة GET؛
- في [3]، نقدم رأس HTTP للمصادقة. الرمز [YWRtaW46YWRtaW4=] هو ترميز Base64 للسلسلة [admin:admin]؛
- في [4]، نرسل الأمر HTTP؛
رد الخادم هو كما يلي:
![]() |
- في [1]، رأس المصادقة HTTP؛
- في [2]، يرد الخادم برد jSON؛
- في [3]، قائمة برؤوس HTTP المتعلقة بتأمين تطبيق الويب؛
ونحصل بالفعل على قائمة الأطباء:
![]() |
لنحاول الآن إرسال طلب HTTP باستخدام رأس مصادقة غير صحيح. تكون الإجابة عندئذٍ كما يلي:
![]() |
- في [1] و [3]: رأس المصادقة HTTP؛
- إلى [2]: استجابة خدمة الويب؛
الآن، دعونا نجرب المستخدم user / user. إنه موجود ولكنه لا يملك حق الوصول إلى خدمة الويب. إذا قمنا بتشغيل برنامج الترميز Base64 مع الحجتين [user user]:
![]() |
نحصل على النتيجة التالية:
![]() |
- في [1] و [3]: رأس المصادقة HTTP؛
- إلى [2]: استجابة خدمة الويب. وهي تختلف عن الاستجابة السابقة التي كانت [401 Unauthorized]. هذه المرة، قام المستخدم بالمصادقة بشكل صحيح ولكنه لا يمتلك الصلاحيات الكافية للوصول إلى URL؛
أصبحت خدمة الويب الآمنة جاهزة للعمل الآن. سنقوم بتكملتها بحيث تسمح بالطلبات عبر النطاقات. وقد ظهرت هذه الحاجة في المستند [Tutoriel AngularJS / Spring 4]، وعلى الرغم من عدم وجود هذه الحاجة هنا، إلا أننا سنقوم بتلبيتها على أي حال.
8.4.14. تنفيذ الطلبات عبر النطاقات
دعونا ندرس مشكلة الطلبات عبر النطاقات. في الوثيقة [Tutoriel AngularJS / Spring 4]، يتم تطوير تطبيق عميل/خادم حيث يكون العميل هو تطبيق AngularJS:
![]() |
- تأتي الصفحات HTML / CSS / JS الخاصة بتطبيق Angular من الخادم [1]؛
- في [2]، تقوم الخدمة [dao] بإرسال طلب إلى خادم آخر، وهو الخادم [2]. وهذا الأمر محظور من قبل المتصفح الذي يقوم بتشغيل تطبيق Angular لأنه يمثل ثغرة أمنية. لا يمكن للتطبيق أن يستعلم إلا عن الخادم الذي أتى منه، أي الخادم [1]؛
في الواقع، من غير الصحيح القول إن المتصفح يمنع تطبيق Angular من الاستعلام عن الخادم [2]. بل إن التطبيق يستعلم عنه في الواقع ليسأله عما إذا كان يسمح لعميل لا ينتمي إلى شبكته بالاستعلام عنه. تُسمى هذه التقنية «CORS» (Cross-Origin Resource Sharing). ويوافق الخادم [2] على ذلك عن طريق إرسال رؤوس HTTP محددة.
لإظهار المشكلات التي قد نواجهها، سنقوم بإنشاء تطبيق عميل/خادم حيث:
- سيكون الخادم هو خادم الويب الخاص بنا / jSON؛
- سيكون العميل عبارة عن صفحة بسيطة HTML مزودة برمز جافا سكريبت يقوم بإرسال طلبات إلى خادم الويب / jSON؛
8.4.14.1. مشروع العميل
![]() |
المشروع هو مشروع Maven يحتوي على الملف التالي: [pom.xml]:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-cors</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-cors</name>
<description>Client for webjson server</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- البحث عن العنصر الأصلي من المستودع -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.Client</start-class>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
- الأسطر 14-19: إنه مشروع Spring Boot؛
- الأسطر 29-32: يتم استخدام التبعية [spring-boot-starter-web] التي تتضمن خادم Tomcat و Spring MVC؛
الصفحة HTML هي التالية:
![]() |
يتم إنشاؤها بواسطة الكود التالي:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Spring MVC</title>
<script type="text/javascript" src="/js/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="/js/client.js"></script>
</head>
<body>
<h2>Client du service web / jSON</h2>
<form id="formulaire">
<!-- الطريقة HTTP -->
Méthode HTTP :
<!-- -->
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<!-- -->
<input type="radio" id="post" name="method" value="post" />POST
<!-- URL -->
<br /> <br />URL cible : <input type="text" id="url" size="30"><br />
<!-- القيمة التي تم إدخالها -->
<br /> Chaîne jSON à poster : <input type="text" id="posted" size="50" />
<!-- زر التأكيد -->
<br /> <br /> <input type="submit" value="Valider" onclick="javascript:requestServer(); return false;"></input>
</form>
<hr />
<h2>Réponse du serveur</h2>
<div id="response"></div>
</body>
</html>
- السطر 6: يتم استيراد المكتبة jQuery؛
- السطر 7: يتم استيراد كود سنقوم بكتابته؛
الكود [client.js] هو التالي:
// البيانات العامة
var url;
var posted;
var response;
var method;
function requestServer() {
// يتم استرداد معلومات النموذج
var urlValue = url.val();
var postedValue = posted.val();
method = document.forms[0].elements['method'].value;
// إجراء استدعاء Ajax يدويًا
if (method === "get") {
doGet(urlValue);
} else {
doPost(urlValue, postedValue);
}
}
function doGet(url) {
// إجراء استدعاء Ajax يدويًا
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url،
type : 'GET',
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// نتيجة نصية
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// خطأ في النظام
response.text(jqXHR.responseText);
}
})
}
function doPost(url, posted) {
// يتم إجراء استدعاء Ajax يدويًا
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url،
type : 'POST',
contentType : 'application/json',
data : posted,
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// نتيجة نصية
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// خطأ في النظام
response.text(jqXHR.responseText);
}
})
}
// أثناء تحميل المستند
$(document).ready(function() {
// يتم استرداد مراجع مكونات الصفحة
url = $("#url");
posted = $("#posted");
response = $("#response");
});
نترك للقارئ مهمة فهم هذا الكود. فقد سبق أن صادفنا كل ما فيه في وقت أو آخر. لكن بعض الأسطر تستحق التوضيح:
- السطر 11:
- يشير [document] إلى المستند الذي تم تحميله بواسطة المتصفح، وهو ما يُعرف باسم DOM (نموذج كائن المستند)،
- [document.forms[0]] يشير إلى النموذج الأول في المستند، حيث يمكن أن يحتوي المستند على عدة نماذج. هنا، يوجد نموذج واحد فقط،
- [document.forms[0].elements['method']] يشير إلى عنصر النموذج الذي يحمل السمة [name='method']. وهناك عنصران:
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<input type="radio" id="post" name="method" value="post" />POST
- السطر 11:
- [document.forms[0].elements['method'].value] هي القيمة التي سيتم إرسالها للمكون الذي يحمل السمة [name='method']. من المعروف أن القيمة التي يتم إرسالها هي قيمة السمة [value] الخاصة بزر الاختيار المحدد. وهنا، ستكون إحدى السلاسل التالية: ['get', 'post']؛
- الأسطر 23-25: نتواصل مع خادم يتطلب رأسًا HTTP [Authorization: Basic code]. نقوم بإنشاء هذا الرأس للمستخدم [admin / admin] الذي هو الوحيد القادر على الاستعلام من الخادم؛
- السطر 26: سيقوم المستخدم بإدخال URL من النوع [/getAllMedecins, /supprimerRv, ...]. لذا يجب استكمال هذه القيم URL؛
- السطر 28: يرسل الخادم قيمة jSON وهي عبارة عن نص. يتم تحديد النوع [text/plain] كنوع للنتيجة لعرضها كما تم استلامها؛
- السطر 33: عرض الرد النصي من الخادم؛
- السطر 39: عرض رسالة الخطأ المحتملة بتنسيق نصي؛
- السطر 52: للإشارة إلى أن العميل يرسل jSON؛
في تطبيق العميل/الخادم الذي تم إنشاؤه:
- العميل هو تطبيق ويب متاح على URL [http://localhost:8081]. هذا هو التطبيق الذي نقوم بإنشائه حاليًا؛
- الخادم هو تطبيق ويب متاح على URL و [http://localhost:8080]. هذا هو خادم الويب الخاص بنا / jSON؛
ونظرًا لأن العميل لا يتصل من نفس المنفذ الذي يتصل منه الخادم، فإن مشكلة الطلبات عبر النطاقات تظهر. [http://localhost:8080] و [http://localhost:8081] هما نطاقان مختلفان.
تطبيق Spring Boot هو تطبيق وحدة تحكم يتم تشغيله بواسطة الفئة القابلة للتنفيذ التالية: [Client]:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class Client extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(Client.class, args);
}
// صفحات ثابتة
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(new String[] { "classpath:/static/" });
}
// تكوين dispatcherServlet
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
// خادم Tomcat المدمج
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8081);
}
}
- السطر 14: الفئة [Client] هي فئة تكوين Spring؛
- السطر 15: يتم تكوين تطبيق Spring MVC. تؤدي هذه العلامة إلى عدد من عمليات التكوين التلقائية؛
- السطر 16: لإعادة تعريف بعض القيم الافتراضية لإطار عمل Spring MVC، يجب توسيع الفئة [WebMvcConfigurerAdapter]؛
- الأسطر 23-26: تتيح الطريقة [addResourceHandlers] تحديد المجلدات التي توجد بها الموارد الثابتة (html، css، js، ...) للتطبيق. هنا، يتم تحديد المجلد [static] الموجود في مسار الفئات (Classpath) للمشروع:
![]() |
- الأسطر 29-37: تكوين bean [dispatcherServlet] الذي يشير إلى سيرفلت Spring MVC؛
- الأسطر 40-43: سيعمل خادم Tomcat المدمج على المنفذ 8081؛
8.4.14.2. URL [/getAllMedecins]
نقوم بتشغيل:
- خادم الويب / json على المنفذ 8080؛
- عميل هذا الخادم على المنفذ 8081؛
ثم نطلب URL [http://localhost:8081/client.html] [1]:
![]() |
- في [2]، نقوم بإجراء GET على URL [http://localhost:8080/getAllMedecins]؛
لا نتلقى أي استجابة من الخادم. عند الاطلاع على وحدة التحكم في التطوير (Ctrl-Shift-I)، نكتشف وجود خطأ:
![]() |
- في [1]، ونحن في علامة التبويب [Network]؛
- في [2]، نلاحظ أن الطلب HTTP الذي تم إرساله ليس [GET] بل [OPTIONS]. في حالة الطلب عبر النطاقات، يتحقق المتصفح من الخادم للتأكد من استيفاء عدد معين من الشروط عن طريق إرسال طلب HTTP [OPTIONS] إليه. وفي هذه الحالة، الطلبات هي تلك المشار إليها بالرموز [5-6]؛
- في [5]، يسأل المتصفح عما إذا كان يمكن الوصول إلى الهدف URL باستخدام GET. يطلب رأس الطلب [Access-Control-Request-Method] استجابةً برأس HTTP [Access-Control-Allow-Methods] يشير إلى قبول الطريقة المطلوبة؛
- في [5]، يرسل المتصفح رأس الطلب HTTP [Origin: http://localhost:8081]. يطلب هذا الرأس استجابة في رأس HTTP [Access-Control-Allow-Origin] تشير إلى قبول المصدر المحدد؛
- في [6]، يسأل المتصفح عما إذا كانت الرؤوس HTTP و[accept] و[authorization] مقبولة. تتوقع رأس الطلب [Access-Control-Request-Headers] استجابة تحتوي على رأس HTTP و[Access-Control-Allow-Headers] تشير إلى قبول الرؤوس المطلوبة؛
- يظهر خطأ في [3]. عند النقر على الأيقونة، يظهر الخطأ [4]؛
- في [4]، تشير الرسالة إلى أن الخادم لم يرسل الرأس HTTP [Access-Control-Allow-Origin] الذي يحدد ما إذا كان مصدر الطلب مقبولًا أم لا؛
- في [7]، يمكن ملاحظة أن الخادم لم يرسل هذا الرأس فعليًّا. ونتيجة لذلك، رفض المتصفح تنفيذ الطلب HTTP GET الذي تم طلبه في البداية؛
علينا تعديل خادم الويب / jSON. نقوم بإجراء تعديل أولي في [ApplicationModel]، وهو أحد عناصر تكوين خدمة الويب:
![]() |
@Component
public class ApplicationModel implements IMetier {
...
// بيانات التكوين
private boolean corsAllowed = true;
private boolean secured = true;
...
public boolean isCorsAllowed() {
return corsAllowed;
}
- السطر 6: نقوم بإنشاء متغير منطقي يحدد ما إذا كنا نقبل أم لا العملاء من خارج نطاق الخادم؛
- الأسطر 10-12: طريقة الوصول إلى هذه المعلومات؛
ثم نقوم بإنشاء وحدة تحكم Spring جديدة MVC:
![]() |
الفئة [RdvMedecinsCorsController] هي كما يلي:
package rdvmedecins.web.controllers;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import rdvmedecins.web.models.ApplicationModel;
@Controller
public class RdvMedecinsCorsController {
@Autowired
private ApplicationModel application;
// إرسال الخيارات إلى العميل
public void sendOptions(String origin, HttpServletResponse response) {
// هل يُسمح بـ CORS؟
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// تعيين الرأس CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// يُسمح ببعض الرؤوس
response.addHeader("Access-Control-Allow-Headers", "accept, authorization");
// يُسمح بـ GET
response.addHeader("Access-Control-Allow-Methods", "GET");
}
// قائمة الأطباء
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
public void getAllMedecins(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
}
- السطران 12-13: الفئة [RdvMedecinsCorsController] هي وحدة تحكم Spring؛
- الأسطر 33-36: تحدد إجراءً يعالج URL و[/getAllMedecins] عند طلبهما باستخدام الأمر HTTP و[OPTIONS]؛
- السطر 34: تقبل الطريقة [getAllMedecins] المعلمات التالية:
- الكائن [@RequestHeader(value = "Origin", required = false)] الذي سيسترد رأس الطلب HTTP [Origin]. وقد تم إرسال هذا الرأس من قبل مرسل الطلب:
يُشار إلى أن الرأس HTTP [Origin] اختياري [required = false]. في هذه الحالة، إذا كان الرأس غائبًا، فستكون قيمة المعلمة [String origin] هي null. وبما أن [required = true] هي القيمة الافتراضية، يتم إلقاء استثناء في حالة غياب الرأس. وقد أردنا تجنب هذه الحالة؛
- السطر 34:
- الكائن [HttpServletResponse response] الذي سيتم إرساله إلى العميل الذي قدم الطلب؛
يتم إدخال هذين المعلمتين بواسطة Spring؛
- السطر 35: يتم تفويض معالجة الطلب إلى الطريقة الموجودة في الأسطر 19-30؛
- السطران 15-16: يتم حقن الكائن [ApplicationModel]؛
- الأسطر 21-23: إذا كان التطبيق مُهيأً لقبول الطلبات عبر النطاقات، وإذا كان المرسل قد أرسل الرأس HTTP [Origin]، وإذا كان هذا المصدر يبدأ بـ [http://localhost]، فسيتم قبول الطلب عبر النطاقات، وإلا فسيتم رفضه؛
- السطر 25: إذا كان العميل في المجال [http://localhost:port]، يتم إرسال الرأس HTTP:
مما يعني أن الخادم يقبل مصدر العميل؛
- السطر 25: أشرنا إلى رأسين خاصين هما HTTP في الطلب HTTP [OPTIONS]:
فيما يتعلق برأس الرسالة HTTP [Access-Control-Request-X]، يرد الخادم برأس رسالة HTTP [Access-Control-Allow-X] يوضح فيه ما هو مسموح به. تكتفي الأسطر 23-26 بإعادة طلب العميل للإشارة إلى أنه تم قبوله؛
نحن الآن جاهزون لإجراء اختبارات جديدة. نقوم بتشغيل الإصدار الجديد من خدمة الويب ونكتشف أن المشكلة لا تزال قائمة. لم يتغير شيء. إذا أضفنا عرضًا على وحدة التحكم في السطر 35 أعلاه، فلن يتم عرضه أبدًا، مما يدل على أن الطريقة [getAllMedecins] في السطر 34 لم يتم استدعاؤها مطلقًا.
بعد إجراء بعض الأبحاث، نكتشف أن Spring MVC يعالج بنفسه الأوامر HTTP و [OPTIONS] باستخدام معالجة افتراضية. وبالتالي، فإن Spring هو الذي يستجيب دائمًا، ولا يتم استدعاء الطريقة [getAllMedecins] في السطر 34 أبدًا. يمكن تغيير هذا السلوك الافتراضي لـ Spring MVC. نقوم بتعديل الفئة [WebConfig] الموجودة:
![]() |
package rdvmedecins.web.config;
...
import org.springframework.web.servlet.DispatcherServlet;
@Configuration
public class WebConfig {
// تكوين dispatcherservlet للرؤوس CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
// التعيين jSON
...
- السطران 10-11: تُستخدم الحبة [dispatcherServlet] لتعريف السيرفلت الذي يدير طلبات العملاء. وهي هنا من النوع [DispatcherServlet]، وهو سيرفلت إطار عمل Spring MVC؛
- السطر 12: يتم إنشاء مثيل من النوع [DispatcherServlet]؛
- السطر 13: يُطلب من السيرفلت أن يقوم بتوجيه الأوامر HTTP و [OPTIONS] إلى التطبيق؛
- السطر 14: يتم تكوين السيرفلت على هذا النحو؛
نُعيد إجراء الاختبارات باستخدام هذا التكوين الجديد. ونحصل على النتيجة التالية:
![]() |
- في [1]، نلاحظ وجود طلبين HTTP موجهين إلى URL و[http://localhost:8080/getAllMedecins]؛
- في [2]، الطلب [OPTIONS]؛
- في [3]، الرؤوس الثلاثة HTTP التي قمنا بتكوينها للتو في استجابة الخادم؛
لننظر الآن إلى الطلب الثاني:
![]() |
- إلى [1]، وهو الطلب الذي تم فحصه؛
- إلى [2]، وهو الطلب GET. بفضل الطلب الأول [OPTIONS]، تلقى المتصفح المعلومات التي طلبها. وهو الآن ينفذ الطلب [GET] الذي تم طلبه في البداية؛
- في [3]، رد الخادم؛
- في [4]، يرسل الخادم jSON؛
- في [5]، حدث خطأ؛
- في [6]، رسالة الخطأ؛
من الصعب شرح ما حدث هنا. رد الخادم [3] هو رد طبيعي [HTTP/1.1 200 OK]. لذلك من المفترض أن نحصل على المستند المطلوب. من المحتمل أن يكون الخادم قد أرسل المستند بالفعل، لكن المتصفح هو الذي يمنع استخدامه لأنه يريد أن تتضمن الاستجابة لطلب GET أيضًا الرأس HTTP [Access-Control-Allow-Origin:http://localhost:8081].
نقوم بتعديل وحدة التحكم [RdvMedecinsController] على النحو التالي:
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
...
// قائمة الأطباء
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins(HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// الاستجابة
Response<List<Medecin>> response;
// رؤوس CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// حالة التطبيق
...
- السطران 1-2: يتم إدخال وحدة التحكم [RdvMedecinsCorsController]؛
- السطران 7-8: يتم إدخال الكائن HttpServletResponse، الذي يحتوي على الرد الذي سيتم إرساله إلى العميل، ورأس الرسالة HTTP [Origin]، في معلمات الطريقة [getAllMedecins]؛
- السطر 12: يتم استدعاء الطريقة [sendOptions] من وحدة التحكم [RdvMedecinsCorsController]، وهي نفس الطريقة التي تم استدعاؤها لمعالجة الطلب HTTP [OPTIONS]. وبالتالي، سترسل نفس الرؤوس HTTP المستخدمة في ذلك الطلب؛
وبعد هذا التعديل، تكون النتائج كما يلي:
![]() |
لقد حصلنا بالفعل على قائمة الأطباء.
8.4.14.3. أما العناوين الأخرى URL و [GET]
نعرض الآن طلبات URL الأخرى التي تم الاستعلام عنها عبر طلب GET. في وحدات التحكم، يتبع كود الإجراءات التي تعالجها نموذج الإجراءات التي عالجت سابقًا URL [/getAllMedecins]. يمكن للقارئ التحقق من الكود في الأمثلة المرفقة مع هذا المستند. وإليك مثالًا:
في [RdvMedecinsCorsController]
// قائمة مواعيد الطبيب
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.OPTIONS)
public void getRvMedecinJour(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
في [RdvMedecinsController]
// قائمة مواعيد الطبيب
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour,
HttpServletResponse httpServletResponse, @RequestHeader(value = "Origin", required = false) String origin)
throws JsonProcessingException {
// الرد
Response<List<Rv>> response = null;
boolean erreur = false;
// رؤوس القوائم CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// حالة التطبيق
...
وفيما يلي لقطات شاشة للتنفيذ:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
8.4.14.4. URL [POST]
لننظر في الحالة التالية:
![]() |
- نقوم بإجراء تحويل من POST و [1] إلى URL و [2]؛
- في [3]، القيمة التي تم نشرها. وهي عبارة عن سلسلة jSON؛
- إجمالاً، نسعى إلى حذف الموعد الذي يحمل الرمز [id] 100؛
لن نقوم بتعديل أي كود في الوقت الحالي. والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
- في [1]، كما هو الحال مع الطلبات [GET]، يقوم المتصفح بإرسال طلب [OPTIONS]؛
- في حالة [2]، يطلب المتصفح إذنًا بالوصول لطلب [POST]. وكان ذلك سابقًا [GET]؛
- في [3]، يطلب إذنًا لإرسال الرؤوس HTTP و[accept, authorization, content-type]. في السابق، كان لدينا الرؤوس الأولى فقط؛
نقوم بتعديل الطريقة [RdvMedecinsCorsController.sendOptions] على النحو التالي:
public void sendOptions(String origin, HttpServletResponse response) {
// هل يُسمح بـ Cors؟
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// تحديد الرأس CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// يُسمح ببعض الرؤوس
response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
// يُسمح بـ GET
response.addHeader("Access-Control-Allow-Methods", "GET, POST");
}
- السطر 9: تمت إضافة الرؤوس HTTP و [Content-Type] (لا يهم استخدام الأحرف الكبيرة أو الصغيرة)؛
- السطر 11: تمت إضافة الطريقة HTTP [POST]؛
وبذلك تُعالج الطرق [POST] بنفس الطريقة التي تُعالج بها الطلبات [GET]. وفيما يلي مثال على URL و [/supprimerRv]:
في [RdvMedecinsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// الرد
Response<Void> response = null;
boolean erreur = false;
// رؤوس CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// حالة التطبيق
if (messages != null) {
...
في [RdvMedecinsCorsController]
@RequestMapping(value = "/supprimerRv", method = RequestMethod.OPTIONS)
public void supprimerRv(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
بالنسبة لـ URL و [/ajouterRv]، نحصل على النتيجة التالية:
![]() |
8.4.14.5. Conclusion
يدعم تطبيقنا الآن الطلبات عبر النطاقات. ويمكن السماح بهذه الطلبات أو حظرها من خلال التكوين في الفئة [ApplicationModel]:
// بيانات التكوين
private boolean corsAllowed = false;
8.5. العميل المبرمج لخدمة الويب / jSON
لنعد إلى البنية العامة للتطبيق الذي نريد كتابته:
![]() |
تمت كتابة الجزء العلوي من المخطط. وهو خادم الويب / jSON. ننتقل الآن إلى الجزء السفلي، وبدايةً إلى طبقته [DAO]. سنقوم بكتابة هذه الطبقة ثم اختبارها باستخدام عميل وحدة التحكم. وستكون بنية الاختبار كما يلي:
![]() |
8.5.1. مشروع عميل وحدة التحكم
سيكون مشروع STS الخاص بعميل وحدة التحكم كما يلي:
![]() |
8.5.2. تكوين Maven
ملف [pom.xml] الخاص بعميل وحدة التحكم هو كما يلي:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webjson-client-console</name>
<description>Client console du serveur web / jSON</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- البحث عن العنصر الأصلي من المستودع -->
</parent>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- مكتبة jSON المستخدمة بواسطة Spring -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- مكون يستخدمه Spring RestTemplate -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
</project>
- الأسطر 15-20: مشروع Spring Boot الأصلي؛
- الأسطر 24-27: عميل وحدة التحكم لخادم الويب / jSON يعتمد على مكون يُسمى [RestTemplate] يتم توفيره بواسطة التبعية [spring-web]؛
- الأسطر 29-36: يتطلب تسلسل/إلغاء تسلسل كائنات jSON مكتبة jSON. نستخدم نسخة معدلة من مكتبة Jackson المستخدمة في Spring Web؛
- الأسطر 38-41: على المستوى الأدنى، يتواصل المكون [RestTemplate] مع الخادم عبر مآخذ TCP/IP. نريد ضبط [timeout] من بين هذه القيم، أي الحد الأقصى لوقت انتظار استجابة الخادم. لا يسمح لنا المكون [RestTemplate] بضبط هذه القيمة. للقيام بذلك، سنقوم بتمرير مكون من المستوى الأدنى، مقدم من التبعية [org.apache.httpcomponents.httpclient]، إلى مُنشئ [RestTemplate]. وهذه التبعية هي التي ستسمح لنا بتحديد قيمة [timeout] للاتصال؛
8.5.3. الحزمة [rdvmedecins.client.entities]
![]() |
تجمع الحزمة [rdvmedecins.client.entities] جميع الكيانات التي ترسلها خدمة الويب / jSON عبر مختلف URL الخاصة بها. لن نكرر تفصيلها مرة أخرى. سنكتفي بالقول إن الكيانات JPA و [Client, Creneau, Medecin, Rv, Personne] قد تم إزالة جميع تعليقاتها JPA وكذلك تعليقاتها jSON. فيما يلي، على سبيل المثال، الفئة [Rv]:
package rdvmedecins.client.entities;
import java.util.Date;
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// يوم الموعد
private Date jour;
// موعد مرتبط بعميل
private Client client;
// موعد مرتبط بفترة زمنية
private Creneau creneau;
// مفاتيح خارجية
private long idClient;
private long idCreneau;
// الشركة المصنعة الافتراضية
public Rv() {
}
// مع المعلمات
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// دالات الاسترجاع والتعيين
...
}
8.5.4. الحزمة [rdvmedecins.client.requests]
![]() |
تجمع الحزمة [rdvmedecins.client.requests] بين الفئتين اللتين يتم إرسال قيمتهما jSON إلى URL و[/ajouterRv] و[supprimerRv]. وهي مطابقة لما هو موجود على جانب الخادم.
8.5.5. الحزمة [rdvmedecins.client.responses]
![]() |
[Response] هو نوع جميع استجابات خدمة الويب / jSON. وهو نوع عام:
package rdvmedecins.client.responses;
import java.util.List;
public class Response<T> {
// ----------------- الخصائص
// حالة العملية
private int status;
// رسائل الخطأ المحتملة
private List<String> messages;
// نص الرد
private T body;
// المنشئات
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// المنشورات والمُعيّنات
...
}
- السطر 5: يختلف النوع [T] باختلاف URL لخدمة الويب / jSON؛
8.5.6. الحزمة [rdvmedecins.client.dao]
![]() |
- [IDao] هي واجهة الطبقة [DAO] و [Dao] هي تطبيقها. سنعود إلى هذا التطبيق لاحقًا؛
8.5.7. الحزمة [rdvmedecins.client.config]
![]() |
تقوم الفئة [DaoConfig] بتكوين التطبيق. وفيما يلي شفرة البرمجة الخاصة بها:
package rdvmedecins.client.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@ComponentScan({ "rdvmedecins.client.dao" })
public class DaoConfig {
@Bean
public RestTemplate restTemplate() {
// إنشاء المكون RestTemplate
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
RestTemplate restTemplate = new RestTemplate(factory);
// النتيجة
return restTemplate;
}
// أدوات التعيين jSON
@Bean
public ObjectMapper jsonMapper(){
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",
creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- السطر 13: الفئة [DaoConfig] هي فئة تكوين Spring؛
- السطر 14: سيتم استكشاف الحزمة [rdvmedecins.client.dao] للبحث عن مكونات Spring فيها. وسنجد فيها المكون [Dao]؛
- الأسطر 17-24: تُعرّف عنصر Spring فريد (singleton) باسم [restTemplate] (اسم الأسلوب). تُرجع هذه الطريقة مثيلًا [RestTemplate]، وهو الأداة الأساسية التي يوفرها Spring للتواصل مع خدمة ويب / jSON؛
- السطر 21: يمكن كتابة [RestTemplate restTemplate = new RestTemplate() ;]. وهذا يكفي في معظم الحالات. لكننا هنا نريد تعيين [timeout] الخاص بالعميل. للقيام بذلك، نقوم بحقن مكون [RestTemplate]، وهو مكون من المستوى الأدنى من النوع [HttpComponentsClientHttpRequestFactory] (السطر 20) الذي سيسمح لنا بتحديد هذه القيم [timeout]. وقد تم عرض التبعية المطلوبة في Maven؛
- الأسطر 28-57: تحدد مُعَيِّنات التعيين jSON. وهذه هي أدوات التعيين jSON المستخدمة على جانب الخادم (انظر الفقرة 8.4.11.3) لتسلسل النوع T من الاستجابة [Response<T>]. وسيتم الآن استخدام أدوات التحويل هذه نفسها على جانب العميل لإلغاء تسلسل النوع T؛
8.5.8. الواجهة [IDao]
لنعد إلى بنية التطبيق:
![]() |
الطبقة [DAO] هي محول بين الطبقة [console] و URL التي تعرضها خدمة الويب / jSON. وستكون واجهتها [IDao] كما يلي:
package rdvmedecins.client.dao;
import java.util.List;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
public interface IDao {
// عنوان URL لخدمة الويب
public void setUrlServiceWebJson(String url);
// مهلة الانتظار
public void setTimeout(int timeout);
// المصادقة
public void authenticate(User user);
// قائمة العملاء
public List<Client> getAllClients(User user);
// قائمة الأطباء
public List<Medecin> getAllMedecins(User user);
// قائمة المواعيد المتاحة لأحد الأطباء
public List<Creneau> getAllCreneaux(User user, long idMedecin);
// البحث عن عميل باستخدام معرّفه
public Client getClientById(User user, long id);
// البحث عن عميل باستخدام رقم التعريف الخاص به
public Medecin getMedecinById(User user, long id);
// البحث عن موعد محدد برقم التعريف الخاص به
public Rv getRvById(User user, long id);
// البحث عن موعد محدد برقمه التعريفي
public Creneau getCreneauById(User user, long id);
// إضافة RV
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient);
// حذف موعد محدد برقم التعريف
public void supprimerRv(User user, long idRv);
// قائمة مواعيد الطبيب في يوم معين
public List<Rv> getRvMedecinJour(User user, long idMedecin, String jour);
// جدول المواعيد
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
}
- السطر 14: الطريقة التي تسمح بتحديد جذر خدمة الويب / jSON، على سبيل المثال [http://localhost:8080]؛
- السطر 17: الطريقة التي تسمح بتحديد جانب العميل. نريد التحكم في هذا المعامل لأن بعض العملاء قد يستغرقون أحيانًا وقتًا طويلاً في انتظار استجابة لن تأتي؛
- السطر 20: الطريقة التي تسمح بتحديد هوية مستخدم [login, passwd]. تطلق استثناءً إذا لم يتم التعرف على المستخدم؛
- الأسطر 22-53: ترتبط كل URL التي يعرضها خدمة الويب / jSON بطريقة من واجهة تتبع توقيعها توقيع الطريقة من جانب الخادم التي تعالج URL المعروضة. لنأخذ على سبيل المثال، الخادم URL التالي:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Response<String> getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
- السطر 1: نلاحظ أن [idMedecin] و [jour] هما معلمات URL. وستكون هذه هي معلمات الإدخال للطريقة المرتبطة بـ URL على جانب العميل؛
- السطر 2: نلاحظ أن الطريقة على جانب الخادم تُرجع نوعًا [Response<String>]. هذا النوع [String] هو نوع القيمة jSON من النوع [AgendaMedecinJour]. سيكون نوع نتيجة الطريقة المرتبطة بـ URL من جانب العميل هو [AgendaMedecinJour]؛
على جانب العميل، يتم تعريف الطريقة التالية:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
تكون هذه التوقيعات مناسبة عندما يرسل الخادم استجابة [int status, List<String> messages, String body] مع [status0]. في هذه الحالة، يكون لدينا [messagesnull && body!=null]. وهي غير مناسبة عندما يكون [status!=0]. في هذه الحالة، نحصل على [messages!=null && body==null]. وعلينا بطريقة أو بأخرى الإشارة إلى حدوث خطأ. ولذلك، سنقوم بإطلاق استثناء من النوع [RdvMedecinsException] على النحو التالي:
package rdvmedecins.client.dao;
import java.util.List;
public class RdvMedecinsException extends RuntimeException {
private static final long serialVersionUID = 1L;
// رمز الخطأ
private int status;
// قائمة رسائل الخطأ
private List<String> messages;
public RdvMedecinsException() {
}
public RdvMedecinsException(int code, List<String> messages) {
super();
this.status = code;
this.messages = messages;
}
// دالات الاسترجاع والتعيين
...
}
- السطران 9 و11: سيستلم الاستثناء قيم حقول [status, messages] من الكائن [Response<T>] المرسل من الخادم؛
- السطر 5: الفئة [RdvMedecinsException] تمتد من الفئة [RuntimeException]. وبالتالي فهي استثناء غير خاضع للرقابة، أي أنه ليس هناك إلزام بإدارتها باستخدام try / catch وإعلانها في توقيع طرق الواجهة؛
من ناحية أخرى، فإن جميع طرق واجهة [IDao] التي تستعلم عن خدمة الويب / jSON تتضمن كمعلمة النوع [User] التالي:
package rdvmedecins.client.entities;
public class User {
// بيانات
private String login;
private String passwd;
// المُنشِئات
public User() {
}
public User(String login, String passwd) {
this.login = login;
this.passwd = passwd;
}
// أدوات الاسترجاع والتعيين
...
}
في الواقع، يجب أن يكون كل تبادل مع خدمة الويب / jSON مصحوبًا برأس HTTP للمصادقة.
8.5.9. الحزمة [rdvmedecins.clients.console]
الآن بعد أن تعرفنا على واجهة الطبقة [DAO]، يمكننا عرض تطبيق وحدة التحكم.
![]() |
الفئة [Main] هي كما يلي:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Main {
// أداة التسلسل jSON
static private ObjectMapper mapper = new ObjectMapper();
// مهلة انتظار الاتصالات بالمللي ثانية
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// يتم استرداد مرجع على الطبقة [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// يتم تعيين URL لخدمة الويب / json
dao.setUrlServiceWebJson("http://localhost:8080");
// يتم تعيين مهلة الانتظار بالمللي ثانية
dao.setTimeout(TIMEOUT);
// المصادقة
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,x]";
try {
dao.authenticate(new User("user", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [x,x]";
try {
dao.authenticate(new User("x", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// قائمة العملاء
message = "/getAllClients";
try {
showResponse(message, dao.getAllClients(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// قائمة الأطباء
message = "/getAllMedecins";
try {
showResponse(message, dao.getAllMedecins(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// قائمة المواعيد المتاحة للطبيب 2
message = "/getAllCreneaux/2";
try {
showResponse(message, dao.getAllCreneaux(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// العميل رقم 1
message = "/getClientById/1";
try {
showResponse(message, dao.getClientById(new User("admin", "admin"), 1L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// الطبيب رقم 2
message = "/getMedecinById/2";
try {
showResponse(message, dao.getMedecinById(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// الموعد رقم 3
message = "/getCreneauById/3";
try {
showResponse(message, dao.getCreneauById(new User("admin", "admin"), 3L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// الموعد رقم 4
message = "/getRvById/4";
try {
showResponse(message, dao.getRvById(new User("admin", "admin"), 4L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// إضافة موعد
message = "/AjouterRv [idClient=4,idCreneau=8,jour=2015-01-08]";
long idRv = 0;
try {
Rv response = dao.ajouterRv(new User("admin", "admin"), "2015-01-08", 8L, 4L);
idRv = response.getId();
showResponse(message, response);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// قائمة مواعيد الطبيب 1 بتاريخ 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// جدول مواعيد الطبيب 1 بتاريخ 2015-01-08
message = "/getAgendaMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getAgendaMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// حذف الموعد المضاف
message = String.format("/supprimerRv [idRv=%s]", idRv);
try {
dao.supprimerRv(new User("admin", "admin"), idRv);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// قائمة مواعيد الطبيب 1 بتاريخ 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// إغلاق السياق
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
private static <T> void showResponse(String message, T response) throws JsonProcessingException {
System.out.println(String.format("URL [%s]", message));
System.out.println(mapper.writeValueAsString(response));
}
}
- السطر 19: مُسلسل jSON الذي سيسمح لنا بعرض استجابة الخادم، السطر 184؛
- السطر 25: المكون [AnnotationConfigApplicationContext] هو مكون Spring قادر على استغلال تعليقات التكوين لتطبيق Spring. نمرر إلى منشئه، الفئة [AppConfig] التي تقوم بتكوين التطبيق؛
- السطر 26: نحصل على مرجع إلى الطبقة [DAO]؛
- الأسطر 27-30: نقوم بتكوينها؛
- الأسطر 32-169: نختبر جميع طرق واجهة [IDao]؛
النتائج التي تم الحصول عليها هي كما يلي:
09:20:56.935 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
/authenticate [admin,admin] : OK
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [user,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/authenticate [x,x]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [admin,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/getAllClients]
[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]
URL [/getAllMedecins]
[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]
URL [/getAllCreneaux/2]
[{"id":25,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":2},{"id":26,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":2},{"id":27,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":2},{"id":28,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":2},{"id":29,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":2},{"id":30,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":2},{"id":31,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":2},{"id":32,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":2},{"id":33,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":2},{"id":34,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":2},{"id":35,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":2},{"id":36,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":2}]
URL [/getClientById/1]
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"}
URL [/getMedecinById/2]
{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"}
URL [/getCreneauById/3]
{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1}
URL [/getRvById/4]
L'erreur n° [2] s'est produite :
Le rendez-vous d'id [4] n'existe pas
URL [/ajouterRv [idClient=4,idCreneau=8,jour=2015-01-08]]
{"id":144,"version":0,"jour":1420671600000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":0,"idCreneau":0}
URL [/getRvMedecinJour/1/2015-01-08]
[{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}]
URL [/getAgendaMedecinJour/1/2015-01-08]
{"medecin":{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},"jour":1420671600000,"creneauxMedecinJour":[{"creneau":{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"rv":{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}},{"creneau":{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"medecin":null,"idMedecin":1},"rv":null}]}
URL [/getRvMedecinJour/1/2015-01-08]
[]
09:21:00.258 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
نترك للقارئ مهمة ربط النتائج بالكود. يوضح هذا الكود كيفية استدعاء كل طريقة في الطبقة [DAO]. ولنكتفِ بالإشارة إلى بعض النقاط:
- الأسطر 2-14: توضح أنه عند حدوث خطأ في المصادقة، يقوم الخادم بإرجاع حالة HTTP أو [403 Forbidden] أو [401 Unauthorized] حسب الحالة؛
- السطور 30-31: تمت إضافة موعد (Rv) للطبيب رقم 1؛
- السطران 32-33: نرى هذا الموعد. وهو الموعد الوحيد في اليوم؛
- السطران 34-35: يظهر أيضًا في جدول مواعيد الطبيب؛
- السطران 36-37: اختفى الموعد. فقد قام الرمز بحذفه في غضون ذلك؛
يتم التحكم في سجلات وحدة التحكم من خلال الملفات التالية:
![]() |
[application.properties]
logging.level.org.springframework.web=OFF
logging.level.org.hibernate=OFF
spring.main.show-banner=false
logging.level.httpclient.wire=OFF
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- يتم تعيين النوع ch.qos.logback.classic.encoder.PatternLayoutEncoder للمشفرات افتراضيًا -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- التحكم في مستوى السجلات -->
<root level="info"> <!-- إيقاف، معلومات، تصحيح أخطاء، تحذير -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
8.5.10. تنفيذ الطبقة [DAO]
يبقى لنا الآن عرض جوهر الطبقة [DAO]، أي تنفيذ واجهتها [IDao]. سنقوم بذلك تدريجيًا.
![]() |
يتم تنفيذ الواجهة [IDao] بواسطة الفئة المجردة [AbstractDao] وفئتها الفرعية [Dao].
الفئة الأم [AbstractDao] هي كما يلي:
package rdvmedecins.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import rdvmedecins.client.entities.User;
public abstract class AbstractDao implements IDao {
// البيانات
@Autowired
protected RestTemplate restTemplate;
protected String urlServiceWebJson;
// URL خدمة الويب / jSON
public void setUrlServiceWebJson(String url) {
this.urlServiceWebJson = url;
}
public void setTimeout(int timeout) {
// تحديد مهلة انتظار طلبات عميل الويب
HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) restTemplate
.getRequestFactory();
factory.setConnectTimeout(timeout);
factory.setReadTimeout(timeout);
}
private String getBase64(User user) {
// يتم ترميز اسم المستخدم وكلمة المرور باستخدام الترميز Base64 - يتطلب
// جافا 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// طلب عام
protected String getResponse(User user, String url, String jsonPost) {
...
}
}
- السطر 20: الفئة مجردة، مما يمنعنا من تعيينها كمكون Spring. وستكون الفئة التابعة لها هي التي سيتم تعيينها كمكون Spring؛
- السطران 23-24: نقوم بحقن المكون [restTemplate] الذي قمنا بتعريفه في فئة التكوين [AppConfig]؛
- السطر 25: URL هي الجذر لخدمة الويب / jSON؛
- الأسطر 32-38: تحدد مهلة انتظار العميل أثناء انتظاره ردًا من الخادم؛
- السطر 34: نسترد المكون [HttpComponentsClientHttpRequestFactory] الذي قمنا بحقنه في الفول [restTemplate] عند إنشائه (انظر [AppConfig])؛
- السطر 36: نحدد الحد الأقصى لوقت انتظار العميل عند إقامته اتصالاً بالخادم؛
- السطر 37: نحدد الحد الأقصى لوقت انتظار العميل عند انتظاره ردًا على أحد طلباته؛
سيتم تجميع تنفيذ طرق الاتصال بالخادم في الطريقة العامة التالية:
// طلب عام
protected String getResponse(User user, String url, String jsonPost) {
...
}
- السطر 2: معلمات [getResponse] هي كما يلي:
- [User user]: المستخدم الذي يقوم بالاتصال؛
- [String url]: قيمة URL المطلوب الاستعلام عنها. وهي تمثل نهاية قيمة URL، حيث يتم توفير الجزء الأول من خلال الحقل [urlServiceWebJson] الخاص بالفئة،
- [String jsonPost]: السلسلة jSON المطلوب إرسالها. إذا كانت هذه القيمة موجودة، فسيتم طلب URL مع POST، وإلا فسيتم طلبها مع GET؛
لنواصل:
// طلب عام
protected String getResponse(User user, String url, String jsonPost) {
// رابط: URL للاتصال
// jsonPost: القيمة jSON يجب نشرها
try {
// تنفيذ الطلب
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url))).accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// يتم تنفيذ الطلب
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e) {
throw new RdvMedecinsException(20, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(21, getMessagesForException(e));
}
}
- السطران 23-24: التعليمات التي ترسل الطلب إلى الخادم وتتلقى الرد منه. يوفر المكون [RestTemplate] عددًا كبيرًا من طرق التبادل مع الخادم. كان من الممكن اختيار طريقة أخرى غير [exchange]. يحدد المعلمة الثانية للاستدعاء نوع الرد المتوقع، وهو في هذه الحالة سلسلة jSON. أما المعلمة الأولى فهي الطلب من النوع [RequestEntity] (السطر 7). نتيجة الطريقة [exchange] هي من النوع [ResponseEntity<String>]. أما النوع [ResponseEntity] فيغلف الاستجابة الكاملة من الخادم، بما في ذلك الرؤوس HTTP والمستند الذي أرسله الخادم. وبالمثل، فإن النوع [RequestEntity] يغلف كامل طلب العميل بما في ذلك الرؤوس HTTP والقيمة المرسلة إن وجدت؛
- السطر 23: هذا هو نص الكائن [ResponseEntity<String>] الذي يتم إرجاعه إلى الطريقة المستدعية، أي السلسلة jSON المرسلة من الخادم؛
- الأسطر 9-21: يتعين علينا إنشاء الطلب من النوع [RequestEntity]. ويختلف هذا الطلب اعتمادًا على ما إذا كنا نستخدم GET أو POST لإجراء الطلب؛
- السطر 9: الاستعلام الخاص بـ GET. توفر الفئة [RequestEntity] طرقًا ثابتة لإنشاء الاستعلامات GET و POST و HEAD،... تسمح الطريقة [RequestEntity.get] بإنشاء استعلام GET من خلال ربط الطرق المختلفة التي تبني هذا الاستعلام:
- تقبل الطريقة [RequestEntity.get] كمعلمة الهدف URL في شكل مثيل URI،
- تسمح الطريقة [accept] بتحديد عناصر رأس HTTP [Accept]. هنا، نشير إلى أننا نقبل النوع [application/json] الذي سيرسله الخادم؛
- ونتيجة تسلسل الطرق هذا هي النوع [HeadersBuilder]؛
- الأسطر 10-12: في حالة عدم كون المعلمة [User user] هي null، يتم تضمين الرأس HTTP [Authorization] في الطلب؛
- السطر 13: تستخدم الطريقة [HeadersBuilder.build] هذه المعلومات المختلفة لإنشاء نوع الاستعلام [RequestEntity]؛
- السطر 15: الاستعلام عن POST. تتيح الطريقة [RequestEntity.post] إنشاء استعلام POST من خلال ربط الطرق المختلفة التي تبني هذا الاستعلام:
- تقبل الطريقة [RequestEntity.post] كمعلمة الـ URL المستهدف في شكل مثيل URI،
- تسمح الطريقة [header] بتحديد الرؤوس HTTP التي نرغب في استخدامها، وهي هنا رأس التفويض،
- الطريقة [header] التالية تضمّن في الطلب رأس [Content-Type: application/json] لإعلامه بأن القيمة المرسلة ستصل إليه في شكل سلسلة jSON؛
- تسمح الطريقة [accept] بالإشارة إلى أننا نقبل النوع [application/json] الذي سيرسله الخادم؛
- الأسطر 17-19: في حالة عدم كون المعلمة [User user] هي null، يتم تضمين الرأس HTTP [Authorization] في الطلب؛
- السطر 20: تحدد الطريقة [BodyBuilder.body] القيمة التي تم إرسالها. وهذه القيمة هي المعلمة الثانية للطريقة العامة [getResponse] (السطر 2)؛
- الأسطر 25-28: في حالة حدوث أي خطأ، يتم إلقاء استثناء من النوع [RdvMedecinsException]؛
الطريقة [getMessagesForException] في السطور 26 و28 هي كما يلي:
// قائمة رسائل الخطأ الخاصة باستثناء
protected static List<String> getMessagesForException(Exception exception) {
// يتم استرداد قائمة رسائل الخطأ الخاصة بالاستثناء
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
// يتم استرداد الرسالة فقط إذا كانت !=null وليست فارغة
String message = cause.getMessage();
if (message != null) {
message = message.trim();
if (message.length() != 0) {
erreurs.add(message);
}
}
// السبب التالي
cause = cause.getCause();
}
return erreurs;
}
توفر الطريقة الخاصة [getBase64] رمز Base64 للسلسلة 'login:passwd' لرأس المصادقة HTTP:
private String getBase64(User user) {
// يتم ترميز اسم المستخدم وكلمة المرور بنظام الترميز Base64 - يتطلب Java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
توسع الفئة [Dao] الفئة [AbstractDao] على النحو التالي:
package rdvmedecins.client.dao;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import rdvmedecins.client.requests.PostAjouterRv;
import rdvmedecins.client.requests.PostSupprimerRv;
import rdvmedecins.client.responses.Response;
@Service
public class Dao extends AbstractDao implements IDao {
// مُخَطِّطو jSON
@Autowired
ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
public List<Client> getAllClients(User user) {
...
}
public List<Medecin> getAllMedecins(User user) {
...
}
...
}
- السطر 22: الفئة [Dao] هي مكون من مكونات Spring. وقد استُخدم هنا التعليق التوضيحي [@Service]. كان من الممكن الاستمرار في استخدام التعليق التوضيحي [@Component] الذي كان مستخدمًا حتى الآن؛
- السطور 26-36: حقن المُعَيِّنات الأربعة jSON المُعرَّفة في فئة التكوين [DaoConfig]؛
تتبع جميع أساليب الفئة [Dao] نفس النمط. سنقوم بتفصيل عملية GET وعملية POST.
أولاً، استعلام [GET]:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour) {
// الرد
Response<AgendaMedecinJour> response;
// التقويم
String jsonResponse = getResponse(user, String.format("%s/%s/%s", "/getAgendaMedecinJour", idMedecin, jour), null);
try {
// التقويم AgendaMedecinJour
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<AgendaMedecinJour>>() {
});
} catch (IOException e) {
throw new RdvMedecinsException(401, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(402, getMessagesForException(e));
}
// تحليل الإجابة
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- السطر 5: يتم استدعاء الأسلوب العام [getResponse]. المعلمات الفعلية المستخدمة هي التالية:
- 1: المستخدم؛
- 2: الهدف URL؛
- 3: القيمة المراد إرسالها. لا توجد قيمة هنا؛
- السطر 5: لم يتم تضمين الاستدعاء في جملة try / catch. من المحتمل أن تطلق الطريقة [getResponse] استثناءً من النوع [RdvMedecinsException]. إذا تم إطلاقه، فسيتم تمرير هذا الاستثناء إلى الطريقة التي استدعت الطريقة [getAgendaMedecinJour] المذكورة أعلاه؛
- السطر 8:URL [/getAgendaMedecinJour] يرسل نوعًا [Response<AgendaMedecinJour>] الذي تم تسلسله إلى jSON على جانب الخادم بواسطة أداة التعيين jSON [jsonMapperLongRv]. ويُستخدم هذا المُعَيِّن نفسه لإلغاء تسلسل السلسلة jSON المستلمة؛
- الأسطر 10-13: في حالة حدوث خطأ في السطر 9، يتم إطلاق استثناء من النوع [RdvMedecinsException]؛
- الأسطر 16-21: يتم تحليل الاستجابة المرسلة من الخادم؛
- الأسطر 17-18: إذا أبلغ الخادم عن وجود خطأ، يتم إثارة استثناء مع المعلومات المرسلة من الخادم؛
- الأسطر 19-21: وإلا يتم عرض جدول مواعيد الطبيب؛
سيكون الطلب POST الذي سيتم فحصه كما يلي:
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
// الإجابة
Response<Rv> response;
try {
// موعد اللقاء
String jsonResponse = getResponse(user, "/ajouterRv",
jsonMapper.writeValueAsString(new PostAjouterRv(idClient, idCreneau, jour)));
// موعد اللقاء موعد اللقاء
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<Rv>>() {
});
} catch (RdvMedecinsException e) {
throw e;
} catch (IOException e) {
throw new RdvMedecinsException(381, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(382, getMessagesForException(e));
}
// تحليل الإجابة
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- السطر 6: يتم استدعاء الأسلوب [getResponse] بالمعلمات التالية:
- 1: المستخدم؛
- 2: الهدف URL،
- 3: القيمة المرسلة: يتم تمرير القيمة jSON من النوع [PostAjouter] التي تم إنشاؤها باستخدام المعلومات المستلمة كمعلمات من قبل الأسلوب. يتم استخدام مُعَيِّن jSON بدون مرشحات؛
- السطر 9: من جانب الخادم، قام مُعَيِّن jSON [jsonMapperLongRv] بتسلسل استجابة الخادم. ومن جانب العميل، يتم استخدام نفس المُعَيِّن لإلغاء التسلسل؛
- السطر 6: يقوم المحول URL [/ajouterRv] بتحويل القيمة jSON إلى نوع [Response<Rv>]؛
- الأسطر 4-11: هنا، تم وضع الطريقة [getResponse] داخل عبارة try / catch لأن تسلسل القيمة المرسلة قد يؤدي إلى إثارة استثناء. من المحتمل أن تطلق الدالة [getResponse] استثناءً من النوع [RdvMedecinsException]. في هذه الحالة، نكتفي بإعادة تشغيلها (الأسطر 11-12)؛
الرمز التالي (الأسطر 13-24) مشابه للرمز الذي تمت دراسته للتو. وبالتالي، فإن الاختلاف الوحيد مع عملية GET هو المعلمة الثانية للطريقة [getResponse] التي يجب أن تكون القيمة jSON للقيمة المراد إرسالها.
وتُبنى الطرق الأخرى على نفس النموذج.
8.5.11. الخلل
أثناء إجراء اختبارات متنوعة، لوحظت حالة شاذة ملخصة في الفئة [Anomalie] التالية:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Anomalie {
// جهاز التسلسل jSON
static private ObjectMapper mapper = new ObjectMapper();
// مهلة انتظار الاتصالات بالمللي ثانية
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// يتم استرداد مرجع على الطبقة [DAO]
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// يتم تعيين URL لخدمة الويب / json
dao.setUrlServiceWebJson("http://localhost:8080");
// يتم تعيين مهلة الانتظار بالمللي ثانية
dao.setTimeout(TIMEOUT);
// المصادقة
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// المصادقة
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// المصادقة
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// إغلاق السياق
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
}
- الأسطر 31-38: يتم مصادقة المستخدم [admin, admin]؛
- الأسطر 40-47: يتم مصادقة المستخدم [admin, x] الذي لديه كلمة مرور خاطئة؛
- الأسطر 49-56: يتم مصادقة المستخدم [user, user]، وهو مستخدم موجود ولكنه غير مصرح له؛
فيما يلي النتائج:
- السطر 2: خلافًا للتوقعات، تم قبول المستخدم [admin, x]؛
إذا قمنا بتعليق الأسطر 33-38 من الكود، نحصل على النتيجة التالية:
وهذه هي النتيجة المتوقعة. يبدو الأمر كما لو أن المستخدم [admin, admin]، بعد أن نجح في تسجيل الدخول للمرة الأولى، لم يعد بحاجة إلى كلمة المرور في المرات التالية. وهذا هو الحال بالفعل. يستخدم Spring Security افتراضيًا جلسة عمل تجعل المستخدم، بمجرد تسجيل دخوله، لا يحتاج إلى إعادة تسجيل الدخول في الطلبات التالية. يمكن تعديل تكوين [Spring Security] في خادم الويب / jSON بحيث لا يكون الأمر كذلك:
![]() |
يجب تعديل الملف [SecurityConfig] على النحو التالي:
@Override
protected void configure(HttpSecurity http) throws Exception {
...
// لا توجد جلسة
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
- السطر 5 يطلب عدم وجود جلسة أمان؛
وقد أدى ذلك إلى حل مشكلة الخطأ.
8.6. كتابة خادم Spring / Thymeleaf
8.6.1. مقدمة
لنعد إلى بنية تطبيق العميل/الخادم المراد إنشاؤه:
![]() |
- تم إنشاء خادم الويب [Web2] / jSON؛
- تم إنشاء الطبقة [DAO] للعميل [Web1]؛
العلاقة بين الخادم [Web1] ومتصفحات العملاء هي علاقة عميل/خادم حيث يكون الخادم هو خادم الويب / jSON. في الواقع، سيقوم [Web1] بتسليم تدفقات HTML مغلفة في سلسلة jSON. بنية العميل/الخادم هي كما يلي:
![]() |
- لدينا بنية عميل [2] / خادم [1] حيث يتواصل العميل والخادم عبر jSON؛
- في [1]، تقوم طبقة الويب Spring MVC / Thymeleaf بتقديم العروض وأجزاء العروض والبيانات في jSON. وبالتالي، فإن الخادم هو خادم ويب / jSON مثل الخادم [Web1]. وهو أيضًا خادم عديم الحالة؛
- في [2]: يتم تنظيم كود جافا سكريبت المضمن في العرض الذي يتم تحميله عند بدء تشغيل التطبيق في طبقات:
- تتولى الطبقة [présentation] التفاعلات مع المستخدم،
- الطبقة [DAO] تتولى الوصول إلى البيانات عبر الخادم [Web2]؛
- سيقوم العميل [2] بتخزين بعض العروض مؤقتًا في ذاكرة التخزين المؤقت لتخفيف العبء عن الخادم؛
سنقوم ببناء خادم الويب / jSON [Web1] المُنفَّذ باستخدام Spring MVC / Thymeleaf على عدة مراحل:
- التعرف على إطار العمل CSS Bootstrap؛
- كتابة المشاهد؛
- كتابة وحدة التحكم؛
ثم بعد ذلك وبشكل منفصل، سنقوم بإنشاء العميل JS الخاص بالخادم [Web1]. ولإظهار أن هذا العميل يتمتع بقدر من الاستقلالية عن الخادم [Web1]، سنقوم بإنشائه باستخدام الأداة [Webstorm] بدلاً من STS.
فيما يلي، سيتم تجاهل بعض التفاصيل لأنها قد تصرف انتباهنا عن الأمر المهم، ألا وهو تنظيم الكود. يمكن للقارئ المهتم العثور على الكود الكامل على موقع هذا المستند.
8.6.2. المشروع STS
![]() |
- في [1]، أكواد Java؛
- في [2]، العروض؛
تكوين Maven في [pom.xml] هو كما يلي:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-springthymeleaf-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-springthymeleaf-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<properties>
<start-class>rdvmedecins.springthymeleaf.server.boot.Boot</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
...
</project>
- الأسطر 16-19: المشروع هو مشروع Thymeleaf؛
- الأسطر 20-24: يعتمد على الطبقة [DAO] التي أنشأناها للتو؛
يتم تكوين Java بواسطة ملفين:
![]() |
يتم تكوين الطبقة [web] بواسطة الملف [WebConfig] التالي:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.SpringTemplateEngine;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
public class WebConfig extends WebMvcConfigurerAdapter {
// ----------------- تكوين الطبقة [web]
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("i18n/messages");
return messageSource;
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
@Bean
SpringTemplateEngine templateEngine(SpringResourceTemplateResolver templateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
return templateEngine;
}
// تكوين dispatcherservlet للرؤوس CORS
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
}
لقد تعاملنا، في وقت أو آخر، مع جميع عناصر هذا التكوين. دعونا نذكر ببساطة أن الأسطر 42-47 ضرورية عندما نرغب في استعلام الخادم باستخدام طلبات عبر النطاقات (CORS). وهذا هو الحال هنا.
تقوم الفئة [AppConfig] بتكوين التطبيق بأكمله:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.client.config.DaoConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// جذر خدمة الويب / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// مهلة الانتظار بالمللي ثانية
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
}
- السطر 11: تستورد [AppConfig] تكوين الطبقة [DAO] والطبقة [web]؛
- السطران 15-16: المعرفات التي ستسمح للتطبيق بالوصول إلى مرحلة بدء تشغيل التطبيق من أجل تخزين الأطباء والعملاء في ذاكرة التخزين المؤقت؛
- السطر 18: URL لخدمة الويب / jSON [Web1]؛
- السطر 20: الرمز timeout الخاص بمكالمات التطبيق HTTP؛
- السطر 22: قيمة منطقية (بوليانية) للسماح أو عدم السماح بالمكالمات بين المجالات؛
وأخيرًا، في [application.properties]، تم تكوين خادم Tomcat للعمل على المنفذ 8081:
![]() |
server.port=8081
8.6.3. ميزات التطبيق
تم وصفها في الفقرة 8.2. وسنستعرضها الآن. باستخدام متصفح، نطلب URL [http://localhost:8081/boot.html]:
![]() |
- إلى [1]، وهي صفحة تسجيل الدخول إلى التطبيق؛
- إلى [2] و [3]، وهما اسم المستخدم وكلمة المرور لمن يرغب في استخدام التطبيق. يوجد مستخدمان: admin/admin (login/password) مع دور (ADMIN) و user/user مع دور (USER). الدور ADMIN هو الوحيد الذي يمتلك حق استخدام التطبيق. أما الدور USER فهو موجود فقط لإظهار الرد الذي يقدمه الخادم في حالة الاستخدام هذه؛
- في [4]، الزر الذي يتيح الاتصال بالخادم؛
- في [5]، لغة التطبيق. هناك لغتان: الفرنسية كإعداد افتراضي والإنجليزية؛
- في [6]، URL الخاص بالخادم [rdvmedecins-springthymeleaf-server]؛
![]() |
- في [1]، يتم تسجيل الدخول؛
![]() |
- بمجرد تسجيل الدخول، يمكن اختيار الطبيب الذي نريد حجز موعد معه [2] ويوم الموعد [3]. بمجرد إدخال اسم الطبيب وتاريخ الموعد، يتم عرض الجدول تلقائيًا:
![]() |
- بمجرد الحصول على جدول مواعيد الطبيب، يمكن حجز موعد [5]؛
![]() |
- في [6]، يتم اختيار المريض للموعد وتأكيد هذا الاختيار في [7]؛
![]() |
بمجرد تأكيد الموعد، يتم إرجاعنا تلقائيًا إلى التقويم حيث يتم الآن تسجيل الموعد الجديد. ويمكن حذف هذا الموعد لاحقًا [8].
لقد تم وصف الميزات الرئيسية. وهي بسيطة. لنختتم بإدارة اللغة:
![]() |
- في [1]، ننتقل من اللغة الفرنسية إلى اللغة الإنجليزية؛
![]() |
- في [2]، يتم تحويل العرض إلى اللغة الإنجليزية، بما في ذلك التقويم؛
8.6.4. الخطوة 1: مقدمة إلى إطار عمل Bootstrap CSS
![]() |
في عميل الويب أعلاه، ستستخدم الصفحات HTML إطار عمل Bootstrap CSS [http://getbootstrap.com/] الذي نقدمه الآن.
8.6.4.1. مشروع الأمثلة
سيكون مشروع الأمثلة كما يلي:
![]() |
- في [1]: المشروع بأكمله؛
- في [2]: أكواد Java؛
- في [3]: نصوص جافا سكريبت؛
![]() |
- في [4]: مكتبات جافا سكريبت؛
- في [5]: طرق عرض Thymeleaf؛
- في [6]: أوراق الأنماط؛
8.6.4.1.1. تكوين Maven
الملف [pom.xml] هو ملف مشروع Maven Thymeleaf:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-bootstrap</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-bootstrap</name>
<description>Démos Bootstrap</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath /> <!-- البحث عن العنصر الأصلي من المستودع -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.BootstrapDemo</start-class>
<java.version>1.7</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
8.6.4.1.2. إعدادات Java
![]() |
تقوم الفئة [BootstrapDemo] بتكوين تطبيق Spring / Thymeleaf:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
@ComponentScan({ "istia.st.rdvmedecins" })
public class BootstrapDemo extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(BootstrapDemo.class, args);
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
}
لقد سبق أن صادفنا هذا النوع من الكود.
8.6.4.1.3. وحدة التحكم Spring
![]() |
وحدة التحكم [BootstrapController] هي كما يلي:
package istia.st.rdvmedecins;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class BootstrapController {
@RequestMapping(value = "/bs-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bso1() {
return "bs-01";
}
@RequestMapping(value = "/bs-02", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs02() {
return "bs-02";
}
@RequestMapping(value = "/bs-03", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs03() {
return "bs-03";
}
@RequestMapping(value = "/bs-04", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs04() {
return "bs-04";
}
@RequestMapping(value = "/bs-05", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs05() {
return "bs-05";
}
@RequestMapping(value = "/bs-06", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs06() {
return "bs-06";
}
@RequestMapping(value = "/bs-07", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs07() {
return "bs-07";
}
@RequestMapping(value = "/bs-08", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs08() {
return "bs-08";
}
}
الإجراءات موجودة فقط لعرض طرق العرض التي يعالجها Thymeleaf.
8.6.4.1.4. الملف [application.properties]
يقوم الملف [application.properties] بتكوين خادم Tomcat المدمج:
server.port=8082
8.6.4.2. المثال رقم 1: الشاشة العملاقة
يعرض الإجراء [/bs-01] العرض التالي [bs-01.xml]:
![]() |
الطريقة [bs-01.xml] هي كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- نواة Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
</head>
<body id="body">
<div class="container">
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- خطأ -->
<div id="erreur" class="alert alert-danger">
<span>Ici, un texte d'erreur</span>
</div>
</div>
</body>
</html>
- السطر 7: ملف CSS الخاص بإطار عمل Bootstrap؛
- السطر 8: ملف CSS محلي؛
- السطر 13: يعرض [1]؛
- الأسطر 19-21: تعرض [2]؛
- السطر 11: تُحدد الفئة CSS [container] منطقة عرض داخل المتصفح؛
- السطر 19: تعرض الفئة CSS [alert] منطقة ملونة. تستخدم الفئة [alert-danger] لونًا محددًا مسبقًا. وهناك العديد منها [alert-info, alert-warning,...]؛
يتم إنشاء الشاشة العملاقة [1] بواسطة العرض التالي [jumbotron.xml]:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- جومبوترون بوتستراب -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1>
Les Médecins
<br />
associés
</h1>
</div>
</div>
</div>
</section>
- السطر 4: المنطقة لها الفئة CSS [jumbotron]؛
- السطر 5: تحدد الفئة [row] سطرًا مكونًا من 12 عمودًا؛
- السطر 6: تحدد الفئة [col-md-2] منطقة مكونة من عمودين في السطر؛
- السطر 7: يتم وضع صورة في هذين العمودين؛
- الأسطر 9-15: يُوضع النص في الأعمدة العشرة الأخرى؛
8.6.4.3. المثال رقم 2: شريط التنقل
يعرض الإجراء [/bs-02] العرض التالي [bs-02.xml]:
![]() |
الجديد هو شريط التنقل [1] مع نموذج الإدخال الخاص به وأزراره:
الطريقة [bs-02.xml] هي كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- نصوص برمجية JS -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/js/bs-02.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar1"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- معلومات -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 10: يتم استيراد jQuery؛
- السطر 11: نص برمجي محلي JS؛
- السطر 16: شريط التنقل؛
يتم إنشاء شريط التنقل بواسطة العرض التالي [navbar1.xml]:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- نموذج تسجيل الدخول -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
</div>
</div>
</div>
</div>
</section>
![]() |
- السطر 3: ستقوم الفئة [navbar] بتحديد نمط شريط التنقل. تمنحها الفئة [navbar-inverse] خلفية سوداء. ستعمل الفئة [navbar-fixed-top] على ضمان بقاء شريط التنقل في أعلى الشاشة عند «التمرير» في الصفحة المعروضة بواسطة المتصفح؛
- الأسطر 5-13: تحدد المنطقة [1]. وهي عادةً سلسلة من الفئات التي لا أفهمها. أستخدم المكون كما هو؛
- الأسطر 14-26: تحدد منطقة «متجاوبة» لشريط الأوامر. على الهاتف الذكي، تختفي هذه المنطقة داخل منطقة القائمة؛
- السطر 15: صورة مخفية حاليًا؛
- الأسطر 17-25: تقوم الفئة [navbar-form] بتصميم نموذج في شريط الأوامر. أما الفئة [navbar-right] فتضعه على يمين هذا النموذج؛
- الأسطر 21-23: منطقتا الإدخال في النموذج المذكور في السطر 17 [2]. وهما موجودتان داخل الفئة [form-group] التي تُزيّن عناصر النموذج، ولكل منهما الفئة [form-control]؛
- السطر 24: الفئة [btn] التي تُعرّف زرًا، مع إضافة الفئة [btn-success] التي تمنحه لونه الأخضر؛
- السطر 24: عند النقر على الزر [Connexion]، يتم تنفيذ الدالة التالية JS:
function connecter() {
showInfo("Connexion demandée...");
}
function showInfo(message) {
$("#info").text(message);
}
فيما يلي مثال:

8.6.4.4. المثال رقم 3: الزر ذو القائمة
يعرض الإجراء [/bs-03] العرض التالي [bs-03.xml]:
![]() |
- الميزة الجديدة هي زر القائمة [1] المعروف أيضًا باسم «القائمة المنسدلة»؛
رمز العرض [bs-03.xml] هو كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-03.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar2"></div>
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- معلومات -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 11: يتطلب زر القائمة ملف JS من Bootstrap؛
- السطر 18: شريط التنقل الجديد؛
الطريقة [navbar2.xml] هي كما يلي:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- نموذج التعريف -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
<!-- اللغات -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- صفحة البداية -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initNavBar2();
/*]]>*/
</script>
</section>
- الأسطر 25-40: تحدد زر القائمة؛
- السطر 27: الفئة [btn-danger] تمنحه لونه الأحمر؛
- الأسطر 32-39: عناصر القائمة. وهي روابط يرتبط كل منها بوظيفة JS؛
- الأسطر 46-51: برنامج نصي JS يُنفَّذ بعد تحميل المستند؛
النص البرمجي JS [bs-03.js] هو كما يلي:
function initNavBar2() {
// قائمة اللغات المنسدلة
$('.dropdown-toggle').dropdown();
}
function connecter() {
showInfo("Connexion demandée...");
}
function setLang(lang) {
var msg;
switch (lang) {
case 'fr':
msg = "Vous avez choisi la langue française...";
break;
case 'en':
msg = "You have selected english language...";
break;
}
showInfo(msg);
}
function showInfo(message) {
$("#info").text(message);
}
- الأسطر 1-4: الدالة التي تقوم بتهيئة [dropdown]. تقوم [$('.dropdown-toggle')] بتحديد موقع العنصر الذي يحمل الفئة [dropdown-toggle]. وهو زر القائمة (السطر 28 من العرض). يتم تطبيق الدالة JS [dropdown()] عليه، وهي محددة في الملف JS [bootstrap.js]. ولا يعمل الزر كزر قائمة إلا بعد هذه العملية؛
- الأسطر 10-21: الدالة التي يتم تنفيذها عند اختيار لغة ما؛
فيما يلي مثال:

8.6.4.5. المثال رقم 4: قائمة
يعرض الإجراء [/bs-04] العرض التالي [bs-04.xml]:
![]() |
تمت إضافة قائمة [1].
الطريقة [bs-04.xml] هي كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- نواة Bootstrap CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- نواة Bootstrap JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-04.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar3"></div>
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- معلومات -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 18: تم إدراج شريط تنقل جديد؛
الطريقة [navbar3.xml] هي كما يلي:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<ul class="nav navbar-nav">
<li class="active" id="lnkAfficherAgenda">
<a href="javascript:afficherAgenda()">Agenda </a>
</li>
<li class="active" id="lnkAccueil">
<a href="javascript:retourAccueil()">Retour Accueil </a>
</li>
<li class="active" id="lnkRetourAgenda">
<a href="javascript:retourAgenda()">Retour Agenda </a>
</li>
<li class="active" id="lnkValiderRv">
<a href="javascript:validerRv()">Valider </a>
</li>
</ul>
<!-- أزرار اليمين -->
<div class="navbar-form navbar-right" role="form">
<!-- تسجيل الخروج -->
<button type="button" class="btn btn-success" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- اللغات -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- الصفحة الرئيسية -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initNavBar3();
/*]]>*/
</script>
</section>
- الأسطر 16-29: إنشاء القائمة التي تحتوي على أربعة خيارات، كل منها مرتبط ببرنامج نصي JS؛
- الأسطر 55-60: برنامج نصي يتم تنفيذه عند تحميل الصفحة؛
البرنامج النصي JS [bs-04.js] هو كما يلي:
...
function initNavBar3() {
// قائمة اللغات المنسدلة
$('.dropdown-toggle').dropdown();
// الصورة المتحركة
loading = $("#loading");
loading.hide();
}
function afficherAgenda() {
showInfo("option [Agenda] cliquée...");
}
function retourAccueil() {
showInfo("option [Retour accueil] cliquée...");
}
function retourAgenda() {
showInfo("option [Retour agenda] cliquée...");
}
function validerRv() {
showInfo("option [Valider] cliquée...");
}
function setMenu(show) {
// روابط القائمة
var lnkAfficherAgenda = $("#lnkAfficherAgenda");
var lnkAccueil = $("#lnkAccueil");
var lnkValiderRv = $("#lnkValiderRv");
var lnkRetourAgenda = $("#lnkRetourAgenda");
// يتم وضعها في قاموس
var options = {
"lnkAccueil" : lnkAccueil,
"lnkAfficherAgenda" : lnkAfficherAgenda,
"lnkValiderRv" : lnkValiderRv,
"lnkRetourAgenda" : lnkRetourAgenda
}
// إخفاء جميع الروابط
for ( var key in options) {
options[key].hide();
}
// نعرض الروابط المطلوبة
for (var i = 0; i < show.length; i++) {
var option = show[i];
options[option].show();
}
}
- الأسطر 2-18: وظيفة تهيئة الصفحة؛
- السطر 4: لإظهار زر قائمة اللغات؛
- الأسطر 6-7: الصورة المتحركة مخفية؛
- الأسطر 26-48: دالة [setMenu] التي تسمح بتحديد الخيارات التي يجب أن تكون مرئية؛
لننتقل إلى وحدة التحكم في التطوير (Ctrl-Shift-I) وندخل الكود التالي [1]:
![]() |
ثم نعود إلى المتصفح. لقد تغيرت القائمة [2]:
8.6.4.6. المثال رقم 5: قائمة منسدلة
يعرض الإجراء [/bs-05] العرض التالي [bs-05.xml]:
![]() |
الميزة الجديدة موجودة في [1]. نستخدم هنا مكونًا متوفرًا خارج Bootstrap، [bootstrap-select] [http://silviomoreto.github.io/bootstrap-select/].
كود العرض [bs-05.xml] هو كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-05.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar3"></div>
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content" th:include="choixmedecin">
</div>
<!-- معلومات -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 8: CSS الضروري للقائمة المنسدلة؛
- السطر 13: الملف JS اللازم للقائمة المنسدلة؛
- السطر 24: القائمة المنسدلة؛
تبدو طريقة العرض [choixmedecin.xml] كما يلي:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
</div>
<!-- نص برمجي محلي -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initChoixMedecin();
/*]]>*/
</script>
</section>
- السطور 7-12: توجد هنا علامة [select] تقليدية، ولكنها تحتوي على فئة خاصة هي [combobox]. السمة [data-style="btn-primary"] تمنح المكون لونه الأزرق؛
- الأسطر 16-21: نص برمجي يتم تنفيذه عند تحميل الصفحة؛
الملف JS [bs-05.js] هو كما يلي:
...
function afficherAgenda() {
var idMedecin = $('#idMedecin option:selected').val();
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin);
}
function initChoixMedecin() {
// قائمة اختيار الأطباء
$('#idMedecin').selectpicker();
// القائمة
setMenu([ "lnkAfficherAgenda" ]);
}
- الأسطر 7-12: الدالة التي يتم تنفيذها عند تحميل الصفحة؛
- السطر 9: الأمر الذي يحول [select] في الصفحة إلى قائمة منسدلة Bootstrap. يشير [$('#idMedecin')] إلى [select] (السطر 7 من عرض [choixmedecin]) والدالة JS [selectpicker] مستمدة من الملف JS [bootstrap-select.js]؛
- السطر 11: يتم عرض خيار واحد فقط من خيارات القائمة؛
- الأسطر 2-5: يتم تنفيذ الدالة JS عند النقر على خيار القائمة [Agenda]؛
- السطر 3: يتم استرداد قيمة الخيار المحدد في القائمة المنسدلة: تقوم الدالة [$('#idMedecin option:selected')] أولاً بالعثور على المكون [id=idMedecin] ثم على الخيار المحدد داخل هذا المكون. ثم تسترد العملية [..].val() قيمة العنصر الذي تم العثور عليه، أي السمة [value] للخيار المحدد؛
فيما يلي مثال على اختيار طبيب:
![]() |
8.6.4.7. المثال رقم 6: تقويم
تعرض الإجراء [/bs-06] العرض التالي [bs-06.xml]:

يؤدي اختيار طبيب أو تاريخ إلى تشغيل الدالة JS التي تعرض كلًا من الطبيب والتاريخ المختارين. فيما يلي مثال على ذلك:
![]() |
بفضل زر «قائمة اللغات»، يمكن تحويل التقويم (والتقويم فقط) إلى اللغة الإنجليزية:

هذا هو المثال الأكثر تعقيدًا في هذه السلسلة. التقويم هو مكون [bootstrap-datepicker] [http://eternicode.github.io/bootstrap-datepicker].
وتبدو طريقة العرض [bs-06.xml] كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-06.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar3"></div>
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron"></div>
<!-- المحتوى -->
<div id="content" th:include="choixmedecinjour">
</div>
<!-- معلومات -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 8: الملف CSS الخاص بالمكون [bootstrap-datepicker]؛
- السطر 16: الملف JS الخاص بالمكون [bootstrap-datepicker]؛
- السطر 17: الملف JS لإدارة تقويم فرنسي. بشكل افتراضي، يكون باللغة الإنجليزية؛
- السطر 15: الملف JS من مكتبة تسمى [moment] تتيح الوصول إلى العديد من وظائف حساب الوقت [http://momentjs.com/]؛
- السطر 28: عرض التقويم؛
عرض [choixmedecinjour.xml] هو كما يلي:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
<div class="col-md-3">
<h2>Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- نص برمجي محلي -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initChoixMedecinJour();
/*]]>*/
</script>
</section>
- الأسطر 17-23: التقويم؛
- السطر 18: الفئة [btn-primary] تمنحه لونه الأزرق؛
- السطر 18: السمة [disabled="true"] تمنع إدخال التاريخ يدويًّا. يجب بالضرورة استخدام التقويم؛
- السطر 16: تم وضع التقويم في قسم [id="calendar_container"]. لتغيير لغة التقويم، لا بد من حذفه ثم إعادة إنشائه. لذا سنحذف محتوى المكون [id="calendar_container"] ثم نضع فيه التقويم الجديد باللغة الجديدة؛
- الأسطر 28-33: كود تهيئة الصفحة؛
الملف JS [bs-06.js] هو كما يلي:
...
var calendar_infos = {};
function initChoixMedecinJour() {
// التقويم
var calendar_container = $("#calendar_container");
calendar_infos = {
"container" : calendar_container,
"html" : calendar_container.html(),
"today" : moment().format('YYYY-MM-DD'),
"langue" : "fr"
}
// إنشاء التقويم
updateCalendar();
// قائمة اختيار الأطباء
$('#idMedecin').selectpicker();
$('#idMedecin').change(function(e) {
afficherAgenda();
})
// القائمة
setMenu([]);
}
- السطر 2: يتم إدارة التقويم بواسطة عدة وظائف JS. ستقوم المتغير [calendar_infos] بجمع المعلومات المتعلقة بالتقويم. وهي متغير عام حتى تتمكن الوظائف المختلفة من رؤيتها؛
- السطر 6: يتم تحديد حاوية التقويم؛
- الأسطر 7-12: المعلومات المخزنة الخاصة بالتقويم؛
- السطر 8: مرجع إلى حاوية التقويم،
- السطر 9: رمز التقويم HTML. باستخدام هاتين المعلومتين، يمكننا حذف التقويم وإعادة إنشائه،
- السطر 10: تاريخ اليوم بتنسيق [aaaa-mm-jj]،
- السطر 11: لغة التقويم؛
- السطر 14: إنشاء التقويم؛
- السطر 16: قائمة الأطباء المنسدلة؛
- الأسطر 17-19: في كل مرة تتغير فيها القيمة المحددة في قائمة الاختيار هذه، سيتم تنفيذ الأسلوب [afficherAgenda]؛
- السطر 21: لا توجد قائمة في شريط التنقل؛
الوظيفة [updateCalendar] هي كما يلي:
function updateCalendar(renew) {
if (renew) {
// إعادة إنشاء التقويم الحالي
calendar_infos.container.html(calendar_infos.html);
}
// تهيئة التقويم
var calendar = $("#calendar");
var settings = {
format : "yyyy-mm-dd",
startDate : calendar_infos.today,
language : calendar_infos.langue,
};
calendar.datepicker(settings);
// تحديد التاريخ الحالي
if (calendar_infos.date) {
calendar.datepicker('setDate', calendar_infos.date)
}
// الأحداث
calendar.datepicker().on('hide', function(e) {
// عرض اليوم المحدد
displayJour();
});
calendar.datepicker().on('changeDate', function(e) {
// تدوين التاريخ الجديد
calendar_infos.date = moment(calendar.datepicker('getDate')).format("YYYY-MM-DD");
// عرض معلومات التقويم
afficherAgenda();
// عرض اليوم المحدد
displayJour();
});
// عرض اليوم المحدد
displayJour();
}
- السطر 1: تقبل الدالة [updateCalendar] معلمة قد تكون موجودة أو غير موجودة. إذا كانت موجودة، يتم إعادة إنشاء التقويم (السطر 4) استنادًا إلى المعلومات الواردة في [calendar_infos]؛
- السطر 7: يتم الإشارة إلى التقويم؛
- الأسطر 8-12: معلمات التهيئة الخاصة به؛
- السطر 9: تنسيق التواريخ التي تديرها [aaaa-mm-jj]،
- السطر 10: التاريخ الأول الذي يمكن تحديده في التقويم. هنا، تاريخ اليوم. لن يمكن تحديد التواريخ السابقة،
- السطر 11: لغة التقويم. ستكون هناك لغتان: ['en'] و ['fr']؛
- السطر 13: تم تكوين التقويم؛
- الأسطر 15-17: إذا تم تهيئة تاريخ [calendar_infos]، فسيتم تعيين هذا التاريخ باعتباره التاريخ الحالي للتقويم؛
- الأسطر 19-22: في كل مرة يُغلق فيها التقويم، يتم عرض التاريخ المحدد؛
- الأسطر 23-30: في كل مرة يحدث فيها تغيير في التاريخ في التقويم:
- السطر 25: يتم تسجيل التاريخ المحدد في [calendar_infos]،
- السطر 27: يتم عرض معلومات عن الأجندة،
- السطر 29: يتم عرض اليوم المحدد؛
- السطر 32: عرض اليوم المحدد إن وجد؛
الطريقة [displayJour] التي تعرض اليوم المحدد هي كما يلي:
// عرض اليوم المحدد
function displayJour() {
if (calendar_infos.date) {
var displayjour = $("#displayjour");
moment.locale(calendar_infos.langue);
jour = moment(calendar_infos.date).format('LL');
displayjour.val(jour);
}
}
- السطر 3: إذا تم تحديد تاريخ بالفعل (في البداية لا يوجد تاريخ محدد في التقويم)؛
- السطر 4: تحديد موقع المكون الذي سيتم كتابة التاريخ فيه؛
- السطر 5: يمكن كتابة هذا التاريخ باللغة الإنجليزية أو الفرنسية. نحدد لغة المكتبة [moment]؛
- السطر 6: يتم عرض التاريخ المحدد باللغة المختارة وبتنسيق طويل؛
- السطر 7: يتم عرض هذا التاريخ؛
فيما يلي مثالان:
![]() | ![]() |
عند تغيير الطبيب أو التاريخ، يتم تنفيذ الأسلوب [afficherAgenda]:
function afficherAgenda() {
// يتم عرض الطبيب والتاريخ
var idMedecin = $('#idMedecin option:selected').val();
if (calendar_infos.date) {
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin + " et le jour " + calendar_infos.date);
}
}
8.6.4.8. المثال رقم 7: جدول HTML "متجاوب"
ملاحظة: «responsive» هو مصطلح إنجليزي يشير إلى أن المكون قادر على التكيف مع حجم الشاشة التي يُعرض عليها. سنعرض مثالاً على ذلك.
يعرض الإجراء [/bs-07] العرض التالي [bs-07.xml] (بملء الشاشة):
![]() |
الجديد هنا هو الجدول HTML [1]. يتم إدارة هذا الجدول بواسطة المكتبة JS [footable]: [https://github.com/fooplugins/FooTable].
إذا قمنا بتصغير حجم نافذة المتصفح، فسنحصل على ما يلي:
![]() |
- تكيفت الجدولة HTML مع حجم الشاشة؛
- في [1]، لرؤية الرابط [Réserver]، يجب النقر على الرمز [+]؛
- في [2]، ما يظهر عند النقر على الرمز [+]؛
تبدو الصفحة [bs-07.xml] كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-07.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar3" />
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron" />
<!-- المحتوى -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda" />
<!-- معلومات -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 10: CSS من المكتبة [footable]؛
- السطر 19: JS من المكتبة [footable]؛
- السطر 31: الجدول HTML الخاص بمفكرة؛
الطريقة [agenda.xml] هي كما يلي:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="row alert alert-danger">
<div class="col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span>Créneau horaire</span>
</th>
<th>
<span>Client</span>
</th>
<th data-hide="phone">
<span>Action</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class='status-metro status-active'>
9h00-9h20
</span>
</td>
<td>
<span></span>
</td>
<td>
<a href="javascript:reserver(14)" class="status-metro status-active">
Réserver
</a>
</td>
</tr>
<tr>
<td>
<span class='status-metro status-suspended'>
9h20-9h40
</span>
</td>
<td>
<span>Mme Paule MARTIN</span>
</td>
<td>
<a href="javascript:supprimer(17)" class="status-metro status-suspended">
Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- صفحة البداية -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initAgenda();
/*]]>*/
</script>
</body>
</html>
- السطر 4: يضع الجدول في سطر [row] ومربع ملون [alert alert-danger]؛
- السطر 5: سيشغل الجدول 6 أعمدة [col-md-6]؛
- السطر 6: يتم تنسيق الجدول HTML بواسطة Bootstrap [class='table']؛
- السطر 9: تشير السمة [data-toggle] إلى العمود الذي يحتوي على الرمز [+/-] الذي يقوم بفتح/طي السطر؛
- السطر 15: تشير السمة [data-hide='phone'] إلى أنه يجب إخفاء العمود إذا كان حجم الشاشة بحجم شاشة الهاتف. يمكن أيضًا استخدام القيمة «tablet»؛
- السطر 31: يتم ربط الدالة JS بالرابط [Réserver]؛
- السطر 46: يتم ربط الدالة JS بالرابط [Supprimer]؛
- السطور 56-61: تهيئة الصفحة؛
عدد من الفئات CSS المستخدمة أعلاه مستمدة من الملف CSS [bootstrapDemo.css]:
@CHARSET "UTF-8";
#فترات th {
text-align: center;
}
#creneaux td {
text-align: center;
font-weight: bold;
}
.status-metro {
display: inline-block;
padding: 2px 5px;
color:#fff;
}
.status-metro.status-active {
background: #43c83c;
}
.status-metro.status-suspended {
background: #fa3031;
}
تأتي الأنماط [status-*] من مثال لاستخدام الجدول [footable] الموجود على موقع المكتبة.
في الملف JS [bs-07.js]، يتم تهيئة الصفحة على النحو التالي:
function initAgenda() {
// جدول المواعيد
$("#creneaux").footable();
}
هذا كل شيء. يشير [$("#creneaux")] إلى الجدول HTML الذي نريد جعله «متجاوبًا». من ناحية أخرى، نجد الدوال JS المرتبطة بالرابطين [Réserver] و [Supprimer]:
function reserver(idCreneau) {
showInfo("Réservation du créneau n° " + idCreneau);
}
function supprimer(idRv) {
showInfo("Suppression du rv n° " + idRv);
}
8.6.4.9. المثال رقم 8: مربع منبثق
يعرض الإجراء [/bs-08] العرض التالي [bs-08.xml]:

في حين أنه في السابق، كان النقر على الرابط [Réserver] يعرض معلومة في مربع المعلومات، فإننا هنا سنقوم بعرض مربع منبثق لاختيار عميل لـ RV:

المكون المستخدم هو المكون [bootstrap-modal] [https://github.com/jschr/bootstrap-modal/].
وتبدو طريقة العرض [bs-08.xml] كما يلي:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-modal.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- نص برمجي محلي -->
<script type="text/javascript" src="resources/js/bs-08.js"></script>
</head>
<body id="body">
<div class="container">
<!-- شريط التنقل -->
<div th:include="navbar3" />
<!-- جومبوترون بوتستراب -->
<div th:include="jumbotron" />
<!-- المحتوى -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda-modal" />
<div th:include="resa" />
<!-- معلومات -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- السطر 19: الملف JS الضروري للنوافذ المنبثقة؛
- السطر 32: العرض [agenda-modal] مطابق للعرض [agenda] باستثناء تفصيل واحد: الدالة JS التي تدير الرابط [Réserver]:
<a href="javascript:showDialogResa(14)" class="status-metro status-active">Réserver</a>
تتولى الوظيفة [showDialogResa] مهمة عرض النافذة المنبثقة لاختيار العميل؛
- السطر 33: العرض [resa.xml] هو مربع الحوار لاختيار العميل:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">عنوان النافذة المنبثقة</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span>Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2>Clients</h2>
<select id="idClient" class="combobox" data-style="btn-primary">
<option value="1">Mme Marguerite Planton</option>
<option value="2">Mr Maxime Franck</option>
<option value="3">Mlle Elisabeth Oron</option>
<option value="4">Mr Gaëtan Calot</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validateResa()">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- صفحة البداية -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initResa();
/*]]>*/
</script>
</section>
- الأسطر 3-37: النافذة المنبثقة؛
- الأسطر 13-30: محتوى هذا المربع (ما سيتم عرضه)؛
- الأسطر 31-34: أزرار مربع الحوار؛
- السطر 32: زر [Annuler] الذي تديره الدالة JS [cancelDialogResa]؛
- السطر 33: زر [Valider] الذي تديره الدالة JS [validateResa]؛
- الأسطر 39-44: البرنامج النصي لتهيئة النافذة المنبثقة؛
وينتج عن ذلك العرض التالي:
![]() |
تجدر الإشارة إلى أن النافذة المنبثقة لا تُعرض بشكل افتراضي. ولهذا السبب، لا نراها عند بدء تشغيل التطبيق على الرغم من وجود رمزها HTML في المستند.
الملف JS [bs-08.js] هو كما يلي:
var idCreneau;
var idClient;
var resa;
function showDialogResa(idCreneau) {
// يتم حفظ معرّف الفترة الزمنية
this.idCreneau = idCreneau;
// عرض نافذة الحجز
var resa = $("#resa");
resa.modal('show');
// السجل
showInfo("Réservation du créneau n° " + idCreneau);
}
function cancelDialogResa() {
// إخفاء مربع الحوار
resa.modal('hide');
}
// تأكيد الحجز
function validateResa() {
// استرداد المعلومات
var idClient = $('#idClient option:selected').val();
// إخفاء مربع الحوار
resa.modal('hide');
// المعلومات
showInfo("Réservation du créneau n° " + idCreneau + " pour le client n° " + idClient)
}
function initResa() {
// قائمة اختيار العملاء
$('#idClient').selectpicker();
// نافذة منبثقة
resa = $("#resa");
resa.modal({});
}
- الأسطر 30-36: دالة تهيئة النافذة المنبثقة؛
- السطر 32: تحتوي النافذة المنبثقة على قائمة منسدلة يجب تهيئتها؛
- الأسطر 34-35: تهيئة النافذة المنبثقة نفسها؛
- الأسطر 5-13: الدالة JS المرتبطة بالرابط [Réserver]؛
- السطر 7: يتم تخزين معلمة الدالة في المتغير العام في السطر 1؛
- السطران 9-10: يتم إظهار النافذة المنبثقة؛
- السطر 12: يتم تسجيل معلومة في مربع المعلومات؛
- الأسطر 15-18: إدارة الزر [Annuler]. نكتفي بإخفاء النافذة المنبثقة (السطر 17)؛
- الأسطر 21-31: الدالة JS المرتبطة بالزر [Valider]؛
- السطر 23: نسترد السمة [value] للعميل المحدد؛
- السطر 25: يتم إخفاء مربع الحوار؛
- السطر 27: يتم تسجيل معلومتين: رقم الموعد المحجوز والعميل الذي تم الحجز له؛
8.6.5. الخطوة 2: كتابة طرق العرض
سنقوم الآن بوصف طرق العرض التي يقدمها الخادم [Web1] بالإضافة إلى قوالبها.
![]() |
8.6.5.1. طريقة العرض [navbar-start]
تعرض شريط التنقل لصفحة التمهيد:

الرمز [navbar-start.xml] هو كما يلي:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- نموذج تحديد الهوية -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" th:placeholder="#{service.url}" class="form-control" id="urlService" />
</div>
<div class="form-group">
<input type="text" th:placeholder="#{username}" class="form-control" id="login" />
</div>
<div class="form-group">
<input type="password" th:placeholder="#{password}" class="form-control" id="passwd" />
</div>
<button type="button" class="btn btn-success" th:text="#{login}" onclick="javascript:connecter()">Sign in</button>
<!-- اللغات -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Action</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- صفحة البدء -->
<script th:inline="javascript">
/*<![CDATA[*/
// تتم تهيئة الصفحة
initNavBarStart();
/*]]>*/
</script>
</section>
لا يوجد نموذج لهذه العرض. وتحتوي على معالجات الأحداث التالية:
event | معالج |
النقر على زر تسجيل الدخول | |
النقر على الرابط [Français] | |
انقر على الرابط [English] |
8.6.5.2. الطريقة [jumbotron]
هذه هي طريقة العرض التي تظهر أسفل شريط التنقل [navbar-start] في صفحة التمهيد:

ورمزها [jumbotron.xml] هو كما يلي:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- جومبوترون بوتستراب -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1 th:utext="#{application.header}" />
</div>
</div>
</div>
</section>
لا تحتوي طريقة العرض [jumbotron] على أي نماذج أو أحداث.
8.6.5.3. الطريقة [login]
هذه هي طريقة العرض التي تظهر أسفل الشاشة العملاقة في صفحة التمهيد:

رمزها [login.xml] هو كما يلي:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{identification}">Identification
</div>
</section>
لا تحتوي هذه العرض على أي نماذج أو أحداث.
8.6.5.4. الطريقة [navbar-run]
هذه هي شريط التنقل الذي يظهر عند نجاح عملية تسجيل الدخول:

ورمزها [navbar-run.xml] هو كما يلي:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- أزرار اليمين -->
<form class="navbar-form navbar-right" role="form">
<!-- تسجيل الخروج -->
<button type="button" class="btn btn-success" th:text="#{options.deconnecter}" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- اللغات -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<!-- تهيئة الصفحة -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initNavBarRun();
/*]]>*/
</script>
</section>
لا يحتوي هذا العرض على قالب. ويحتوي على معالجات الأحداث التالية:
event | معالج |
النقر على زر تسجيل الخروج | |
النقر على الرابط [Français] | |
انقر على الرابط [English] |
8.6.5.5. الطريقة [accueil]
هذه هي طريقة العرض التي تظهر مباشرة أسفل شريط التنقل [navbar-run]:

ورمزها [accueil.html] هو كما يلي:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{choixmedecinjour.title}">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{rv.medecin}">Médecin</h2>
<select name="idMedecin" id="idMedecin" class="combobox" data-style="btn-primary">
<option th:each="medecinItem : ${rdvmedecins.medecinItems}" th:text="${medecinItem.texte}" th:value="${medecinItem.id}"/>
</select>
</div>
<div class="col-md-3">
<h2 th:text="#{rv.jour}">Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- جدول الأعمال -->
<div id="agenda"></div>
<!-- نص برمجي محلي -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initChoixMedecinJour();
/*]]>*/
</script>
</html>
ونموذجه هو كما يلي:
- [rdvmedecins.medecinItems] (السطر 8): قائمة الأطباء؛
في شكلها الحالي، لا يبدو أن العرض يحتوي على مدير أحداث. في الواقع، يتم تعريف هذه الأحداث في الدالة [initChoixMedecinJour]. وقد تم عرض هذه الدالة في الفقرة 8.6.4.7، الصفحة 467، وبشكل أكثر تحديدًا في الصفحة 470. وتوجد فيها مديرات الأحداث التالية:
event | مدير |
اختيار طبيب | |
اختيار تاريخ |
8.6.5.6. عرض [agenda]
تعرض طريقة العرض [agenda] يومًا من جدول مواعيد الطبيب:

ورمزها [agenda.xml] هو كما يلي:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h3 class="alert alert-info" th:text="${agenda.titre}">Agenda de Mme Pélissier le 13/10/2014</h3>
<h4 class="alert alert-danger" th:if="${agenda.creneaux.length}==0" th:text="#{agenda.medecinsanscreneaux}">Ce médecin n'a pas encore de créneaux
de consultation</h4>
<th:block th:if="${agenda.creneaux.length}!=0">
<div class="row tab-content alert alert-warning">
<div class="tab-pane active col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span th:text="#{agenda.creneauhoraire}">Créneau horaire</span>
</th>
<th>
<span th:text="#{agenda.client}">Client</span>
</th>
<th data-hide="phone">
<span th:text="#{agenda.action}">Action</span>
</th>
</tr>
</thead>
<tbody>
<tr th:each="creneau,iter : ${agenda.creneaux}">
<td>
<span th:if="${creneau.action}==1" class="status-metro status-active" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
<span th:if="${creneau.action}==2" class="status-metro status-suspended" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
</td>
<td>
<span th:text="${creneau.client}">Client</span>
</td>
<td>
<a th:if="${creneau.action}==1" th:href="@{'javascript:reserverCreneau('+${creneau.id}+')'}" th:text="${creneau.commande}"
class="status-metro status-active">Réserver
</a>
<a th:if="${creneau.action}==2" th:href="@{'javascript:supprimerRv('+${creneau.idRv}+')'}" th:text="${creneau.commande}"
class="status-metro status-suspended">Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- الحجز -->
<section th:include="resa" />
</th:block>
<!-- تهيئة الصفحة -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initAgenda();
/*]]>*/
</script>
</body>
</html>
يحتوي نموذج هذه العرض على عنصر واحد فقط:
- [agenda] (السطر 4): نموذج معقد بعض الشيء مصمم خصيصًا لعرض التقويم؛
وتحتوي على مديري الأحداث التاليين:
event | مدير |
النقر على الزر [Supprimer] | |
النقر على الرابط [Réserver] |
الطريقة [resa] في السطر 47 هي الطريقة التي يتم عرضها عندما ينقر المستخدم على الرابط [Réserver]:

ورمزها [resa.xml] هو كما يلي:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">عنوان النافذة المنبثقة</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span th:text="#{resa.titre}">Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{resa.client}">Client</h2>
<select name="idClient" id="idClient" class="combobox" data-style="btn-primary">
<option th:each="clientItem : ${clientItems}" th:text="${clientItem.texte}" th:value="${clientItem.id}" />
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()" th:text="#{resa.annuler}">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validerRv()" th:text="#{resa.valider}">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- تهيئة الصفحة -->
<script th:inline="javascript">
/*<![CDATA[*/
// يتم تهيئة الصفحة
initResa();
/*]]>*/
</script>
</body>
</html>
يحتوي نموذجه على عنصر واحد فقط:
- [clientItems] (السطر 24): قائمة العملاء؛
وتحتوي على مديري الأحداث التاليين:
event | معالج |
النقر على الزر [Annuler] | |
انقر على الزر [Valider] |
8.6.5.7. عرض [erreurs]
هذه هي الصفحة التي تظهر إذا تعذر تنفيذ الإجراء الذي طلبه المستخدم:

الرمز [erreurs.xml] هو كما يلي:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-danger">
<h4>
<span th:text="#{erreurs.titre}">Les erreurs suivantes se sont produites :</span>
</h4>
<ul>
<li th:each="message : ${erreurs}" th:text="${message}" />
</ul>
</div>
</section>
يحتوي نموذجه على عنصر واحد فقط:
- [erreurs] (السطر 8): قائمة الأخطاء المطلوب عرضها؛
لا تحتوي طريقة العرض على مدير أحداث.
8.6.5.8. Résumé
يوضح الجدول التالي العروض ونماذجها:
طريقة العرض | القالب | مديرو الأحداث |
navbar-start | ||
شاشة عملاقة | ||
تسجيل الدخول | ||
شريط التنقل - التشغيل | ||
الصفحة الرئيسية | ||
جدول الأعمال | ||
الحجز | ||
أخطاء |
8.6.6. الخطوة 3: كتابة الإجراءات
لنعد إلى بنية خدمة الويب [Web1]:
![]() |
سنرى الآن ما هي URL التي تعرضها [Web1] وكيفية تنفيذها:
8.6.6.1. القواعد URL التي تعرضها الخدمة [Web1]
وهي كما يلي:
- URL لكل من العروض السابقة أو تجميع منها؛
- URL لإضافة RV؛
- ملف URL لحذف ملف RV؛
وتُرجع جميعها استجابة من النوع [Reponse] على النحو التالي:
public class Reponse {
// ----------------- الخصائص
// حالة العملية
private int status;
// شريط التنقل
private String navbar;
// الشاشة الكبيرة
private String jumbotron;
// محتوى الصفحة
private String content;
// جدول الأعمال
private String agenda;
...
}
- السطر 5: حالة الرد: 1 (OK)، 2 (خطأ)؛
- السطر 7: التدفق HTML للطرق [navbar-start] أو [navbar-run] حسب الحالة؛
- السطر 9: التدفق HTML من العرض [jumbotron]؛
- السطر 13: التدفق HTML من العرض [agenda]؛
- السطر 9: التدفق HTML من العروض [accueil]، [erreurs]، [login] حسب الحالة؛
القيم المعروضة لـ URL هي التالية
يضع العرض [navbar-start] في [Reponse.navbar] | |
يضع العرض [navbar-run] في [Reponse.navbar] | |
يضع العرض [accueil] في [Reponse.content] | |
يضع العرض [jumbotron] في [Reponse.jumbotron] | |
يضع العرض [agenda] في [Reponse.agenda] | |
يضع العرض [login] في [Reponse.content] | |
| |
ضع العرض [navbar-run] في [Reponse.navbar]، والعرض [jumbotron] في [Reponse.jumbotron]، والعرض [accueil] في [Reponse.content]، والعرض [agenda] في [Reponse.agenda] | |
يضيف الموعد المحدد ويضع الأجندة الجديدة في [Reponse.agenda] | |
يحذف الموعد المحدد ويضع الأجندة الجديدة في [Reponse.agenda] |
8.6.6.2. السينجلتون [ApplicationModel]
![]() |
يتم إنشاء مثيل واحد للفئة [ApplicationModel] وإدراجه في وحدة التحكم الخاصة بالتطبيق. وفيما يلي كودها:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
....
}
- السطر 6: [ApplicationModel] هو مكون Spring؛
- السطر 7: الذي ينفذ واجهة الطبقة [DAO]. نقوم بذلك حتى لا تضطر الإجراءات إلى معرفة الطبقة [DAO] بل تعرف فقط العنصر الفريد [ApplicationModel]. وبذلك تصبح بنية [Web1] كما يلي:
![]() |
لنعد إلى كود الفئة [ApplicationModel]:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
// الطبقة [DAO]
@Autowired
private IDao dao;
// الإعدادات
@Autowired
private AppConfig appConfig;
// البيانات الواردة من الطبقة [DAO]
private List<ClientItem> clientItems;
private List<MedecinItem> medecinItems;
// بيانات التكوين
private String userInit;
private String mdpUserInit;
private boolean corsAllowed;
// استثناء
private RdvMedecinsException rdvMedecinsException;
// الشركة المصنعة
public ApplicationModel() {
}
@PostConstruct
public void init() {
// التكوين
userInit = appConfig.getUSER_INIT();
mdpUserInit = appConfig.getMDP_USER_INIT();
dao.setTimeout(appConfig.getTIMEOUT());
dao.setUrlServiceWebJson(appConfig.getWEBJSON_ROOT());
corsAllowed = appConfig.isCORS_ALLOWED();
// يتم تخزين قوائم الأطباء والعملاء المنسدلة مؤقتًا
List<Medecin> medecins = null;
List<Client> clients = null;
try {
medecins = dao.getAllMedecins(new User(userInit, mdpUserInit));
clients = dao.getAllClients(new User(userInit, mdpUserInit));
} catch (RdvMedecinsException ex) {
rdvMedecinsException = ex;
}
if (rdvMedecinsException == null) {
// يتم إنشاء عناصر القوائم المنسدلة
medecinItems = new ArrayList<MedecinItem>();
for (Medecin médecin : medecins) {
medecinItems.add(new MedecinItem(médecin));
}
clientItems = new ArrayList<ClientItem>();
for (Client client : clients) {
clientItems.add(new ClientItem(client));
}
}
}
// وظائف الاسترجاع والتعيين
...
// تنفيذ واجهة [IDao]
@Override
public void setUrlServiceWebJson(String url) {
dao.setUrlServiceWebJson(url);
}
@Override
public void setTimeout(int timeout) {
dao.setTimeout(timeout);
}
@Override
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
return dao.ajouterRv(user, jour, idCreneau, idClient);
}
...
}
- السطر 11: إدراج مرجع تنفيذ الطبقة [DAO]. ثم يتم استخدام هذا المرجع لتنفيذ الواجهة [IDao] (الأسطر 64-80)؛
- السطر 14: إدخال تكوين التطبيق؛
- الأسطر 33-37: استخدام هذه التهيئة لتكوين عناصر مختلفة من بنية التطبيق؛
- الأسطر 38-46: يتم تخزين المعلومات التي ستغذي القوائم المنسدلة للأطباء والعملاء في ذاكرة التخزين المؤقت. وبالتالي، نفترض أنه في حالة تغيير طبيب أو عميل، يجب إعادة تشغيل التطبيق. الفكرة هنا هي إظهار أن كائن Spring من نوع «singleton» يمكن أن يعمل كذاكرة تخزين مؤقت للتطبيق الويب؛
تشتق كل من الفئتين [MedecinItem] و [ClientItem] من الفئة التالية [PersonneItem]:
package rdvmedecins.springthymeleaf.server.models;
import rdvmedecins.client.entities.Personne;
public class PersonneItem {
// عنصر في قائمة
private Long id;
private String texte;
// منشئ
public PersonneItem() {
}
public PersonneItem(Personne personne) {
id = personne.getId();
texte = String.format("%s %s %s", personne.getTitre(), personne.getPrenom(), personne.getNom());
}
// دالات الاسترجاع والتعيين
...
}
- السطر 8: سيكون الحقل [id] هو قيمة السمة [value] لإحدى خيارات القائمة المنسدلة؛
- السطر 9: سيكون الحقل [texte] هو النص المعروض لأحد خيارات القائمة المنسدلة؛
8.6.6.3. الفئة [BaseController]
![]() |
الفئة [BaseController] هي الفئة الأم لعناصر التحكم [RdvMedecinsController] و [RdvMedecinsCorsController]. لم يكن إنشاء هذه الفئة الأم إلزامياً. وقد تم تجميع طرق مساعدة من الفئة [RdvMedecinsController] فيها، وهي طرق غير أساسية باستثناء واحدة. ويمكن تصنيفها إلى ثلاث مجموعات:
- الأساليب المساعدة؛
- الطرق التي تعرض العروض مدمجة مع نماذجها؛
- طريقة تهيئة إجراء
| طريقتان مساعدتان توفران قائمة برسائل الخطأ. وقد سبق لنا أن تعرفنا عليهما واستخدمناهما؛ |
| تعرض طريقة العرض [accueil] بدون قالب |
| يعرض العرض [agenda] ونموذجه |
| يعرض عرض [login] بدون نموذج |
| يعرض الرد للعميل عندما تنتهي العملية المطلوبة بخطأ |
| طريقة تهيئة جميع إجراءات وحدة التحكم [RdvMedecinsController] |
دعونا نلقي نظرة على اثنتين من هذه الطرق.
تُعد الطريقة [getPartialViewAgenda] هي الأكثر تعقيدًا في إنشاء العرض، وهو عرض التقويم. وفيما يلي شفرة هذه الطريقة:
// تدفق [agenda]
protected String getPartialViewAgenda(ActionContext actionContext, AgendaMedecinJour agenda, Locale locale) {
// السياقات
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
// يتم إنشاء نموذج الصفحة [agenda]
ViewModelAgenda modelAgenda = setModelforAgenda(agenda, springContext, locale);
// جدول الأعمال مع نموذجه
thymeleafContext.setVariable("agenda", modelAgenda);
thymeleafContext.setVariable("clientItems", application.getClientItems());
return engine.process("agenda", thymeleafContext);
}
- السطران 9-10: عنصرا نموذج التقويم:
- السطر 9: التقويم المعروض.
- السطر 10: قائمة العملاء المعروضة عندما يقوم المستخدم بحجز موعد؛
الطريقة [setModelforAgenda] في السطر 7 هي كما يلي:
// قالب الصفحة [Agenda]
private ViewModelAgenda setModelforAgenda(AgendaMedecinJour agenda, WebApplicationContext springContext, Locale locale) {
// عنوان الصفحة
String dateFormat = springContext.getMessage("date.format", null, locale);
Medecin médecin = agenda.getMedecin();
String titre = springContext.getMessage("agenda.titre", new String[] { médecin.getTitre(), médecin.getPrenom(),
médecin.getNom(), new SimpleDateFormat(dateFormat).format(agenda.getJour()) }, locale);
// مواعيد الحجز
ViewModelCreneau[] modelCréneaux = new ViewModelCreneau[agenda.getCreneauxMedecinJour().length];
int i = 0;
for (CreneauMedecinJour creneauMedecinJour : agenda.getCreneauxMedecinJour()) {
// موعد الطبيب
Creneau créneau = creneauMedecinJour.getCreneau();
ViewModelCreneau modelCréneau = new ViewModelCreneau();
modelCréneaux[i] = modelCréneau;
// المعرف
modelCréneau.setId(créneau.getId());
// الفترة الزمنية
modelCréneau.setCreneauHoraire(String.format("%02dh%02d-%02dh%02d", créneau.getHdebut(), créneau.getMdebut(),
créneau.getHfin(), créneau.getMfin()));
Rv rv = creneauMedecinJour.getRv();
// العميل والطلب
String commande;
if (rv == null) {
modelCréneau.setClient("");
commande = springContext.getMessage("agenda.reserver", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setAction(ViewModelCreneau.ACTION_RESERVER);
} else {
Client client = rv.getClient();
modelCréneau.setClient(String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom()));
commande = springContext.getMessage("agenda.supprimer", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setIdRv(rv.getId());
modelCréneau.setAction(ViewModelCreneau.ACTION_SUPPRIMER);
}
// الفترة التالية
i++;
}
// عرض نموذج الجدول
ViewModelAgenda modelAgenda = new ViewModelAgenda();
modelAgenda.setTitre(titre);
modelAgenda.setCreneaux(modelCréneaux);
return modelAgenda;
}
- السطر 6: التقويم له عنوان:

أو:

نلاحظ أن تنسيق التاريخ يعتمد على اللغة. سنبحث عن هذا التنسيق في ملفات الرسائل (السطر 4).
- الأسطر 11-40: لكل فترة زمنية، يجب عرض الشكل التالي:
![]()
أو العرض:
![]()
- السطور 19-20: تعرض الفترة الزمنية؛
- الأسطر 25-28: في حالة ما إذا كان الموعد متاحًا. يجب عندئذٍ عرض الزر [Réserver]؛
- السطور 31-36: في حالة احتلال الفترة الزمنية. يجب عندئذٍ عرض كل من العميل وزر [Supprimer]؛
الطريقة الأخرى التي نقدم شرحًا أكثر تفصيلًا لها هي الطريقة [getActionContext]. يتم استدعاؤها في بداية كل إجراء من إجراءات [RdvMedecinsController]. وتكون توقيعها كما يلي:
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController)
وهي تُرجع النوع [ActionContext] التالي:
public class ActionContext {
// البيانات
private WebContext thymeleafContext;
private WebApplicationContext springContext;
private Locale locale;
private List<String> erreurs;
...
}
- السطر 4: سياق Thymeleaf للإجراء؛
- السطر 5: سياق Spring الخاص بالإجراء؛
- السطر 6: الإعدادات المحلية للإجراء؛
- السطر 7: قائمة محتملة برسائل الخطأ؛
معلماتها هي كما يلي:
- [lang]: اللغة المطلوبة للإجراء «en» أو «fr»؛
- [origin]: الرأس HTTP [origin] في حالة استدعاء بين المجالات؛
- [request]: الطلب HTTP قيد المعالجة، وهو ما يُعرف منذ فترة باسم «إجراء»؛
- [response]: الرد الذي سيتم إرساله على هذا الطلب؛
- [result]: تتلقى كل عملية من [RdvMedecinsController] قيمة مرسلة يتم اختبار صحتها. [result] هي نتيجة هذا الاختبار؛
- [rdvMedecinsController]: وحدة التحكم الحاوية للإجراءات؛
يتم تنفيذ الأسلوب [getActionContext] بالطريقة التالية:
// سياق إجراء ما
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController) {
// اللغة؟
if (lang == null) {
lang = "fr";
}
// الإعدادات المحلية
Locale locale = null;
if (lang.trim().toLowerCase().equals("fr")) {
// الفرنسية
locale = new Locale("fr", "FR");
} else {
// كل ما عدا ذلك باللغة الإنجليزية
locale = new Locale("en", "US");
}
// رؤوس الصفحات CORS
rdvMedecinsCorsController.sendOptions(origin, response);
// ActionContext
ActionContext actionContext = new ActionContext(new WebContext(request, response, request.getServletContext(),locale), WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()), locale, null);
// أخطاء التهيئة
RdvMedecinsException e = application.getRdvMedecinsException();
if (e != null) {
actionContext.setErreurs(e.getMessages());
return actionContext;
}
// أخطاء في POST؟
if (result != null && result.hasErrors()) {
actionContext.setErreurs(getErreursForModel(result, locale, actionContext.getSpringContext()));
return actionContext;
}
// لا توجد أخطاء
return actionContext;
}
- الأسطر 3-15: بدءًا من المعلمة [lang]، يتم تحديد الإعدادات المحلية للعملية؛
- السطر 17: يتم إرسال الرؤوس HTTP اللازمة لطلبات الاستعلام عبر المجالات. لن ندخل في التفاصيل هنا. التقنية المستخدمة هي تلك المذكورة في الفقرة 8.4.14؛
- السطر 19: إنشاء كائن [ActionContext] دون أخطاء؛
- السطر 21: رأينا في الفقرة 8.6.6.2 أن الكائن الفردي [ApplicationModel] يصل إلى قاعدة البيانات لاسترداد بيانات كل من العملاء والأطباء. قد يفشل هذا الوصول. عندئذٍ يتم تسجيل الاستثناء الذي يحدث. في السطر 21، نسترد هذا الاستثناء؛
- الأسطر 22-25: في حالة حدوث استثناء عند تشغيل التطبيق، يصبح تنفيذ أي إجراء مستحيلاً. وعندئذٍ نُرجع كائن [ActionContext] مع رسائل خطأ الاستثناء لأي إجراء؛
- السطر 27-20: يتم تحليل المعلمة [result] لمعرفة ما إذا كانت القيمة المرسلة صالحة أم لا. إذا كانت غير صالحة، يتم إرجاع كائن [ActionContext] مع رسائل الخطأ المناسبة؛
- السطر 32: حالة خالية من الأخطاء؛
ننتقل الآن إلى فحص إجراءات وحدة التحكم [RdvMedecinsController]
8.6.6.4. الإجراء [/getNavBarStart]
تُرجع الإجراء [/getNavBarStart] العرض [navbar-start]. وتكون توقيعها كما يلي:
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
وهي تُرجع النوع [Reponse] التالي:
public class Reponse {
// ----------------- الخصائص
// حالة العملية
private int status;
// شريط التنقل
private String navbar;
// الشاشة العملاقة
private String jumbotron;
// نص الصفحة
private String content;
// جدول الأعمال
private String agenda;
...
}
ولها المعلمات التالية:
- [PostLang postlang]: القيمة المرسلة التالية:
public class PostLang {
// البيانات
@NotNull
private String lang;
...
}
الفئة [PostLang] هي الفئة الأم لجميع القيم المنشورة. في الواقع، يجب على العميل دائمًا تحديد اللغة التي يجب تنفيذ الإجراء بها.
يتم تنفيذ الطريقة [getNavbarStart] على النحو التالي:
// navbar-start
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// يتم إعادة عرض [navbar-start]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
return reponse;
}
- السطر 7: تهيئة الإجراء؛
- الأسطر 10-13: إذا أبلغت طريقة تهيئة الإجراء عن وجود أخطاء، يتم إرسالها في الرد إلى العميل (السطر 12) مع الحالة 2:
- الأسطر 15-18: يتم إرسال العرض [navbar-start] مع الحالة 1:
فيما يلي، سنكتفي بتفصيل الميزات الجديدة فقط.
8.6.6.5. الإجراء [/getNavbarRun]
تُنتج العملية [/getNavBarRun] العرض [navbar-run]:
// شريط التنقل - التشغيل
@RequestMapping(value = "/getNavbarRun", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarRun(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// يتم إعادة عرض [navbar-run]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
return reponse;
}
يمكن أن تُنتج العملية نوعين من الاستجابات:
- الاستجابة التي تحتوي على خطأ (الأسطر 10-13):
- الاستجابة مع العرض [navbar-run]:
8.6.6.6. الإجراء [/getJumbotron]
تُرجع العملية [/getJumbotron] العرض [jumbotron]:
// جومبوترون
@RequestMapping(value = "/getJumbotron", method = RequestMethod.POST)
@ResponseBody
public Reponse getJumbotron(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// يتم إعادة عرض [jumbotron]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
return reponse;
}
يمكن أن تُنتج الإجراء نوعين من الاستجابات:
- الاستجابة التي تحتوي على خطأ (الأسطر 10-13):
- الاستجابة مع العرض [jumbotron]:
8.6.6.7. الإجراء [/getLogin]
تُرجع العملية [/getLogin] العرض [login]:
@RequestMapping(value = "/getLogin", method = RequestMethod.POST)
@ResponseBody
public Reponse getLogin(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// يتم إرجاع العرض [login]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
reponse.setContent(getPartialViewLogin(thymeleafContext));
return reponse;
}
يمكن أن تُرجع الإجراء نوعين من الاستجابات:
- الاستجابة التي تحتوي على خطأ (الأسطر 9-11):
- الاستجابة مع العرض [login]:
8.6.6.8. الإجراء [/getAccueil]
تُرجع العملية [/getAccueil] العرض [accueil]. وتكون توقيعها كما يلي:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- في السطر 3، تكون القيمة التي تم إدراجها من النوع [PostUser] كما يلي:
public class PostUser extends PostLang {
// البيانات
@NotNull
private User user;
...
}
- السطر 1: الفئة [PostUser] تمتد من الفئة [PostLang] وبالتالي تتضمن لغة؛
- السطر 4: المستخدم الذي يسعى للحصول على العرض؛
رمز التنفيذ هو التالي:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// الطريقة [accueil] محمية
try{
// المستخدم
User user = postUser.getUser();
// يتم التحقق من بيانات الاعتماد [userName, password]
application.authenticate(user);
}catch(RdvMedecinsException e){
// يتم إرجاع خطأ
return getViewErreurs(thymeleafContext, e.getMessages());
}
// يتم إرجاع العرض [accueil]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
- الأسطر 15-22: تجدر الإشارة إلى أن الصفحة [accueil] محمية، وبالتالي يجب مصادقة المستخدم؛
يمكن أن تُرجع العملية نوعين من الاستجابات:
- الاستجابة مع خطأ (السطران 11 و21):
- الاستجابة مع العرض [accueil] (الأسطر 24-27):
8.6.6.9. الإجراء [/getNavbarRunJumbotronAccueil]
تُرجع العملية [/getNavbarRunJumbotronAccueil] طرق العرض [navbar-run, jumbotron, accueil]. ولها التوقيع التالي:
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser post, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- السطر 3: القيمة المرسلة من النوع [PostUser]؛
تنفيذ الإجراء هو كما يلي:
// شريط التنقل + jumbotron + الصفحة الرئيسية
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// الطريقة [accueil] محمية
try {
// المستخدم
User user = postUser.getUser();
// يتم التحقق من بيانات الاعتماد [userName, password]
application.authenticate(user);
} catch (RdvMedecinsException e) {
// يتم إرجاع خطأ
return getViewErreurs(thymeleafContext, e.getMessages());
}
// يتم إرسال الرد
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
يمكن أن تُرجع الإجراء نوعين من الردود:
- الاستجابة التي تحتوي على خطأ (السطران 13 و23):
- الرد مع المشاهدات [navbar-run, jumbotron, accueil] (الأسطر 26-31):
8.6.6.10. الإجراء [/getAgenda]
تُرجع العملية [/getAgenda] العرض [agenda]. وتكون توقيعها كما يلي:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- السطر 3: القيمة التي تم إرسالها هي من النوع [PostGetAgenda] كما يلي:
public class PostGetAgenda extends PostUser {
// البيانات
@NotNull
private Long idMedecin;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- السطر 1: الفئة [PostGetAgenda] تمتد من الفئة [PostUser]، وبالتالي تتضمن لغة ومستخدمًا؛
- السطر 5: رقم الطبيب الذي نريد الحصول على جدول مواعيده؛
- السطر 8: اليوم المطلوب من الأجندة؛
التنفيذ كما يلي:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postGetAgenda.getLang(), origin, request, response, result, rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
Locale locale = actionContext.getLocale();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// يتم التحقق من صحة الإرسال
if (result != null) {
new PostGetAgendaValidator().validate(postGetAgenda, result);
if (result.hasErrors()) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
...
}
- حتى السطر 14، لدينا كود أصبح الآن تقليديًا؛
- الأسطر 16-21: يتم إجراء تحقق إضافي من القيمة المرسلة. يجب أن يكون التاريخ لاحقًا أو مساويًا لتاريخ اليوم. وللتحقق من ذلك، نستخدم أداة التحقق من الصحة:
package rdvmedecins.web.validators;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import rdvmedecins.springthymeleaf.server.requests.PostGetAgenda;
import rdvmedecins.springthymeleaf.server.requests.PostValiderRv;
public class PostGetAgendaValidator implements Validator {
public PostGetAgendaValidator() {
}
@Override
public boolean supports(Class<?> classe) {
return PostGetAgenda.class.equals(classe) || PostValiderRv.class.equals(classe);
}
@Override
public void validate(Object post, Errors errors) {
// اليوم المختار للموعد
Date jour = null;
if (post instanceof PostGetAgenda) {
jour = ((PostGetAgenda) post).getJour();
} else {
if (post instanceof PostValiderRv) {
jour = ((PostValiderRv) post).getJour();
}
}
// تحويل التواريخ إلى التنسيق yyyy-MM-dd
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strJour = sdf.format(jour);
String strToday = sdf.format(new Date());
// يجب ألا يسبق اليوم المحدد تاريخ اليوم الحالي
if (strJour.compareTo(strToday) < 0) {
errors.rejectValue("jour", "todayandafter.postChoixMedecinJour", null, null);
}
}
}
- السطر 19: يعمل أداة التحقق مع فئتين: [PostGetAgenda] و [PostValiderRv]؛
لنعد إلى كود الإجراء [/getAgenda]:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
...
// الإجراء
try {
// جدول مواعيد الطبيب
AgendaMedecinJour agenda = application.getAgendaMedecinJour(postGetAgenda.getUser(), postGetAgenda.getIdMedecin(),
new SimpleDateFormat("yyyy-MM-dd").format(postGetAgenda.getJour()));
// الرد
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException e1) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, e1.getMessages());
} catch (Exception e2) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- السطران 9-10: باستخدام المعلمات المرسلة، يتم طلب جدول مواعيد الطبيب؛
- السطران 12-13: يتم إرجاع جدول المواعيد:
- السطران 17 و21: يتم إرجاع رد يحتوي على أخطاء:
8.6.6.11. الإجراء [/getNavbarRunJumbotronAccueilAgenda]
تقوم العملية [/getNavbarRunJumbotronAccueilAgenda] بإرجاع طرق العرض [navbar-run, jumbotron, accueil, agenda]. وفيما يلي طريقة تنفيذها:
@RequestMapping(value = "/getNavbarRunJumbotronAccueilAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueilAgenda(@Valid @RequestBody PostGetAgenda post, BindingResult result,
HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(post.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// جدول الأعمال
Reponse agenda = getAgenda(post, result, request, response, null);
if (agenda.getStatus() != 1) {
return agenda;
}
// يتم إرسال الرد
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
reponse.setAgenda(agenda.getAgenda());
return reponse;
}
- الأسطر 15-18: نستفيد من وجود الإجراء [/getAgenda] لاستدعائه. ثم ننظر إلى status في الرد (السطر 16). إذا تم الكشف عن خطأ، لا نمضي قدمًا ونقوم بإرجاع الرد؛
- السطر 20: يتم إرسال العروض المطلوبة:
8.6.6.12. الإجراء [/supprimerRv]
تسمح العملية [/supprimerRv] بحذف موعد. وتكون صيغتها كما يلي:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- السطر 3: القيمة المرسلة هي من النوع [PostSupprimerRv] كما يلي:
public class PostSupprimerRv extends PostUser {
// البيانات
@NotNull
private Long idRv;
..
}
- السطر 1: الفئة [PostSupprimerRv] تمتد من الفئة [PostUser]، وبالتالي تتضمن لغة ومستخدمًا؛
- السطر 5: رقم الموعد المراد حذفه؛
تنفيذ الإجراء كما يلي:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postSupprimerRv.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// القيم المرسلة
User user = postSupprimerRv.getUser();
long idRv = postSupprimerRv.getIdRv();
// يتم حذف الموعد
AgendaMedecinJour agenda = null;
try {
// استعادته
Rv rv = application.getRvById(user, idRv);
Creneau creneau = application.getCreneauById(user, rv.getIdCreneau());
long idMedecin = creneau.getIdMedecin();
Date jour = rv.getJour();
// حذف الموعد المرتبط
application.supprimerRv(user, idRv);
// إعادة إنشاء جدول مواعيد الطبيب
agenda = application.getAgendaMedecinJour(user, idMedecin, new SimpleDateFormat("yyyy-MM-dd").format(jour));
// يتم إرجاع جدول المواعيد الجديد
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- السطر 22: يتم استرداد الموعد المطلوب حذفه. إذا لم يكن موجودًا، تحدث استثناء؛
- الأسطر 23-25: بناءً على هذا الموعد، يتم تحديد الطبيب واليوم المعني. هذه المعلومات ضرورية لإعادة إنشاء جدول مواعيد الطبيب؛
- السطر 27: يتم حذف الموعد؛
- السطر 29: نطلب جدول مواعيد الطبيب الجديد. هذا أمر مهم. فبالإضافة إلى الفترة الزمنية التي تم تحريرها للتو، ربما أجرى مستخدمون آخرون للتطبيق تعديلات على جدول المواعيد. ومن المهم إعادة إرسال أحدث نسخة منه إلى المستخدم؛
- الأسطر 31-34: يتم إرجاع جدول المواعيد:
8.6.6.13. الإجراء [/validerRv]
تقوم العملية [/validerRv] بإضافة موعد إلى جدول مواعيد أحد الأطباء. وتكون توقيعها كما يلي:
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- السطر 3: القيمة المرسلة هي من النوع [PostValiderRv] كما يلي:
public class PostValiderRv extends PostUser {
// البيانات
@NotNull
private Long idCreneau;
@NotNull
private Long idClient;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- السطر 1: الفئة [PostValiderRv] توسع نطاق الفئة [PostUser]، وبالتالي تتضمن لغة ومستخدمًا؛
- السطر 5: رقم الفترة الزمنية؛
- السطر 7: رقم العميل الذي تم الحجز لصالحه؛
- السطر 10: يوم الموعد؛
تنفيذ الإجراء كما يلي:
// التحقق من صحة موعد
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// سياقات الإجراء
ActionContext actionContext = getActionContext(postValiderRv.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebApplicationContext springContext = actionContext.getSpringContext();
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// أخطاء؟
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// التحقق من صحة تاريخ الموعد
if (result != null) {
new PostGetAgendaValidator().validate(postValiderRv, result);
if (result.hasErrors()) {
// يتم إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
// القيم التي تم إدخالها
User user = postValiderRv.getUser();
long idClient = postValiderRv.getIdClient();
long idCreneau = postValiderRv.getIdCreneau();
Date jour = postValiderRv.getJour();
// الإجراء
try {
// يتم استرداد المعلومات المتعلقة بالموعد
Creneau créneau = application.getCreneauById(user, idCreneau);
long idMedecin = créneau.getIdMedecin();
// يتم إضافة موعد اللقاء
application.ajouterRv(postValiderRv.getUser(), new SimpleDateFormat("yyyy-MM-dd").format(jour), idCreneau,idClient);
// يتم تحديث التقويم
AgendaMedecinJour agenda = application.getAgendaMedecinJour(user, idMedecin,
new SimpleDateFormat("yyyy-MM-dd").format(jour));
// يتم عرض التقويم الجديد
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// إرجاع العرض [erreurs]
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
}
الرمز مشابه لرمز الإجراء [/supprimerRv].
8.6.7. الخطوة 4: اختبارات خادم Spring/Thymeleaf
سنقوم الآن باختبار الإجراءات المختلفة السابقة باستخدام المكون الإضافي لمتصفح Chrome [Advanced Rest Client] (انظر الفقرة 9.6).
8.6.7.1. تكوين الاختبارات
تتطلب جميع الإجراءات قيمة مرسلة. سنرسل متغيرات من السلسلة التالية: jSON:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
تتضمن هذه القيمة المرسلة معلومات زائدة عن الحاجة بالنسبة لمعظم الإجراءات. ومع ذلك، يتم تجاهل هذه المعلومات من قبل الإجراءات التي تتلقاها ولا تتسبب في حدوث أي خطأ. وتتميز هذه القيمة المرسلة بأنها تغطي مختلف القيم المطلوب إرسالها.
8.6.7.2. الإجراء [/getNavbarStart]
![]() |
- إلى [1]، الإجراء الذي تم اختباره؛
- إلى [2]، وهي القيمة التي تم إرسالها؛
- في [3]، القيمة التي تم نشرها هي سلسلة jSON؛
- في [4]، يتم طلب العرض [navbar-start] باللغة الإنجليزية؛
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
لقد تلقينا العرض [navbar-start] باللغة الإنجليزية (المناطق المظللة).
الآن، لنقم بخطأ. نغير قيمة السمة [lang] من القيمة التي تم إرسالها إلى null. ونحصل على النتيجة التالية:
![]() |
تلقينا رد خطأ (الحالة 2) يشير إلى أن الحقل [lang] إلزامي.
8.6.7.3. الإجراء [/getNavbarRun]
نطلب الإجراء [getNavbarRun] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
8.6.7.4. الإجراء [/getJumbotron]
نطلب الإجراء [getJumbotron] بالقيمة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
8.6.7.5. الإجراء [/getLogin]
نطلب تنفيذ الإجراء [getLogin] بالقيمة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
8.6.7.6. الإجراء [/getAccueil]
نطلب تنفيذ الإجراء [getAccueil] بالقيمة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
نكرر العملية باستخدام مستخدم مجهول:
{"user":{"login":"x","passwd":"x"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
نبدأ من جديد مع مستخدم موجود ولكنه غير مخول باستخدام التطبيق:
{"user":{"login":"user","passwd":"user"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
8.6.7.7. الإجراء [/getAgenda]
نطلب الإجراء [getAgenda] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
نكرر العملية باستخدام تاريخ سابق لليوم الحالي:
![]() |
نبدأ من جديد مع طبيب غير موجود:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":11, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي التالية:
![]() |
8.6.7.8. الإجراء [/getNavbarRunJumbotronAccueil]
نطلب الإجراء [getNavbarRunJumbotronAccueil] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
وينطبق الأمر نفسه مع مستخدم مجهول:
![]() |
8.6.7.9. الإجراء [/getNavbarRunJumbotronAccueilAgenda]
نطلب الإجراء [getNavbarRunJumbotronAccueilAgenda] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي التالية:
![]() |
نقوم بإدخال طبيب غير موجود:
![]() |
8.6.7.10. الإجراء [/supprimerRv]
نطلب الإجراء [supprimerRv] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
الرقم المرجعي (Rv) رقم 93 غير موجود. والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
مع موعد موجود:
![]() |
يمكن التحقق من قاعدة البيانات للتأكد من أن الموعد قد تم حذفه بالفعل. يتم إرسال جدول المواعيد الجديد.
8.6.7.11. الإجراء [/validerRv]
نطلب الإجراء [validerRv] بالقيمة المرسلة التالية:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
والنتيجة التي تم الحصول عليها هي كما يلي:
![]() |
يمكن التحقق من قاعدة البيانات للتأكد من أن الموعد قد تم إنشاؤه بالفعل. تم إرسال جدول المواعيد الجديد.
نقوم بنفس الإجراء باستخدام رقم فترة زمنية غير موجود:
![]() |
نقوم بنفس الإجراء باستخدام رقم عميل غير موجود:
![]() |
8.6.8. الخطوة 5: كتابة برنامج العميل بجافا سكريبت
لنعد إلى بنية الخادم [Web1]:
![]() |
العميل [2] الخاص بالخادم [Web1] هو عميل جافا سكريبت من النوع APU (تطبيق ذو صفحة واحدة):
- يطلب العميل صفحة التمهيد من خادم ويب (ليس بالضرورة [Web1])؛
- ويطلب الصفحات التالية من الخادم [Web1] عبر استدعاءات Ajax؛
لبناء هذا العميل، سنستخدم أداة [Webstorm] (انظر الفقرة 9.8). وقد بدت لي هذه الأداة أكثر عملية من STS. وتتمثل ميزتها الرئيسية في أنها توفر ميزة الإكمال التلقائي عند كتابة الكود بالإضافة إلى بعض خيارات أداة refactoring. وهذا يساعد على تجنب العديد من الأخطاء.
8.6.8.1. مشروع JS
يحتوي مشروع JS على الهيكل التالي:
![]() |
- في [1]، العميل JS بأكمله. [boot.html] هي صفحة البداية. وستكون هذه هي الصفحة الوحيدة التي يتم تحميلها بواسطة المتصفح؛
- في [2]، أوراق أنماط مكونات Bootstrap؛
- في [3]، الصور القليلة التي يستخدمها التطبيق؛
![]() |
- في [4]، البرامج النصية JS. وهنا يكمن عملنا؛
- في [5]، المكتبات المستخدمة JS: jQuery بشكل أساسي، ومكتبات مكونات Bootstrap؛
8.6.8.2. بنية الكود
تم تقسيم الكود إلى ثلاث طبقات:
![]() |
- تجمع الطبقة [présentation] وظائف تهيئة الصفحة [boot.xml] بالإضافة إلى وظائف مكونات Bootstrap المختلفة. ويتم تنفيذها بواسطة الملف [ui.js]؛
- الطبقة [événements] تضم جميع معالجات الأحداث الخاصة بالطبقة [présentation]. ويتم تنفيذها بواسطة الملف [evts.js]؛
- الطبقة [DAO] تُرسل الطلبات HTTP إلى الخادم [Web1]. ويتم تنفيذها بواسطة الملف [dao.js]؛
8.6.8.3. الطبقة [présentation]
![]() |
يتم تنفيذ الطبقة [présentation] بواسطة الملف [ui.js] التالي:
//الطبقة [présentation]
var ui = {
// المتغيرات العامة؛
"agenda": "",
"resa": "",
"langue": "",
"urlService": "http://localhost:8081",
"page": "login",
"jourAgenda": "",
"idMedecin": "",
"user": {},
"login": {},
"exceptionTitle": {},
"calendar_infos": {},
"erreur": "",
"idCreneau": "",
"done": "",
// مكونات العرض
"body": "",
"navbar": "",
"jumbotron": "",
"content": "",
"exception": "",
"exception_text": "",
"exception_title": "",
"loading": ""
};
// طبقة الأحداث
var evts = {};
// الطبقة [dao]
var dao = {};
// ------------ المستند جاهز
$(document).ready(function () {
// تهيئة المستند
console.log("document.ready");
// مكونات الصفحة
ui.navbar = $("#navbar");
ui.jumbotron = $("#jumbotron");
ui.content = $("#content");
ui.erreur = $("#erreur");
ui.exception = $("#exception");
ui.exception_text = $("#exception-text");
ui.exception_title = $("#exception-title");
// يتم تخزين صفحة تسجيل الدخول لتتمكن من استعادتها
ui.login.lang = ui.langue;
ui.login.navbar = ui.navbar.html();
ui.login.jumbotron = ui.jumbotron.html();
ui.login.content = ui.content.html();
// URL الخاص بالخدمة
$("#urlService").val(ui.urlService);
});
// ------------------------ وظائف تهيئة مكونات Bootstrap
ui.initNavBarStart = function () {
...
};
ui.initNavBarRun = function () {
...
};
ui.initChoixMedecinJour = function () {
...
};
ui.updateCalendar = function (renew) {
...
};
// يعرض اليوم المحدد
ui.displayJour = function () {
...
};
ui.initAgenda = function () {
...
};
ui.initResa = function () {
...
};
- لعزل الطبقات عن بعضها البعض، تقرر وضعها في ثلاثة كائنات:
- [ui] للطبقة [présentation] (الأسطر 2-27)،
- [evts] للطبقة الخاصة بإدارة الأحداث (السطر 29)،
- [dao] للطبقة [DAO] (السطر 31)؛
يتيح هذا الفصل بين الطبقات في ثلاثة كائنات تجنب عدد من تعارضات أسماء المتغيرات والوظائف. تستخدم كل طبقة متغيرات ووظائف مسبوقة ببادئة الكائن الذي يغلف الطبقة.
- الأسطر 38-44: يتم تخزين الحقول التي ستظل موجودة دائمًا بغض النظر عن العروض المعروضة. وهذا يتجنب إجراء عمليات بحث متكررة وغير ضرورية في jQuery؛
- الأسطر 46-49: يتم تخزين صفحة التمهيد محليًّا حتى يمكن استعادتها عندما يقوم المستخدم بإنهاء الاتصال دون تغيير اللغة؛
- الأسطر 54-83: وظائف تهيئة مكونات Bootstrap. وقد تم عرضها جميعًا في دراسة هذه المكونات في الفقرة 8.6.4؛
8.6.8.4. الدوال المساعدة في الطبقة [événements]
![]() |
تم وضع معالجات الأحداث في الملف [evts.js]. هناك العديد من الدوال التي تستخدمها معالجات الأحداث بانتظام. وسنعرضها الآن:
// بدء الانتظار
evts.beginWaiting = function () {
// بدء الانتظار
ui.loading = $("#loading");
ui.loading.show();
ui.exception.hide();
ui.erreur.hide();
evts.travailEnCours = true;
};
// نهاية الانتظار
evts.stopWaiting = function () {
// نهاية الانتظار
evts.travailEnCours = false;
ui.loading = $("#loading");
ui.loading.hide();
};
// عرض النتيجة
evts.showResult = function (result) {
// يتم عرض البيانات المستلمة
var data = result.data;
// تحليل الحالة
switch (result.status) {
case 1:
// خطأ؟
if (data.status == 2) {
ui.erreur.html(data.content);
ui.erreur.show();
} else {
if (data.navbar) {
ui.navbar.html(data.navbar);
}
if (data.jumbotron) {
ui.jumbotron.html(data.jumbotron);
}
if (data.content) {
ui.content.html(data.content)
}
if (data.agenda) {
ui.agenda = $("#agenda");
ui.resa = $("#resa");
}
}
break;
case 2:
// عرض الخطأ
evts.showException(data);
break;
}
};
// ------------ وظائف متنوعة
evts.showException = function (data) {
// عرض الخطأ
ui.exception.show();
ui.exception_text.html(data);
ui.exception_title.text(ui.exceptionTitle[ui.langue]);
};
- السطر 2: يتم استدعاء الدالة [evts.beginwaiting] قبل أي إجراء غير متزامن [DAO]؛
- السطران 4-5: يتم عرض الصورة المتحركة التي تشير إلى حالة الانتظار؛
- السطران 6-7: يتم إخفاء منطقة عرض الأخطاء والاستثناءات (وهما ليسا نفس الشيء)؛
- السطر 8: يُشار إلى أن عملية غير متزامنة جارية؛
- السطر 12: يتم استدعاء الدالة [evts.stopwaiting] بعد أن تعطي العملية غير المتزامنة [DAO] نتيجتها؛
- السطر 14: نلاحظ أن العملية غير المتزامنة قد اكتملت؛
- السطر 15: يتم إخفاء الصورة المتحركة التي تشير إلى حالة الانتظار؛
- السطر 20: تعرض الدالة [evts.showResult] النتيجة [result] لعملية غير متزامنة [DAO]. والنتيجة هي كائن JS بالصيغة التالية {'status':status,'data':data,'sendMeBack':sendMeBack}.
- الأسطر 47-50: تُستخدم في حالة وجود [result.status==2]. ويحدث ذلك عندما يرسل الخادم [Web1] استجابةً تحتوي على رأس خطأ HTTP (على سبيل المثال 403 forbidden). في هذه الحالة، [data] هي السلسلة jSON التي أرسلها الخادم للإبلاغ عن الخطأ؛
- السطر 25: الحالة التي تم فيها تلقي استجابة صالحة من الخادم [Web1]. يحتوي الحقل [data] عندئذٍ على استجابة الخادم: {'status':status,'navbar':navbar,'jumbotron':jumbotron,'agenda':agenda,'content':content}؛
- السطر 27: الحالة التي أرسل فيها الخادم [Web1] استجابة خطأ {'status':2,'navbar':null,'jumbotron':null,'agenda':null,'content':erreurs}؛
- السطران 28-29: يتم عرض الصفحة [erreurs]؛
- الأسطر 31-33: عرض شريط التنقل (إن وجد)؛
- الأسطر 34-36: عرض «الجامبوتروم» (jumbotron) إذا كان متاحًا؛
- الأسطر 37-39: عرض الحقل [data.content] إذا لزم الأمر. يمثل، حسب الحالة، إحدى طرق العرض [accueil, agenda]؛
- الأسطر 40-43: إذا تم تحديث التقويم، يتم استرداد بعض المراجع الخاصة بمكوناته حتى لا يتم البحث عنها في كل مرة نحتاج إليها؛
- السطر 54: وظيفة الدالة [evts.showException] هي عرض نص الاستثناء الموجود في معلمتها [data]؛
- السطران 57 و58: يتم عرض نص الاستثناء؛
- السطر 58: يعتمد عنوان الاستثناء على اللغة الحالية؛
يحتوي الملف [evts.js] على أكثر من 300 سطر من التعليمات البرمجية، ولن أعلق على كل منها. سأكتفي ببعض الأمثلة لتوضيح فحوى هذه الطبقة.
8.6.8.5. تسجيل دخول المستخدم

يتم تسجيل دخول المستخدم بواسطة الدالة التالية:
// ------------------------ الاتصال
evts.connecter = function () {
// يتم استرداد القيم المطلوب إرسالها
var login = $("#login").val().trim();
var passwd = $("#passwd").val().trim();
// تحديد URL الخاص بالخادم
ui.urlService = $("#urlService").val().trim();
dao.setUrlService(ui.urlService);
// معلمات الطلب
var post = {
"user": {
"login": login,
"passwd": passwd
},
"lang": ui.langue
};
var sendMeBack = {
"user": {
"login": login,
"passwd": passwd
},
"caller": evts.connecterDone
};
// إجراء الطلب
evts.execute([{
"name": "accueil-sans-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- السطران 4-5: يتم استرداد اسم المستخدم وكلمة المرور؛
- السطران 7-8: يتم استرداد URL من الخدمة [Web1]. ويتم تخزينها في كل من الطبقة [ui] والطبقة [dao]؛
- الأسطر 10-16: القيمة المراد إرسالها: اللغة الحالية والمستخدم الذي يحاول تسجيل الدخول؛
- الأسطر 17-23: الكائن [sendMeBack] هو كائن يتم تمريره إلى الدالة [DAO] التي سيتم استدعاؤها، والتي يجب أن تعيد هذا الكائن إلى الدالة الموجودة في السطر 22. هنا، الكائن [sendMeBack] يغلف المستخدم الذي يحاول تسجيل الدخول؛
- الأسطر 25-29: الدالة [evts.execute] قادرة على تنفيذ سلسلة من الإجراءات غير المتزامنة. هنا، يتم تمرير قائمة تتكون من إجراء واحد. حقول هذه القائمة هي كما يلي:
- [name]: اسم الإجراء غير المتزامن المراد تنفيذه،
- [post]: القيمة المطلوب إرسالها إلى الخادم [Web1]،
- [sendMeBack]: القيمة التي يجب أن ترسلها العملية غير المتزامنة مع نتيجتها؛
قبل تفصيل الدالة [evts.execute]، لنلقِ نظرة على الدالة [evts.connecterDone] في السطر 22. هذه هي الدالة التي يجب أن ترسل إليها الدالة غير المتزامنة [DAO] التي تم استدعاؤها نتيجتها:
evts.connecterDone = function (result) {
// عرض النتيجة
evts.showResult(result);
// هل تم الاتصال بنجاح؟
if (result.status == 1 && result.data.status == 1) {
// الصفحة
ui.page = "accueil-sans-agenda";
// تسجيل المستخدم
ui.user = result.sendMeBack.user;
}
};
- السطر 3: يتم عرض النتيجة التي أرسلها الخادم [Web1]؛
- السطر 5: إذا كانت هذه النتيجة خالية من الأخطاء، يتم تخزين نوع الصفحة الجديدة (السطر 7) وكذلك المستخدم الذي تمت مصادقته (السطر 9)؛
تنفذ الدالة [evts.execute] سلسلة من الإجراءات غير المتزامنة:
// تنفيذ سلسلة من الإجراءات
evts.execute = function (actions) {
// العمل جارٍ؟
if (evts.travailEnCours) {
// لا يتم القيام بأي شيء
return;
}
// في انتظار
evts.beginWaiting();
// تنفيذ الإجراءات
dao.doActions(actions, evts.stopWaiting);
};
- السطر 2: المعلمة [actions] هي قائمة بالإجراءات غير المتزامنة المطلوب تنفيذها؛
- الأسطر 4-7: لا يُقبل التنفيذ إلا إذا لم تكن هناك عملية أخرى قيد التنفيذ بالفعل؛
- السطر 9: يتم بدء عملية الانتظار؛
- السطر 11: يُطلب من الطبقة [DAO] تنفيذ سلسلة الإجراءات. المعلمة الثانية هي اسم الدالة التي يجب تنفيذها عندما تعود جميع إجراءات السلسلة بنتائجها؛
لن ندخل الآن في تفاصيل الدالة [dao.doActions]. سنقوم بدراسة حدث آخر.
8.6.8.6. تغيير اللغة

يتم تغيير اللغة بواسطة الدالة التالية:
// ------------------------ تغيير اللغة
evts.setLang = function (lang) {
// تغيير اللغة؟
if (lang == ui.langue) {
// لا يتم القيام بأي شيء
return;
}
// لغة جديدة
ui.langue = lang;
// ما هي الصفحة التي يجب ترجمتها؟
switch (ui.page) {
case "login":
evts.getLogin();
break;
case "accueil-sans-agenda":
evts.getAccueilSansAgenda();
break;
case "accueil-avec-agenda":
evts.getAccueilAvecAgenda(ui);
break;
}
};
- السطر 2: المعلمة [lang] هي اللغة الجديدة: 'fr' أو 'en'؛
- الأسطر 4-7: إذا كانت اللغة الجديدة هي اللغة الحالية، فلا يتم اتخاذ أي إجراء؛
- السطر 9: يتم حفظ اللغة الجديدة؛
- الأسطر 12-20: في حالة تغيير اللغة، يجب إعادة إنشاء الصفحة المعروضة حاليًا بواسطة المتصفح. هناك ثلاث صفحات محتملة:
- الصفحة المسماة [login] حيث تكون الصفحة المعروضة هي صفحة المصادقة،
- الصفحة المسماة [accueil-sans-agenda] وهي الصفحة التي تُعرض مباشرة بعد المصادقة الناجحة،
- الصفحة المسماة [accueil-avec-agenda] وهي الصفحة التي تظهر بمجرد عرض أول جدول أعمال. بعد ذلك، تظل هذه الصفحة معروضة بشكل دائم حتى يقوم المستخدم بإنهاء الاتصال؛
سنتناول حالة الصفحة [accueil-avec-agenda]. توجد ثلاث إصدارات لهذه الوظيفة:
![]() |
- تقوم النسخة [ getAccueilAvecAgenda-one] بتنفيذ إجراء غير متزامن واحد؛
- الإصدار [ getAccueilAvecAgenda-parallel] يُنفذ أربعة إجراءات غير متزامنة بالتوازي؛
- الإصدار [ getAccueilAvecAgenda-sequence] يقوم بتنفيذ أربعة إجراءات غير متزامنة واحدة تلو الأخرى؛
8.6.8.7. الدالة [ getAccueilAvecAgenda-one]
وهي الدالة التالية:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// معلمات الطلب
var post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
var sendMeBack = {
"caller": evts.getAccueilAvecAgendaDone
};
// الاستعلام
evts.execute([{
"name": "accueil-avec-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- الأسطر 4-9: القيمة المراد إرسالها تتضمن المستخدم المسجل الدخول، واللغة المطلوبة، ورقم الطبيب الذي نريد الاطلاع على جدوله، واليوم المطلوب من الجدول؛
- الأسطر 10-12: الكائن [sendMeBack] هو الكائن الذي سيتم إرجاعه إلى الدالة الموجودة في السطر 11. هنا، لا يحتوي على أي معلومات؛
- الأسطر 14-18: تنفيذ سلسلة من الإجراءات غير المتزامنة، وهي الإجراء المسمى [accueil-avec-agenda] (السطر 15)؛
- السطر 11: الدالة التي يتم تنفيذها عندما تُرجع العملية غير المتزامنة [accueil-avec-agenda] نتيجتها؛
تعرض الدالة [evts.getAccueilAvecAgendaDone] في السطر 11 نتيجة الدالة غير المتزامنة المسماة [accueil-avec-agenda]:
evts.getAccueilAvecAgendaDone = function (result) {
// عرض النتيجة
evts.showResult(result);
// صفحة جديدة؟
if (result.status == 1 && result.data.status == 1) {
ui.page = "accueil-avec-agenda";
}
};
- السطر 1: [result] هي نتيجة الدالة غير المتزامنة المسماة [accueil-avec-agenda]؛
- السطر 3: يتم عرض هذه النتيجة؛
- السطر 5: إذا كانت النتيجة خالية من الأخطاء، يتم تسجيل الصفحة الجديدة (السطر 6)؛
8.6.8.8. الدالة [ getAccueilAvecAgenda-parallel]
هي الدالة التالية:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// إجراءات [navbar-run, jumbotron, accueil, agenda] في //
// شريط التنقل-تشغيل
var navbarRun = {
"name": "navbar-run"
};
navbarRun.post = {
"lang": ui.langue
};
navbarRun.sendMeBack = {
"caller": evts.showResult
};
// شاشة عملاقة
var jumbotron = {
"name": "jumbotron"
};
jumbotron.post = {
"lang": ui.langue
};
jumbotron.sendMeBack = {
"caller": evts.showResult
};
// الصفحة الرئيسية
var accueil = {
"name": "accueil"
};
accueil.post = {
"lang": ui.langue,
"user": ui.user
};
accueil.sendMeBack = {
"caller": evts.showResult
};
// جدول الأعمال
var agenda = {
"name": "agenda"
};
agenda.post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin': ui.idMedecin،
'اليوم: ui.jourAgenda،
"caller": evts.getAgendaDone
};
// تنفيذ الإجراءات في //
evts.execute([navbarRun, jumbotron, accueil, agenda])
};
- السطر 51: يتم تنفيذ أربعة إجراءات غير متزامنة هذه المرة. وسيتم تنفيذها بالتوازي؛
- الأسطر 5-13: تعريف الإجراء [navbarRun] الذي يسترد شريط التنقل [navbar-run]؛
- السطر 12: الدالة التي سيتم تنفيذها عندما تعرض العملية غير المتزامنة [navbarRun] نتيجتها؛
- الأسطر 15-23: تعريف الإجراء [jumbotron] الذي يسترد العرض [jumbotron]؛
- السطر 22: الدالة التي يجب تنفيذها عندما تعود الإجراء غير المتزامن [jumbotron] بنتيجته؛
- الأسطر 25-34: تعريف الإجراء [accueil] الذي يسترد العرض [accueil]؛
- السطر 33: الدالة التي يجب تنفيذها عندما تعود العملية غير المتزامنة [accueil] بنتائجها؛
- الأسطر 36-49: تعريف الإجراء [agenda] الذي يسترد العرض [jumbotron]؛
- السطر 48: الدالة التي يجب تنفيذها عندما تعود الإجراء غير المتزامن [agenda] بنتائجه؛
8.6.8.9. الدالة [ getAccueilAvecAgenda-sequence]
وهي الدالة التالية:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// الإجراءات [navbar-run, jumbotron, accueil, agenda] بالترتيب
// جدول الأعمال
var agenda = {
"name" : "agenda"
};
agenda.post = {
"user" : ui.user,
"lang" : ui.langue,
"idMedecin" : ui.idMedecin,
"jour" : ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin' : ui.idMedecin،
'اليوم: ui.jourAgenda،
"caller" : evts.getAgendaDone
};
// الصفحة الرئيسية
var accueil = {
"name" : "accueil"
};
accueil.post = {
"lang" : ui.langue,
"user" : ui.user
};
accueil.sendMeBack = {
"caller" : evts.showResult,
"next" : agenda
};
// شاشة عملاقة
var jumbotron = {
"name" : "jumbotron"
};
jumbotron.post = {
"lang" : ui.langue
};
jumbotron.sendMeBack = {
"caller" : evts.showResult,
"next" : accueil
};
// شريط التنقل-تشغيل
var navbarRun = {
"name" : "navbar-run"
};
navbarRun.post = {
"lang" : ui.langue
};
navbarRun.sendMeBack = {
"caller" : evts.showResult,
"next" : jumbotron
};
// تنفيذ الإجراءات بالتسلسل
evts.execute([ navbarRun ])
};
- السطر 54: يتم تنفيذ الإجراء [navbarRun]. عند انتهائها، ننتقل إلى الإجراء التالي: [jumbotron]، السطر 51. ثم يتم تنفيذ هذا الإجراء بدوره. عند انتهائه، ننتقل إلى الإجراء التالي: [accueil]، السطر 40. ويتم تنفيذ هذا الإجراء بدوره. وعند انتهائها، ننتقل إلى الإجراء التالي: [agenda]، السطر 29. ويتم تنفيذ هذا الإجراء بدوره. وعند انتهائه، نتوقف لأن الإجراء [agenda] لا يتبعه أي إجراء آخر.
8.6.8.10. الطبقة [DAO]
![]() |
يجمع الملف [dao.js] جميع وظائف الطبقة [DAO]. سنعرض هذه الوظائف تدريجيًا:
// URL المعروضة بواسطة الخادم
dao.urls = {
"login": "/getLogin",
"accueil": "/getAccueil",
"jumbotron": "/getJumbotron",
"agenda": "/getAgenda",
"supprimerRv": "/supprimerRv",
"validerRv": "/validerRv",
"navbar-start": "/getNavbarStart",
"navbar-run": "/getNavbarRun",
"accueil-sans-agenda": "/getNavbarRunJumbotronAccueil",
"accueil-avec-agenda": "/getNavbarRunJumbotronAccueilAgenda"
};
// --------------- الواجهة
// عنوان URL للخادم
dao.setUrlService = function (urlService) {
dao.urlService = urlService;
};
- الأسطر 16-18: الوظيفة التي تسمح بتعيين URL لخدمة [Web1]؛
- الأسطر 2-13: القاموس الذي يربط اسم الإجراء غير المتزامن بـ URL الخاص بالخادم [Web1] المطلوب الاستعلام عنه؛
// ------------------ إدارة عامة للإجراءات
// تنفيذ سلسلة من الإجراءات غير المتزامنة
dao.doActions = function (actions, done) {
// معالجة الإجراءات
dao.actionsCount = actions.length;
dao.actionIndex = 0;
for (var i = 0; i < dao.actionsCount; i++) {
// استعلام غير متزامن DAO
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, actions[i], done);
}
};
- السطر 3: تقوم الدالة [dao.doActions] بتنفيذ سلسلة من الإجراءات غير المتزامنة [actions]. المعلمة [done] هي الدالة التي يجب تنفيذها عندما تعود جميع الإجراءات بنتائجها؛
- الأسطر 7-12: يتم تنفيذ الإجراءات غير المتزامنة بشكل متوازٍ. ومع ذلك، في حالة وجود إجراء لاحق لأحدها، يتم تنفيذ هذا الإجراء عند انتهاء الإجراء الذي يسبقه؛
- السطر 9: كائن [Deferred] في الحالة [pending]؛
- السطر 10: عندما ينتقل هذا الكائن إلى الحالة [resolved]، سيتم تنفيذ الدالة [dao.actionDone]؛
- السطر 11: يتم تنفيذ الإجراء رقم i من القائمة بشكل غير متزامن. يتم تمرير المعلمة [done] من السطر 3 كمعلمة؛
الدالة [dao.actionDone] التي يتم تنفيذها في نهاية كل إجراء غير متزامن هي كما يلي:
// تم استلام نتيجة
dao.actionDone = function (result) {
// المتصل؟
var sendMeBack = result.sendMeBack;
if (sendMeBack && sendMeBack.caller) {
sendMeBack.caller(result);
}
// التالي؟
if (sendMeBack && sendMeBack.next) {
// طلب DAO غير متزامن
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, sendMeBack.next, sendMeBack.done);
}
// انتهى؟
dao.actionIndex++;
if (dao.actionIndex == dao.actionsCount) {
// تم؟
if (sendMeBack && sendMeBack.done) {
sendMeBack.done(result);
}
}
};
- السطر 2: تتلقى الدالة [dao.actionDone] النتيجة [result] من إحدى الإجراءات غير المتزامنة في قائمة الإجراءات المطلوب تنفيذها؛
- الأسطر 4-7: إذا كانت العملية غير المتزامنة المنتهية قد حددت دالة لإرجاع النتيجة إليها، يتم استدعاء هذه الدالة؛
- الأسطر 9-14: إذا كان للإجراء غير المتزامن الذي اكتمل إجراء تالٍّ، يتم تنفيذ هذا الإجراء بدوره؛
- السطر 16: تم إنهاء إجراء. يتم زيادة عداد الإجراءات المنتهية. أي إجراء له عدد غير محدد من الإجراءات التالية يُحسب كإجراء واحد؛
- الأسطر 19-21: إذا تم تحديد دالة [done] في البداية لتنفيذها عندما تعود جميع الإجراءات التالية بنتائجها، يتم الآن تنفيذ هذه الدالة؛
تقوم الطريقة [dao.doAction] بتنفيذ إجراء غير متزامن:
// تنفيذ إجراء
dao.doAction = function (deferred, action, done) {
// وظيفة «done» المراد تضمينها في الإجراء
if (action.sendMeBack) {
action.sendMeBack.done = done;
} else {
action.sendMeBack = {
"done": done
};
}
// تنفيذ الإجراء
dao.executePost(deferred, action.sendMeBack, dao.urls[action.name], action.post)
};
- الأسطر 4-10: كما رأينا للتو، يجب أن تتمتع الدالة التي ستعالج نتيجة الإجراء غير المتزامن الذي سيتم تنفيذه بإمكانية الوصول إلى الدالة [done]. ولذلك، نضع هذه الأخيرة في الكائن [sendMeBack]، وهو الكائن الذي سيكون جزءًا من نتيجة العملية غير المتزامنة؛
- السطر 12: يتم تنفيذ الدالة [dao.executePost] التي تقوم باستدعاء الدالة HTTP إلى الخادم [Web1]. القيمة المستهدفة URL هي القيمة URL المرتبطة باسم الإجراء المطلوب تنفيذه؛
تقوم الدالة [dao.executePost] بتنفيذ استدعاء HTTP:
// طلب HTTP
dao.executePost = function (deferred, sendMeBack, url, post) {
// يتم إجراء استدعاء Ajax يدويًا
$.ajax({
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
url: dao.urlService + url,
type: 'POST',
data: JSON3.stringify(post),
dataType: 'json',
success: function (data) {
// يتم عرض النتيجة
deferred.resolve({
"status": 1,
"data": data,
"sendMeBack": sendMeBack
});
},
error: function (jqXHR, textStatus, errorThrown) {
var data;
if (jqXHR.responseText) {
data = jqXHR.responseText;
} else {
data = textStatus;
}
// عرض الخطأ
deferred.resolve({
"status": 2,
"data": data,
"sendMeBack": sendMeBack
});
}
});
};
لقد تناولنا هذه الوظيفة وعلقنا عليها سابقًا. يُلاحظ ببساطة في السطر 9 أن الوظيفة المستهدفة URL هي عملية ربط الوظيفة URL الخاصة بالخادم [Web1] مع الوظيفة URL المرتبطة باسم الإجراء.
8.6.8.11. صفحة التمهيد
![]() |

تعرض صفحة التمهيد [boot.html] العرض الموضح أعلاه. وهي الصفحة الوحيدة التي يتم تحميلها مباشرةً بواسطة المتصفح. أما الصفحات الأخرى فيتم الحصول عليها من خلال استدعاءات Ajax. وفيما يلي كودها:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="css/bootstrap-3.1.1-min.css"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap-select.min.css"/>
<link rel="stylesheet" type="text/css" href="css/datepicker3.css"/>
<link rel="stylesheet" type="text/css" href="css/footable.core.min.css"/>
<!-- أنماط مخصصة لهذا القالب -->
<link rel="stylesheet" type="text/css" href="css/rdvmedecins.css"/>
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="vendor/bootstrap.js"></script>
<script type="text/javascript" src="vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="vendor/footable.js"></script>
<!-- نصوص برمجية المستخدمين -->
<script type="text/javascript" src="js/json3.js"></script>
<script type="text/javascript" src="js/ui.js"></script>
<script type="text/javascript" src="js/evts.js"></script>
<script type="text/javascript" src="js/getAccueilAvecAgenda-sequence.js"></script>
<script type="text/javascript" src="js/dao.js"></script>
</head>
<body id="body">
<div id="navbar">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="images/loading.gif" alt="waiting..." style="display: none"/>
<!-- نموذج تسجيل الدخول -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" placeholder="URL du serveur" class="form-control" id="urlService"/>
</div>
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" id="login"/>
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" id="passwd"/>
</div>
<button type="button" class="btn btn-success" onclick="javascript:evts.connecter()">Connexion</button>
<!-- اللغات -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span> <span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li><a href="javascript:evts.setLang('fr')">Français</a></li>
<li><a href="javascript:evts.setLang('en')">English</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<!-- جومبوترون بوتستراب -->
<div id="jumbotron">
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="images/caduceus.jpg" alt="RvMedecins"/>
</div>
<div class="col-md-10">
<h1>
Cabinet médical<br/>Les Médecins associés
</h1>
</div>
</div>
</div>
</div>
<!-- لوحات الأخطاء -->
<div id="erreur"></div>
<div id="exception" class="alert alert-danger" style="display: none">
<h3 id="exception-title"></h3>
<span id="exception-text"></span>
</div>
<!-- المحتوى -->
<div id="content">
<div class="alert alert-info">Authentifiez-vous pour accéder à l'application</div>
</div>
</div>
<!-- صفحة التمهيد -->
<script>
// جاري تهيئة الصفحة
ui.langue = 'fr';
ui.exceptionTitle['fr'] = "L'erreur suivante s'est produite côté serveur :";
ui.exceptionTitle['en'] = "The following server error was met:";
ui.initNavBarStart();
</script>
</body>
</html>
- لقد سبق أن تناولنا هذا النوع من الصفحات في الفصل الخاص بـ Bootstrap (الفقرة 8.6.4)؛
- الأسطر 99-105: تهيئة بعض عناصر الطبقة [présentation]؛
- السطر 27، يتم استخدام البرنامج النصي [getAccueilAvecAgenda-sequence.js]. وبتغيير البرنامج النصي في هذا السطر، نحصل على ثلاثة أنماط مختلفة لعرض الصفحة [accueil-avec-agenda]:
- يُحصل [getAccueilAvecAgenda-one.js] على الصفحة باستدعاء واحد لـ HTTP،
- يُحصل [getAccueilAvecAgenda-parallel.js] على الصفحة من خلال أربع استدعاءات متزامنة لـ HTTP،
- [getAccueilAvecAgenda-sequence.js] يحصل على الصفحة من خلال أربع استدعاءات متتالية لـ HTTP؛
8.6.8.12. Tests
هناك طرق مختلفة لإجراء الاختبارات. سنستخدم هنا الأداة [Webstorm]:
![]() |
- في [1] نفتح مشروعًا. ما علينا سوى تحديد المجلد [2] الذي يحتوي على الشجرة الثابتة (HTML، CSS، JS) للموقع المراد اختباره؛
![]() |
- في ملف [3]، الموقع الثابت؛
- في [4-5]، يتم تحميل الصفحة [boot.html]؛
![]() |
- في [5]، نرى أن خادمًا مضمنًا في [Webstorm] قد أرسل الصفحة [boot.html] من المنفذ [63342]. هذه نقطة مهمة يجب فهمها لأنها تعني أن البرامج النصية في الصفحة [boot.html] ستقوم بإجراء استدعاءات عبر النطاقات إلى الخادم [Web1] الذي يعمل بدوره على [localhost:8081]. يعلم المتصفح الذي قام بتحميل الصفحة [boot.html] أنه قام بتحميلها من الصفحة [localhost:63342]. ولذلك، لن يقبل أن تقوم هذه الصفحة بإجراء استدعاءات إلى الموقع [localhost:8081] لأن المنفذ مختلف. لذلك سيقوم بتنفيذ عمليات الاتصال بين المجالات الموضحة في الفقرة 8.4.14. ولهذا السبب، يجب تهيئة التطبيق [Web1] لقبول عمليات الاتصال هذه بين المجالات. ويتم تحديد ذلك في الملف [AppConfig] على خادم Spring / Thymeleaf:
![]() |
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// الإدارة / الإدارة
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// الجذر خدمة الويب / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// مهلة الانتظار بالمللي ثانية
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
نترك للقارئ مهمة إجراء الاختبارات على العميل JS. يجب أن يكون قادرًا على إعادة إنتاج الوظائف الموضحة في الفقرة 8.6.3.
بمجرد إقرار صحة العميل JS، يمكن نشره في مجلد الخادم [Web1] لتجنب الحاجة إلى السماح بالطلبات عبر النطاقات:
![]() |
فيما سبق، قمنا بنسخ الموقع الذي تم اختباره إلى المجلد [src / main / resources / static]. بعد ذلك، يمكننا طلب URL و [http://localhost:8081/boot.html]:

الآن لم نعد بحاجة إلى الطلبات عبر النطاقات ويمكننا كتابة ما يلي في ملف التكوين [AppConfig] الخاص بالخادم [Web1]:
// CORS
private final boolean CORS_ALLOWED=false;
سيستمر التطبيق أعلاه في العمل. أما إذا عدنا إلى التطبيق [Webstorm]، فلن يعمل بعد الآن:


إذا انتقلنا إلى وحدة التحكم في التطوير (Ctrl-Shift-I)، فسنرى سبب الخطأ:

إنه خطأ طلب غير مصرح به عبر النطاقات.
8.6.8.13. Conclusion
لقد قمنا بتنفيذ البنية التالية: JS:
![]() |
- الطبقات منفصلة بشكل واضح؛
- ولدينا تطبيق من نوع APU (تطبيق ذو صفحة واحدة). وهذه الميزة هي التي ستسمح لنا الآن بإنشاء تطبيق أصلي لمختلف الأجهزة المحمولة (Android، IoS، Windows Phone)؛
- لقد أنشأنا نموذجًا قادرًا على تنفيذ الإجراءات غير المتزامنة بشكل متوازٍ أو متسلسل أو مزيج من الاثنين؛
8.6.9. الخطوة 6: إنشاء تطبيق أصلي لنظام أندرويد
تتيح أداة [Phonegap] [http://phonegap.com/] إنتاج ملف قابل للتنفيذ للهواتف المحمولة (Android، IoS، Windows 8، ...) من تطبيق HTML / JS / CSS. وهناك طرق مختلفة لتحقيق هذا الهدف. نستخدم أبسطها: أداة متوفرة عبر الإنترنت على موقع Phonegap [http://build.phonegap.com/apps]. ستقوم هذه الأداة بـ«تحميل» ملف zip الخاص بالموقع الثابت المراد تحويله. يجب أن يكون اسم صفحة التمهيد [index.html]. لذا، نقوم بتغيير اسم الصفحة [boot.html] إلى [index.html]:
![]() |
ثم نقوم بضغط المجلد، وهو هنا [rdvmedecins-client-js-03]. بعد ذلك ننتقل إلى موقع Phonegap [http://build.phonegap.com/apps]:
![]() |
- قبل [1]، قد تضطر إلى إنشاء حساب؛
- في [1]، نبدأ؛
- في [2]، نختار خطة مجانية تسمح بتطبيق Phonegap واحد فقط؛
![]() |
- في [3]، قم بتنزيل التطبيق المضغوط [4]؛
![]() |
- في [5]، نسمي التطبيق؛
- في [6]، يتم إنشاء التطبيق. قد تستغرق هذه العملية دقيقة واحدة. يرجى الانتظار حتى تشير أيقونات المنصات المحمولة المختلفة إلى اكتمال عملية الإنشاء؛
![]() |
- تم إنشاء ملفات Android الثنائية [7] وملفات Windows الثنائية [8] فقط؛
- انقر على [7] لتنزيل ملف Android الثنائي؛
![]() |
- في [9]، تم تنزيل الملف الثنائي [apk]؛
قم بتشغيل محاكي [GenyMotion] لجهاز لوحي يعمل بنظام أندرويد (انظر الفقرة 9.9):
![]() |
في الصورة أعلاه، يتم تشغيل محاكي جهاز لوحي بنظام أندرويد الإصدار 19 (API). بمجرد تشغيل المحاكي،
- قم بإلغاء قفله عن طريق سحب القفل (إن وجد) إلى الجانب ثم تركه؛
- باستخدام الماوس، اسحب الملف [PGBuildApp-debug.apk] الذي قمت بتنزيله وأسقطه على المحاكي. سيتم تثبيته وتشغيله بعد ذلك؛
![]() |
يجب تغيير URL إلى [1]. للقيام بذلك، في نافذة الأوامر، اكتب الأمر [ipconfig] (السطر 1 أدناه) الذي سيعرض العناوين المختلفة لـ IP على جهازك:
C:\Users\Serge Tahé>ipconfig
Configuration IP de Windows
Carte réseau sans fil Connexion au réseau local* 15 :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
Carte Ethernet Connexion au réseau local :
Suffixe DNS propre à la connexion. . . : ad.univ-angers.fr
Adresse IPv6 de liaison locale. . . . .: fe80::698b:455a:925:6b13%4
Adresse IPv4. . . . . . . . . . . . . .: 172.19.81.34
Masque de sous-réseau. . . . . . . . . : 255.255.0.0
Passerelle par défaut. . . . . . . . . : 172.19.0.254
Carte réseau sans fil Wi-Fi :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
...
قم بتدوين إما عنوان IP الخاص بشبكة الواي فاي (الأسطر 6-9)، أو عنوان IP على الشبكة المحلية (الأسطر 11-17). ثم استخدم هذا العنوان IP في URL لخادم الويب:
![]() |
بعد ذلك، قم بالاتصال بخدمة الويب:
![]() |
اختبر التطبيق على المحاكي. يجب أن يعمل. من جانب الخادم، يمكن السماح أو عدم السماح برؤوس CORS في الفئة [ApplicationModel]:
// CORS
private final boolean CORS_ALLOWED=false;
لا يهم ذلك بالنسبة لتطبيق Android. فهذا التطبيق لا يعمل في متصفح. لكن متطلبات الرؤوس CORS تأتي من المتصفح وليس من الخادم.
8.6.10. استنتاجات دراسة الحالة
لقد قمنا بتطوير البنية التالية:
![]() |
إنها بنية ثلاثية الطبقات معقدة. وكان الهدف منها إعادة استخدام الطبقة [Web2] التي كانت طبقة الخادم لتطبيق [AngularJS-Spring MVC] الوارد في المستند [Tutoriel AngularJS / Spring 4] فيURL و[http://tahe.developpez.com/angularjs-spring4/]. وهذا هو السبب الوحيد لوجود بنية ثلاثية الطبقات. بينما في التطبيق [AngularJS-Spring MVC]، كان عميل [Web2] هو عميل [AngularJS]، أما هنا، فإن عميل [Web2] هو بنية ثنائية الطبقات (2tier) تتكون من [jQuery] / [Spring MVC / Thymeleaf]. وقد قمنا بزيادة عدد الطبقات، لذا سنفقد بعضًا من الأداء.
تم تطوير التطبيق الذي ندرسه هنا على مدار الوقت في ثلاث وثائق مختلفة:
- من [Introduction aux frameworks JSF2, Primefaces et Primefaces mobile] إلى URL ثم إلى [http://tahe.developpez.com/java/primefaces/]. وقد تم تطوير دراسة الحالة آنذاك باستخدام أطر العمل JSF2 / Primefaces. Primefaces هي مكتبة مكونات تعمل بتقنية AJAX وتغني عن كتابة جافا سكريبت. وكان التطبيق الذي تم تطويره آنذاك أقل تعقيدًا من التطبيق الذي ندرسه هنا. وكان يحتوي على إصدار ويب تقليدي للكمبيوتر وإصدار للجوال للهواتف؛
- [Tutoriel AngularJS / Spring 4] إلى URL و[http://tahe.developpez.com/angularjs-spring4/]. وكان التطبيق الذي تم تطويره آنذاك يتمتع بنفس خصائص التطبيق الذي يتم دراسته في هذا المستند. كما تم تكييف التطبيق ليعمل على نظام أندرويد؛
- هذا المستند؛
من هذا العمل، تبرز لي النقاط التالية:
- كان تطبيق [Primefaces] هو الأسهل بكثير في البرمجة، وقد أثبتت نسخته المخصصة للويب المحمول كفاءتها. ولا يتطلب هذا التطبيق معرفة بلغة جافا سكريبت. لا يمكن تكييفه أصلاً مع أنظمة OS الخاصة بأجهزة الجوال المختلفة، ولكن هل هذا ضروري؟ يبدو من الصعب تغيير نمط التطبيق. فنحن نعمل بالفعل باستخدام أوراق أنماط Primefaces. وقد يكون هذا عيباً؛
- كان كتابة تطبيق [AngularJS-Spring MVC] أمرًا معقدًا. بدا لي إطار العمل [AngularJS] صعب الفهم إلى حد ما عندما نريد إتقانه. تتميز بنية [client Angular] / [service web / jSON implémenté par Spring MVC] بأنها منظمة للغاية وعالية الأداء. ويمكن تطبيق هذه البنية على أي تطبيق ويب. وهي البنية التي تبدو لي الأكثر واعدة لأنها تستلزم مهارات مختلفة على جانب العميل وجانب الخادم (JS+HTML+CSS من جانب العميل، وJava أو غيرها من جانب الخادم)، مما يتيح تطوير كل من العميل والخادم بشكل متوازٍ؛
- بالنسبة للتطبيق الذي تم تطويره في هذا المستند باستخدام بنية ثلاثية الطبقات [client jQuery] / [serveur Web1 / Spring MVC / Thymeleaf] / [serveur Web2 / Spring MVC]، قد يجد البعض أن تقنية [jQuery+Spring MVC+Thymelaf] أسهل في الفهم من تقنية [AngularJS]. يمكن إعادة استخدام طبقة [DAO] الخاصة بعميل جافا سكريبت التي قمنا بكتابتها في تطبيقات أخرى؛

























































































































































































































































































