JAVAINTRO:IO System

From Sapikhvno-WIKI

Contents

შეტანა-გამოტანის სისტემა

თუკი თქვენ გაქვთ სხვა პროგრამირების ენებზე მუშაობის გარკვეული გამოცდილება მაინც, ალბათ აუცილებლად გექნებოდათ შეხება შეტანა-გამოტანის სისტემასთან. მსგავსი ამოცანების გადასაჭრელად ჯავას სტანდარტული პაკეტი შეიცავს კლასების საკმაოდ დიდ სიმრავლეს. ეს კლასები მოქცეულია პაკეტში java.io

სისტემის ევოლუციის სათავეში დგას საწყისი ვერსია, Java 1.0 რომელიც განსაზღვრავდა ბაიტებზე დაფუძნებულ შეტანა-გამოტანას. შემდეგ ვერსიაში (Java 1.1) მას დაემატა სიმბოლური შეტანა-გამოტანის შესაძლებლობები, რათა მიღწეული ყოფილიყო თავსებადობა Unicode სტანდარტთან, რომელიც ოპერირებს მრავალბაიტიანი სიმბოლოებით და მონაცემის ტიპი char ჯავაში იკავებს არა ერთ, არამედ ორ ბაიტს. ამ დროსვე მოხდა ზოგიერთი მეთოდის მოძველებულად (deprecated) გამოცხადება.

შემდეგი მნიშვნელოვანი ცვლილება მოხდა ვერსიაში Java 1.4, როდესაც განისაზღვრა ახალი პაკეტი java.nio უკეთესი წარმადობისათვის, სხვადასხვაგვარი კოდირების და სხვა ამოცანების გადასაჭრელად. საწყისი კლასები გადაიწერა java.nio პაკეტის გამოყენებით.

მუშაობა ფაილებთან

შეტანა-გამოტანის უმარტივესი მაგალითი ფაილებთან მუშაობაა. ამ მიზნით ჯავა შეიცავს კლასს File. ეს კლასი აღწერს არა იმდენად ფაილს (თავისი შიგთავსით), რამდენადაც ფაილური სისტემის ელემენტს. მაგალითად - დირექტორიას.

კლას File-ს აქვს რამდენიმე საჭირო კონსტრუქტორი. უმარტივეს მათგანს პარამეტრად გადაეცემა აბსოლუტური ან შედარებითი (relative) გზა ფაილისაკენ. მაგალითად File currentDir = new File("/."); შექმნის ობიექტს File რომელიც Windows-ში მიუთითებს მიმდინარე დისკის ძირეულ დირექტორიას. Unix-სისტემებში კი მთლიანი ფაილური სისტემის ძირეულ დირექტორიას.

მაგალითად, შემდეგი პროგრამული კოდი ბეჭდავს ყველა ფაილს და დირექტორიას, რომელიც მოთავსებულია ძირეულ დირექტორიაში:

import java.io.File;
import java.io.IOException;
public class FileDemo {
    public static void main(String[] args) throws IOException {
        File currentDir = new File("/.");
        System.out.println("Absolute path: " + currentDir.getAbsolutePath());
        // დირექტორიის შიგთავსი
        String[] files = currentDir.list();
        for (int i = 0; i < files.length; i++) {
            // კონსტრუქტორის პირველ პარამეტრად გადაეცემა დირექტორია, 
            // მეორედ - ფაილის სახელი მის შიგნით
            File dirItem = new File(currentDir, files[i]);            
            System.out.print(dirItem.getAbsolutePath());
            System.out.print(" | ");
            // კანონიკური გზა ფაილის სახელს წმენდს ".", ".." სიმბოლოებისაგან და ა.შ
            System.out.print(dirItem.getCanonicalPath());
            System.out.print(" ");
            if (dirItem.isDirectory()) {
                System.out.println(" - Directory");
            }
            if (dirItem.isFile()) {
                System.out.println(" - File");                
            }
        }
    }
}

ნაკადები (Streams) და შეტანა-გამოტანა

როგორც ზემოთ აღინიშნა, კლასი File აღწერს არა ფაილს თავისი შიგთავსით, არამედ ფაილური სისტემის ობიექტს (ფაილი, დირექტორია, ბმული და ა.შ.). ამის საპირისპიროდ, თავად მონაცემებზე მანიპულაციისათვის გამოიყენება ნაკადები. როგორც ასევე აღინიშნა, ნაკადები ოპერირებენ ბაიტებზე.

