الإدخال والإخراج وNIO.2

مشروع: أداة ملاحظات مستندة إلى الملفات

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

مشروع: أداة ملاحظات مستندة إلى الملفات

كلّ مفهوم تمّ تدريسه في هذا الدرس — Path، وFiles، والتدفقات ذات المخزن المؤقت، وتعبير try-with-resources، وترميز المحارف — يتقاطع في هذا المشروع الختامي. ستبني أداة ملاحظات صغيرة لكنها كاملة تعمل من سطر الأوامر، تحفظ الملاحظات في ملف نصّي، وتدعم السرد والإضافة والحذف بالفهرس والبحث. الهدف ليس مجرّد جعلها تعمل، بل اتخاذ قرارات تصميميّة واعية وفهم المقايضات الكامنة وراء كلّ منها.

ما تفعله الأداة

  • add — إضافة ملاحظة جديدة إلى الملف.
  • list — عرض جميع الملاحظات مع فهارسها المبدوءة بالرقم 1.
  • delete <index> — حذف الملاحظة عند الفهرس المحدّد.
  • search <term> — عرض كل ملاحظة تحتوي على مصطلح البحث (بصرف النظر عن حالة الأحرف).

تُخزَّن الملاحظات بمعدّل سطر واحد لكلّ ملاحظة في ~/.notes/notes.txt. هذا الملف الوحيد هو "قاعدة البيانات" بأكملها.

هيكل المشروع

نضع كل شيء في ملف واحد للإيجاز؛ في مشروع حقيقي ستقسّم NoteRepository وNoteService وMain إلى فئات منفصلة.

import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.List; import java.util.stream.Collectors; public class NotesTool { private static final Path NOTES_DIR = Path.of(System.getProperty("user.home"), ".notes"); private static final Path NOTES_FILE = NOTES_DIR.resolve("notes.txt"); public static void main(String[] args) throws IOException { ensureFileExists(); if (args.length == 0) { printUsage(); return; } switch (args[0]) { case "add" -> add(joinArgs(args, 1)); case "list" -> list(); case "delete" -> delete(Integer.parseInt(args[1])); case "search" -> search(joinArgs(args, 1)); default -> printUsage(); } } private static String joinArgs(String[] args, int from) { StringBuilder sb = new StringBuilder(); for (int i = from; i < args.length; i++) { if (i > from) sb.append(' '); sb.append(args[i]); } return sb.toString(); } private static void printUsage() { System.out.println("Usage: notes <add|list|delete|search> [args]"); } }
لماذا user.home؟ تخزين البيانات في المجلد الرئيسي للمستخدم (~/.notes/) عرف معروف في UNIX/Linux/macOS يفصل البيانات الشخصية عن التطبيق ذاته. كما أنه لا يتطلّب صلاحيات مرتفعة، ويستمر صالحًا حتى بعد إعادة تثبيت التطبيق.

إنشاء مجلد التخزين عند الحاجة

Files.createDirectories غير مؤثّر التكرار (idempotent) — تُنشئ كل جزء ناقص في المسار وتنجح صامتةً إذا كان المسار موجودًا بالفعل. افضّل دائمًا استخدامها على Files.createDirectory حين لا تضمن وجود المجلد الأب.

