اذهب إلى المحتوى

معالجة الملفات في جافا


رضوى العربي

سنفحص خلال هذا القسم بعض الأمثلة البرمجية على تعامل البرامج مع الملفات باستخدام التقنيات المُوضحَّة التي عرضناها في المقالين السابقين، مقال قنوات الدخل والخرج وعمليتي القراءة والكتابة في جافا ومقال مدخل إلى التعامل مع الملفات في جافا.

نسخ الملفات

سنناقش الآن برنامج سطر أوامرٍ بسيط لنسخ الملفات، حيث تُعدّ عملية نسخ الملفات واحدةً من أكثر العمليات شيوعًا، ولهذا تُوفِّر جميع أنظمة التشغيل أمرًا مُخصَّصًا لتلك العملية بالفعل، ولكن ما يزال من المفيد من الناحية التعليمية أن ننظر إلى طريقة تنفيذ ذلك باستخدام برنامج جافا. تتشابه غالبية العمليات على الملفات مع عملية نسخ ملف، باستثناء اختلاف طريقة معالجتها لبيانات الملف المُدْخَلة قبل إعادة كتابتها إلى ملف الخرج؛ أي يُمكِن كتابة برامج لكل تلك العمليات بنفس الكيفية تقريبًا. تعرَّضنا في فصل المعاملات (parameters) في جافا لبرنامجٍ يَستخدِم الصنف TextIO لنسخ ملفاتٍ نصية، بينما يَعمَل البرنامج بالأسفل مع جميع أنواع الملفات.

ينبغي أن يتمكَّن البرنامج من نسخ أي ملف، وبالتالي لا يُمكِن للملف أن يكون بصيغةٍ مقروءة، وهذا يَعنِي أننا سنضطّر لمعالجته باستخدام أصناف مجاري البايتات InputStream و OutputStream. يَنسَخ البرنامج البيانات من مجرًى من النوع InputStream إلى مجرًى آخر من النوع OutputStream، بحيث يَنسَخ بايتًا واحدًا في كل مرة.

إذا كان source متغيرًا يُشير إلى مجرى الدخل من الصنف InputStream، فستقرأ الدالة source.read()‎ بايتًا واحدًا. تعيد تلك الدالة القيمة "-1" بعد الانتهاء من قراءة كلِّ البايتات الموجودة بملف الدْخَل. بالمثل، إذا كان copy مُتغيّرًا يُشير إلى مجرى الخرج من الصنف OutputStream، فستكتب الدالة copy.write(b)‎ بايتًا واحدًا في ملف الخرج. يُمكِننا بناءً على ما سبق كتابة البرنامج بهيئة حلقة while محاطةً بتعليمة try..catch؛ نظرًا لإمكانية عمليات الدْخَل والخرج في التبليغ عن اعتراضات:

while(true) {
   int data = source.read();
   if (data < 0)
      break;
   copy.write(data);
}

يَستقبِل أمر نسخ الملفات بنظام تشغيل، مثل UNIX وسطاء سطر الأوامر command line arguments لتخصيص أسماء الملفات المطلوبة، حيث يستطيع المُستخدِم مثلًا كتابة أمرٍ، مثل copy original.dat backup.dat، لينسخ ملفًا موجودًا اسمه "original.dat" إلى ملفٍ اسمه "backup.dat".

تستطيع برامج جافا استخدام وسطاء سطر الأوامر بنفس الطريقة؛ حيث تُخزَّن قيمها ضمن مصفوفةٍ من السلاسل النصية اسمها args، والتي يَستقبِلها البرنامج main()‎ مثل معاملٍ، ويستطيع بذلك البرنامج استرجاع القيم المُمرَّرة للوسطاء (انظر فصل المعاملات (parameters) في جافا). على سبيل المثال، إذا كان "CopyFile" هو اسم البرنامج، وشَغّله المُستخدِم بكتابة الأمر التالي:

java CopyFile work.dat oldwork.dat

فستُساوِي قيمة args[0]‎ بالبرنامج السلسلة النصية "work.dat"؛ أما قيمة args[1]‎ فستُساوِي السلسلة النصية "oldwork.dat". تُشير قيمة args.length إلى عدد الوسطاء المُمرَّرين.