იერარქიის სათავეში დგანან კლასები java.io.InputStream და java.io.OutputStream. როგორც სახელებიდან ჩანს, პირველი მათგანი გამოიყენება ინფორმაციის ამოსაკითხად, მეორე კი ჩასაწერად.

კლასი java.io.InputStream

აბსტრაქტული კლასი InputStream განსაზღვრავს ზოგად ინტერფეისს ბაიტების ამოსაკითხად. ის სულ რამდენიმე მეთოდს შეიცავს, რომელთაგან ყველაზე ხშირად გამოიყენება read(), რომელიც კითხულობს ერთ ბაიტს და აბრუნებს მას. თუკი ნაკადის ბოლო მიღწეულია, დასაბრუნებელი მნიშვნელობაა -1. ასევე read(byte[] b), რომელიც ცდილობს, ამოიკითხოს ნაკადიდან ბაიტები და ჩაწეროს ისინი მასივში. მეთოდი აბრუნებს ამოკითხული ბაიტების რაოდენობას, ანდა -1 -ს, თუკი ნაკადიდან ამოკითხვა შეუძლებელია.

მუშაობის დასრულებისას ნაკადი უნდა დაიხუროს მეთოდით close() რათა გათავისუფლდეს სისტემური რესურსები, რომლებიც წასაკითხად გამოიყენებოდა.

ამოკითხვა შეიძლება მოხდეს შემდეგი წყაროებიდან:

  • ბაიტების მასივი, ByteArrayInputStream
  • ობიექტი String, StringBufferInputStream (მოძველებულია და ჩანაცვლებულია შესაბამისი Reader-ით. იხილეთ ქვემოთ)
  • ფაილი, FileInputStream
  • რამდენიმე სხვა InputStream, ამოკითხვა ხდება მონაცვლეობით. SequenceInputStream
  • "კონვეირი" (pipe). შესაძლებელია ერთი ნაკადიდან ამოკითხული ინფორმაცია ავტომატურად ჩავწეროთ მეორეში
  • სხვა ნაკადები. მათი საშუალებით შეიძლება წავიკითხოთ მოშორებული რესურსებიდან, სოკეტებიდან და ა.შ.

მაგალითად, ფაილიდან წასაკითხად ვქმნით FileInputStream ობიექტს და გადავცემთ მას ფაილის სახელს, ანდა ობიექტს File, რომელიც უკვე შექმნილია. მაგალითად, ასე:

FileInputStream fis = new FileInputStream("c:\\myfile.txt");

მიაქციეთ ყურადღება ფაილის სახელს - სიმბოლო '\' არის ე.წ. escape-სიმბოლო და თუ String-ში გვინდა მისი გამოყენება, \\ უნდა დავწეროთ. ისიც გასათვალისწინებელია, რომ ამ გამოძახებისას შეიძლება გამოსროლილი იქნას FileNotFoundException, პაკეტიდან java.io

წაკითხვა ფილტრების საშუალებით

შეტანა-გამოტანის სისტემა აქტიურად იყენებს ე.წ. დეკორატორის პატერნს. დეკორატორების საშუალებით ერთი ობიექტი "ეხვევა" მეორეში. მეორე ობიექტს იგივე ინტერფეისი აქვს, რაც პირველს. უბრალოდ მომხმარებელს სთავაზობს დამატებით ფუნქციონალობას.

მაგალითად, შეტანისათვის არსებობს BufferedInputStream. მას კონსტრუქტორში გადაეცემა სხვა InputStream. მაგალითად, ასე:

BufferedInputStream bis = new BufferedInputStream(new FileInputStream("c:\\myfile.txt"));

როდესაც ამოკითხვა ხდება ამ ობიექტიდან, მაშინ ნაცვლად იმისა რომ ფაილიდან თითო-თითო ბაიტის ამოკითხვა მოხდეს, პროცესი ბუფერიზებულია, რაც წარმადობას მნიშვნელოვნად ამაღლებს. შესაბამისად, ბუფერიზებული წაკითხვა ყოველთვის რეკომენდებულია.