private static void ensureFileExists() throws IOException { Files.createDirectories(NOTES_DIR); // لا فعل إذا كانت موجودة if (!Files.exists(NOTES_FILE)) { Files.createFile(NOTES_FILE); // فقط عند الغياب الفعلي } }

إضافة ملاحظة

استخدم Files.writeString مع StandardOpenOption.APPEND لإضافة سطر دون قراءة الملف كاملًا. هذه العملية O(1) من حيث الذاكرة بصرف النظر عن عدد الملاحظات الموجودة.

private static void add(String note) throws IOException { if (note.isBlank()) { System.out.println("Note cannot be empty."); return; } // تعقيم: إزالة فواصل الأسطر المضمّنة حتى لا تتلف بنية الملف String sanitised = note.replace('\n', ' ').replace('\r', ' ').strip(); Files.writeString(NOTES_FILE, sanitised + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.APPEND); System.out.println("Note added."); }
حدّد مجموعة المحارف دائمًا. تقبل Files.writeString تحميلًا زائدًا يأخذ Charset. الاعتماد على مجموعة المحارف الافتراضية للمنصّة فخّ لنقل الكود — ملف كُتب على Windows (Cp1252) قد لا يُقرأ على خادم Linux (UTF-8). ثبّت StandardCharsets.UTF_8 في كل مكان.

سرد الملاحظات

تحمّل Files.readAllLines كل سطر إلى List<String>. لملف ملاحظات هذا مقبول؛ إذا كان الملف قد يبلغ جيجابايتات فانتقل إلى Files.lines() (تدفق كسول) عوضًا عن ذلك.

private static void list() throws IOException { List<String> notes = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); if (notes.isEmpty()) { System.out.println("No notes yet."); return; } for (int i = 0; i < notes.size(); i++) { System.out.printf("[%d] %s%n", i + 1, notes.get(i)); } }

حذف ملاحظة بالفهرس

لا توجد طريقة فعّالة لحذف سطر من منتصف ملف نصّي في الموضع مباشرةً — أنظمة الملفات لا تدعم تقليص منطقة دون إعادة كتابة كل ما يليها. النمط المعياري هو اقرأ الكل، احذف، اكتب الكل. لأداة ملاحظات هذا مقبول؛ لسجل ضخم بجيجابايتات ستستخدم تنسيق تخزين مختلفًا.

private static void delete(int index) throws IOException { List<String> notes = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); if (index < 1 || index > notes.size()) { System.out.println("Invalid index. Use 'list' to see valid indices."); return; } String removed = notes.remove(index - 1); // تحويل الفهرس من 1 إلى 0 Files.write(NOTES_FILE, notes, // يكتب كل عنصر كسطر StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); System.out.println("Deleted: " + removed); }
احذر من الوصول المتزامن. دورة قراءة-تعديل-كتابة أعلاه ليست ذرّية. إذا شغّل عمليتان الأداة في آنٍ واحد، ستستبدل إحدى عمليتَي الكتابة الأخرى بصمت. لأداة شخصية هذا غير مهم، لكن في بيئة مشتركة ستحتاج إلى قفل الملف عبر FileChannel.lock() أو قاعدة بيانات حقيقية.

البحث في الملاحظات

استخدم واجهة التدفقات مع Files.readAllLines() للبحث. يُطبع الناتج بالفهارس الأصلية من الملف حتى يتمكّن المستخدم من حذف الملاحظة بالرقم لاحقًا.

private static void search(String term) throws IOException { String lower = term.toLowerCase(); List<String> all = Files.readAllLines(NOTES_FILE, StandardCharsets.UTF_8); boolean found = false; for (int i = 0; i < all.size(); i++) { if (all.get(i).toLowerCase().contains(lower)) { System.out.printf("[%d] %s%n", i + 1, all.get(i)); found = true; } } if (!found) { System.out.println("No notes match \"" + term + "\"."); } }

تجميع الأجزاء — تشغيل الأداة

تجميع وتشغيل من الطرفية:

javac NotesTool.java java NotesTool add "Buy groceries" java NotesTool add "Read chapter 9 of Effective Java" java NotesTool list # [1] Buy groceries # [2] Read chapter 9 of Effective Java java NotesTool search "java" # [2] Read chapter 9 of Effective Java java NotesTool delete 1 # Deleted: Buy groceries java NotesTool list # [1] Read chapter 9 of Effective Java

قرارات التصميم والمقايضات

  • نص عادي مقابل ثنائي: النص العادي قابل للقراءة البشرية، يُنسخ احتياطيًا ببساطة بـ cp، ويمكن البحث فيه بـ grep. العيب أن فواصل الأسطر داخل الملاحظة ستفسد التنسيق — ومن هنا جاء التعقيم في add().
  • الإلحاق مقابل إعادة الكتابة عند الإضافة: الإلحاق سريع وآمن عند انقطاع الكهرباء (الأسطر الموجودة غير متأثرة). إعادة كتابة الملف كاملًا عند كل إضافة أبطأ وأكثر خطورة للملفات الكبيرة.
  • قراءة الكل عند الحذف: أمر لا مفرّ منه مع الملفات المسطّحة. المقايضة موثّقة صراحةً — هذا النمط مقبول لمئات الملاحظات؛ لآلاف ستنتقل إلى تنسيق كـ SQLite عبر JDBC.
  • UTF-8 في كل مكان: تمرير StandardCharsets.UTF_8 صراحةً لكل قراءة وكتابة يجعل الأداة قابلة للنقل بصرف النظر عن مجموعة المحارف الافتراضية للـ JVM.
  • لا حاجة لـ try-with-resources هنا: Files.readAllLines وFiles.writeString وFiles.write هي توابع مريحة تفتح القناة الأساسية وتستخدمها وتغلقها داخليًا. إذا نزلت إلى BufferedReader أو FileWriter مباشرةً، فـ try-with-resources إلزامي.

الخلاصة

جمع هذا المشروع كل أدوات الدرس: Path وFiles للوصول الاصطلاحي عبر NIO.2، والتعامل الصريح مع ترميز UTF-8، وStandardOpenOption.APPEND للكتابة الفعّالة، ونمط قراءة-تعديل-كتابة للحذف في الموضع، والبحث المستند إلى الحلقات. القرارات التصميمية — النص العادي، سطر واحد لكل ملاحظة، التخزين في المجلد الرئيسي — مقصودة وكل منها موثّقة بمقايضتها. هذا النوع من التفكير هو ما يميّز قاعدة كود قابلة للصيانة عن واحدة تعمل فحسب.