يَحصُل برنامج CopyFile.java على اسمي الملفين من خلال وسطاء سطر الأوامر، ويَطبَع رسالة خطأ إن لم يَجِدهما. هناك طريقتان لاستخدام البرنامج، هما:

  • أولًا، قد يحتوي سطر الأوامر ببساطةٍ على اسمي ملفين، ويَطبَع البرنامج في تلك الحالة رسالة خطأ وينتهي إذا كان ملف الخرج المُخصَّص موجودًا مُسبقًا؛ لكي لا يَكْتُب بملفٍ مهمٍ عن طريق الخطأ.
  • ثانيًا، قد يحتوى سطر الأوامر على ثلاثة وسطاء، ولا بُدّ في تلك الحالة أن يكون الوسيط الأول هو الخيار "‎-f"؛ أما الثاني والثالث فهما اسما الملفين. تُعدّل كتابة الوسيط "‎-f" من سلوك البرنامج، حيث يُفسِّره البرنامج على أنه رُخصةً للكتابة بملف الخرج حتى لو كان موجودًا مُسبقًا. لاحِظ أن "‎-f" هي في الواقع اختصار لكلمة "force"؛ نظرًا لأنها تجبر البرنامج على نسخ الملف حتى في الحالات التي كان البرنامج سيتعامل معها كما لو كانت خطأً بصورةٍ افتراضية. يُمكِنك الاطلاع على شيفرة البرنامج لترى طريقة تفسيره لوسطاء سطر الأوامر:
import java.io.*;

// 1
public class CopyFile {

   public static void main(String[] args) {

      String sourceName;   // اسم ملف المصدر كما خُصّص بسطر الأوامر
      String copyName;     // اسم ملف النسخة المُخصَّص

      InputStream source;  // مجرًى للقراءة من ملف المصدر
      OutputStream copy;   // مجرًى للكتابة بنسخة الملف
       // ‫اضبطها إلى القيمة true إذا كان الخيار "f-" موجودًا بسطر الأوامر
      boolean force;  
      int byteCount;  // عدد البايتات المنسوخة حتى الآن

      // 2

      if (args.length == 3 && args[0].equalsIgnoreCase("-f")) {
         sourceName = args[1];
         copyName = args[2];
         force = true;
      }
      else if (args.length == 2) {
         sourceName = args[0];
         copyName = args[1];
         force = false;
      }
      else {
         System.out.println(
                 "Usage:  java CopyFile <source-file> <copy-name>");
         System.out.println(
                 "    or  java CopyFile -f <source-file> <copy-name>");
         return;
      }

      /* أنشئ مجرى الدخل، وأنهِ البرنامج في حالة حدوث خطأ */

      try {
         source = new FileInputStream(sourceName);
      }
      catch (FileNotFoundException e) {
         System.out.println("Can't find file \"" + sourceName + "\".");
         return;
      }

      // 4

      File file = new File(copyName);
      if (file.exists() && force == false) {
          System.out.println(
               "Output file exists.  Use the -f option to replace it.");
          return;  
      }

      /* أنشئ مجرى الخرج وأنهِ البرنامج في حالة حدوث خطأ */

      try {
         copy = new FileOutputStream(copyName);
      }
      catch (IOException e) {
         System.out.println("Can't open output file \"" + copyName + "\".");
         return;
      }

      // 3

      byteCount = 0;

      try {
         while (true) {
            int data = source.read();
            if (data < 0)
               break;
            copy.write(data);
            byteCount++;
         }
         source.close();
         copy.close();
         System.out.println("Successfully copied " + byteCount + " bytes.");
      }
      catch (Exception e) {
         System.out.println("Error occurred while copying.  "
                                   + byteCount + " bytes copied.");
         System.out.println("Error: " + e);
      }

   }  // end main()


} // end class CopyFile

حيث يُقصد بـ:

  • [1]: أنشئ نسخةً من ملف. يجب تخصيص كُلٍ من اسم الملف الأصلي، واسم ملف النسخة على أنهما وسائطٌ بسطر الأوامر. يُمكِننا بالإضافة إلى ذلك كتابة الخيار "‎-f" على أنه وسيطٌ أول، وسيكتب البرنامج في تلك الحالة على الملف الذي يحمل اسم ملف النسخة في حالة وجوده مسبقًا؛ أما إذا لم يكن هذا الخيار موجودًا، فسيبلِّغ البرنامج عن خطأ وينتهي إذا كان الملف موجودًا. يُبلِّغ البرنامج أيضًا عن عدد البايتات التي نسخها من الملف.
  • [2]: احصل على أسماء الملفات من سطر الأوامر وافحص فيما إذا كان الخيار "‎-f" موجودًا. إذا لم يكن الأمر بأيٍّ من الصيغ المحتملة، اطبع رسالة خطأ وأنهِ البرنامج.
  • [3]: اِنسَخ بايتًا واحدًا بكل مرة من مجرى الدخل إلى مجرى الخرج حتى يعيد التابع read()‎ القيمة "-1"، والتي تُعدّ إشارةً إلى الوصول إلى نهاية المجرى. إذا حدث خطأٌ، اطبع رسالة خطأ، وكذلك اطبع رسالةً في حالة نسخ الملف بنجاح.
  • [4]: إذا كان ملف الخرج موجودًا بالفعل، ولم يُخصِّص المُستخدِم الخيار "‎-f"، اطبع رسالة خطأ وأنهِ البرنامج.