არსებობს კიდევ DataInputStream, რომელიც გარდა სტანდარტული ინტერფეისისა, საშუალებას იძლევა რომ ნაკადიდან ამოვიკითხოთ მარტივი ტიპები. მაგალითად int, long და ა.შ. ასევე სხვა საინტერესო კლასები.

კლასი java.io.OutputStream

აბსტრაქტული კლასი OutputStream განსაზღვრავს ზოგად ინტერფეისს ბაიტების ჩასაწერად. მას აქვს მეთოდი write სხვადასხვა სიგნატურებით, რომელთა საშუალებითაც შეიძლება ჩავწეროთ ერთი ბაიტი, ბაიტების მასივი ან ამ მასივის ფრაგმენტი. აგრეთვე არის მეთოდი flush() და close(). ბუფერიზებული ჩაწერის შემთხვავაში მეთოდი flush() ათავისუფლებს ბუფერს და შიგთავსს წერს შესაბამის ადგილას. მეთოდი close(), ბუნებრივია, აკეთებს flush() -საც.

როგორც InputStream, ასევე OutputStream მუშაობს შემდეგ წყაროებთან:

  • ბაიტების მასივი, ByteArrayOutputStream
  • ფაილი, FileOutputStream
  • "კონვეირი" (pipe). შესაძლებელია ერთი ნაკადიდან ამოკითხული ინფორმაცია ავტომატურად ჩავწეროთ მეორეში
  • სხვა ნაკადები. მათი საშუალებით შეიძლება ჩავწეროთ მოშორებული რესურსებში, სოკეტებში და ა.შ.

ჩაწერა ფილტრების საშუალებით

ამოკითხვის მსგავსად, ჩაწერისასაც შეიძლება გამოყენებული იქნას ფილტრები. მაგალითად, არსებობს BufferedOutputStream, რომელიც უზრუნველყოფს ბუფერიზაციას გამოტანის დროს. მაგალითად, ფაილში ჩაწერისას შეგვიძლია FileOutputStream "გავახვიოთ" BufferedOutputStream-ში და ამით დისკზე თითო-თითო ბაიტის ჩაწერის ნაცვლად გამოვიყენოთ ბუფერიზაცია. ბუნებრივია, ამ დროს მნიშვნელობა ენიჭება flush() -ის გამოძახებას. ანუ ინფორმაცია, რომელიც ჩავწერეთ BufferedOutputStream-ში, დისკზე მოხვდება ან მაშინ როდესაც ბუფერი აივსება და მისი დაცლა გახდება საჭირო, ან როდესაც flush()-ს გამოვიძახებთ, ანდა როდესაც ნაკადს დავხურავთ.

შენიშვნა: ფილტრის დახურვისას ავტომატურად ხდება მასში "გახვეული" ნაკადის დახურვაც. ეს ეხება როგორც ჩაწერის, ისე ამოკითხვის ფილტრებს.

ამოკითხვის ანალოგიურად არსებობს DataInputStream, რომელიც უზრუნველყოფს "გახვეულ" ნაკადში მარტივი ტიპების ჩაწერას.

კონვეირები

არსებობს კლასები PipedInputStream და PipedOutputStream, რომელთა საშუალებით შეიძლება კონვეირის ორგანიზება. იმისათვის, რომ უფრო გასაგები გახდეს საკითხის არსი, განვიხილოთ კონვეირი ოპერაციულ სისტემაში.

კონვეირი განსაკუთრებით აქტიურად გამოიყენება Unix-სისტემებში . დავუშვათ, რომ გვაქვს ორი პროგრამა. პირველი მათგანი ეკრანზე ბეჭდავს ინფორმაციას რაღაც ფორმატით. მეორე მათგანი კი სტანდარტული შეტანის ნაკადში ელოდება ინფორმაციას იგივე ფორმატით. მაშინ შეგვიძლია რომ ერთი პროგრამის მიერ გამოტანილი ინფორმაცია ავტომატურად მივაწოდოთ მეორეს შესავალზე, ბრძანებით:

program1 | program2

ასე მუშაობს კონვეირი ოპერაციულ სისტემაში.

ანალოგიურად, ჯავაში შეიძლება გვქონდეს ორი პარალელური პროცესი (პარალელური პროცესები განხილული იქნება სხვა პარაგრაფში), ერთი წერდეს ინფორმაცია OutputStream-ში, მეორე პროცესი კი კითხულობდეს InputStream-იდან. მაშინ შეიძლება პირველ მათგანს შევუქმნათ PipedInputStream. მაგალითად ასე:

