مشروع: تحليل البيانات باستخدام Streams
في هذا الدرس الختامي ستُطبّق كل ما تعلّمته خلال هذا التعليمي — التصفية والتحويل والجمع والتقليل وflatMap والترتيب والتعامل مع Optional — على سيناريو واقعي واحد: تحليل مجموعة بيانات تحتوي على سجلات الموظفين. في نهاية الدرس سيكون لديك برنامج متكامل يطرح عشرة أسئلة عمليّة ويجيب عن كل منها بخطّ معالجة (pipeline) مركّز.
مجموعة البيانات
نبدأ بـ record بسيط يمثّل كل موظف. تمنحنا السجلات (records) الثبات (immutability) وتوليد البنّاء (constructor) والدوال الجالبة (getters) وtoString تلقائيًا.
public record Employee(
String name,
String department,
double salary,
int yearsOfExperience,
List<String> skills
) {}
ثم نبني قائمة سنستعلم عنها طوال المشروع:
import java.util.*;
import java.util.stream.*;
List<Employee> employees = List.of(
new Employee("Alice", "Engineering", 95_000, 7, List.of("Java", "Kotlin", "SQL")),
new Employee("Bob", "Engineering", 82_000, 3, List.of("Java", "Python")),
new Employee("Carol", "Marketing", 68_000, 5, List.of("SEO", "Analytics")),
new Employee("David", "Engineering", 110_000, 12, List.of("Java", "Scala", "Spark")),
new Employee("Eve", "HR", 60_000, 2, List.of("Communication", "Excel")),
new Employee("Frank", "Marketing", 73_000, 6, List.of("SEO", "PPC", "Analytics")),
new Employee("Grace", "HR", 67_000, 8, List.of("Recruiting", "Excel")),
new Employee("Henry", "Engineering", 91_000, 5, List.of("Python", "Docker", "SQL")),
new Employee("Irene", "Marketing", 78_000, 9, List.of("Analytics", "Branding")),
new Employee("James", "Engineering", 99_000, 10, List.of("Java", "Kubernetes", "SQL"))
);
لماذا نستخدم record؟ السجلات (مُقدَّمة في Java 16) مثالية لحاملات البيانات البسيطة مثل صفوف في مجموعة بيانات. فهي تفرض الثبات وتُزيل الكود المتكرّر وتُشير للقارئ بأن الكلاس مجرّد حاوية بيانات بلا سلوك خفيّ.
السؤال الأول — كم موظفًا في قسم الهندسة؟
long engineeringCount = employees.stream()
.filter(e -> e.department().equals("Engineering"))
.count();
System.out.println("Engineering headcount: " + engineeringCount); // 5
السؤال الثاني — ما متوسط الراتب في الشركة بأكملها؟
OptionalDouble avgSalary = employees.stream()
.mapToDouble(Employee::salary)
.average();
avgSalary.ifPresent(avg ->
System.out.printf("Company average salary: $%.2f%n", avg));
السؤال الثالث — من هو الموظف الأعلى راتبًا؟
Optional<Employee> topEarner = employees.stream()
.max(Comparator.comparingDouble(Employee::salary));
topEarner.ifPresent(e ->
System.out.println("Top earner: " + e.name() + " ($" + e.salary() + ")"));
السؤال الرابع — ما جميع المهارات الفريدة المستخدمة في قسم الهندسة؟
flatMap هو الأداة المناسبة هنا: كل موظف لديه قائمة مهارات، لذا نحتاج إلى دمج القوائم المتعددة في تدفق واحد قبل إزالة التكرار.
List<String> engineeringSkills = employees.stream()
.filter(e -> e.department().equals("Engineering"))
.flatMap(e -> e.skills().stream())
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println("Engineering skills: " + engineeringSkills);
// [Docker, Java, Kotlin, Kubernetes, Python, SQL, Scala, Spark]
السؤال الخامس — متوسط الراتب لكل قسم
يجيب Collectors.groupingBy مقرونًا بـ averagingDouble تنازليًا على هذا السؤال في تمرير واحد:
Map<String, Double> avgByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.averagingDouble(Employee::salary)
));
avgByDept.forEach((dept, avg) ->
System.out.printf("%-15s avg salary: $%.2f%n", dept, avg));
السؤال السادس — أسماء الموظفين الذين يتقاضون أكثر من 90,000 دولار، مرتّبة أبجديًا
List<String> highEarnerNames = employees.stream()
.filter(e -> e.salary() > 90_000)
.map(Employee::name)
.sorted()
.collect(Collectors.toList());
System.out.println("Earning > $90k: " + highEarnerNames);
ضع filter قبل map. التصفية أولًا تُقلّل عدد العناصر التي تصل إلى خطوة التحويل الأكثر تكلفةً. وإن كانت JVM قادرة أحيانًا على إعادة ترتيب العمليات، فإن كتابة filter ثم map يُوضّح النية دائمًا وهو آمن بلا استثناء.
السؤال السابع — إجمالي ميزانية الرواتب لكل قسم
Map<String, Double> budgetByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::department,
Collectors.summingDouble(Employee::salary)
));
budgetByDept.forEach((dept, total) ->
System.out.printf("%-15s total budget: $%.0f%n", dept, total));
السؤال الثامن — الموظف الأكثر خبرة في كل قسم
يختار Collectors.toMap مع دالة دمج (merge function) الفائز عند تعارض موظفَين في نفس المفتاح:
Map<String, Employee> mostExperienced = employees.stream()
.collect(Collectors.toMap(
Employee::department,
e -> e,
(a, b) -> a.yearsOfExperience() >= b.yearsOfExperience() ? a : b
));
mostExperienced.forEach((dept, e) ->
System.out.println(dept + " → " + e.name() + " (" + e.yearsOfExperience() + " yrs)"));
السؤال التاسع — هل يوجد موظفون يعرفون Java وSQL معًا؟
استخدم anyMatch لفحص الوجود مع التوقف المبكر — يتوقف فور إيجاد أول تطابق:
boolean javaAndSql = employees.stream()
.anyMatch(e -> e.skills().containsAll(List.of("Java", "SQL")));
System.out.println("Someone knows Java & SQL: " + javaAndSql); // true
السؤال العاشر — إحصاءات ملخّصة لرواتب قسم الهندسة
يلتقط DoubleSummaryStatistics العدد والمجموع والحد الأدنى والحد الأقصى والمتوسط في عملية إنهاء (terminal operation) واحدة:
DoubleSummaryStatistics stats = employees.stream()
.filter(e -> e.department().equals("Engineering"))
.mapToDouble(Employee::salary)
.summaryStatistics();
System.out.println("Engineering salary stats:");
System.out.println(" Count : " + stats.getCount());
System.out.printf (" Min : $%.0f%n", stats.getMin());
System.out.printf (" Max : $%.0f%n", stats.getMax());
System.out.printf (" Avg : $%.2f%n", stats.getAverage());
System.out.printf (" Total : $%.0f%n", stats.getSum());
خلاصة ما مارسناه
- filter + count — إحصاء الأفراد حسب القسم (س١).
- mapToDouble + average — التجميع العددي مع
OptionalDouble (س٢).
- max مع Comparator — إيجاد الفائز الواحد عبر
Optional (س٣).
- flatMap + distinct + sorted — تسطيح المجموعات المتداخلة (س٤).
- groupingBy + averagingDouble / summingDouble — التجميع المتعدد (س٥، س٧).
- filter + map + sorted + collect — خط المعالجة الكلاسيكي (س٦).
- toMap مع دالة دمج — تجميع مفتاحي مع معالجة التعارض (س٨).
- anyMatch — فحص الوجود مع التوقف المبكر (س٩).
- summaryStatistics — إحصاءات عددية شاملة في تمرير واحد (س١٠).
Streams ليست حلًا سحريًا لكل شيء. بالنسبة للقوائم الصغيرة جدًا، قد تكون حلقة for العادية أبسط وبنفس الأداء. اختر Streams حين يجعل الأسلوب الإعلاني (declarative) النية أوضح — وهو ما ينطبق دائمًا تقريبًا على التصفية والتجميع والتحليل في مجموعات البيانات الحقيقية.
الخلاصة
لقد بنيت الآن برنامج تحليل بيانات متكاملًا باستخدام Streams API وحدها. الفكرة الأساسية هي أن كل سؤال عملي يُترجَم بشكل طبيعي إلى خط معالجة: صفّي للوصول إلى الصفوف ذات الصلة، ثم حوّلي أو اسطّحي للقيم التي تهمّك، ثم اجمعي أو قلّلي للوصول إلى الإجابة النهائية. أتقن هذا النموذج الذهني وستستطيع الاستعلام عن أي مجموعة بيانات في الذاكرة بطلاقة تامة في Java.