لا تَعمَل عملية نسخ بايتٍ واحدٍ بكل مرة بالكفاءة المطلوبة، حيث يُمكِن تحسينها باستخدام نسخٍ أخرى من التابعين read()‎ و write()‎، والتي بإمكانها قراءة وكتابة عدة بايتات بنفس الوقت (انظر واجهة برمجة التطبيقات لمزيدٍ من التفاصيل). يُمكننا بدلًا من ذلك أن نحيط مجاري تدفق الدخل والخرج بكائناتٍ من النوع BufferedInputStream و BufferedOutputStream، والتي يُمكِنها قراءة أو كتابة كتلٍ من البيانات من وإلى الملف مباشرةً، ويتطلّب ذلك تعديل سطرين فقط من البرنامج المسؤول عن إنشاء مجاري التدفق. فمثلًا، يُمكِننا أن نُنشِئ مجرى الدخل على النحو التالي:

source = new BufferedInputStream(new FileInputStream(sourceName));

وبذلك يُمكِننا استخدام المجرى المُدعَّم بخاصية التخزين المؤقت buffered stream بنفس طريقة استخدام المجرى العادي.

يُمكِنك الإطلاع على البرنامج التوضيحي CopyFileAsResources.java، والذي يُنجز نفس مهمة البرنامج CopyFile، ولكنه يَستخدِم نمط المورد resource pattern ضمن تعليمة try..catch؛ ليتأكَّد من غلق المجاري بجميع الحالات، وهو ما ناقشناه بنهاية القسم "تعليمة Try" من فصل الاستثناءات exceptions وتعليمة try..catch في جافا.

البيانات الدائمة

بمجرد انتهاء برنامجٍ معينٍ من العمل، تُلغَى جميع البيانات التي خزَّنها البرنامج بمتغيراتٍ أو كائناتٍ أثناء تنفيذه، مع أننا قد نرغب أحيانًا في الإبقاء على بعض من تلك البيانات بحيث تظل متاحةً للبرنامج عند تنفيذه مرةً أخرى. يطرح ذلك السؤال التالي: كيف يُمكِننا الاحتفاظ بالبيانات وإتاحتها للبرنامج مرةً أخرى؟ الإجابة ببساطة هي بتخزينها بملف، أو قاعدة بيانات database لبعض التطبيقات، رغم أننا إذا شئنا الدقة فهي تُعدّ ملفات أيضًا؛ حيث تكون البيانات الموجودة ضمن قاعدة بيانات مُخزَّنةً بالنهاية ضمن ملفات.

لنأخذ مثالًا على ذلك، وهو برنامج "دليل هاتف" يَسمَح للمُستخدِم بالاحتفاظ بقائمةٍ من الأسماء وأرقام الهواتف. لن يكون للبرنامج أي معنًى إذا اضطّر المُستخدِم لإعادة إنشاء القائمة من الصفر بكل مرةٍ يُشغِّل فيها البرنامج، وإنما ينبغي أن نُفكِر بدليل الهاتف كما لو أنه تجميعةٌ دائمة persistent من البيانات، وأن نفكر بالبرنامج على أنه مجرد واجهةٍ لتلك التجميعة. سيَسمَح البرنامج للمُستخدِم بالبحث بدليل الهاتف من خلال الاسم، وكذلك بإدخال بياناتٍ جديدة. وينبغي بالطبع الاحتفاظ بأي تغييراتٍ يُجريها المُستخدِم لما بعد انتهاء البرنامج.

يُعد البرنامج PhoneDirectoryFileDemo.java تنفيذًا implementation بسيطًا لتلك الفكرة. لاحِظ أنه صُمِّم ليكون فقط مثالًا على طريقة توظيف الملفات ضمن برنامج، فلا تُحملّه أكثر من حجمه، فهو ليس برنامجًا حقيقيًا. يُخزِّن البرنامج بيانات دليل الهاتف بملفٍ اسمه "‎.phonebookdemo" بالمجلد الرئيسي للمُستخدِم، والذي يُحدِّده البرنامج بالاستعانة بالتابع System.getProperty()‎ الذي ذكرناه في مقال مدخل إلى التعامل مع الملفات في جافا المشار إليه في الأعلى.