PipedInputStream pis = new PipedInputStream();

მეორეს კი PipedOutputStream

PipedOutputStream pos = new PipedOutputStream();

შემდეგ კი დავაკავშიროთ ისინი:

pis.connect(pos);

როდესაც პირველ პროცესში ხდება pos.write() ოპერაცია, ინფორმაცია ინახება ბუფერში და შემდეგ როდესაც მეორე პროცესი მოახდენს ამოკითხვას pis.read() -ით, მას დაუბრუნდება პირველი პროცესის მიერ ჩადებული ინფორმაცია.

წამკითხველები (Readers) და ჩამწერები (Writers)

როგორც შესავალში აღვნიშნეთ, Java 1.1-ში ბაიტებზე დაფუძნებულ შეტანა-გამოტანას დაემატა სიმბოლური შეტანა-გამოტანის კლასები. საბაზისო კლასებს წარმოადგენენ java.io.Reader და java.io.Writer.

სხვადასხვა წყაროების მიხედვით არსებობს შესაბამისი შთამომავალი კლასები:

  • სიმბოლური მასივი (char[]) - CharArrayReader, CharArrayWriter
  • String ობიექტი - StringReader, StringWriter
  • ფაილები - FileReader, FileWriter
  • ნაკადი - InputStreamReader, InputStreamWriter
  • კონვეირი - PipedReader, PipedWriter

განსხვავებით ნაკადებისაგან, Reader/Writer განსაზღვრავს მეთოდებს სიმბოლოებზე. მაგალითად Reader-ის მეთოდი read() კითხულობს სიმბოლოს და აბრუნებს მას. Writer-ის მეთოდი write() პარამეტრად იღებს სიმბოლოს და წერს მას.ასევე არსებობს მეთოდი write(), რომელსაც პარამეტრად გადაეცემა String.

მაგალითად, ჩავწეროთ ფაილში სტრიქონი "Hello World!";

FileWriter pw = new FileWriter("c:\\myfile.txt"));
pw.write("Hello World!");

ფილტრების გამოყენება

ისევე, როგორც ნაკადებში, ამ შემთხვევაშიც არსებობს სხვადასხვაგვარი ფილტრები. მაგალითად BufferedReader და BufferedWriter. მაგალითად, ფაილში ბუფერიზებული ჩაწერისათვის შეიძლება შევქმნათ ასეთი ობიექტი და ჩავწეროთ მასში ინფორმაცია:

 BufferedWriter bw = new BufferedWriter(new FileWriter("c:\\myfile.txt"));
 bw.write("Hello World!");
 bw.close();

გადამყვანი ნაკადებზე

არასწორია იმის თქმა, რომ Reader/Writer კლასების შემოღებით ჯავაში ნაკადების გამოყენება აღარაა რეკომენდებული. პირიქით, ამ მოქმედებით თავად ნაკადების გამოყენებაც გარკვეულ კალაპოტში მოექცა. არსებობს სპეციალური Reader და Writer, რომლებიც საშუალებას გვაძლევენ, სიმბოლური ინფორმაცია ჩავწეროთ ნაკადში ან წავიკითხოთ მისგან.

მაგალითად, დავწეროთ მეთოდი sayHelloToStream, რომელიც პარამეტრად იღებს OutputStream-ს:

public void sayHelloToStream(OutputStream os) throws IOException {
    BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(os));
    bw.write("Hello World!");
    bw.close();        
} 

მეთოდმა შეიძლება გამოისროლოს IOException, თუკი მოხდა შეტანა-გამოტანის შეცდომა (ჩაწერისას ან ნაკადის დახურვისას). გამოძახება შეიძლება იყოს ასეთი:

sayHelloToStream(new FileOutputStream("c:\\myfile.txt"));

კონვეირები

ისევე, როგორც ნაკადების შემთხვევაში, არსებობს სპეციალური ტიპის წამკითხველი და ჩამწერი - PipedReader და PipedWriter. მათი მუშაობის პრინციპიც ანალოგიურია ნაკადი-კონვეირებისა. ქვემოთ მოყვანილი კოდი საკმაოდ თვალნათლივ გამოხატავს კონვეირის მუშაობის ძირითად პრინციპს:

public class PipeTest {
   public static void main(String[] args) throws Exception {
       PipedReader prd = new PipedReader();
       final PipedWriter pw = new PipedWriter(prd);
       Thread t = new Thread(new Runnable() {
           public void run() {
               try {
                   Thread.sleep(5000);
                   System.out.println("Writing...");
                   pw.write("Life is too short for java!");
                   pw.close();
               } catch (Exception ex) {
                   throw new RuntimeException(ex);
               }
           }
       });
       t.start();
       int c;
       System.out.println("Reading&Printing...");
       while ((c = prd.read()) != -1) {
           System.out.print((char)c);
       }
       System.out.println();   
       t.join();     
   }
}

კოდი მუშაობს შემდეგი პრინციპით: იშვება დამოუკიდებელი პროცესი რომელმაც უნდა უზრუნველყოს კონვეირში ჩაწერა (სინამდვილეში Thread-ს ნაკადი ეწოდება. სიტყვა ”პროცესს” აქ ვიყენებთ მხოლოდ იმიტომ რომ არ მოხდეს აღრევა შეტანა-გამოტანის ნაკადებთან). ეს პროცესი ბლოკირებულია 5 წამის განმავლობაში (ამას უზრუნველჰყოფს Thread.sleep(5000) -ის გამოძახება). ამ დროს ძირითადი პროცესი იწყებს კონვეირიდან კითხვას და ეკრანზე გამოტანას. საბოლოოდ, ეკრანზე ინფორმაცია გამოდის შემდეგი მიმდევრობით:

Reading&Printing...
Writing...
Life is too short for java!

შენიშვნა: მიაქციეთ ყურადღება რომ ჩაწერის შემდეგ close()-ს გამოძახება ხდება იმავე პროცესში. ეს კრიტიკულია - მთავარ პროცესში მისი გამოძახების შემთხვევაში კოდი შეცდომებს გამოიტანს მას შემდეგ რაც მოხდება მისი შესრულება.

კოდირების ზოგიერთი საკითხი

როგორც ვთქვით, Reader და Writer კლასები ოპერირებენ არა ბაიტებზე, არამედ სიმბოლოებზე. ამიტომ მნიშვნელოვანია რამდენიმე სიტყვა უნდა ითქვას სიმბოლოთა კოდირებაზე როგორც ზოგადად, ისე კონკრეტულად შეტანა-გამოტანის შემთხვევისათვის. ამ საკითხის უკეთ გასაგებად აუცილებელია კარგად გვესმოდეს სიმბოლოთა ძირითადი კოდირებების არსი.

ტიპი char შეიცავს სიმბოლოებს, კოდირებულს Unicode სტანდარტით და ის იკავებს ორ ბაიტს. ზოგიერთ სიტუაციაში საჭირო ხდება რომ String ტიპის ობიექტი ჩავწეროთ ფაილში UTF-8 კოდირებით, რომელიც ძალიან პოპულარულია ინტერნეტში. ეს კოდირება გულისხმობს სხვადასხვა სიმბოლოთათვის ცვლადი რაოდენობის ბაიტების გამოყოფას. მაგალითად, ლათინური სიმბოლოებისათვის გამოყოფილია 1 ბაიტი. კირილიცა (რუსული) იყენებს 2-ბაიტიან ჩანაწერს. ქართული - 3-ბაიტიანს.

InputStreamReader და OutputStreamWriter კლასები გვთავაზობენ კონტროლის მოქნილ მექანიზმებს ასეთი შემთხვევისათვის. როგორც ზემოთ უკვე აღინიშნა, ამ კლასების საშუალებით სიმბოლოების მიმდევრობა იკითხება ბაიტებზე ორიენტირებული ნაკადებიდან (InputStreamReader) ან იწერება მათში (OutputStreamWriter). შესაბამისად, კონსტრუქტორებში გათვალისწინებულია კოდირების მითითების საშუალებაც.

მაგალითად, შემდეგი ფრაგმენტი

OutputStreamWriter osw = new OutputStreamWriter(new FileOutputStream("c:\\myfile.txt"), "UTF-8");
osw.write("\u10d2\u10d0\u10db\u10d0\u10e0\u10ef\u10dd\u10d1\u10d0");
osw.close();