عندما يبدأ البرنامج بالعمل، فإنه يَفحَص أولًا فيما إذا كان الملف موجودًا بالفعل؛ فإذا كان موجودًا، فإنه يحتوي بالضرورة على بيانات دليل الهاتف الخاصة بالمُستخدِم، والتي خُزِّنت أثناء تشغيله لنفس البرنامج بمرةٍ سابقة، ويقرأ البرنامج في تلك الحالة بيانات الملف، ويُخزِّنها بكائنٍ اسمه phoneBook من النوع TreeMap؛ حيث يُمثِّل هذا الكائن دليل الهاتف أثناء تشغيل البرنامج (انظر القسم "واجهة تمثيل الخرائط" من فصل الخرائط Maps في جافا).

علينا الآن الاتفاق على طريقة تمثيل بيانات دليل الهاتف قبل تخزينها بملف. سنختار تمثيلًا بسيطًا، يُمثِّل فيه كل سطرٍ ضمن الملف مُدْخَلًا واحدًا مكوَّنًا من اسم ورقم هاتف يَفصِل بينهما علامة النسبة المئوية %. تقرأ الشيفرة التالية ملف بيانات دليل الهاتف إذا كان موجودًا ومكتوبًا وفقًا لطريقة التمثيل المُتفَق عليها:

File userHomeDirectory = new File( System.getProperty("user.home") );
File dataFile = new File( userHomeDirectory, ".phone_book_data" );
        // A file named .phone_book_data in the user's home directory.

if ( ! dataFile.exists() ) {
   System.out.println("No phone book data file found.  A new one");
   System.out.println("will be created, if you add any entries.");
   System.out.println("File name:  " + dataFile.getAbsolutePath());
}
else {
   System.out.println("Reading phone book data...");
   try( Scanner scanner = new Scanner(dataFile) ) {
      while (scanner.hasNextLine()) {
             // اقرأ سطرًا واحدًا من الملف يحتوي على زوج اسم ورقم هاتف

         String phoneEntry = scanner.nextLine();
         int separatorPosition = phoneEntry.indexOf('%');
         if (separatorPosition == -1)
            throw new IOException("File is not a phonebook data file.");
         name = phoneEntry.substring(0, separatorPosition);
         number = phoneEntry.substring(separatorPosition+1);
         phoneBook.put(name,number);
      }
   }
   catch (IOException e) {
      System.out.println("Error in phone book data file.");
      System.out.println("File name:  " + dataFile.getAbsolutePath());
      System.out.println("This program cannot continue.");
      System.exit(1);
   }
}

بعد ذلك، يَسمَح البرنامج للمُستخدِم بإجراء عدّة عملياتٍ على دليل الهاتف، بما في ذلك تعديل محتوياته؛ فإذا عدَّل المُستخدِم أيًا من بيانات دليل الهاتف بينما البرنامج مُشغَّل، فسيُجرى هذا التعديل فقط على كائن الصنف TreeMap. وعندما يحين موعد انتهاء البرنامج، يُمكِننا عندها كتابة تلك البيانات المُعدَّلة بالملف باستخدام الشيفرة التالية:

if (changed) {
   System.out.println("Saving phone directory changes to file " + 
         dataFile.getAbsolutePath() + " ...");
   PrintWriter out;
   try {
      out = new PrintWriter( new FileWriter(dataFile) );
   }
   catch (IOException e) {
      System.out.println("ERROR: Can't open data file for output.");
      return;
   }
   for ( Map.Entry<String,String> entry : phoneBook.entrySet() )
      out.println(entry.getKey() + "%" + entry.getValue() );
   out.flush();
   out.close();
   if (out.checkError())
      System.out.println("ERROR: Some error occurred while writing data file.");
   else
      System.out.println("Done.");
}

ينتج عن ذلك أن جميع البيانات -بما في ذلك التعديلات التي أجراها المُستخدِم- ستكون متاحةً بالمرة التالية التي يُنفَّذ خلالها البرنامج. عرضنا بالأعلى شيفرة البرنامج المُتعلِّقة بمعالجة الملف فقط، ولكن يُمكِنك بالطبع الإطلاع على باقي أجزاء البرنامج من هنا.

حفظ الكائنات بملف

عندما نرغب بحفظ أي بياناتٍ ضمن ملف، يجب أن نقرّر أولًا صيغة تمثيل تلك البيانات. نظرًا لاتِّباع كُلٍ من برامج الخرج المسؤولة عن كتابة البيانات وبرامج الدْخَل المسؤولة عن قرائتها نفس الصيغة المُقرَّرة، فستُصبح الملفات قابلةً لإعادة الاستخدام. ربما سيكون البرنامج بذلك مكتوبًا كتابةً صحيحة correctness، ولكن لا يُعدّ ذلك الأمر الهام الوحيد، وإنما يجب أيضًا أن تكون طريقة تمثيل البيانات بالملفات متينة (انظر الفصل مقدمة إلى صحة البرامج ومتانتها في جافا). سنناقش طرائقًا مختلفةً لتمثيل نفس البيانات لفهم ما يعنيه ذلك.

سيعتمد المثال الذي سنُناقشه على المثال SimplePaint2.java من القسم "البرمجة باستخدام ArrayList" من فصل مفهوم المصفوفات الديناميكية (ArrayLists) في جافا (قد ترغب بتشغّيله لكي تتذكَّر إمكانياته)، حيث يُمكِّن هذا البرنامج المُستخدِم من استخدام الفأرة لرسم بعض الرسوم، وسنُضيف إليه الآن إمكانية القراءة من والكتابة إلى ملف؛ وسيَسمَح ذلك للمُستخدِم بحفظ رسمة معينة بملف، وقراءتها لاحقًا من نفس الملف، مما يُمكِّنه من إكمال العمل عليها لاحقًا. يتطلّب ذلك حفظ جميع البيانات المُتعلّقة بالرسمة ضمن ملف، لكي يتمكَّن البرنامج من إعادة رسمها بالكامل مرةً أخرى بعد قراءته للملف الخاص بالرسمة.

يُمكِنك الإطلاع على النسخة الأحدث من البرنامج بملف الشيفرة المصدرية SimplePaintWithFiles.java، والتي أضفنا إليها قائمة "File" تُنفِّذ الأمرين "Save" و "Open"؛ لحفظ بيانات البرنامج بملف وكذلك قراءة البيانات المحفوظة بملف مرةً أخرى إلى البرنامج على الترتيب.

تتكوّن بيانات الرسمة من لون الخلفية، وقائمةً بالمنحنيات التي رسمها المُستخدِم. تَتكوَّن بيانات كل منحنًى منها من قائمة نقاطٍ من النوع Point2D المُعرَّف بحزمة javafx.geometry؛ فإذا كان pt متُغيِّرًا من النوع Point2D، فسيُعيد تابعي المُتغيّر pt.getX()‎ و pt.getY()‎ قيمًا من النوع double تُمثِّل إحداثيات تلك النقطة بالمستوى xy. يُمكِن تخصيص لون كل منحنًى على حدى، كما يُمكِن للمنحنى أن يكون "متماثلًا symmetric"؛ بمعنى أنه بالإضافة إلى رسم المنحنى نفسه، تُرسَم انعكاسات المنحنى الأفقية والرأسية أيضًا. تُخزَّن بيانات كل منحنًى ضمن كائنٍ من النوع CurveData المُعرَّف بالبرنامج على النحو التالي:

// 1
private static class CurveData {
   Color color;  // لون المنحنى
   boolean symmetric;  // هل ينبغي رسم الانعكاسات الأفقية والرأسية؟

    ArrayList<Point2D> points;  // النقاط الموجودة على المنحنى
}

حيث أن [1] هو كائنٌ من النوع CurveData يُمثِّل البيانات المطلوبة لإعادة رسم إحدى المنحنيات التي رسمها المُستخدِم.

سنَستخدِم قائمةً من النوع ArrayList<CurveData>‎ لحمل بيانات جميع المنحنيات التي رَسَمَها المُستخدِم.

لنفكر الآن بالطريقة التي سنَحفَظ بها بيانات الرسمة ضمن ملفٍ نصي. في العموم، علينا تخزين جميع البيانات الضرورية لإعادة رسم الرسمة بملف خرجٍ ووفقًا لصيغةٍ مُحدّدة. بعد ذلك، ينبغي أن يتبِّع التابع المسؤول عن قراءة الملف نفس الصيغة تمامًا أثناء قرائته للبيانات، حيث سيتعيّن عليه اِستخدَام تلك البيانات لإعادة بناء بنى البيانات data structures التي تُمثِّل نفس الرسمة بينما البرنامج مُشغَّل.

سنضطّر عند كتابة البيانات إلى التعبير عنها باستخدام قيم بياناتٍ بسيطة، مثل سلسلةٍ نصية أو قيمةٍ تنتمي لأيٍّ من الأنواع الأساسية primitive types؛ حيث يُمكِننا مثلًا التعبير عن اللون باستخدام ثلاثة أعدادٍ تُمثِّل مكوّنات اللون الأحمر والأخضر والأزرق. قد تكون الفكرة الأولى التي تخطر بذهنك هو مجرد طباعة كل البيانات الضرورية وفقًا لترتيبٍ محدّد، وهي في الواقع ليست الفكرة الأفضل. لنفترض أن out كائنٌ من النوع PrintWriter المُستخدَم لكتابة البيانات بالملف، يُمكِننا إذًا كتابة ما يلي:

Color bgColor = getBackground();    // اكتب لون الخلفية إلى الملف
out.println( bgColor.getRed() );
out.println( bgColor.getGreen() );
out.println( bgColor.getBlue() );

out.println( curves.size() );       // اكتب عدد المنحنيات

for ( CurveData curve : curves ) {  // لكل منحنًى، اكتب ما يلي
   out.println( curve.color.getRed() );      // لون المنحنى
   out.println( curve.color.getGreen() );   
   out.println( curve.color.getBlue() );
   out.println( curve.symmetric ? 0 : 1 );   // خاصية تماثل المنحنى
   out.println( curve.points.size() );       // عدد النقاط الموجودة على المنحنى
   for ( Point2D pt : curve.points ) {       // إحداثيات كل نقطة
      out.println( pt.getX() );
      out.println( pt.getY() );
   }
}

سيتمكَّن التابع المسؤول عن معالجة الملف من قراءة بياناته، وإعادة إنشاء ما يُكافئها من بنية بيانات. إذا كان التابع يَستخدِم كائنًا من النوع Scanner، وليَكُن اسمه هو scanner لقراءة بيانات الملف، يُمكِننا إذًا كتابة ما يلي:

Color newBackgroundColor;                // اقرأ لون الخلفية
double red = scanner.nextDouble();
double green = scanner.nextDouble();
double blue = scanner.nextDouble();
newBackgroundColor = Color.color(red,green,blue);

ArrayList<CurveData> newCurves = new ArrayList<>();

int curveCount = scanner.nextInt();      // عدد المنحنيات المقروءة
for (int i = 0; i < curveCount; i++) {
   CurveData curve = new CurveData();
   double r = scanner.nextDouble();            // اقرأ لون المنحنى
   double g = scanner.nextDouble();
   double b = scanner.nextDouble();
   curve.color = Color.color(r,g,b);
   int symmetryCode = scanner.nextInt(); // اقرأ خاصية تماثل المنحنى
   curve.symmetric = (symmetryCode == 1);
   curveData.points = new ArrayList<>();
   int pointCount = scanner.nextInt();  // عدد النقاط الموجودة على المنحنى
   for (int j = 0; j < pointCount; j++) {
      int x = scanner.nextDouble();        // اقرأ إحداثيات النقطة
      int y = scanner.nextDouble();
      curveData.points.add(new Point2D(x,y));
   }
   newCurves.add(curve);
}

curves = newCurves;                     // اضبط بنى البيانات الجديدة

setBackground(newBackgroundColor);

ينبغي أن يقرأ تابع الدْخَل البيانات الموجودة بالملف بنفس الترتيب الذي اِستخدَمه تابع الخرج أثناء كتابتها. في حين تَفِي تلك الطريقة بالغرض، لا يكون ملف البيانات الناتج مفهومًا للقارئ على الإطلاق، تمامًا كما لو كنا قد كتبنا الملف بالصيغة الثنائية binary format؛ فهو مكوّنٌ فقط من سلسلةٍ طويلةٍ من الأعداد. يجعل ذلك الملف هشًا؛ حيث سيؤدي أي تعديلٍ بسيطٍ على طريقة تمثيل البيانات ضمن إصدار أحدث من البرنامج، مثل إضافة خاصيةٍ جديدة إلى المنحنيات، إلى إهدار الملفات القديمة، إلا إذا وفَّر الملف معلومةً عن إصدار البرنامج المُستخدَم لإنشائه.

ولهذا، قررنا الاعتماد على صيغة بياناتٍ أكثر تعقيدًا ولكنها ستُعطِي معنًى أكثر وضوحًا، فبدلًا من الاكتفاء بكتابة مجموعةٍ من الأعداد، اخترنا إضافة كلماتٍ إليها تُمثِّل معنى تلك الأعداد. نَعرِض فيما يلي مثالًا على ملف بيانات قصيرٍ نوعًا ما، لكنه يُبيّّن جميع الخاصيات المُدعَّمة حاليًا. ستتمكَّن غالبًا من فهم معناه بالكامل بمجرد قراءته:

SimplePaintWithFiles 1.0
background 0.4 0.4 0.5

startcurve
  color 1 1 1
  symmetry true
  coords 10 10
  coords 200 250
  coords 300 10
endcurve

startcurve
  color 0 1 1
  symmetry false
  coords 10 400
  coords 590 400
endcurve