ჩაწერს ფაილში ქართულად ტექსტს "გამარჯობა" და ფაილი იქნება კოდირებული UTF-8 კოდირებით.

ასეთივე პრინციპით მუშაობს გადამყვანი InputStream-ზე. ამ შემთხვევაში როდესაც წაკითხვა ხდება InputStreamReader -იდან, მაშინ ინფორმაციის ამოღება ხდება InputStream-იდან, რომელიც მასში არის "გახვეული".

სტანდარტული შეტანა-გამოტანა

სტანდარტული შეტანა-გამოტანის ამოსავალი წერტილია კლასი System პაკეტიდან java.lang რომლისგანაც შეგვიძლია ავიღოთ შეტანა-გამოტანის ნაკადები.

  • in - შეტანის ნაკადი. ტიპი - InputStream
  • out - გამოტანის ნაკადი. ტიპი - PrintStream
  • err - შეცდომათა ნაკადი. ტიპი - PrintStream

მიაქციეთ ყურადღება იმას, რომ in აბრუნებს ზოგად ტიპ InputStream. მაშინ, როცა out და err აბრუნებენ PrintStream -ს. ეს უკანასკნელი არის ნაკადის სპეციალური სახეობა და წარმოადგენს ფილტრს (FilterOutputStream -ის შვილია). მას გარდა write() მეთოდისა, რომელიც მემკვიდრეობით მოდის OutputStream -იდან, აქვს მეთოდი print() და println() სხვადასხვაგვარი სიგნატურებით.

ასევე PrintStream-ს ახასიათებს ისიც რომ მისი მეთოდების უმრავლესობა არ ახდენს IOException გამონაკლისი სიტუაციის გენერაციას (განსხვავებით სხვა OutputStream -ებისაგან) და შეცდომების შემოწმების სხვაგვარ მექანიზმებს ითვალისწინებს.

შესაბამისად, ჩვენთვის ცნობილი გამოძახება System.out.println("Hello World!"); შეგვიძლია დავშალოთ:

PrintStream ps = System.out;
ps.println("Hello World!");

უფრო საინტერესოა წაკითხვა System.in ნაკადიდან. მაგალითად, ასე:

import java.io.*;
public class InputDemo {
    public static void main(String[] args) throws IOException {
        BufferedReader in = new BufferedReader(
                new InputStreamReader(System.in));
        String s;
        System.out.print("Please type your name: ");
        while ((s = in.readLine()) != null && s.length() != 0) {
            System.out.println("Hello " + s);
            System.out.print("Please type your name: ");
        }
        System.out.println("Goood bye!");
    }
}

კოდი საკმაოდ მარტივია საიმისოდ რომ ნათელი გახდეს, რას აკეთებს ის: სტანდარტული შეტანის ნაკადში იბეჭდება შეტყობინება, რომელიც მომხმარებელს ეკითხება სახელს. შემდეგ კი ესალმება. ციკლი სრულდება მაშინ, როდესაც შეტანილია ცარიელი სტრიქონი (null ან ""). სტრიქონების სიცარიელის შემოწმება და სხვა მანიპულაციები განხილული იქნება მოგვიანებით.

მოყვანილი კოდის ანალიზისას ნათელი ხდება არამარტო ის თუ როგორ უნდა წავიკითხოთ სტანდარტული შეტანის ნაკადიდან. სურვილისამებრ მისი მოდიფიცირება შეიძლება ისე რომ წაკითხვა ხდებოდეს ფაილიდან.

უნდა აღინიშნოს ისიც, რომ ჩვენ შეგვიძლია მოვახდინოთ System.out -ის გადამისამართება ფაილში. მაგალითად, ასეთი გამოძახებით:

System.setOut(new PrintStream(new FileOutputStream("c:\\output.txt")));

ან, უფრო მოკლედ:

System.setOut(new PrintStream("c:\\output.txt"));

მეორე გამოძახება დასაშვებია იმის გამო რომ კლას PrintStream-ს აქვს კონსტრუქტორი, რომელიც საშუალებას გვაძლევს რომ მას გადავცეთ ფაილის სახელი. ნებისმიერ შემთხვევაში, გამოძახება System.out.println("Hello World!"); ჩაწერს სტრიქონს c:\output.txt ფაილში.




თავფურცელი სარჩევი საავტორო უფლებები