يشير السطر الأول إلى البرنامج المسؤول عن إنشاء ملف البيانات، وسيتمكَّن بذلك البرنامج من إجراء اختبارٍ بسيطٍ على الملف الذي اختار المُستخدِم فتحَه، بفحص أول كلمةٍ موجودةٍ به، ويُمكِنه بناءً على ذلك التأكُّد مما إذا كان الملف من النوع الصحيح. بالإضافة إلى ذلك، يحتوي السطر الأول على رقم إصدار 1.0، والذي ينبغي أن يتغيّر إلى أرقام إصدارٍ أعلى في حال تغيّرت صيغة الملف في الإصدارات الأحدث من البرنامج. يستطيع البرنامج بذلك أن يَفحَص رقم إصدار الملف؛ فإذا كان البرنامج قادرًا على معالجة الملفات المكتوبة وفقًا للإصدار 1.0 فقط، ووجد أن صيغة الملف مكتوبةً وفقًا لإصدارٍ آخر، مثل 1.2، يُمكنِه أن يُوضِح للمُستخدِم أن عليه استخدام نسخةٍ أحدث من البرنامج ليتمكَّن من قراءة ملف البيانات ذاك.

يُخصِّص السطر الثاني من البرنامج لون خلفية الصورة، وهو ما يَتضَح ببساطة من خلال كلمة "background" ببداية السطر، وتُمثِّل الأعداد الثلاثة مكوِّنات اللون الأحمر والأخضر والأزرق على الترتيب؛ بينما يُمثِّل الباقي من الملف بيانات المنحنيات المرسومة بالصورة. تَفصِل الكلمتان "startcurve" و "endcurve" بيانات كل منحنًى عن الآخر؛ والتي تتكوَّن من خاصيات اللون والتماثل وكذلك إحداثيات النقاط الواقعة على المنحنى.

يُمكِننا إنشاء هذا النوع من الملفات يدويًا وتعديلها بسهولة، لأن معناها واضح، وقد أنشأنا ملف البيانات بالأعلى بواسطة محرر نصوص لا بواسطة البرنامج. يُمكِننا إضافة المزيد من الخيارات بسهولة؛ فقد تدعَم الإصدارات الأحدث من البرنامج مثلًا خاصية "السُمْك thickness" لرسم منحنياتٍ بخطوط عرضٍ مختلفة، ويُمكِنها أيضًا دعم رسم أشكالٍ أخرى، مثل المستطيلات والأشكال البيضاوية بنفس السهولة.

من السهل أيضًا كتابة هذا النوع من البيانات عن طريق برنامج. لنفترض مثلًا أن out من النوع PrintWriter، وأننا سنَستخدِمه لكتابة بيانات الرسمة بملف، فستُجرِي الشيفرة التالية ذلك ببساطة:

out.println("SimplePaintWithFiles 1.0"); // Version number.
out.println( "background " + backgroundColor.getRed() + " " +
        backgroundColor.getGreen() + " " + backgroundColor.getBlue() );
for ( CurveData curve : curves ) {
    out.println();
    out.println("startcurve");
    out.println("  color " + curve.color.getRed() + " " +
            curve.color.getGreen() + " " + curve.color.getBlue() );
    out.println( "  symmetry " + curve.symmetric );
    for ( Point2D pt : curve.points )
        out.println( "  coords " + pt.getX() + " " + pt.getY() );
    out.println("endcurve");
}

يَستخدِم التابع doSave()‎ -ضمن هذا البرنامج- الشيفرة بالأعلى، وهو يُشبه كثيرًا التابع الذي عرضناه في الفصل السابق. لاحِظ أن هذا التابع يَستخدِم صندوق نافذة اختيار ملف ليَسمَح للمُستخدِم باختيار ملف الخرج.

قد تكون قراءة بيانات الملف أصعب بعض الشيء؛ حيث ينبغي للبرنامج المسؤول عن قراءة الملف أن يتعامل مع كل تلك الكلمات الزائدة الموجودة به. اخترنا كتابة ذلك البرنامج بطريقةٍ تَسمَح بتبديل ترتيب ظهور البيانات ضمن الملف، فسيَسمَح البرنامج مثلًا بتخصيص لون الخلفية بنهاية الملف بدلًا من بدايته، كما سيَسمَح بعدم تخصيصها من الأساس، وسيَستخدِم البرنامج في تلك الحالة اللون الأبيض لونًا افتراضيًا للخلفية.

تمكَّننا من إجراء ذلك بسبب عنونة كل عنصرٍ من البيانات بكلمةٍ تَصِف معناه، واعتمد البرنامج بالتالي على تلك الكلمات لاستنتاج ما ينبغي فعله. سيَقرأ ذلك التابع ملفات البيانات التي أنشأها التابع doSave()‎، وسيَستخدِم الصنف Scanner أثناء عملية القراءة. تَعرِض الشيفرة التالية شيفرة التابع بالكامل، والمُعرَّف ببرنامج SimplePaintWithFiles.java:

private void doOpen() {
    FileChooser fileDialog = new FileChooser();
    fileDialog.setTitle("Select File to be Opened");
    fileDialog.setInitialFileName(null);  // No file is initially selected.
    if (editFile == null)
        fileDialog.setInitialDirectory(new File(System.getProperty("user.home")));
    else
        fileDialog.setInitialDirectory(editFile.getParentFile());
    File selectedFile = fileDialog.showOpenDialog(window);
    if (selectedFile == null)
        return;  // User canceled.
    Scanner scanner;
    try {
        scanner = new Scanner( selectedFile );
    }
    catch (Exception e) {
        Alert errorAlert = new Alert(Alert.AlertType.ERROR,
                "Sorry, but an error occurred\nwhile trying to open the file.");
        errorAlert.showAndWait();
        return;
    }
    try {
        String programName = scanner.next();
        if ( ! programName.equals("SimplePaintWithFiles") )
            throw new IOException("File is not a SimplePaintWithFiles data file.");
        double version = scanner.nextDouble();
        if (version > 1.0)
            throw new IOException("File requires a newer version of SimplePaintWithFiles.");
        Color newBackgroundColor = Color.WHITE;
        ArrayList<CurveData> newCurves = new ArrayList<CurveData>();
        while (scanner.hasNext()) {
            String itemName = scanner.next();
            if (itemName.equalsIgnoreCase("background")) {
                double red = scanner.nextDouble();
                double green = scanner.nextDouble();
                double blue = scanner.nextDouble();
                newBackgroundColor = Color.color(red,green,blue);
            }
            else if (itemName.equalsIgnoreCase("startcurve")) {
                CurveData curve = new CurveData();
                curve.color = Color.BLACK;
                curve.symmetric = false;
                curve.points = new ArrayList<Point2D>();
                itemName = scanner.next();
                while ( ! itemName.equalsIgnoreCase("endcurve") ) {
                    if (itemName.equalsIgnoreCase("color")) {
                        double r = scanner.nextDouble();
                        double g = scanner.nextDouble();
                        double b = scanner.nextDouble();
                        curve.color = Color.color(r,g,b);
                    }
                    else if (itemName.equalsIgnoreCase("symmetry")) {
                        curve.symmetric = scanner.nextBoolean();
                    }
                    else if (itemName.equalsIgnoreCase("coords")) {
                        double x = scanner.nextDouble();
                        double y = scanner.nextDouble();
                        curve.points.add( new Point2D(x,y) );
                    }
                    else {
                        throw new Exception("Unknown term in input.");
                    }
                    itemName = scanner.next();
                }
                newCurves.add(curve);
            }
            else {
                throw new Exception("Unknown term in input.");
            }
        }
        scanner.close();
        backgroundColor = newBackgroundColor;
        curves = newCurves;
        redraw();
        editFile = selectedFile;
        window.setTitle("SimplePaint: " + editFile.getName());
    }
    catch (Exception e) {
        Alert errorAlert = new Alert(Alert.AlertType.ERROR,
                "Sorry, but an error occurred while\ntrying to read the data:\n" 
                        + e);
        errorAlert.showAndWait();
    }    
}

لقد ناقشنا صيغ الملفات على هذا النحو المُفصَّل لنُحفِّزك على التفكير بمشكلة تمثيل البيانات المُعقّدة بصيغٍ يُمكِن تخزينها ضمن ملف، وسنتعرَّض لنفس المشكلة أثناء نقل البيانات عبر الشبكات. لا يُمكِننا في الواقع أن نقول أن حلًا معينًا هو الحل الصحيح لتلك المشكلة في العموم، ولكن بالطبع تُعدّ بعض الحلول أفضل من الأخرى، وسنُناقش في فصل لاحق واحدًا من أكثر الحلول شيوعًا لمشكلة تمثيل البيانات عمومًا.

بالإضافة إلى قدرة البرنامج SimplePaintWithFiles على حفظ بيانات الرسوم بصيغٍ نصية، فإنه قادرٌ أيضًا على حفظها مثل ملفات صورٍ يُمكِن طباعتها أو وضعها بصفحة إنترنت على سبيل المثال. يُعدّ ذلك مثالًا عامًا على تقنيات معالجة الصور، والتي سنناقشها في جزئية لاحقة من هذه السلسلة، والتي تَستخدِم تقنيات أخرى لم نتعرَّض لها بعد.

ترجمة -بتصرّف- للقسم Section 3: Programming With Files من فصل Chapter 11: Input/Output Streams, Files, and Networking من كتاب Introduction to Programming Using Java.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...