Программирование на Java

         

BufferedInputStream и BufferedOutputStream


На практике при считывании с внешних устройств ввод данных почти всегда необходимо буферизировать. Для буферизации данных служат классы BufferedInputStream и BufferedOutputStream.

BufferedInputStream содержит массив байт, который служит буфером для считываемых данных. То есть когда байты из потока считываются либо пропускаются (метод skip()), сначала заполняется буферный массив, причем, из надстраиваемого потока загружается сразу много байт, чтобы не требовалось обращаться к нему при каждой операции read или skip. Также класс BufferedInputStream добавляет поддержку методов mark() и reset(). Эти методы определены еще в классе InputStream, но там их реализация по умолчанию бросает исключение IOException. Метод mark() запоминает точку во входном потоке, а вызов метода reset() приводит к тому, что все байты, полученные после последнего вызова mark(), будут считываться повторно, прежде, чем новые байты начнут поступать из надстроенного входного потока.

BufferedOutputStream предоставляет возможность производить многократную запись небольших блоков данных без обращения к устройству вывода при записи каждого из них. Сначала данные записываются во внутренний буфер. Непосредственное обращение к устройству вывода и, соответственно, запись в него, произойдет, когда буфер заполнится. Инициировать передачу содержимого буфера на устройство вывода можно и явным образом, вызвав метод flush(). Так же буфер освобождается перед закрытием потока. При этом будет закрыт и надстраиваемый поток (так же поступает BufferedInputStream).

Следующий пример наглядно демонстрирует повышение скорости считывания данных из файла с использованием буфера:

Пример 15.7.

(html, txt)

Результатом могут быть, например, такие значения:

Пример 15.8.

(html, txt)

В данном случае не производилось никаких дополнительных вычислений, занимающих процессорное время, только запись и считывание из файла. При этом считывание с использованием буфера заняло в 10 (!) раз меньше времени, чем аналогичное без буферизации. Для более быстрого выполнения программы запись в файл производилась с буферизацией, однако ее влияние на скорость записи нетрудно проверить, убрав из программы строку, создающую BufferedOutputStream.

Классы BufferedI/OStream добавляют только внутреннюю логику обработки запросов, но не добавляют никаких новых методов. Следующие два фильтра предоставляют некоторые дополнительные возможности для работы с потоками.


// Определить время считывания без буферизации timeStart = System.currentTimeMillis(); inStream = new FileInputStream(fileName); while(inStream.read()!=-1){ } time = System.currentTimeMillis() - timeStart; inStream.close(); System.out.println("Direct read time: " + (time) + " millisec");

// Теперь применим буферизацию timeStart = System.currentTimeMillis(); inStream = new FileInputStream(fileName); inStream = new BufferedInputStream(inStream); while(inStream.read()!=-1){ } time = System.currentTimeMillis() - timeStart; inStream.close(); System.out.println("Buffered read time: " + (time) + " millisec"); } catch (IOException e) { System.out.println("IOException: " + e.toString()); e.printStackTrace(); }

Пример 15.7.

Результатом могут быть, например, такие значения:

Writing time: 359 millisec Direct read time: 6546 millisec Buffered read time: 250 millisec

Пример 15.8.

В данном случае не производилось никаких дополнительных вычислений, занимающих процессорное время, только запись и считывание из файла. При этом считывание с использованием буфера заняло в 10 (!) раз меньше времени, чем аналогичное без буферизации. Для более быстрого выполнения программы запись в файл производилась с буферизацией, однако ее влияние на скорость записи нетрудно проверить, убрав из программы строку, создающую BufferedOutputStream.

Классы BufferedI/OStream добавляют только внутреннюю логику обработки запросов, но не добавляют никаких новых методов. Следующие два фильтра предоставляют некоторые дополнительные возможности для работы с потоками.


DataInputStream и DataOutputStream


До сих пор речь шла только о считывании и записи в поток данных в виде byte. Для работы с другими примитивными типами данных Java определены интерфейсы DataInput и DataOutput и их реализации – классы-фильтры DataInputStream и DataOutputStream. Их место в иерархии классов ввода/вывода можно увидеть на рис.15.1.

Интерфейсы DataInput и DataOutput определяют, а классы DataInputStream и DataOutputStream, соответственно, реализуют методы считывания и записи значений всех примитивных типов. При этом происходит конвертация этих данных в набор byte и обратно. Чтение необходимо организовать так, чтобы данные запрашивались в виде тех же типов, в той же последовательности, как и производилась запись. Если записать, например, int и long, а потом считывать их как short, чтение будет выполнено корректно, без исключительных ситуаций, но числа будут получены совсем другие.

Это наглядно показано в следующем примере:



Пример 15.9.

(html, txt)

Результат выполнения программы:

Чтение в правильной последовательности:

readByte: -128 readInt: 128 readLong: 128 readDouble: 128.0

Чтение в измененной последовательности:

readInt: -2147483648 readDouble: -0.0 readLong: -9205252085229027328

Итак, значение любого примитивного типа может быть передано и считано из потока данных.



Создать выходной поток FileOutputStream outFile


byte[] bytesToWrite = {1, 2, 3}; byte[] bytesReaded = new byte[10]; String fileName = "d:\\test.txt"; try { // Создать выходной поток FileOutputStream outFile = new FileOutputStream(fileName); System.out.println("Файл открыт для записи");

// Записать массив outFile.write(bytesToWrite); System.out.println("Записано: " + bytesToWrite.length + " байт");

// По окончании использования должен быть закрыт outFile.close(); System.out.println("Выходной поток закрыт");

// Создать входной поток FileInputStream inFile = new FileInputStream(fileName); System.out.println("Файл открыт для чтения");

// Узнать, сколько байт готово к считыванию int bytesAvailable = inFile.available(); System.out.println("Готово к считыванию: " + bytesAvailable + " байт");

// Считать в массив int count = inFile.read(bytesReaded,0,bytesAvailable); System.out.println("Считано: " + count + " байт"); for (i=0;i<count;i++) System.out.print(bytesReaded[i]+","); System.out.println(); inFile.close(); System.out.println("Входной поток закрыт"); } catch (FileNotFoundException e) { System.out.println("Невозможно произвести запись в файл: " + fileName); } catch (IOException e) { System.out.println("Ошибка ввода/вывода: " + e.toString()); }
Пример 15.1.
Закрыть окно


Файл открыт для записи Записано: 3 байт Выходной поток закрыт Файл открыт для чтения Готово к считыванию: 3 байт Считано: 3 байт 1,2,3, Входной поток закрыт
Пример 15.2.
Закрыть окно


try { int countRead = 0; byte[] toRead = new byte[100]; PipedInputStream pipeIn = new PipedInputStream(); PipedOutputStream pipeOut = new PipedOutputStream(pipeIn);

// Считывать в массив, пока он полностью не будет заполнен while(countRead<toRead.length) {

// Записать в поток некоторое количество байт for(int i=0; i<(Math.random()*10); i++) { pipeOut.write((byte)(Math.random()*127)); }

// Считать из потока доступные данные, // добавить их к уже считанным. int willRead = pipeIn.available(); if(willRead+countRead>toRead.length)

//Нужно считать только до предела массива willRead = toRead.length-countRead; countRead += pipeIn.read(toRead, countRead, willRead); } } catch (IOException e) { System.out.println ("Impossible IOException occur: "); e.printStackTrace(); }
Пример 15.3.
Закрыть окно


// inStream - объект класса PipedInputStream try { while(true) { byte[] readedBytes = null; synchronized(inStream) { int bytesAvailable = inStream.available(); readedBytes = new byte[bytesAvailable]; inStream.read(readedBytes); } // обработка полученных данных из readedBytes // … } catch(IOException e) {

/* IOException будет брошено, когда поток inStream, либо связанный с ним PipedOutputStream, уже закрыт, и при этом производится попытка считывания из inStream */

System.out.println("работа с потоком inStream завершена"); }
Пример 15.4.
Закрыть окно


FileInputStream inFile1 = null; FileInputStream inFile2 = null; SequenceInputStream sequenceStream = null; FileOutputStream outFile = null; try { inFile1 = new FileInputStream("file1.txt"); inFile2 = new FileInputStream("file2.txt"); sequenceStream = new SequenceInputStream(inFile1, inFile2); outFile = new FileOutputStream("file3.txt"); int readedByte = sequenceStream.read(); while(readedByte!=-1){ outFile.write(readedByte); readedByte = sequenceStream.read(); } } catch (IOException e) { System.out.println("IOException: " + e.toString()); } finally { try{sequenceStream.close();}catch(IOException e){}; try{outFile.close();}catch(IOException e){}; }
Пример 15.5.
Закрыть окно


Vector vector = new Vector(); vector.add(new StringBufferInputStream("Begin file1\n")); vector.add(new FileInputStream("file1.txt")); vector.add(new StringBufferInputStream("\ nEnd of file1, begin file2\n")); vector.add(new FileInputStream("file2.txt")); vector.add(new StringBufferInputStream("\nEnd of file2")); Enumeration enum = vector.elements(); sequenceStream = new SequenceInputStream(enum);
Пример 15.6.
Закрыть окно


try { String fileName = "d:\\file1"; InputStream inStream = null; OutputStream outStream = null;

//Записать в файл некоторое количество байт long timeStart = System.currentTimeMillis(); outStream = new FileOutputStream(fileName); outStream = new BufferedOutputStream(outStream); for(int i=1000000; --i>=0;) { outStream.write(i); } long time = System.currentTimeMillis() - timeStart; System.out.println("Writing time: " + time + " millisec"); outStream.close();

// Определить время считывания без буферизации timeStart = System.currentTimeMillis(); inStream = new FileInputStream(fileName); while(inStream.read()!=-1){ } time = System.currentTimeMillis() - timeStart; inStream.close(); System.out.println("Direct read time: " + (time) + " millisec");

// Теперь применим буферизацию timeStart = System.currentTimeMillis(); inStream = new FileInputStream(fileName); inStream = new BufferedInputStream(inStream); while(inStream.read()!=-1){ } time = System.currentTimeMillis() - timeStart; inStream.close(); System.out.println("Buffered read time: " + (time) + " millisec"); } catch (IOException e) { System.out.println("IOException: " + e.toString()); e.printStackTrace(); }
Пример 15.7.
Закрыть окно


Writing time: 359 millisec Direct read time: 6546 millisec Buffered read time: 250 millisec
Пример 15.8.
Закрыть окно


try { ByteArrayOutputStream out = new ByteArrayOutputStream(); DataOutputStream outData = new DataOutputStream(out); outData.writeByte(128); // этот метод принимает аргумент int, но записывает // лишь младший байт outData.writeInt(128); outData.writeLong(128); outData.writeDouble(128); outData.close(); byte[] bytes = out.toByteArray(); InputStream in = new ByteArrayInputStream(bytes); DataInputStream inData = new DataInputStream(in); System.out.println("Чтение в правильной последовательности: "); System.out.println("readByte: " + inData.readByte()); System.out.println("readInt: " + inData.readInt()); System.out.println("readLong: " + inData.readLong()); System.out.println("readDouble: " + inData.readDouble()); inData.close(); System.out.println("Чтение в измененной последовательности:"); in = new ByteArrayInputStream(bytes); inData = new DataInputStream(in); System.out.println("readInt: " + inData.readInt()); System.out.println("readDouble: " + inData.readDouble()); System.out.println("readLong: " + inData.readLong()); inData.close(); } catch (Exception e) { System.out.println("Impossible IOException occurs: " + e.toString()); e.printStackTrace(); }
Пример 15.9.
Закрыть окно


// Родительский класс, не реализующий Serializable public class Parent { public String firstName; private String lastName; public Parent(){ System.out.println("Create Parent"); firstName="old_first"; lastName="old_last"; } public void changeNames() { firstName="new_first"; lastName="new_last"; } public String toString() { return super.toString()+",first="+firstName+",last="+lastName; } } // Класс Child, впервые реализовавший Serializable public class Child extends Parent implements Serializable { private int age; public Child(int age) { System.out.println("Create Child"); this.age=age; } public String toString() { return super.toString()+",age="+age; } } // Наследник Serializable-класса public class Child2 extends Child { private int size; public Child2(int age, int size) { super(age); System.out.println("Create Child2"); this.size=size; } public String toString() { return super.toString()+",size="+size; } } // Запускаемый класс для теста public class Test { public static void main(String[] arg) { try { FileOutputStream fos=new FileOutputStream("output.bin"); ObjectOutputStream oos=new ObjectOutputStream(fos); Child c=new Child(2); c.changeNames(); System.out.println(c); oos.writeObject(c); oos.writeObject(new Child2(3, 4)); oos.close(); System.out.println("Read objects:"); FileInputStream fis=new FileInputStream("output.bin"); ObjectInputStream ois=new ObjectInputStream(fis); System.out.println(ois.readObject()); System.out.println(ois.readObject()); ois.close(); } catch (Exception e) { // упрощенная обработка для краткости e.printStackTrace(); } } }
Пример 15.10.
Закрыть окно


Create Parent Create Child Child@ad3ba4,first=new_first,last=new_last,age= 2 Create Parent Create Child Create Child2 Read objects: Create Parent Child@723d7c,first=old_first,last=old_last,age=2 Create Parent Child2@22c95b,first=old_first,last=old_last,age=3,size=4
Пример 15.11.
Закрыть окно


import java.io.*; class Point implements Serializable { double x; double y; public Point(double x, double y) { this.x = x; this.y = y; } public String toString() { return "("+x+","+y+") reference="+super.toString(); } } class Line implements Serializable { Point point1; Point point2; int index; public Line() { System.out.println("Constructing empty line"); } Line(Point p1, Point p2, int index) { System.out.println("Constructing line: " + index); this.point1 = p1; this.point2 = p2; this.index = index; } public int getIndex() { return index; } public void setIndex(int newIndex) { index = newIndex; } public void printInfo() { System.out.println("Line: " + index); System.out.println(" Object reference: " + super.toString()); System.out.println(" from point "+point1); System.out.println(" to point "+point2); } } public class Main { public static void main(java.lang.String[] args) { Point p1 = new Point(1.0,1.0); Point p2 = new Point(2.0,2.0); Point p3 = new Point(3.0,3.0); Line line1 = new Line(p1,p2,1); Line line2 = new Line(p2,p3,2); System.out.println("line 1 = " + line1); System.out.println("line 2 = " + line2); String fileName = "d:\\file"; try{ // записываем объекты в файл FileOutputStream os = new FileOutputStream(fileName); ObjectOutputStream oos = new ObjectOutputStream(os); oos.writeObject(line1); oos.writeObject(line2); // меняем состояние line1 и записываем его еще раз line1.setIndex(3); //oos.reset(); oos.writeObject(line1); // закрываем потоки // достаточно закрыть только поток-надстройку oos.close(); // считываем объекты System.out.println("Read objects:"); FileInputStream is = new FileInputStream(fileName); ObjectInputStream ois = new ObjectInputStream(is); for (int i=0; i<3; i++) { // Считываем 3 объекта Line line = (Line)ois.readObject(); line.printInfo(); } ois.close(); } catch(ClassNotFoundException e) { e.printStackTrace(); } catch(IOException e) { e.printStackTrace(); } } }
Пример 15.12.
Закрыть окно


Constructing line: 1 Constructing line: 2 line 1 = Line@7d39 line 2 = Line@ 4ec Read objects: Line: 1 Object reference: Line@331e from point (1.0,1.0) reference=Point@36bb to point (2.0,2.0) reference=Point@386e Line: 2 Object reference: Line@6706 from point (2.0,2.0) reference=Point@386e to point (3.0,3.0) reference=Point@68ae Line: 1 Object reference: Line@331e from point (1.0,1.0) reference=Point@36bb to point (2.0,2.0) reference=Point@386e
Пример 15.13.
Закрыть окно


Constructing line: 1 Constructing line: 2 line 1 = Line@ea2dfe line 2 = Line@ 7182c1 Read objects: Line: 1 Object reference: Line@a981ca from point (1.0,1.0) reference=Point@1503a3 to point (2.0,2.0) reference=Point@a1c887 Line: 2 Object reference: Line@743399 from point (2.0,2.0) reference=Point@a1c887 to point (3.0,3.0) reference=Point@e7b241 Line: 3 Object reference: Line@67d940 from point (1.0,1.0) reference=Point@e83912 to point (2.0,2.0) reference=Point@fae3c6
Пример 15.14.
Закрыть окно


String fileName = "d:\\file.txt";

//Строка, которая будет записана в файл String data = " Some data to be written and read.\n"; try{ FileWriter fw = new FileWriter(fileName); BufferedWriter bw = new BufferedWriter(fw); System.out.println("Write some data to file: " + fileName);

// Несколько раз записать строку for(int i=(int)(Math.random()*10);--i>=0;) bw.write(data); bw.close();

// Считываем результат FileReader fr = new FileReader(fileName); BufferedReader br = new BufferedReader(fr); String s = null; int count = 0; System.out.println("Read data from file: " + fileName);

// Считывать данные, отображая на экран while((s=br.readLine())!=null) System.out.println("row " + ++count + " read:" + s); br.close(); } catch(Exception e) { e.printStackTrace(); }
Пример 15.15.
Закрыть окно


import java.io.*; public class FileDemo { public static void findFiles( File file, FileFilter filter, PrintStream output) throws IOException{ if (file.isDirectory()) { File[] list = file.listFiles(); for (int i=list.length; --i>=0;) { findFiles(list[i], filter, output); } } else { if (filter.accept(file)) output.println("\t" + file.getCanonicalPath()); } } public static void main(String[] args) { class NameFilter implements FileFilter { private String mask; NameFilter(String mask) { this.mask = mask; } public boolean accept(File file){ return (file.getName().indexOf(mask)!=-1)?true:false; } } File pathFile = new File("."); String filterString = ".java"; try { FileFilter filter = new NameFilter(filterString); findFiles(pathFile, filter, System.out); } catch(Exception e) { e.printStackTrace(); } System.out.println("work finished"); } }
Пример 15.16.
Закрыть окно

Граф сериализации


До этого мы рассматривали объекты, которые имеют поля лишь примитивных типов. Если же сериализуемый объект ссылается на другие объекты, их также необходимо сохранить (записать в поток байт), а при десериализации – восстановить. Эти объекты, в свою очередь, также могут ссылаться на следующие объекты. При этом важно, что если несколько ссылок указывают на один и тот же объект, то этот объект должен быть сериализован лишь однажды, а при восстановлении все ссылки должны вновь указывать на него одного. Например, сериализуемый объект A ссылается на объекты B и C, каждый из которых, в свою очередь, ссылается на один и тот же объект D. После деeсериализации не должно возникать ситуации, когда B ссылается на D1, а C – на D2, где D1 и D2 – равные, но все же различные объекты.

Для организации такого процесса стандартный механизм сериализации строит граф, включающий в себя все участвующие объекты и ссылки между ними. Если очередная ссылка указывает на некоторый объект, сначала проверяется – нет ли такого объекта в графе. Если есть – объект второй раз не сериализуется. Если нет – новый объект добавляется в граф.

При построении графа может встретиться объект, порожденный от класса, не реализующего интерфейс Serializable. В этом случае сериализация прерывается, генерируется исключение java.io.NotSerializableException.

Рассмотрим пример:

Пример 15.12.

(html, txt)

В этой программе работа идет с классом Line (линия), который имеет 2 поля типа Point (линия описывается двумя точками). Запускаемый класс Main создает два объекта класса Line, причем, одна из точек у них общая. Кроме этого, линия имеет номер (поле index). Созданные линии (номера 1 и 2) записываются в поток, после чего одна из них получает новый номер (3) и вновь сериализуется.

Выполнение этой программы приведет к выводу на экран примерно следующего:

Пример 15.13.

(html, txt)

Из примера видно, что после восстановления у линий сохраняется общая точка, описываемая одним и тем же объектом (хеш-код 386e).

Третий записанный объект идентичен первому, причем, совпадают даже объектные ссылки. Несмотря на то, что при записи третьего объекта значение index было изменено на 3, в десериализованном объекте оно осталось равным 1. Так произошло потому, что объект, описывающий первую линию, уже был задействован в сериализации и, встретившись во второй раз, повторно записан не был.

Чтобы указать, что сеанс сериализации завершен, и получить возможность передавать измененные объекты, у ObjectOutputStream нужно вызвать метод reset(). В рассматриваемом примере для этого достаточно убрать комментарий в строке

//oos.reset();

Если теперь запустить программу, то можно увидеть, что третий объект получит номер 3.

Пример 15.14.

(html, txt)

Однако это будет уже новый объект, ссылка на который отличается от первой считанной линии. Более того, обе точки будут также описываться новыми объектами. То есть в новом сеансе все объекты были записаны, а затем восстановлены заново.


Пример 15.12.

В этой программе работа идет с классом Line (линия), который имеет 2 поля типа Point (линия описывается двумя точками). Запускаемый класс Main создает два объекта класса Line, причем, одна из точек у них общая. Кроме этого, линия имеет номер (поле index). Созданные линии (номера 1 и 2) записываются в поток, после чего одна из них получает новый номер (3) и вновь сериализуется.

Выполнение этой программы приведет к выводу на экран примерно следующего:

Constructing line: 1 Constructing line: 2 line 1 = Line@7d39 line 2 = Line@4ec Read objects: Line: 1 Object reference: Line@331e from point (1.0,1.0) reference=Point@36bb to point (2.0,2.0) reference=Point@386e Line: 2 Object reference: Line@6706 from point (2.0,2.0) reference=Point@386e to point (3.0,3.0) reference=Point@68ae Line: 1 Object reference: Line@331e from point (1.0,1.0) reference=Point@36bb to point (2.0,2.0) reference=Point@386e

Пример 15.13.

Из примера видно, что после восстановления у линий сохраняется общая точка, описываемая одним и тем же объектом (хеш-код 386e).

Третий записанный объект идентичен первому, причем, совпадают даже объектные ссылки. Несмотря на то, что при записи третьего объекта значение index было изменено на 3, в десериализованном объекте оно осталось равным 1. Так произошло потому, что объект, описывающий первую линию, уже был задействован в сериализации и, встретившись во второй раз, повторно записан не был.

Чтобы указать, что сеанс сериализации завершен, и получить возможность передавать измененные объекты, у ObjectOutputStream нужно вызвать метод reset(). В рассматриваемом примере для этого достаточно убрать комментарий в строке

//oos.reset();

Если теперь запустить программу, то можно увидеть, что третий объект получит номер 3.

Constructing line: 1 Constructing line: 2 line 1 = Line@ea2dfe line 2 = Line@7182c1 Read objects: Line: 1 Object reference: Line@a981ca from point (1.0,1.0) reference=Point@1503a3 to point (2.0,2.0) reference=Point@a1c887 Line: 2 Object reference: Line@743399 from point (2.0,2.0) reference=Point@a1c887 to point (3.0,3.0) reference=Point@e7b241 Line: 3 Object reference: Line@67d940 from point (1.0,1.0) reference=Point@e83912 to point (2.0,2.0) reference=Point@fae3c6

Пример 15.14.

Однако это будет уже новый объект, ссылка на который отличается от первой считанной линии. Более того, обе точки будут также описываться новыми объектами. То есть в новом сеансе все объекты были записаны, а затем восстановлены заново.


Класс File


Если классы потоков осуществляют реальную запись и чтение данных, то класс File – это вспомогательный инструмент, призванный обеспечить работу с файлами и каталогами.

Объект класса File является абстрактным представлением файла и пути к нему. Он устанавливает только соответствие с ним, при этом для создания объекта неважно, существует ли такой файл на диске. После создания можно выполнить проверку, вызвав метод exists, который возвращает значение true, если файл существует. Создание или удаление объекта класса File никоим образом не отображается на реальных файлах. Для работы с содержимым файла можно получить экземпляры FileI/OStream.

Объект File может указывать на каталог (узнать это можно путем вызова метода isDirectory). Метод list возвращает список имен (массив String) содержащихся в нем файлов (если объект File не указывает на каталог – будет возвращен null).

Следующий пример демонстрирует использование объектов класса File:

Пример 15.16.

(html, txt)

При выполнении этой программы на экран будут выведены названия (в каноническом виде) всех файлов, с расширением .java, содержащихся в текущем каталоге и всех его подкаталогах.

Для определения того, что файл имеет расширение .java, использовался интерфейс FileFilter с реализацией в виде внутреннего класса NameFilter. Интерфейс FileFilter определяет только один метод accept, возвращающий значение, определяющее, попадает ли переданный файл в условия фильтрации. Помимо этого интерфейса, существует еще одна разновидность интерфейса фильтра – FilenameFilter, где метод accept определен несколько иначе: он принимает не объект файла к проверке, а объект File, указывающий на каталог, где находится файл для проверки, и строку его названия. Для проверки совпадения, с учетом регулярных выражений, нужно соответствующим образом реализовать метод accept. В конкретном приведенном примере можно было обойтись и без использования интерфейсов FileFilter или FilenameFilter. На практике их можно использовать для вызова методов list объектов File – в этих случаях будут возвращены файлы с учетом фильтра.

Также класс File предоставляет возможность получения некоторой информации о файле.

Методы canRead и canWrite – возвращается boolean значение, можно ли будет приложению производить чтение и изменение содержимого из файла, соответственно.


getName – возвращает строку – имя файла (или каталога).

getParent, getParentName – возвращают каталог, где файл находится в виде строки названия и объекта File, соответственно.

getPath – возвращает путь к файлу (при этом в строку преобразуется абстрактный путь, на который указывает объект File).

isAbsolutely – возвращает boolean значение, является ли абсолютным путь, которым указан файл. Определение, является ли путь абсолютным, зависит от системы, где запущена Java-машина. Так, для Windows абсолютный путь начинается с указания диска, либо символом '\'. Для Unix абсолютный путь начинается символом '/' .

isDirectory, isFile – возвращает boolean значение, указывает ли объект на каталог либо файл, соответственно.

isHidden – возвращает boolean значение, указывает ли объект на скрытый файл.

lastModified – дата последнего изменения.

length – длина файла в байтах.

Также можно изменить некоторые свойства файла – методы setReadOnly, setLastModified, назначение которых очевидно из названия. Если нужно создать файл на диске, это позволяют сделать методы createNewFile, mkDir, mkDirs. Соответственно, createNewFile создает пустой файл (если таковой еще не существует), mkDir создает каталог, если для него все родительские уже существуют, а mkDirs создаст каталог вместе со всеми необходимыми родительскими.

Файл можно и удалить – для этого предназначены методы delete и deleteOnExit. При вызове метода delete файл будет удален сразу же, а при вызове deleteOnExit по окончании работы Java-машины (только при корректном завершении работы) отменить запрос уже невозможно.

Таким образом, класс File дает возможность достаточно полного управления файловой системой.


Класс RandomAccessFile


Этот класс реализует сразу два интерфейса – DataInput и DataOutput – следовательно, может производить запись и чтение всех примитивных типов Java. Эти операции, как следует из названия, производятся с файлом. При этом их можно производить поочередно, произвольным образом перемещаясь по файлу с помощью вызова метода seek(long) (переводит на указанную позицию в файле). Узнать текущее положение указателя в файле можно вызовом метода getFilePointer.

При создании объекта этого класса конструктору в качестве параметров нужно передать два параметра: файл и режим работы. Файл, с которым будет проводиться работа, указывается либо с помощью String – название файла, либо объектом File, ему соответствующим. Режим работы (mode) – представляет собой строку либо "r"(только чтение), либо "rw"(чтение и запись). Попытка открыть несуществующий файл только на чтение приведет к исключению FileNotFoundException. При открытии на чтение и запись он будет незамедлительно создан (или же будет брошено исключение FileNotFoundException, если это невозможно осуществить).

После создания объекта RandomAccessFile можно воспользоваться методами интерфейсов DataInput и DataOutput для проведения с файлом операций считывания и записи. По окончании работы с файлом его следует закрыть, вызвав метод close.



Класс StreamTokenizer


Экземпляр StreamTokenizer создается поверх существующего объекта, либо InputStream, либо Reader. Как и java.util.StringTokenizer, этот класс позволяет разбивать данные на лексемы (token), выделяемые из потока по определенным свойствам. Поскольку работа ведется со словами, конструктор, принимающий InputStream, объявлен как deprecated (предлагается оборачивать байтовый поток классом InputStreamReader и вызывать второй конструктор). Общий принцип работы такой же, как и у StringTokenizer, – задаются параметры разбиения, после чего вызывается метод nextToken(), пока не будет достигнут конец потока. Способы задания разбиения у StreamTokenizer довольно разнообразны, но просты, и поэтому здесь не рассматриваются.



Классы ByteArrayInputStream и ByteArrayOutputStream


Самый естественный и простой источник, откуда можно считывать байты, – это, конечно, массив байт. Класс ByteArrayInputStream представляет поток, считывающий данные из массива байт. Этот класс имеет конструктор, которому в качестве параметра передается массив byte[]. Соответственно, при вызове методов read() возвращаемые данные будут браться именно из этого массива. Например:

byte[] bytes = {1,-1,0}; ByteArrayInputStream in = new ByteArrayInputStream(bytes); int readedInt = in.read(); // readedInt=1 System.out.println("first element read is: " + readedInt); readedInt = in.read(); // readedInt=255. Однако // (byte)readedInt даст значение -1

System.out.println("second element read is: " + readedInt); readedInt = in.read(); // readedInt=0 System.out.println("third element read is: " + readedInt);

Если запустить такую программу, на экране отобразится следующее:

first element read is: 1 second element read is: 255 third element read is: 0

При вызове метода read() данные считывались из массива bytes, переданного в конструктор ByteArrayInputStream. Обратите внимание, в данном примере второе считанное значение равно 255, а не -1, как можно было бы ожидать. Чтобы понять, почему это произошло, нужно вспомнить, что метод read считывает byte, но возвращает значение int, полученное добавлением необходимого числа нулей (в двоичном представлении). Байт, равный -1, в двоичном представлении имеет вид 11111111 и, соответственно, число типа int, получаемое приставкой 24-х нулей, равно 255 (в десятичной системе). Однако если явно привести его к byte, получим исходное значение.

Аналогично, для записи байт в массив применяется класс ByteArrayOutputStream. Этот класс использует внутри себя объект byte[], куда записывает данные, передаваемые при вызове методов write(). Чтобы получить записанные в массив данные, вызывается метод toByteArray(). Пример:

ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(10); out.write(11); byte[] bytes = out.toByteArray();

В этом примере в результате массив bytes будет состоять из двух элементов: 10 и 11.

Использовать классы ByteArrayInputStream и ByteArrayOutputStream может быть очень удобно, когда нужно проверить, что именно записывается в выходной поток. Например, при отладке и тестировании сложных процессов записи и чтения из потоков. Эти классы хороши тем, что позволяют сразу просмотреть результат и не нужно создавать ни файл, ни сетевое соединение, ни что-либо еще.



Классы FileInputStream и FileOutputStream


Класс FileInputStream используется для чтения данных из файла. Конструктор такого класса в качестве параметра принимает название файла, из которого будет производиться считывание. При указании строки имени файла нужно учитывать, что она будет напрямую передана операционной системе, поэтому формат имени файла и пути к нему может различаться на разных платформах. Если при вызове этого конструктора передать строку, указывающую на несуществующий файл или каталог, то будет брошено java.io.FileNotFoundException. Если же объект успешно создан, то при вызове его методов read() возвращаемые значения будут считываться из указанного файла.

Для записи байт в файл используется класс FileOutputStream. При создании объектов этого класса, то есть при вызовах его конструкторов, кроме имени файла, также можно указать, будут ли данные дописываться в конец файла, либо файл будет перезаписан. Если указанный файл не существует, то сразу после создания FileOutputStream он будет создан. При вызовах методов write() передаваемые значения будут записываться в этот файл. По окончании работы необходимо вызвать метод close(), чтобы сообщить системе, что работа по записи файла закончена. Пример:

Пример 15.1.

(html, txt)

Результатом работы программы будет:

Пример 15.2.

(html, txt)

При работе с FileInputStream метод available() практически наверняка вернет длину файла, то есть число байт, сколько вообще из него можно считать. Но не стоит закладываться на это при написании программ, которые должны устойчиво работать на различных платформах,– метод available() возвращает число байт, которое может быть на данный момент считано без блокирования. Тот факт, что, скорее всего, это число и будет длиной файла, является всего лишь частным случаем работы на некоторых платформах.

В приведенном примере для наглядности закрытие потоков производилось сразу же после окончания их использования в основном блоке. Однако лучше закрывать потоки в finally блоке.

... } finally { try{inFile.close();}catch(IOException e){}; }

Такой подход гарантирует, что поток будет закрыт и будут освобождены все связанные с ним системные ресурсы.


// Считать в массив int count = inFile.read(bytesReaded,0,bytesAvailable); System.out.println("Считано: " + count + " байт"); for (i=0;i<count;i++) System.out.print(bytesReaded[i]+","); System.out.println(); inFile.close(); System.out.println("Входной поток закрыт"); } catch (FileNotFoundException e) { System.out.println("Невозможно произвести запись в файл: " + fileName); } catch (IOException e) { System.out.println("Ошибка ввода/вывода: " + e.toString()); }

Пример 15.1.

Результатом работы программы будет:

Файл открыт для записи Записано: 3 байт Выходной поток закрыт Файл открыт для чтения Готово к считыванию: 3 байт Считано: 3 байт 1,2,3, Входной поток закрыт

Пример 15.2.

При работе с FileInputStream метод available() практически наверняка вернет длину файла, то есть число байт, сколько вообще из него можно считать. Но не стоит закладываться на это при написании программ, которые должны устойчиво работать на различных платформах,– метод available() возвращает число байт, которое может быть на данный момент считано без блокирования. Тот факт, что, скорее всего, это число и будет длиной файла, является всего лишь частным случаем работы на некоторых платформах.

В приведенном примере для наглядности закрытие потоков производилось сразу же после окончания их использования в основном блоке. Однако лучше закрывать потоки в finally блоке.

... } finally { try{inFile.close();}catch(IOException e){}; }

Такой подход гарантирует, что поток будет закрыт и будут освобождены все связанные с ним системные ресурсы.


Классы FilterInputStream и FilterOutputStream и их наследники


Задачи, возникающие при вводе/выводе весьма разнообразны - это может быть считывание байт из файлов, объектов из файлов, объектов из массивов, буферизованное считывание строк из массивов и т.д. В такой ситуации решение с использованием простого наследования приводит к возникновению слишком большого числа подклассов. Более эффективно применение надстроек (в ООП этот шаблон называется адаптер) Надстройки – наложение дополнительных объектов для получения новых свойств и функций. Таким образом, необходимо создать несколько дополнительных объектов – адаптеров к классам ввода/вывода. В java.io их еще называют фильтрами. При этом надстройка-фильтр включает в себя интерфейс объекта, на который надстраивается, поэтому может быть, в свою очередь, дополнительно надстроена.

В java.io интерфейс для таких надстроек ввода/вывода предоставляют классы FilterInputStream (для входных потоков) и FilterOutputStream (для выходных потоков). Эти классы унаследованы от основных базовых классов ввода/вывода – InputStream и OutputStream, соответственно. Конструктор FilterInputStream принимает в качестве параметра объект InputStream и имеет модификатор доступа protected.

Классы FilterI/OStream являются базовыми для надстроек и определяют общий интерфейс для надстраиваемых объектов. Потоки-надстройки не являются источниками данных. Они лишь модифицируют (расширяют) работу надстраиваемого потока.



Классы InputStream и OutputStream


InputStream – это базовый класс для потоков ввода, т.е. чтения. Соответственно, он описывает базовые методы для работы с байтовыми потоками данных. Эти методы необходимы всем классам, которые наследуются от InputStream.

Простейшая операция представлена методом read() (без аргументов). Он является абстрактным и, соответственно, должен быть определен в классах-наследниках. Этот метод предназначен для считывания ровно одного байта из потока, однако возвращает при этом значение типа int. В том случае, если считывание произошло успешно, возвращаемое значение лежит в диапазоне от 0 до 255 и представляет собой полученный байт (значение int содержит 4 байта и получается простым дополнением нулями в двоичном представлении). Обратите внимание, что полученный таким образом байт не обладает знаком и не находится в диапазоне от -128 до +127, как примитивный тип byte в Java.

Если достигнут конец потока, то есть в нем больше нет информации для чтения, то возвращаемое значение равно -1.

Если же считать из потока данные не удается из-за каких-то ошибок, или сбоев, будет брошено исключение java.io.IOException. Этот класс наследуется от Exception, т.е. его всегда необходимо обрабатывать явно. Дело в том, что каналы передачи информации, будь то Internet или, например, жесткий диск, могут давать сбои независимо от того, насколько хорошо написана программа. А это означает, что нужно быть готовым к ним, чтобы пользователь не потерял нужные данные.

Метод read() – это абстрактный метод, но именно с соблюдением всех указанных условий он должен быть реализован в классах-наследниках.

На практике обычно приходится считывать не один, а сразу несколько байт – то есть массив байт. Для этого используется метод read(), где в качестве параметров передается массив byte[]. При выполнении этого метода в цикле производится вызов абстрактного метода read() (определенного без параметров) и результатами заполняется переданный массив. Количество байт, считываемое таким образом, равно длине переданного массива. Но при этом может так получиться, что данные в потоке закончатся еще до того, как будет заполнен весь массив. То есть возможна ситуация, когда в потоке данных (байт) содержится меньше, чем длина массива. Поэтому метод возвращает значение int, указывающее, сколько байт было реально считано. Понятно, что это значение может быть от 0 до величины длины переданного массива.

Если же мы изначально хотим заполнить не весь массив, а только его часть, то для этих целей используется метод read(), которому, кроме массива byte[], передаются еще два int значения. Первое – это позиция в массиве, с которой следует начать заполнение, второе – количество байт, которое нужно считать. Такой подход, когда для получения данных передается массив и два int числа – offset (смещение) и length (длина), является довольно распространенным и часто встречается не только в пакете java.io.

При вызове методов read() возможно возникновение такой ситуации, когда запрашиваемые данные еще не готовы к считыванию. Например, если мы считываем данные, поступающие из сети, и они еще просто не пришли. В таком случае нельзя сказать, что данных больше нет, но и считать тоже нечего - выполнение останавливается на вызове метода read() и получается "зависание".

Чтобы узнать, сколько байт в потоке готово к считыванию, применяется метод available(). Этот метод возвращает значение типа int, которое показывает, сколько байт в потоке готово к считыванию. При этом не стоит путать количество байт, готовых к считыванию, с тем количеством байт, которые вообще можно будет считать из этого потока. Метод available() возвращает число – количество байт, именно на данный момент готовых к считыванию.

Когда работа с входным потоком данных окончена, его следует закрыть. Для этого вызывается метод close(). Этим вызовом будут освобождены все системные ресурсы, связанные с потоком.

Точно так же, как InputStream – это базовый класс для потоков ввода, класс OutputStream – это базовый класс для потоков вывода.

В классе OutputStream аналогичным образом определяются три метода write() – один принимающий в качестве параметра int, второй – byte[] и третий – byte[], плюс два int-числа. Все эти методы ничего не возвращают (void).

Метод write(int) является абстрактным и должен быть реализован в классах-наследниках. Этот метод принимает в качестве параметра int, но реально записывает в поток только byte – младшие 8 бит в двоичном представлении. Остальные 24 бита будут проигнорированы. В случае возникновения ошибки этот метод бросает java.io.IOException, как, впрочем, и большинство методов, связанных с вводом-выводом.

Для записи в поток сразу некоторого количества байт методу write() передается массив байт. Или, если мы хотим записать только часть массива, то передаем массив byte[] и два int-числа – отступ и количество байт для записи. Понятно, что если указать неверные параметры – например, отрицательный отступ, отрицательное количество байт для записи, либо если сумма отступ плюс длина будет больше длины массива, – во всех этих случаях кидается исключение IndexOutOfBoundsException.

Реализация потока может быть такой, что данные записываются не сразу, а хранятся некоторое время в памяти. Например, мы хотим записать в файл какие-то данные, которые получаем порциями по 10 байт, и так 200 раз подряд. В таком случае вместо 200 обращений к файлу удобней будет скопить все эти данные в памяти, а потом одним заходом записать все 2000 байт. То есть класс выходного потока может использовать некоторый внутренний механизм для буферизации (временного хранения перед отправкой) данных. Чтобы убедиться, что данные записаны в поток, а не хранятся в буфере, вызывается метод flush(), определенный в OutputStream. В этом классе его реализация пустая, но если какой-либо из наследников использует буферизацию данных, то этот метод должен быть в нем переопределен.

Когда работа с потоком закончена, его следует закрыть. Для этого вызывается метод close(). Этот метод сначала освобождает буфер (вызовом метода flush), после чего поток закрывается и освобождаются все связанные с ним системные ресурсы. Закрытый поток не может выполнять операции вывода и не может быть открыт заново. В классе OutputStream реализация метода close() не производит никаких действий.

Итак, классы InputStream и OutputStream определяют необходимые методы для работы с байтовыми потоками данных. Эти классы являются абстрактными. Их задача – определить общий интерфейс для классов, которые получают данные из различных источников. Такими источниками могут быть, например, массив байт, файл, строка и т.д. Все они, или, по крайней мере, наиболее распространенные, будут рассмотрены далее.



Классы Reader и Writer и их наследники


Рассмотренные классы – наследники InputStream и OutputStream – работают с байтовыми данными. Если с их помощью записывать или считывать текст, то сначала необходимо сопоставить каждому символу его числовой код. Такое соответствие называется кодировкой.

Известно, что Java использует кодировку Unicode, в которой символы представляются двухбайтовым кодом. Байтовые потоки зачастую работают с текстом упрощенно – они просто отбрасывают старший байт каждого символа. В реальных же приложениях могут использовать различные кодировки (даже для русского языка их существует несколько). Поэтому в версии Java 1.1 появился дополнительный набор классов, основывающийся на типах Reader и Writer. Их иерархия представлена на рис. 15.2.

Эта иерархия очень схожа с аналогичной для байтовых потоков InputStream и OutputStream. Главное отличие между ними – Reader и Writer работают с потоком символов (char). Только чтение массива символов в Reader описывается методом read(char[]), а запись в Writer – write(char[]).

В таблице 15.1 приведены соответствия классов для байтовых и символьных потоков.


Рис. 15.2.  Иерархия классов Reader и Writer.

Таблица 15.1. Соответствие классов для байтовых и символьных потоков.

Байтовый потокСимвольный поток

InputStream

Reader

OutputStream

Writer

ByteArrayInputStream

CharArrayReader

ByteArrayOutputStream

CharArrayWriter

Нет аналога

InputStreamReader

Нет аналога

OutputStreamWriter

FileInputStream

FileReader

FileOutputStream

FileWriter

FilterInputStream

FilterReader

FilterOutputStream

FilterWriter

BufferedInputStream

BufferedReader

BufferedOutputStream

BufferedWriter

PrintStream

PrintWriter

DataInputStream

Нет аналога

DataOutputStream

Нет аналога

ObjectInputStream

Нет аналога

ObjectOutputStream

Нет аналога

PipedInputStream

PipedReader

PipedOutputStream

PipedWriter

StringBufferInputStream

StringReader

Нет аналога

StringWriter

LineNumberInputStream

LineNumberReader

PushBackInputStream

PushBackReader

SequenceInputStream

Нет аналога

<
/p> Как видно из таблицы, различия крайне незначительны и предсказуемы.

Например, конечно же, отсутствует преобразование в символьное представление примитивных типов Java и объектов (DataInput/Output, ObjectInput/Output). Добавлены классы-мосты, преобразующие символьные потоки в байтовые: InputStreamReader и OutputStreamWriter . Именно на их основе реализованы FileReader и FileWriter. Метод available() класса InputStream в классе Reader отсутствует, он заменен методом ready(), возвращающим булевое значение, – готов ли поток к считыванию (то есть будет ли считывание произведено без блокирования).

В остальном же использование символьных потоков идентично работе с байтовыми потоками. Так, программный код для записи символьных данных в файл будет выглядеть примерно следующим образом:

Пример 15.15.

(html, txt)

Классы-мосты InputStreamReader и OutputStreamWriter при преобразовании символов также используют некоторую кодировку. Ее можно задать, передав в конструктор в качестве аргумента ее название. Если оно не будет соответствовать никакой из известных кодировок, будет брошено исключение UnsupportedEncodingException. Вот некоторые из корректных значений этого аргумента (чувствительного к регистру!) для распространенных кодировок: "Cp1251", "UTF-8", "8859_1" и т.д.


LineNumberInputStream


Класс LineNumberInputStream во время чтения данных производит подсчет, сколько строк было считано из потока. Номер строки, на которой в данный момент происходит чтение, можно узнать путем вызова метода getLineNumber(). Также можно и перейти к определенной строке вызовом метода setLineNumber(int lineNumber).

Под строкой при этом понимается набор байт, оканчивающийся либо '\n', либо '\r', либо их комбинацией '\r\n', именно в этой последовательности.

Аналогичный класс для исходящего потока отсутствует. LineNumberInputStream, начиная с версии 1.1, объявлен deprecated, то есть использовать его не рекомендуется. Его заменил класс LineNumberReader (рассматривается ниже), принцип работы которого точно такой же.



PipedInputStream и PipedOutputStream


Классы PipedInputStream и PipedOutputStream характеризуются тем, что их объекты всегда используются в паре – к одному объекту PipedInputStream привязывается (подключается) один объект PipedOutputStream. Они могут быть полезны, если в программе необходимо организовать обмен данными между модулями (например, между потоками выполнения).

Эти классы применяются следующим образом: создается по объекту PipedInputStream и PipedOutputStream, после чего они могут быть соединены между собой. Один объект PipedOutputStream может быть соединен с ровно одним объектом PipedInputStream, и наоборот. Затем в объект PipedOutputStream записываются данные, после чего они могут быть считаны именно в подключенном объекте PipedInputStream. Такое соединение можно обеспечить либо вызовом метода connect() с передачей соответствующего объекта PipedI/OStream (будем так кратно обозначать пару классов, в данном случае PipedInputStream и PipedOutputStream), либо передать этот объект еще при вызове конструктора.

Использование связки PipedInputStream и PipedOutputStream показано в следующем примере:

Пример 15.3.

(html, txt)

Данный пример носит чисто демонстративный характер (в результате его работы массив toRead будет заполнен случайными числами). Более явно выгода от использования PipedI/OStream в основном проявляется при разработке многопоточного приложения. Если в программе запускается несколько потоков исполнения, организовать передачу данных между ними удобно с помощью этих классов. Для этого нужно создать связанные объекты PipedI/OStream, после чего передать ссылки на них в соответствующие потоки. Поток выполнения, в котором производится чтение данных, может содержать подобный код:

Пример 15.4.

(html, txt)

Если с объектом inStream одновременно могут работать несколько потоков выполнения, то необходимо использовать блок synchronized (как и сделано в примере), который гарантирует, что в период между вызовами inStream.available() и inStream.read(…) ни в каком другом потоке выполнения не будет производиться считывание из inStream. Поэтому вызов inStream.read(readedBytes) не приведет к блокировке и все данные, готовые к считыванию, будут считаны.


// inStream - объект класса PipedInputStream try { while(true) { byte[] readedBytes = null; synchronized(inStream) { int bytesAvailable = inStream.available(); readedBytes = new byte[bytesAvailable]; inStream.read(readedBytes); } // обработка полученных данных из readedBytes // … } catch(IOException e) {

/* IOException будет брошено, когда поток inStream, либо связанный с ним PipedOutputStream, уже закрыт, и при этом производится попытка считывания из inStream */

System.out.println("работа с потоком inStream завершена"); }

Пример 15.4.

Если с объектом inStream одновременно могут работать несколько потоков выполнения, то необходимо использовать блок synchronized (как и сделано в примере), который гарантирует, что в период между вызовами inStream.available() и inStream.read(…) ни в каком другом потоке выполнения не будет производиться считывание из inStream. Поэтому вызов inStream.read(readedBytes) не приведет к блокировке и все данные, готовые к считыванию, будут считаны.


PrintStream


Этот класс используется для конвертации и записи строк в байтовый поток. В нем определен метод print(…), принимающий в качестве аргумента различные примитивные типы Java, а также тип Object. При вызове передаваемые данные будут сначала преобразованы в строку вызовом метода String.valueOf(), после чего записаны в поток. Если возникает исключение, оно обрабатывается внутри метода print и дальше не бросается (узнать, произошла ли ошибка, можно с помощью метода checkError()). При записи символов в виде байт используется кодировка, принятая по умолчанию в операционной системе (есть возможность задать ее явно при запуске JVM).

Этот класс также является deprecated, поскольку работа с кодировками требует особого подхода (зачастую у двухбайтовых символов Java старший байт просто отбрасывается). Поэтому в версии Java 1.1 появился дополнительный набор классов, основывающийся на типах Reader и Writer. Они будут рассмотрены позже. В частности, вместо PrintStream теперь рекомендуется применять PrintWriter. Однако старый класс продолжает активно использоваться, поскольку статические поля out и err класса System имеют именно это тип.



PushBackInputStream


Этот фильтр позволяет вернуть во входной поток считанные из него данные. Такое действие производится вызовом метода unread(). Понятно, что обеспечивается подобная функциональность за счет наличия в классе специального буфера – массива байт, который хранит считанную информацию. Если будет произведен откат (вызван метод unread), то во время следующего считывания эти данные будут выдаваться еще раз как только полученные. При создании объекта можно указать размер буфера.



Расширение стандартной сериализации


Некоторым сложно организованным классам требуется особый подход для сериализации. Для расширения стандартного механизма можно объявить в классе два метода с точно такой сигнатурой:

private void writeObject( java.io.ObjectOutputStream out) throws IOException; private void readObject( java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;

Если в классе объявлены такие методы, то при сериализации объекта для записи его состояния будет вызван writeObject, который должен сгенерировать последовательность байт и записать ее в поток out, полученный в качестве аргумента. При этом можно вызвать стандартный механизм записи объекта путем вызова метода

out.defaultWriteObject();

Этот метод запишет все не-transient и не-static поля в поток данных.

В свою очередь, при десериализации метод readObject должен считать данные из потока in (также полученного в качестве аргумента) и восстановить значения полей класса. При реализации этого метода можно обратиться к стандартному механизму с помощью метода:

in.defaultReadObject();

Этот метод считывает описание объекта из потока и присваивает значения соответствующих полей в текущем объекте.

Если же процедура сериализации в корне отличается от стандартной, то для таких классов предназначен альтернативный интерфейс java.io.Externalizable.

При использовании этого интерфейса в поток автоматически записывается только идентификация класса. Сохранить и восстановить всю информацию о состоянии экземпляра должен сам класс. Для этого в нем должны быть объявлены методы writeExternal() и readExternal() интерфейса Externalizable. Эти методы должны обеспечить сохранение состояния, описываемого полями самого класса и его суперкласса.

При восстановлении Externalizable-объекта экземпляр создается путем вызова конструктора без аргументов, после чего вызывается метод readExternal.

Метод writeExternal имеет сигнатуру:

void writeExternal(ObjectOutput out) throws IOException;

Для сохранения состояния вызываются методы ObjectOutput, с помощью которых можно записать как примитивные, так и объектные значения. Для корректной работы в соответствующем методе

void readExternal(ObjectInput in) throws IOException,ClassNotFoundException;

эти значения должны быть считаны в том же самом порядке.



SequenceInputStream


Класс SequenceInputStream объединяет поток данных из других двух и более входных потоков. Данные будут вычитываться последовательно – сначала все данные из первого потока в списке, затем из второго, и так далее. Конец потока SequenceInputStream будет достигнут только тогда, когда будет достигнут конец потока, последнего в списке.

В этом классе имеется два конструктора – принимающий два потока и принимающий Enumeration (в котором, конечно, должны быть только экземпляры InputStream и его наследников). Когда вызывается метод read(), SequenceInputStream пытается считать байт из текущего входного потока. Если в нем больше данных нет (считанное из него значение равно -1), у него вызывается метод close() и следующий входной поток становится текущим. Так продолжается до тех пор, пока не будут получены все данные из последнего потока. Если при считывании обнаруживается, что больше входных потоков нет, SequenceInputStream возвращает -1. Вызов метода close() у SequenceInputStream закрывает все содержащиеся в нем входные потоки.

Пример:

Пример 15.5.

(html, txt)

В результате выполнения этого примера в файл file3.txt будет записано содержимое файлов file1.txt и file2.txt – сначала полностью file1.txt, потом file2.txt. Закрытие потоков производится в блоке finally. Поскольку при вызове метода close() может возникнуть IOException, необходим try-catch блок. Причем, каждый вызов метода close() взят в отдельный try-catch блок - для того, чтобы возникшее исключение при закрытии одного потока не помешало закрытию другого. При этом нет необходимости закрывать потоки inFile1 и inFile2 – они будут автоматически закрыты при использовании в sequnceStream - либо когда в них закончатся данные, либо при вызове у sequenceStream метода close().

Объект SequenceInputStream можно было создать и другим способом: сначала получить объект Enumeration, содержащий все потоки, и передать его в конструктор SequenceInputStream:

Пример 15.6.

(html, txt)

Если заменить в предыдущем примере инициализацию sequenceStream на приведенную здесь, то в файл file3.txt, кроме содержимого файлов file1.txt и file2.txt, будут записаны еще три строки – одна в начале файла, одна между содержимым файлов file1.txt и file2.txt и еще одна в конце file3.txt.



Сериализация объектов (serialization)


Для объектов процесс преобразования в последовательность байт и обратно организован несколько сложнее – объекты имеют различную структуру, хранят ссылки на другие объекты и т.д. Поэтому такая процедура получила специальное название - сериализация (serialization), обратное действие, – то есть воссоздание объекта из последовательности байт – десериализация.

Поскольку сериализованный объект – это последовательность байт, которую можно легко сохранить в файл, передать по сети и т.д., то и объект затем можно восстановить на любой машине, вне зависимости от того, где проводилась сериализация. Разумеется, Java позволяет не задумываться при этом о таких факторах, как, например, используемая операционная система на машине-отправителе и получателе. Такая гибкость обусловила широкое применение сериализации при создании распределенных приложений, в том числе и корпоративных (enterprise) систем.



Система ввода/вывода. Потоки данных (stream)


Подавляющее большинство программ обменивается данными с внешним миром. Это, безусловно, делают любые сетевые приложения – они передают и получают информацию от других компьютеров и специальных устройств, подключенных к сети. Оказывается, можно точно таким же образом представлять обмен данными между устройствами внутри одной машины. Так, например, программа может считывать данные с клавиатуры и записывать их в файл, или же наоборот - считывать данные из файла и выводить их на экран. Таким образом, устройства, откуда может производиться считывание информации, могут быть самыми разнообразными – файл, клавиатура, входящее сетевое соединение и т.д. То же касается и устройств вывода – это может быть файл, экран монитора, принтер, исходящее сетевое соединение и т.п. В конечном счете, все данные в компьютерной системе в процессе обработки передаются от устройств ввода к устройствам вывода.

Обычно часть вычислительной платформы, которая отвечает за обмен данными, так и называется – система ввода/вывода. В Java она представлена пакетом java.io (input/output). Реализация системы ввода/вывода осложняется не только широким спектром источников и получателей данных, но еще и различными форматами передачи информации. Ею можно обмениваться в двоичном представлении, символьном или текстовом, с применением некоторой кодировки (только для русского языка их насчитывается более 4 штук), или передавать числа в различных представлениях. Доступ к данным может потребоваться как последовательный (например, считывание HTML-страницы), так и произвольный (сложная работа с несколькими частями одного файла). Зачастую для повышения производительности применяется буферизация.

В Java для описания работы по вводу/выводу используется специальное понятие поток данных (stream). Поток данных связан с некоторым источником, или приемником, данных, способным получать или предоставлять информацию. Соответственно, потоки делятся на входящие – читающие данные и выходящие – передающие (записывающие) данные. Введение концепции stream позволяет отделить основную логику программы, обменивающейся информацией с любыми устройствами одинаковым образом, от низкоуровневых операций с такими устройствами ввода/вывода.

В Java потоки естественным образом представляются объектами. Описывающие их классы как раз и составляют основную часть пакета java.io. Они довольно разнообразны и отвечают за различную функциональность. Все классы разделены на две части – одни осуществляют ввод данных, другие – вывод.

Существующие стандартные классы помогают решить большинство типичных задач. Минимальной "порцией" информации является, как известно, бит, принимающий значение 0 или 1 (это понятие также удобно применять на самом низком уровне, где данные передаются электрическим сигналом; условно говоря, 1 представляется прохождением импульса, 0 – его отсутствием). Традиционно используется более крупная единица измерения – байт, объединяющая 8 бит. Таким образом, значение, представленное одним байтом, находится в диапазоне от 0 до 28-1=255, или, если использовать знак, – от -128 до +127. Примитивный тип byte в Java в точности соответствует последнему – знаковому диапазону.

Базовые, наиболее универсальные, классы позволяют считывать и записывать информацию именно в виде набора байт. Чтобы их было удобно применять в различных задачах, java.io содержит также классы, преобразующие любые данные в набор байт.

Например, если нужно сохранить результаты вычислений – набор значений типа double – в файл, то их можно сначала превратить в набор байт, а затем эти байты записать в файл. Аналогичные действия совершаются и в ситуации, когда требуется сохранить объект (т.е. его состояние) – преобразование в набор байт и последующая их запись в файл. Понятно, что при восстановлении данных в обоих рассмотренных случаях проделываются обратные действия – сначала считывается последовательность байт, а затем она преобразуется в нужный формат.

На рисунке 15.1 представлены иерархии классов ввода/вывода. Как и говорилось, все типы поделены на две группы. Представляющие входные потоки классы наследуются от InputStream, а выходные – от OutputStream.


Рис. 15.1.  Иерархия классов ввода/вывода.



Стандартная сериализация


Для представления объектов в виде последовательности байт определены унаследованные от DataInput и DataOutput интерфейсы ObjectInput и ObjectOutput, соответственно. В java.io имеются реализации этих интерфейсов – классы ObjectInputStream и ObjectOutputStream.

Эти классы используют стандартный механизм сериализации, который предлагает JVM. Для того, чтобы объект мог быть сериализован, класс, от которого он порожден, должен реализовывать интерфейс java.io.Serializable. В этом интерфейсе не определен ни один метод. Он нужен лишь для указания, что объекты класса могут участвовать в сериализации. При попытке сериализовать объект, не имеющий такого интерфейса, будет брошен java.io.NotSerializableException.

Чтобы начать сериализацию объекта, нужен выходной поток OutputStream, в который и будет записываться сгенерированная последовательность байт. Этот поток передается в конструктор ObjectOutputStream. Затем вызовом метода writeObject() объект сериализуется и записывается в выходной поток. Например:

ByteArrayOutputStream os = new ByteArrayOutputStream(); Object objSave = new Integer(1); ObjectOutputStream oos = new ObjectOutputStream(os); oos.writeObject(objSave);

Чтобы увидеть, во что превратился объект objSave, можно просмотреть содержимое массива:

byte[] bArray = os.toByteArray();

А чтобы восстановить объект, его нужно десериализовать из этого массива:

ByteArrayInputStream is = new ByteArrayInputStream(bArray); ObjectInputStream ois = new ObjectInputStream(is); Object objRead = ois.readObject();

Теперь можно убедиться, что восстановленный объект идентичен исходному:

System.out.println("readed object is: " + objRead.toString()); System.out.println("Object equality is: " + (objSave.equals(objRead))); System.out.println("Reference equality is: " + (objSave==objRead));

Результатом выполнения приведенного выше кода будет:

readed object is: 1 Object equality is: true Reference equality is: false

Как мы видим, восстановленный объект не совпадает с исходным (что очевидно – ведь восстановление могло происходить и на другой машине), но равен сериализованному по значению.

Как обычно, для упрощения в примере была опущена обработка ошибок. Однако, сериализация (десериализация) объектов довольно сложная процедура, поэтому возникающие сложности не всегда очевидны. Рассмотрим основные исключения, которые может генерировать метод readObject() класса ObjectInputStream.

Предположим, объект некоторого класса TestClass был сериализован и передан по сети на другую машину для восстановления. Может случиться так, что у считывающей JVM на локальном диске не окажется описания этого класса (файл TestClass.class). Поскольку стандартный механизм сериализации записывает в поток байт лишь состояние объекта, для успешной десериализации необходимо наличие описание класса. В результате будет брошено исключение ClassNotFoundException.

Причина появления java.io.StreamCorruptedException вполне очевидна из названия – неправильный формат входного потока. Предположим, происходит попытка считать сериализованный объект из файла. Если этот файл испорчен (для эксперимента можно открыть его в текстовом редакторе и исправить несколько символов), то стандартная процедура десериализации даст сбой. Эта же ошибка возникнет, если считать некоторое количество байт (с помощью метода read) непосредственно из надстраиваемого потока InputStream. В таком случае ObjectInputStream снова обнаружит сбой в формате данных и будет брошено исключение java.io.StreamCorruptedException.

Поскольку ObjectOutput наследуется от DataOutput, ObjectOutputStream может быть использован для последовательной записи нескольких значений как объектных, так и примитивных типов в произвольной последовательности. Если при считывании будет вызван метод readObject, а в исходном потоке следующим на очереди записано значение примитивного типа, будет брошено исключение java.io.OptionalDataException. Очевидно, что для корректного восстановления данных из потока их нужно считывать именно в том порядке, в каком были записаны.



StringBufferInputStream


Иногда бывает удобно работать с текстовой строкой String как с потоком байт. Для этого можно воспользоваться классом StringBufferInputStream. При создании объекта этого класса необходимо передать конструктору объект String. Данные, возвращаемые методом read(), будут считываться именно из этой строки. При этом символы будут преобразовываться в байты с потерей точности – старший байт отбрасывается (напомним, что символ char состоит из двух байт).



Восстановление состояния


Итак, сериализация объекта заключается в сохранении и восстановлении состояния объекта. В Java в большинстве случаев состояние описывается значениями полей объекта. Причем, что важно, не только тех полей, которые были явно объявлены в классе, от которого порожден объект, но и унаследованных полей.

Предположим, мы бы попытались своими силами реализовать стандартный механизм сериализации. Нам передается выходной поток, в который нужно записать состояние нашего объекта. С помощью DataOutput интерфейса можно легко сохранить значения всех доступных полей (будем для простоты считать, что они все примитивного типа). Однако в большинстве случаев в родительских классах могут быть объявлены недоступные нам поля (например, private). Тем не менее, такие поля, как правило, играют важную роль в определении состояния объекта, так как они могут влиять на результат работы унаследованных методов. Как же сохранить их значения?

С другой стороны, не меньшей проблемой является восстановление объекта. Как говорилось раньше, объект может быть создан только вызовом его конструктора. У класса, от которого порожден десериализуемый объект, может быть несколько конструкторов, причем, некоторые из них, или все, могут иметь аргументы. Какой из них вызвать? Какие значения передать в качестве аргументов?

После создания объекта необходимо установить считанные значения его полей. Однако многие классы имеют специальные set-методы для этой цели. В таких методах могут происходить проверки, могут меняться значения вспомогательных полей. Пользоваться ли этими методами? Если их несколько, то как выбрать правильный и какие параметры ему передать? Снова возникает проблема работы с недоступными полями, полученными по наследству. Как же в стандартном механизме сериализации решены все эти вопросы?

Во-первых, рассмотрим подробнее работу с интерфейсом Serializable. Заметим, что класс Object не реализует этот интерфейс. Таким образом, существует два варианта – либо сериализуемый класс наследуется от Serializable-класса, либо нет. Первый вариант довольно прост. Если родительский класс уже реализовал интерфейс Serializable, то наследникам это свойство передается автоматически, то есть все объекты, порожденные от такого класса, или любого его наследника, могут быть сериализованы.

Если же наш класс впервые реализует Serializable в своей ветке наследования, то его суперкласс должен отвечать специальному требованию – у него должен быть доступный конструктор без параметров. Именно с помощью этого конструктора будет создан десериализуемый объект и будут проинициализированы все поля, унаследованные от классов, не наследующих Serializable.

Рассмотрим пример:


Пример 15.10.

(html, txt)

В этом примере объявлено 3 класса. Класс Parent не реализует Serializable и, следовательно, не может быть сериализован. В нем объявлено 2 поля, которые при создании получают значения, содержащие слово "old" ("старый"). Кроме этого, объявлен метод, позволяющий модифицировать эти поля. Он выставляет им значения, содержащие слово "new" ("новый’). Также переопределен метод toString(), чтобы дать возможность узнать значения этих полей.

Поскольку класс Parent имеет доступный конструктор по умолчанию, его наследник может реализовать интерфейс Serializable. Обратите внимание, что у самого класса Child такого конструктора уже нет. Также объявлено поле и модифицирован метод toString().

Наконец, класс Child2 наследуется от Child, а потому автоматически является допустимым для сериализации. Аналогично, имеет новое поле, значение которого отображает toString().

Запускаемый класс Test сериализует в файл output.bin два объекта. Обратите внимание, что у первого из них предварительно вызывается метод changeNames(), который модифицирует значения полей, унаследованных от класса Parent.

Результат выполнения примера:

Пример 15.11.

(html, txt)

Во всех конструкторах вставлена строка, выводящая сообщение на консоль. Так можно отследить, какие конструкторы вызываются во время десериализации. Видно, что для объектов, порожденных от Serializable-классов, конструкторы не вызываются вовсе. Идет обращение лишь к конструктору без параметров не-Serializable-суперкласса.

Сравним значения полей первого объекта и его копии, полученной десериализацией. Поля, унаследованные от не-Serializable-класса (firstName, lastName), не восстановились. Они имеют значения, полученные в конструкторе Parent без параметров. Поля, объявленные в Serializable-классе, свои значения сохранили. Это верно и для второго объекта – собственные поля Child2 и унаследованные от Child имеют точно такие же значения, что и до сериализации. Их значения были записаны, а потом считаны и напрямую установлены из потока данных.

Иногда в классе есть поля, которые не должны участвовать в сериализации. Тому может быть несколько причин. Например, это поле малосущественно (временная переменная) и сохранять его нет необходимости. Если сериализованный объект передается по сети, то исключение такого поля из сериализации позволяет уменьшить нагрузку на сеть и ускорить работу приложения.

Некоторые поля хранят значения, которые не будут иметь смысла при пересылке объекта на другую машину, или при воссоздании его спустя какое-то время. Например, сетевое соединение, или подключение к базе данных, в таких случаях нужно устанавливать заново.

Затем, в объекте может храниться конфиденциальная информация, например, пароль. Если такое поле будет сериализовано и передано по сети, его значение может быть перехвачено и прочитано, или даже подменено.

Для исключения поля объекта из сериализации его необходимо объявить с модификатором transient. Например, следующий класс:

class Account implements java.io.Serializable { private String name; private String login; private transient String password; /* объявление других элементов класса ... */ }

У такого класса поле password в сериализации участвовать не будет и при восстановлении оно получит значение по умолчанию (в данном случае null).

Особого внимания требуют статические поля. Поскольку они принадлежат классу, а не объекту, они не участвуют в сериализации. При восстановлении объект будет работать с таким значением static-поля, которое уже установлено для его класса в этой JVM.



Create Parent Create Child Child@ad3ba4,first=new_first,last=new_last,age= 2 Create Parent Create Child Create Child2 Read objects: Create Parent Child@723d7c,first=old_first,last=old_last,age=2 Create Parent Child2@22c95b,first=old_first,last=old_last,age=3,size=4

Пример 15.11.

Во всех конструкторах вставлена строка, выводящая сообщение на консоль. Так можно отследить, какие конструкторы вызываются во время десериализации. Видно, что для объектов, порожденных от Serializable-классов, конструкторы не вызываются вовсе. Идет обращение лишь к конструктору без параметров не-Serializable-суперкласса.

Сравним значения полей первого объекта и его копии, полученной десериализацией. Поля, унаследованные от не-Serializable-класса (firstName, lastName), не восстановились. Они имеют значения, полученные в конструкторе Parent без параметров. Поля, объявленные в Serializable-классе, свои значения сохранили. Это верно и для второго объекта – собственные поля Child2 и унаследованные от Child имеют точно такие же значения, что и до сериализации. Их значения были записаны, а потом считаны и напрямую установлены из потока данных.

Иногда в классе есть поля, которые не должны участвовать в сериализации. Тому может быть несколько причин. Например, это поле малосущественно (временная переменная) и сохранять его нет необходимости. Если сериализованный объект передается по сети, то исключение такого поля из сериализации позволяет уменьшить нагрузку на сеть и ускорить работу приложения.

Некоторые поля хранят значения, которые не будут иметь смысла при пересылке объекта на другую машину, или при воссоздании его спустя какое-то время. Например, сетевое соединение, или подключение к базе данных, в таких случаях нужно устанавливать заново.

Затем, в объекте может храниться конфиденциальная информация, например, пароль. Если такое поле будет сериализовано и передано по сети, его значение может быть перехвачено и прочитано, или даже подменено.

Для исключения поля объекта из сериализации его необходимо объявить с модификатором transient. Например, следующий класс:

class Account implements java.io.Serializable { private String name; private String login; private transient String password; /* объявление других элементов класса ... */ }

У такого класса поле password в сериализации участвовать не будет и при восстановлении оно получит значение по умолчанию (в данном случае null).

Особого внимания требуют статические поля. Поскольку они принадлежат классу, а не объекту, они не участвуют в сериализации. При восстановлении объект будет работать с таким значением static-поля, которое уже установлено для его класса в этой JVM.


В данной лекции вы познакомились


В данной лекции вы познакомились с таким важным понятием, как потоки данных (stream). Потоки являются очень эффективным способом решения задач, связанных с передачей и получением данных, независимо от особенностей используемых устройств ввода/вывода. Как вы теперь знаете, именно в пакете java.io содержатся стандартные классы, решающие задачи обмена данными в самых различных форматах.
Были описаны базовые классы байтовых потоков InputStream и OutputStream, а также символьных потоков Reader и Writer. Все классы потоков явным или неявным образом наследуются от них. Краткий обзор показал, для чего предназначен каждый класс, как с ним работать, какие классы не рекомендованы к использованию. Изучено, как передавать в потоки значения примитивных типов Java. Особое внимание было уделено операциям с объектами, для которых существует специальный механизм сериализации.
Наконец, были описаны классы для работы с файловой системой – File и RandomAccessFile.

Application layer (layer 7)


Последний уровень – уровень приложений, на котором определяются взаимодействующие стороны, учитывается авторизация пользователя, определяется качество обслуживания (quality of service) и, собственно, обеспечивается выполнение прикладных задач, таких, как обмен файлами, электронными письмами и т.д. Уровень приложения – это не само приложение, хотя зачастую программы выполняют некоторые функции Application layer.

Уже упоминались многие протоколы этого уровня: FTP, HTTP, telnet. Этот список легко продолжить, например, протоколы POP3 и SMTP для получения и отправки электронных писем, или протоколы DNS (Domain Name System, служба имен доменов), обеспечивающие преобразование числовых IP- адресов в текстовые доменные имена и обратно. Хотя Internet с технической точки зрения построен на основе IP-адресации, текстовые имена понятнее и легче запоминаются, а потому гораздо более распространены среди обычных пользователей.

Рассмотрим принцип работы DNS более подробно. Все привыкли обращаться к, например, web-серверам по доменному имени. С другой стороны для установления соединения требуется IP-адрес. Так, при обращении к серверу www.ru устанавливается TCP-соединение с хостом 194.87.0.50.

Поскольку в сети огромное количество серверов, DNS-имена являются иерархическими, иначе с ними было бы очень затруднительно работать. Иерархические части имени записываются через точку. Первый уровень указывается последним. Первоначально существовало 7 трехбуквенных доменов первого уровня:

com – commercial (коммерческие организации);

org – non-profit (некоммерческие организации);

net – network service (организация работы сети);

edu – educational (образование, зачастую – американские университеты);

int – international (международные организации);

gov – government (правительство, организации американского правительства);

mil – military (военные, американские военные организации).

Кроме того, для каждой страны был заведен двухбуквенный домен, например, ru - Россия, su – СССР, us – США, fr – Франция и т.д. В последнее время вводятся новые доменные имена верхнего уровня, такие, как biz и info.


В каждом домене первого уровня может быть множество доменов второго уровня. Так, существует множество сайтов в домене ru, или com. У домена второго уровня может быть множество доменов третьего уровня и т.д.

Как же определить, какому IP-адресу соответствует доменное имя сервера, к которому обращается пользователь? Для этого существует аналогичная иерархическая система DNS-серверов, каждый из которых отвечает за свой домен. В сетевых настройках компьютера указывается адрес локального DNS-сервера. При запросе к нему сервер сначала проверяет список имен, за которые отвечает он сам, и кеш. Если искомое имя ему неизвестно, он делает запрос вышестоящему DNS-серверу. Например, при обращении к intuit.ru будет сделан запрос к DNS-серверу, отвечающему за домен ru.

В свою очередь, сервер intuit.ru знает про все имена в своей зоне intuit.ru, либо, в случае обращения к домену следующего уровня (например, node1.host1.intuit.ru), знает адрес другого сервера (host1.intuit.ru), который за него отвечает, и на этот сервер перенаправляет запрос.

Таким образом можно установить IP-адрес для любого зарегистрированного доменного имени.


ARP


Как уже было сказано ранее, в оперативной памяти компьютера находится ARP-таблица. В ней содержатся MAC-адрес удаленной машины и соответствующий ему IP-адрес. Для просмотра этой таблицы используется команда arp. Например, arp –a выводит все известные MAC- адреса.


Существует два типа записей в ARP-таблице – статический и динамический. Статическая запись вносится вручную и существует до тех пор, пока вручную же не будет удалена, или компьютер (маршрутизатор) не будет перезагружен.

Динамическая запись появляется при попытке отправить сообщение на IP- адрес, для которого неизвестен MAC-адрес. В этом случае формируется ARP-запрос, который позволяет этот адрес определить, после чего соответствующая динамическая запись добавляется в ARP-таблицу. Храниться там она будет не постоянно. После определенного времени она будет автоматически удалена, если к данному IP-адресу не было обращений. Задержка на получение MAC-адреса составляет порядка нескольких миллисекунд, так что для пользователя это будет практически незаметно, зато появляется возможность отследить изменения в конфигурации сети (в соответствии IP- и MAC-адресов).



Class D


Этот класс используется для особых задач (multicast-группы). Диапазон адресов – 224.0.0.0-239.255.255.255.



Class E


Этот класс адресов зарезервирован для применения в будущем. Диапазон адресов – 240.0.0.0-247.255.255.255.

Два адреса в каждой подсети являются зарезервированными. IP-адрес, в котором вся хост-часть состоит из бинарных нулей, используется для обозначения адреса самой сети. Например, сеть класса A может иметь адрес 112.0.0.0, а компьютер, подключенный к ней, – адрес 112.2.3.4. Адрес сети используется роутерами для задания маршрута.

Второй зарезервированный адрес – броадкаст-адрес (broadcast). Этот адрес применяется, когда источник хочет послать данные всем устройствам в локальной сети. Для этого хост-часть заполняется бинарными единицами. Например, для рассмотренной сети 112.0.0.0 это будет адрес 112.255.255.255, а для сети класса B 171.10.0.0 броадкаст-адрес будет выглядеть как 171.10.255.255. Данные, посланные по адресу 171.10.255.255, будут получены всеми устройствами в сети 171.10.0.0.



Data layer (layer 2)


Физический уровень пересылает просто набор сигналов – битов. При этом не учитывается, что несколько компьютеров, подключенных к одной среде передачи данных (например, к одному кабелю), могут начать одновременно передавать информацию в виде электрических импульсов, что, очевидно, приведет к смешению сигналов. Поэтому одной из задач Data layer (канальный уровень) является проверка доступности среды передачи. Также этот уровень отвечает за доставку фреймов между источником и адресатом в пределах сети с одной топологией. Для обеспечения такой функциональности Data layer разделяют на два подуровня:

логическая передача данных (Logical Link Control, LLC);

управление доступом к среде (Media Access Control, MAC).

LLC отвечает за переход со второго уровня на более высший – третий сетевой уровень.

MAC отвечает за передачу данных на более низкий уровень – Physical layer.

Рассмотрим эти подуровни более подробно.



30 hops max, 38 byte


traceroute to netserv1.chg.ru (193.233.46.3), 30 hops max, 38 byte packets 1 n3-core.mipt.ru (194.85.80.1) 1.508 ms 0.617 ms 0.798 ms 2 mipt-gw-eth0.mipt.ru (193.125.142.177) 2.362 ms 2.666 ms 1.449 ms 3 msu-mipt-atm0.mipt.ru (212.16.1.1) 5.536 ms 5.993 ms 10.431 ms 4 M9-LYNX.ATM6-0.11.M9-R2.msu.net (193.232.127.229) 12.994 ms 7.830 ms 6.816 ms 5 Moscow-BNS045-ATM4-0-3.free.net (147.45.20.37) 12.228 ms 7.041 ms 8.731 ms 6 ChgNet-gw.free.net (147.45.20.222) 77.103 ms 75.234 ms 92.334 ms 7 netserv1.chg.ru (193.233.46.3) 96.627 ms 94.714 ms 134.676 ms
Пример 16.1.
Закрыть окно


import java.io.*; import java.net.*;

public class NetServer { public static final int PORT = 2500; private static final int TIME_SEND_SLEEP = 100; private static final int COUNT_TO_SEND = 10; private ServerSocket servSocket;

public static void main(String[] args) { NetServer server = new NetServer(); server.go(); }

public NetServer() { try{ servSocket = new ServerSocket(PORT); } catch(IOException e) { System.err.println("Unable to open Server Socket : " + e.toString()); } }

public void go() {

// Класс-поток для работы с //подключившимся клиентом class Listener implements Runnable { Socket socket; public Listener(Socket aSocket) { socket = aSocket; } public void run() { try { System.out.println("Listener started"); int count = 0; OutputStream out = socket.getOutputStream(); OutputStreamWriter writer = new OutputStreamWriter(out); PrintWriter pWriter = new PrintWriter(writer); while (count<COUNT_TO_SEND) { count++; pWriter.print(((count>1)?",":"")+ "Say" + count); sleeps(TIME_SEND_SLEEP); } pWriter.close(); } catch(IOException e) { System.err.println("Exception : " + e.toString()); } } }

// Основной поток, циклически выполняющий метод accept() System.out.println("Server started"); while (true) { try { Socket socket = servSocket.accept(); Listener listener = new Listener(socket); Thread thread = new Thread(listener); thread.start(); } catch(IOException e) { System.err.println("IOException : " + e.toString()); } } }

public void sleeps(long time) { try { Thread.sleep(time); } catch(InterruptedException e) { } } }
Пример 16.2.
Закрыть окно


import java.io.*; import java.net.*;

public class NetClient implements Runnable { public static final int PORT = 2500; public static final String HOST = "localhost"; public static final int CLIENTS_COUNT = 5; public static final int READ_BUFFER_SIZE = 10;

private String name = null;

public static void main(String[] args) { String name = "name"; for (int i=1; i<=CLIENTS_COUNT; i++) { NetClient client = new NetClient(name+i); Thread thread = new Thread(client); thread.start(); } }

public NetClient(String name) { this.name = name; }

public void run() { char[] readed = new char[READ_BUFFER_SIZE]; StringBuffer strBuff = new StringBuffer(); try { Socket socket = new Socket(HOST, PORT); InputStream in = socket.getInputStream(); InputStreamReader reader = new InputStreamReader(in); while (true) { int count = reader.read(readed, 0, READ_BUFFER_SIZE); if (count==-1) break; strBuff.append(readed, 0, count); Thread.yield(); } } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } System.out.println("client " + name + " read : " + strBuff.toString()); } }
Пример 16.3.
Закрыть окно


import java.io.*; import java.net.*;

public class DatagramDemoServer { public static final int PORT = 2000; private static final int LENGTH_RECEIVE = 1; private static final byte[] answer = ("received").getBytes(); private DatagramSocket servSocket = null; private boolean keepRunning = true; public static void main(String[] args) { DatagramDemoServer server = new DatagramDemoServer(); server.service(); }

public DatagramDemoServer() { try { servSocket = new DatagramSocket(PORT); } catch(SocketException e) { System.err.println("Unable to open socket : " + e.toString()); } } protected void service() { DatagramPacket datagram; InetAddress clientAddr; int clientPort; byte[] data; while (keepRunning) { try { data = new byte[LENGTH_RECEIVE]; datagram = new DatagramPacket(data, data.length); servSocket.receive(datagram); clientAddr = datagram.getAddress(); clientPort = datagram.getPort(); data = getSendData(datagram.getData()); datagram = new DatagramPacket(data, data.length, clientAddr, clientPort); servSocket.send(datagram); } catch(IOException e) { System.err.println("I/O Exception : " + e.toString()); } } } protected byte[] getSendData(byte b[]) { byte[] result = new byte[b.length+answer.length]; System.arraycopy(b, 0, result, 0, b.length); System.arraycopy(answer, 0, result, b.length, answer.length); return result; } }
Пример 16.4.
Закрыть окно

IP-адрес


IP-адрес представляется 32-битным бинарным числом, которое часто записывают в виде 4 десятичных чисел, от 0 до 255 каждое. Например: 60.13.54.11, 130.154.201.1, 194.11.3.200. Логически он состоит из двух частей – адреса машины (host) и адреса сети (network). Сетевая часть IP-адреса показывает, к какой сети принадлежит адресат, а хост-часть (host) идентифицирует сетевое устройство в этой сети. Компьютеры с одинаковой сетевой частью находятся в одной локальной сети, а потому могут легко обмениваться данными. Если же у них различные network-ID, то, даже находясь в одном физическом сегменте, они обычно не могут "увидеть" друг друга.

Так как IP-адрес состоит из 4-х октетов (так называют эти числа, поскольку 256=28), один, два или три первых октета могут использоваться для определения сетевого адреса, остальные задают host-части. Для удобства выделения адресов пользователям (ведь, как правило, организации требуется их сразу несколько), было введено 5 классов адресов. Их обозначают латинскими буквами от A до E. В открытых сетях используются первые три из них.

В таблице 16.2 дано примерное разбиение IP-адресов на сетевую (N) и машинную (H) части в зависимости от класса сети.

Таблица 16.2. Примерное разбиение IP-адресов.

1 октет2 октет3 октет4 октет
Класс A

N

H

H

H

Класс B

N

N

H

H

Класс C

N

N

N

H



IPCONFIG (IFCONFIG)


Начнем с утилиты, которая позволяет просматривать, проверять и изменять сетевые настройки. Обычно эти настройки включают в себя информацию 3-го (сетевого) уровня – IP-адрес, маску подсети и т.д. Для работы с ними в ОС Windows можно использовать команду ipconfig. Она выдает информацию об IP- адресе, маске подсети (netmask), роутере по умолчанию (default gateway). Задав дополнительный параметр -all, можно получить более подробную информацию – имя компьютера, имя домена, тип сетевой карты, MAC-адрес и т.д.


В ОС Solaris для получения IP-адреса и прочих сетевых настроек используется команда ifconfig. Она также показывает название интерфейса, IP-адреса, маску подсети, MAC-адрес.




Класс A


В классе A для идентификации сети, к которой принадлежит адрес, используется первый октет, причем, первый бит всегда равен 0. Остальные октеты задают адрес хоста. Таким образом, адрес сети класса A может быть в диапазоне 0-126. 127-й адрес зарезервирован для специального использования – все адреса, начинающиеся со 127, считаются локальными для сетевого адаптера, то есть всегда отправитель сам является и получателем. Остальные свободные три октета применяются для задания адреса хоста в данной сети. Это означает, что в одной сети может быть использовано до 224 адресов (из них два крайних, то есть 0 и 224-1, зарезервированы, они рассматриваются ниже). Стало быть, в каждой из 127 сетей класса A можно адресовать 16,777,214 машин.

Диапазон адресов 10.0.0.0-10.255.255.255 в публичных сетях не используется. Эти адреса специально зарезервированы для применения в локальных сетях и глобальными маршрутизаторами не обрабатываются.



Класс B


В сети класса B первые два октета (причем, первый бит всегда равен 1, второй – 0) используются для определения сети, последние два октета – для определения адреса хоста. Диапазон адресов сети класса B лежит в пределах от 128.0.x.x до 191.255.x.x, что дает 16,384 таких сетей. В каждой из них может быть не более 65,534=216-2 адресов (два крайних адреса исключаются).

В этой подсети зарезервированными для локального использования являются следующие адреса: 172.16.0.0-172.31.0.0.



Класс C


Диапазон сети класса C определяется первыми тремя октетами (первые биты всегда 110). И в десятичном виде эта сеть может начинаться со 192 по 223. Для определения адреса хоста используется последний октет. Таким образом, в каждой из 2,097,152 сетей класса C может быть задействовано 28 (без двух крайних) или 254 адреса.

Зарезервированными для локального использования являются следующие адреса: 192.168.0.0-192.168.255.255.



LLC sublayer


Этот подуровень был создан для обеспечения независимости от существующих технологий. Он обеспечивает обмен данными с сетевым (третьим) уровнем вне зависимости от физической среды передачи данных. LLC получает данные с сетевого уровня, добавляет в них служебную информацию и передает пакет для последующей инкапсуляции и обработки протоколом уровня MAC. Например, это может быть Ethernet, Token Ring, Frame Relay.



MAC sublayer


Этот подуровень обеспечивает доступ к физическому уровню. Для передачи пакетов по сети необходимо организовать идентификацию компьютеров в сети. Для этого у каждого компьютера на канальном уровне определен уникальный адрес, который еще иногда называют физическим адресом, или MAC-адресом.

Он записан в энергонезависимой памяти сетевой карты) и задается производителем. Длина MAC-адреса 48 бит, или 6 байт (каждый байт состоит из 8 бит), которые записываются в шестнадцатеричном формате. Первые 3 байта называются OUI (Organizational Unique Identifier), организационный уникальный идентификатор. Этот номер выдается каждому производителю сетевого оборудования международной организацией IEEE (Institute of Electrical and Electronic Engineers, Институт инженеров по электротехнике и радиоэлектронике, источник многих стандартов и спецификаций). Последние 3 байта являются идентификационным номером самой сетевой карты. Производитель гарантирует, что все его адаптеры имеют различные номера. Такая система адресов гарантирует, что в сети не будет двух компьютеров с одинаковыми физическими адресами.

Записываться физический адрес может в разных форматах, например: 00:00:B4:90:4C:8C, 00-00-B4-90-4C-8C, 0000.B490.4C8C – разные производители используют разные стандарты. Рассмотрим, например, адрес 0000.1c12.3456. Здесь 0000.1с – идентификатор производителя, а 12.3456 – идентификатор сетевой карты.

Один из самых распространенных протоколов MAC-уровня – протокол Ethernet. В сетях, построенных на его основе, применяется специальный метод для организации доступа к среде передачи данных – CSMA/CD (carrier sense multiply access/collision detect, коллективный доступ с опознаванием несущей и обнаружением коллизий). Предполагается, что основой сети является общая шина (например, коаксиальный кабель), к которой подключены все компьютеры. В результате сообщение, отправленное одной машиной, доставляется всем подключенным сетевым устройствам. CSMA/CD описывает целый комплекс мер, необходимых для предотвращения и корректной обработки коллизий (collision), то есть ситуаций, когда несколько компьютеров одновременно начали передачу данных. Очевидно, что в таком случае никто не сможет получить корректную информацию из сети.


Рассмотрим более подробно процесс передачи данных на Data layer. Пусть один компьютер собирается послать данные другому. Во время процесса инкапсуляции MAC-адрес этой машины и MAC-адрес получателя будут записаны в служебные поля. Сгенерированное сообщение по правилам протокола Ethernet отсылается через общую шину всем машинам, подключенным к этому участку сети.

Каждый компьютер, получивший сообщение, проверяет, кому оно было адресовано. Если MAC-адрес, указанный во фрейме, и MAC-адрес, записанный в сетевом адаптере получателя, совпадают, то пакет принимается и передается на вышестоящий уровень для дальнейшей обработки. Если же адрес в пакете не совпадает с адресом сетевой карты, то такой пакет отбрасывается.

Иногда бывает необходимо послать сообщение, которое должно быть получено всеми узлами локальной сети. В этом случае в пакете указывается MAC-адрес получателя в виде FF-FF-FF-FF-FF-FF. Этот адрес используется для широковещания (broadcast), которое примут все сетевые устройства и передадут на вышестоящий уровень.

Рассмотрим устройства, применяемые для построения сетей в разных топологиях.

Топология шина ("bus") описывает общую среду передачи данных, которая уже рассматривалась для иллюстрации протокола Ethernet. Специальных устройств для построения такой сети не используется (впрочем, конкретные технологии могут предъявлять специфические требования; например, концы коаксиального кабеля должны подключаться к особому устройству – терминатору, но это не влияет на структуру сети).

На топологии кольцо ("ring") основывается протокол Token Ring. Физически сеть представляет собой замкнутое кольцо, в котором каждый компьютер двумя отрезками кабеля соединяется со своими соседями. В отличие от сети, работающей на основе Ethernet, здесь используется более сложная схема. Передача ведется последовательно по кольцу в одном направлении. В сети циркулирует кадр специального формата – маркер (token). Если машина не имеет данных для передачи, она при получении маркера передает его дальше по кольцу. В противном случае она изымает его из обращения, что дает ей доступ к сети, и затем отправляет пакет с адресом получателя, который начинает передаваться по кольцу. Когда он доходит до адресата, тот делает пометку, что пакет получен. Машина-отправитель, получив подтверждение, отправляет соседу новый маркер для обеспечения возможности другим станциям сети передавать данные. Хотя этот алгоритм более сложен, он обеспечивает свойства отказоустойчивости.



При построении сети на основе топологии "звезда" нужно использовать, кроме сетевых карт в компьютере, дополнительное сетевое оборудование в центре, куда подключаются все "лучи звезды". Например, в качестве такого устройства может применяться концентратор (hub). В этом случае каждый компьютер подключается к нему с помощью кабеля "витая пара". Алгоритм работы концентратора очень прост – получив пакет на один из своих портов, он пересылает его на все остальные. В результате снова получается общая шина, точнее, – логическая общая шина, поскольку физическая структура сети звездно-образная. Технология Ethernet позволяет снизить количество коллизий с помощью CSMA/CD. Недостатком концентратора является то, что пользователи сети могут "прослушивать" чужой трафик (в том числе перехватить пароль, если он передается в открытом виде). Общая максимальная скорость делится между всеми подключенными пользователями. То есть, если скорость передачи данных составляет 10 Мбит/с, то в среднем на каждого пользователя может приходиться всего 2 Мбит/с.

Более дорогим, но и более производительным решением является использование коммутатора (switch). Коммутатор, в отличие от концентратора, имеет в памяти таблицу, сопоставляющую номера его портов и MAC-адреса подключенных к нему компьютеров. Он анализирует у каждого пересылаемого фрейма адрес отправителя, пытаясь определить, какие машины подключены к каждому из его портов. Таким образом коммутатор заполняет свою таблицу. Далее при прохождении очередного фрейма он проверяет адрес получателя, и если он знает, к какому порту подключена эта машина, он посылает фрейм только на один этот порт. Если адрес получателя коммутатору неизвестен, то он отправляет фрейм на все порты, кроме того, с которого этот пакет пришел. Таким образом, получается, что если два компьютера обмениваются данными между собой, то они не перегружают своими пакетами другие порты и, соответственно, их пакеты практически невозможно перехватить.

Построенные таким образом сети могут охватывать несколько сотен машин и иметь протяженность в несколько километров. Как правило, такая сеть охватывает одно или несколько зданий одного предприятия, а потому называется локальной сетью (Local area network, LAN).


Netstat


Утилита netstat позволяет определить, какие порты открыты и по каким портам происходит передача данных между узлами сети. Например, если запустить web-браузер и открыть для просмотра web-страницу, то, запустив netstat, можно увидеть следующую строку:

TCP mycomp:3687 www.ru:http ESTABLISHED

В проведенном примере первое значение – TCP – тип протокола (может быть TCP или UDP), далее следует имя локальной машины и локальный порт, www.ru:http – имя удаленного хоста и порта, к которому производится обращение (поскольку использовался порт по умолчанию для протокола HTTP, то отображается не его числовое значение 80, а имя протокола), ESTABLISHED – показывает, что TCP-соединение установлено.


В ОС Windows с помощью команды netstat –an можно получить список всех открытых портов (параметр –n не определяет DNS-имя, а выводит только IP-адрес). Из примера выше видно, что установленных соединений нет, а все открытые порты находятся в состоянии "прослушивания", т.е. к этому порту можно обратиться для установки соединения. TCP-порт 139 отвечает за установку Netbios-сессий (например, для передачи данных через "сетевое окружение").


В ОС Solaris для получения информации об используемых портах также применяется утилита netstat. Формат вывода практически одинаков.



Network layer (layer 3)


В предыдущей лекции мы рассмотрели второй уровень в модели OSI. Одним из ограничений этого уровня является использование "плоской" одноуровневой модели адресации. При попытке построить большую сеть, применяя для идентификации компьютеров MAC-адреса, мы получим огромное количество broadcast-трафика. Протокол, который поддерживается третьим уровнем, задействует иерархическую структуру для уникальной идентификации компьютеров.

Для примера представим себе телефонную сеть. Она также имеет иерархическую адресацию. Например, в номере +7-095-101-12-34 первая цифра обозначает код страны, далее идет код области/города(095), а затем указывается сам телефон (101-12-34). Последний номер также является составным. 101 – это код станции, куда подключен телефон, а 12-34 определяет местоположение телефона. Благодаря такой иерархической структуре мы можем определить расположение требуемого абонента с наименьшими затратами. Иерархическая адресация для компьютерной сети также должна позволять устанавливать связь между разрозненными и удаленными сетями.

На сетевом уровне (Network layer) существует несколько протоколов, которые позволяют передавать данные между сетями. Наиболее распространенным из них на сегодняшний день является IP. Его предшественник, протокол IPX, сейчас уже практически не используется в публичных сетях, но его можно найти в частных, закрытых сетях.

Основное устройство, применяемое на 3-м уровне, называется роутером (router), или маршрутизатором. Он соединяет удаленные локальные сети (LAN), образуя глобальную сеть (Wide area network, WAN). Роутер имеет два или более сетевых интерфейса и таким образом подключен сразу к нескольким локальным сетям. Получив пакет с локального устройства или компьютера, принадлежащего к одной из LAN, роутер просматривает заголовок третьего уровня. На основании полученной информации роутер принимает решение, что делать с пакетом. Если получатель пакета находится в той же локальной сети, что и отправитель, роутер игнорирует его, поскольку сообщение, как уже рассматривалось, доставляется средствами более низкоуровневых протоколов (например, Ethernet).


В противном случае пакет нужно передать в одну из других LAN, к которым подключен роутер. Основная задача этого устройства – выбор пути, по которому будет пересылаться сообщение. Поскольку может существовать множество связей между некоторыми двумя сетями отправителя и получателя, роутер должен выбрать наиболее оптимальный путь. Пересылка пакета от одного узла сети к следующему называется hop (дословно – прыжок, скачок). Выбор очередного узла, которому роутер перешлет сообщение, может зависеть от многих факторов – загрузка сети, наименьший путь до получателя, стоимость трафика по различным маршрутам и т.д.

Новая система адресации, вводимая на сетевом уровне, должна облегчать роутеру определение пути для доставки пакета через глобальные сети. Рассмотрим реализацию наиболее популярного на сегодняшний день протокола IP более подробно.

При прохождении данных с верхних уровней на нижние на сетевом уровне к пакету добавляется служебный заголовок этого уровня. В заголовке IP-пакета содержится необходимая для дальнейшей передачи информация, такая как адреса отправителя и получателя. Понятие IP-адреса очень важно для понимания работы глобальных сетей, поэтому остановимся на нем более подробно.


Основы модели OSI


В течение последних нескольких десятилетий размеры и количество сетей значительно выросли. В 80-х годах существовало множество типов сетей. И практически каждая из них была построена на своем типе оборудования и программного обеспечения, зачастую не совместимых между собой. Это приводило к значительным трудностям при попытке соединить несколько сетей (например, различный тип адресации делал эти попытки практически безнадежными).

Эта проблема была рассмотрена Всемирной организацией по стандартизации (International Organization for Standardization, ISO) и было принято решение разработать модель сети, которая могла бы помочь разработчикам и производителям сетевого оборудования и программного обеспечения действовать сообща. В результате в 1984 г. была создана модель OSI – модель взаимодействия открытых систем (Open Systems Interconnected). Она состоит из семи уровней, на которые разделяется задача организации сетевого взаимодействия. Схематично они представлены в таблице 16.1.

Таблица 16.1. Уровни модели OSI.

Номер уровняНазвание уровняЕдиница информации
Layer 7Уровень приложенийДанные (data)
Layer 6Представительский уровеньДанные (data)
Layer 5Сессионный уровеньДанные (data)
Layer 4Транспортный уровеньСегмент (segment)
Layer 3Сетевой уровеньПакет (packet)
Layer 2Уровень передачи данныхФрейм (frame)
Layer 1Физический уровеньБит (bit)

Хотя сегодня существуют разнообразные модели сетей, большинство разработчиков придерживается именно этой общепризнанной схемы.

Рассмотрим процесс передачи информации между двумя компьютерами. Программное обеспечение формирует сообщение на уровне 7 (приложений), состоящее из заголовка и полезных данных. В заголовке содержится служебная информация, которая необходима уровню приложений адресата для обработки пересылаемой информации (например, это может быть информация о файле, который необходимо передать, или операции, которую нужно выполнить). После того, как сообщение было сформировано, уровень приложений направляет его "вниз" на представительский уровень (layer 6). Полученное сообщение, состоящее из служебной информации уровня 7 и полезных данных, для уровня 6 представляется как одно целое (хотя уровень 6 может считывать служебную информацию уровня 7). Протокол представительского уровня выполняет необходимые действия на основании данных, полученных из заголовка уровня приложений, и добавляет заголовок своего уровня, в котором содержится информация для соответствующего (6-го) уровня адресата. Полученное в результате сообщение передается далее "вниз" сеансовому уровню, где также добавляется служебная информация. Дополненное сообщение передается на следующий транспортный уровень и т.д. на каждом последующем уровне (схематично это представлено на рис.16.1). При этом служебная информация может добавляться не только в начало сообщения, но и в конец (например, на 3-м уровне, рис.16.2). В итоге получается сообщение, содержащее служебную информацию всех семи уровней.


Рис. 16.1.  Инкапсуляция и декапсуляция пакета.


Рис. 16.2.  Добавление служебной информации в начало и конец пакета.


Процесс "обертывания" передаваемых данных служебной информацией называется инкапсуляцией (encapsulation).

Далее это сообщение передается через сеть в виде битов. Бит – это минимальная порция информации, которая может принимать значение 0 или 1. Таким образом, все сообщение кодируется в виде набора нулей и единиц, например, 010110101. В простейшем случае на физическом уровне для передачи формируется электрический сигнал, состоящий из серии электрических импульсов (0 - нет сигнала, 1 - есть сигнал). Именно эта единица принята для измерения скорости передачи информации. Современные сети обычно предоставляют каналы с производительностью в десятки и сотни Кбит/с и Мбит/с.

Получатель на физическом уровне получает сообщение в виде электрического сигнала (рис.16.3). Далее происходит процесс, обратный инкапсуляции,– декапсуляция (decapsulation). На каждом уровне происходит разбор служебной информации. После декапсуляции сообщения на первом уровне (считывания и обработки служебной информации 1-го уровня) это сообщение, содержащее служебную информацию второго уровня и данные в виде полезных данных и служебной информации вышестоящих уровней, передается на следующий уровень. На канальном (2-м) уровне снова происходит анализ системной информации и сообщение передается на следующий уровень. И так до тех пор, пока сообщение не дойдет до уровня приложений, где в виде конечных данных передается принимающему приложению.


Рис. 16.3.  Представление данных в виде электрического импульса.

В качестве примера можно привести обращение браузера к web-серверу. Приложение клиента – браузер – формирует запрос для получения web-страницы. Этот запрос передается приложением на уровень 7 и далее последовательно на каждый уровень модели OSI. Достигнув физического уровня, наш первоначальный запрос "обрастает" служебной информацией каждого уровня. После этого он передается по физической сети (кабелям) в виде электрических импульсов на сервер. На сервере происходит разбор соответствующей системной информации каждого уровня, в результате чего посланный запрос достигает приложения web-сервера. Там он обрабатывается, после чего клиенту отправляется ответ. Процесс отправки ответа аналогичен отправке запроса – за исключением того, что сообщение посылает сервер, а получает клиент.



Так как каждый уровень модели OSI стандартизирован, потребители могут использовать совместно оборудование и программное обеспечение различных производителей. В результате web-сервер под управлением операционной системы Sun Solaris может передать HTML-страницу пользователю MS Windows.

Разумеется, совместимость можно обеспечить лишь до некоторого уровня. Если одна машина передает данные в виде радиоволн, а другая в виде световых импульсов, то их взаимодействие без использования дополнительного оборудования невозможно. Поэтому было введено понятие сете-независимых и сете-зависимых уровней.

Три нижних уровня – физический, канальный и сетевой – являются сете-зависимыми. Например, смена Ethernet на ATM влечет за собой полную смену протокола физического и канального уровней.

Три верхних уровня – приложений, представительский и сессионный – ориентированы на прикладные задачи и практически не зависят от физической технологии построения сети. Так, переход от Token Ring на Ethernet не требует изменений в перечисленных уровнях.

Транспортный уровень является промежуточным между сете-зависимыми и сете-независимыми уровнями. Он скрывает все детали функционирования нижних уровней от верхних. Это позволяет разработчику приложений не задумываться о технических средствах реализации транспортировки сетевых сообщений.

Вместе с названием сообщение (message) в стандартах ISO для обозначения единицы данных используют термин протокольный блок данных (Protocol Data Unit, PDU). В разных протоколах применяются и другие названия, закрепленные стандартами, или просто традиционные. Например, в семействе протоколов TCP/IP протокол TCP разделяет поток данных на сегменты, протокол UDP работает с датаграммами (или дейтаграммами, от datagram), сам протокол IP использует термин пакеты. Часто так же говорят о кадрах или фреймах.

Для более глубокого понимания принципов работы сети рассмотрим каждый уровень по отдельности.


Пакет java.net


Перейдем к рассмотрению средств Java для работы с сетью.

Классы, работающие с сетевыми протоколами, располагаются в пакете java.net, и простейшим из них является класс URL. С его помощью можно сконструировать uniform resource locator (URL), который имеет следующий формат:

protocol://host:port/resource

Здесь protocol – название протокола, используемого для связи; host – IP-адрес, или DNS-имя сервера, к которому производится обращение; port – номер порта сервера (если порт не указан, то используется значение по умолчанию для указанного протокола); resource – имя запрашиваемого ресурса, причем, оно может быть составным, например:

ftp://myserver.ru/pub/docs/Java/JavaCourse.txt

Затем можно воспользоваться методом openStream(), который возвращает InputStream, что позволяет считать содержимое ресурса. Например, следующая программа при помощи LineNumberReader считывает первую страницу сайта http://www.ru и выводит ее на консоль.

import java.io.*; import java.net.*;

public class Net { public static void main(String args[]) { try { URL url = new URL("http://www.ru"); LineNumberReader r = new LineNumberReader(new InputStreamReader(url.openStream())); String s = r.readLine(); while (s!=null) { System.out.println(s); s = r.readLine(); } System.out.println(r.getLineNumber()); r.close(); } catch (MalformedURLException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }

Из примера мы видим, что работа с сетью, как и работа с потоками, требует дополнительной работы с исключительными ситуациями. Ошибка MalformedURLException появляется в случае, если строка c URL содержит ошибки.

Более функциональным классом является URLConnection, который можно получить с помощью метода класса URL.openConnection(). У этого класса есть два метода – getInputStream() (именно с его помощью работает URL.openStream()) и getOutputStream(), который можно использовать для передачи данных на сервер, если он поддерживает такую операцию (многие публичные web-серверы закрыты для таких действий).


Класс URLConnection является абстрактным. Виртуальная машина предоставляет реализации этого класса для каждого протокола, например, в том же пакете java.net определен класс HttpURLConnection. Понятно, что классы URL и URLConnection предоставляют возможность работы через сеть на прикладном уровне с помощью высокоуровневых протоколов.

Пакет java.net также предоставляет доступ к протоколам более низкого уровня – TCP и UDP. Для этого сначала надо ознакомиться с классом InetAddress, который является Internet-адресом, или IP. Экземпляры этого класса создаются не с помощью конструкторов, а с помощью статических методов:

InetAddress getLocalHost() InetAddress getByName(String name) InetAddress[] getAllByName(String name)

Первый метод возвращает IP-адрес машины, на которой исполняется Java- программа. Второй метод возвращает адрес сервера, чье имя передается в качестве параметра. Это может быть как DNS-имя, так и числовой IP, записанный в виде текста, например, "67.11.12.101". Наконец, третий метод определяет все IP-адреса указанного сервера.

Для работы с TCP-протоколом используются классы Socket и ServerSocket. Первым создается ServerSocket – сокет на стороне сервера. Его простейший конструктор имеет только один параметр – номер порта, на котором будут приниматься входящие запросы. После создания вызывается метод accept(), который приостанавливает выполнение программы и ожидает, пока какой-нибудь клиент не инициирует соединение. В этом случае работа сервера возобновляется, а метод возвращает экземпляр класса Socket для взаимодействия с клиентом:

try { ServerSocket ss = new ServerSocket(3456); Socket client=ss.accept(); // Метод не возвращает // управление, пока не подключится клиент } catch (IOException e) { e.printStackTrace(); }

Клиент для подключения к серверу также использует класс Socket. Его простейший конструктор принимает два параметра - адрес сервера (в виде строки, или экземпляра InetAddress) и номер порта. Если сервер принял запрос, то сокет конструируется успешно и далее можно воспользоваться методами getInputStream() или getOutputStream().



try { Socket s = new Socket("localhost", 3456); InputStream is = s.getInputStream(); is.read(); } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }

Обратите внимание на обработку исключительной ситуации UnknownHostException, которая будет генерироваться, если виртуальная машина с помощью операционной системы не сможет распознать указанный адрес сервера в случае, если он задан строкой. Если же он задан экземпляром InetAddress, то эту ошибку надо обрабатывать при вызове статических методов данного класса.

На стороне сервера класс Socket используется точно таким же образом – через методы getInputStream() и getOutputStream(). Приведем более полный пример:

import java.io.*; import java.net.*; public class Server { public static void main(String args[]) { try { ServerSocket ss = new ServerSocket(3456); System.out.println("Waiting..."); Socket client=ss.accept(); System.out.println("Connected"); client.getOutputStream().write(10); client.close(); ss.close(); } catch (IOException e) { e.printStackTrace(); } } }

Сервер по запросу клиента отправляет число 10 и завершает работу. Обратите внимание, что при завершении вызываются методы close() для открытых сокетов.

Класс клиента:

import java.io.*; import java.net.*; public class Client { public static void main(String args[]) { try { Socket s = new Socket("localhost", 3456); InputStream is = s.getInputStream(); System.out.println("Read: "+is.read()); s.close(); } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } } }

После запуска сервера, а затем клиента, можно увидеть результат – полученное число, 10, после чего обе программы закроются.

Рассмотрим эти классы более подробно. Во-первых, класс ServerSocket имеет конструктор, в который передается, кроме номера порта, еще и адрес машины. Это может показаться странным, ведь сервер открывается на той же машине, где работает программа, зачем специально указывать ее адрес? Однако, если компьютер имеет несколько сетевых интерфейсов (сетевых карточек), то он имеет и несколько сетевых адресов. С помощью такого детализированного конструктора можно указать, по какому именно адресу ожидать подключения. Это должен быть именно локальный адрес машины, иначе возникнет ошибка.



Аналогично, класс Socket имеет расширенный конструктор для указания как локального адреса, с которого будет устанавливаться соединение, так и локального порта (иначе операционная система выделяет произвольный свободный порт).

Во-вторых, можно воспользоваться методом setSoTimeout(int timeout) класса ServerSocket, чтобы указать время в миллисекундах, на протяжении которого нужно ожидать подключение клиента. Это позволяет серверу не "зависать", если никто не пытается начать с ним работать. Тайм-аут задается в миллисекундах, нулевое значение означает бесконечное время ожидания.

Важно подчеркнуть, что после установления соединения с клиентом сервер выходит из метода accept(), то есть перестает быть готовым принимать новые запросы. Однако, как правило, желательно, чтобы сервер мог работать с несколькими клиентами одновременно. Для этого необходимо при подключении очередного пользователя создавать новый поток исполнения, который будет обслуживать его, а основной поток снова войдет в метод accept(). Приведем пример такого решения:

Пример 16.2.

(html, txt)

Теперь объявим клиента. Эта программа будет запускать несколько потоков, каждый из которых независимо подключается к серверу, считывает его ответ и выводит на консоль.

Пример 16.3.

(html, txt)

Теперь рассмотрим UDP. Для работы с этим протоколом и на стороне клиента, и на стороне сервера используется класс DatagramSocket. У него есть следующие конструкторы:

DatagramSocket() DatagramSocket(int port) DatagramSocket(int port, InetAddress laddr)

При вызове первого конструктора сокет открывается на произвольном доступном порту, что уместно для клиента. Конструктор с одним параметром, задающим порт, как правило, применяется на серверах, чтобы клиенты знали, на каком порту им нужно пытаться устанавливать соединение. Наконец, последний конструктор необходим для машин, у которых присутствует несколько сетевых интерфейсов.

После открытия сокетов начинается обмен датаграммами. Они представляются экземплярами класса DatagramPacket. При отсылке сообщения применяется следующий конструктор:



DatagramPacket(byte[] buf, int length, InetAddress address, int port)

Массив содержит данные для отправки (созданный пакет будет иметь длину, равную length), а адрес и порт указывают получателя пакета. После этого вызывается метод send() класса DatagramSocket.

try { DatagramSocket s = new DatagramSocket(); byte data[]={1, 2, 3}; InetAddress addr = InetAddress.getByName("localhost"); DatagramPacket p = new DatagramPacket(data, 3, addr, 3456); s.send(p); System.out.println("Datagram sent"); s.close(); } catch (SocketException e) { e.printStackTrace(); } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }

Для получения датаграммы также создается экземпляр класса DatagramPacket, но в конструктор передается лишь массив, в который будут записаны полученные данные (также указывается ожидаемая длина пакета). Сокет необходимо создать с указанием порта, иначе, скорее всего, сообщение просто не дойдет до адресата. Используется метод receive() класса DatagramSocket (аналогично методу ServerSocket.accept(), этот метод также прерывает выполнение потока, пока не придет запрос от клиента). Пример реализации получателя:

try { DatagramSocket s = new DatagramSocket(3456); byte data[]=new byte[3]; DatagramPacket p = new DatagramPacket(data, 3); System.out.println("Waiting..."); s.receive(p); System.out.println("Datagram received: "+ data[0]+", "+data[1]+", "+data[2]); s.close(); } catch (SocketException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }

Если запустить сначала получателя, а затем отправителя, то можно увидеть, что первый напечатает содержимое полученной датаграммы, а потом программы завершат свою работу.

В заключение приведем пример сервера, который получает датаграммы и отправляет их обратно, дописав к ним слово received.

Пример 16.4.

(html, txt)



При вызове первого конструктора сокет открывается на произвольном доступном порту, что уместно для клиента. Конструктор с одним параметром, задающим порт, как правило, применяется на серверах, чтобы клиенты знали, на каком порту им нужно пытаться устанавливать соединение. Наконец, последний конструктор необходим для машин, у которых присутствует несколько сетевых интерфейсов.

После открытия сокетов начинается обмен датаграммами. Они представляются экземплярами класса DatagramPacket. При отсылке сообщения применяется следующий конструктор:

DatagramPacket(byte[] buf, int length, InetAddress address, int port)

Массив содержит данные для отправки (созданный пакет будет иметь длину, равную length), а адрес и порт указывают получателя пакета. После этого вызывается метод send() класса DatagramSocket.

try { DatagramSocket s = new DatagramSocket(); byte data[]={1, 2, 3}; InetAddress addr = InetAddress.getByName("localhost"); DatagramPacket p = new DatagramPacket(data, 3, addr, 3456); s.send(p); System.out.println("Datagram sent"); s.close(); } catch (SocketException e) { e.printStackTrace(); } catch (UnknownHostException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }

Для получения датаграммы также создается экземпляр класса DatagramPacket, но в конструктор передается лишь массив, в который будут записаны полученные данные (также указывается ожидаемая длина пакета). Сокет необходимо создать с указанием порта, иначе, скорее всего, сообщение просто не дойдет до адресата. Используется метод receive() класса DatagramSocket (аналогично методу ServerSocket.accept(), этот метод также прерывает выполнение потока, пока не придет запрос от клиента). Пример реализации получателя:

try { DatagramSocket s = new DatagramSocket(3456); byte data[]=new byte[3]; DatagramPacket p = new DatagramPacket(data, 3); System.out.println("Waiting..."); s.receive(p); System.out.println("Datagram received: "+ data[0]+", "+data[1]+", "+data[2]); s.close(); } catch (SocketException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); }


Physical layer (layer 1)


Как видно из общей схемы расположения уровней в модели OSI, физический уровень (Physical layer) самый первый. Этот уровень описывает среду передачи данных. Стандартизируются физические устройства, отвечающие за передачу электрических сигналов (разъемы, кабели и т.д.) и правила формирования этих сигналов. Рассмотрим по порядку все составляющие этого уровня.

Большая часть сетей строится на кабельной структуре (хотя существуют сети, основанные на передаче информации с помощью, например, радиоволн). Сейчас существуют различные типы кабелей. Наиболее распространенные из них:

телефонный провод;

коаксиальный кабель;

витая пара;

оптоволокно.

Телефонный кабель начал использоваться для передачи данных со времен появления первых компьютеров. Главным преимуществом телефонных линий было наличие уже созданной и развитой инфраструктуры. С ее помощью можно передавать данные между компьютерами, находящимися на разных материках, так же легко, как и вести разговор людям, которые находятся за много тысяч километров друг от друга. На сегодняшний день использование телефонных линий также остается популярным. Пользователи, которых устраивает небольшая скорость передачи данных, могут получить доступ к Internetу со своих домашних компьютеров. Основными недостатками использования телефонного кабеля является небольшая скорость передачи, т.к. соединение происходит не напрямую, а через телефонные станции. При этом требование к качеству передаваемого сигнала при передаче данных значительно выше, чем при передаче "голоса". А так как большинство аналоговых АТС не справляется с этой задачей (уровень "шума", или помех, и качество сигнала оставляет желать лучшего), то скорость передачи данных очень низкая. Хотя при подключении к современным цифровым АТС можно получить высокую и надежную скорость связи.

Коаксиальный кабель использовался в сетях еще несколько лет назад, но сегодня это большая редкость. Такой тип кабеля по строению практически идентичен обычному телевизионному коаксиальному кабелю – центральная медная жила отделена слоем изоляции от оплетки. Некоторые отличия есть в электрических характеристиках (в телевизионном кабеле используется кабель с волновым сопротивлением 75 Ом, в сетевом – 50 Ом).


Основными недостатками этого кабеля является низкая скорость передачи данных (до 10 Мбит/с), подверженность воздействиям внешних помех. Кроме того, подключение компьютеров в таких сетях происходит параллельно, а значит, максимальная возможная скорость пропускания делится на всех пользователей. Но, по сравнению с телефонным кабелем, коаксиал позволяет объединять близко расположенные компьютеры с намного лучшим качеством связи и более высокой скоростью передачи данных.

Витая пара ("twisted pair") – наиболее распространенное средство для передачи данных между компьютерами. В данном типе кабеля используется медный попарно скрученный провод, что позволяет уменьшить количество помех и наводок, как при передаче сигнала по самому кабелю, так и при воздействии внешних помех.

Существует несколько категорий этого кабеля. Перечислим основные из них. Cat 3 – был стандартизирован в 1991 г., электрические характеристики позволяли поддерживать частоты передачи до 16 МГц, использовался для передачи данных и голоса. Более высокая категория – Cat 5, была специально разработана для поддержки высокоскоростных протоколов. Поэтому его электрические характеристики лежат в пределах до 100Мгц. На таком типе кабеля работают протоколы передачи данных 10, 100, 1000 Мбит/с. На сегодняшний день кабель Cat5 практически вытеснил Cat 3. Основное преимущество витой пары перед телефонными и коаксиальными кабелями – более высокая скорость передачи данных. Также использование Cat 5 в большинстве случаев позволяет, не меняя кабельную структуру, повысить производительность сети (переходом от 10 к 100 и от 100 к 1000 Мбит/с).

Оптоволокно используется для соединения больших сегментов сети, которые располагаются далеко друг от друга, или в сетях, где требуется большая полоса пропускания, помехоустойчивость. Оптический кабель состоит из центрального проводника света (сердцевины) – стеклянного волокна, окруженного другим слоем стекла – оболочкой, обладающей меньшим показателем преломления, чем сердцевина. Распространяясь по сердцевине, лучи света не выходят за ее пределы, отражаясь от покрывающего слоя оболочки. Световой луч обычно формируется полупроводниковым или диодным лазером. В зависимости от распределения показателя преломления и от величины диаметра сердечника различают:

одномодовое волокно;многомодовое волокно.



Понятие "мода" описывает режим распространения световых лучей в сердечнике кабеля. В одномодовом кабеле используется проводник очень малого диаметра, соизмеримого с длиной волны света. В многомодовом кабеле применяются более широкие сердечники, которые легче изготовить. В этих кабелях в сердечнике одновременно существует несколько световых лучей, отражающихся от оболочки под разными углами. Угол отражения луча называется модой луча. Оптоволокно обладает следующими преимуществами: устойчивость к электромагнитным помехам, высокие скоростные характеристики на больших расстояниях. Основным недостатком является как дороговизна самого кабеля, так и трудоемкость монтажных работ, так как все работы выполняются на дорогостоящем высокоточном оборудовании.

Физический уровень также отвечает за преобразование сигналов между различными средами передачи данных. Например, при необходимости соединить сегменты сети, построенные на оптоволокне и витой паре, применяют так называемые конверторы (в данном случае они преобразуют световой импульс в электрический).

Для включения компьютера в сеть используется специальное устройство – сетевой адаптер (Network adapter), позволяющий обмениваться наборами битов, представленными электрическими сигналами. Сетевая карта (так чаще называют сетевой адаптер) обычно имеет шину ISA или PCI для подключения в компьютер и соответствующий разъем для подключения к среде передачи данных (например, для витой пары, коаксиал и т.п.).

Теперь, когда мы знаем, как происходит соединение компьютеров в одну сеть, рассмотрим варианты физической схемы такой сети, или, другими словами, физической топологии (структуры локальной сети).

Топология "шина" (bus) показана на рис. 16.4.


Рис. 16.4.  Топология "шина" (bus).

Все компьютеры и сетевые устройства подсоединены к одному проводу и фактически напрямую соединены между собой.

Топология "кольцо" (ring) показана на рис. 16.5.


Рис. 16.5.  Топология "кольцо" (ring).



Кольцо состоит из сетевых устройств и кабелей между ними, образующих одно замкнутое кольцо.

Топология "звезда" показана на рис. 16.6.


Рис. 16.6.  Топология "звезда" (star).

Все компьютеры и сетевые устройства подключены к одному центральному устройству.

Топология "расширенная звезда" (extended star) показана на рис. 16.7.


Рис. 16.7.  Топология "расширенная звезда"(extended star).

Такая схема практически аналогична топологии "звезда", за одним исключением. Каждое устройство соединено с локальным центральным устройством, а оно, в свою очередь, соединено с центром другой "звезды".


Ping


Для выявления различных неполадок в сети существует несколько утилит, которые позволяют определить, на каком уровне модели OSI произошел сбой, или указаны неверные настройки сетевых протоколов. Одна из таких утилит – ping.

Эта утилита позволяет определить ошибки на сетевом уровне (layer 3), используя протокол ICMP (Internet Control Message Protocol) – протокол межсетевых управляющих сообщений. Формат использования этой утилиты довольно прост: ping 194.87.0.50 (где 194.87.0.50 – IP-адрес удаленного компьютера). Если сеть работает корректно, в результате выводится время ожидания прихода ответа от удаленного компьютера и время жизни пакета (TTL, time to live, количество "хопов", после которого пакет был бы отброшен; этот параметр показывает, сколько оставалось допустимых переходов у пакета-ответа).


Протокол ICMP находится на стыке двух уровней – сетевого и транспортного. Основной принцип действия этого протокола – формирование ICMP эхо-запроса (echo-request) и эхо-ответа (echo-reply). Запрос эха и ответ на него может использоваться для проверки достижимости хоста- получателя и его способности отвечать на запросы. Также прохождение эхо-запроса и эхо-ответа проверяет работоспособность основной части транспортной системы, маршрутизацию на машине источника, работоспособность и корректную маршрутизацию на роутерах между источником и получателем, а также работоспособность и правильность маршрутизации получателя.

Таким образом, если на посланный echo-request возвращается корректный echo-reply от машины, которой был послан запрос, можно сказать, что транспортная система работает корректно. И если браузер не может отобразить web-страницу, то проблема, по всей видимости, не в первых трех уровнях модели OSI.

Из примера видно, что по умолчанию размер посылаемого пакета - 32 байта, далее выводится время задержки ответа и TTL. В этом примере показано успешное выполнение команды ping. В случаях, когда запросы echo request посылаются, но echo reply не возвращаются, выводится сообщение об истечении времени ожидания ответа.




Подсети. Маска подсети


Введение классов сетей во многом упростило задачу распределения адресов по организациям. Но не всегда имеет смысл использовать, например, целую сеть класса C, если в ней реально будет размещено лишь 10 компьютеров. Для более рационального использования сетей организуют подсети.

Адрес подсети включает в себя сетевую часть от сети класса A, B или C и так называемое поле подсети (subnet field). Для этого значения выделяют дополнительные биты, принадлежащие хост-части (то есть для адреса подсети может быть использовано до 3-х октетов из сети класса A, до 2-х из сети класса B, и 1 для C, соответственно). Таких битов может быть минимально один (таким образом одна сеть разделяется на две подсети), а максимально столько, чтобы для хост-части оставалось еще два бита (иначе подсеть будет состоять лишь из двух служебных адресов - адреса подсети и броадкаст-адреса). Для сетей класса A это дает от 1 до 22 битов, для B – от 1 до 14 битов, для C – от 1 до 6.

Разбиение на подсети уменьшает также размеры броадкаст-доменов, что необходимо, иначе для сети класса A броадкаст-запрос может рассылаться на 16 миллионов компьютеров. И если каждый из них пошлет хотя бы по одному такому запросу, нагрузка на сеть будет чрезмерно большой. Если же компьютер находится в выделенной подсети, то в соседние сети и подсети роутер пересылать броадкаст-запрос не будет, вследствие чего экономится полоса пропускания физических каналов связи.

Для определения длины адреса подсети используется специальное понятитие – маска подсети. Это число определяет, какая часть IP-адреса применяется для задания сетевой и подсетевой части. Маску подсети можно определить следующем образом. Запишем IP-адрес в бинарном виде. Все разряды, относящиеся к network- и subnet-части, заменим на 1, все значения, относящиеся к host-части,– на 0. В результате получим маску подсети.

Например, маска подсети для целой сети класса A будет выглядеть как 255.0.0.0, для сети класса B: 255.255.0.0, для сети класса C – 255.255.255.0. Для разделения на подсети, как было сказано выше, нужно некоторые биты хост-части выделить для поля подсети. Например, маска 255.255.255.192 определяет подсеть класса C, для которой количество хостов будет равно 62.



Порты


Как было рассмотрено, для протокола IP достаточно знать IP-адрес, чтобы обработать сообщение. Оба протокола транспортного уровня, TCP и UDP, дополнительно используют порты (port) для взаимодействия с вышестоящими уровнями. Порт описывается числом от 0 до 65535 и позволяет операционной системе распределять пакеты, приходящие на транспортный уровень, между различными прикладными программами. Предположим, пользователь одновременно скачивает файл с FTP-сервера и работает с удаленным сервером базы данных. От обоих этих серверов пользовательская машина будет получать по сети пакеты и необходимо правильно передавать их соответствующим приложениям (FTP-клиенту и БД-клиенту).

Часть портов зарезервирована под стандартные приложения. Например, для FTP зарезервирован порт 21, для telnet – 23, для HTTP – 80. Далее приведен список распределения портов:

порты меньше 255 используются для публичных сервисов;порты из диапазона 255-1023 назначаются компаниями-разрабочиками для приложений;номера свыше 1023 – не регулируемые.

Таким образом, говоря об установленном TCP-соединении, имеют ввиду 4 числа: IP-адрес и порт одной стороны и те же параметры второй стороны. Например, если пользователь со своей машины 194.11.22.33 обратился через браузер к web-серверу 213.180.194.129, то это означает, что установлено соединение 194.11.22.33:10123-213.180.194.129:80 (номер 10123 выбран произвольно – используется любой незанятый порт).

Используется также термин "сокет" (socket), под которым подразумевается пара "IP-адрес:порт" – адресная "точка" для сетевых обращений.



Presentation layer (layer 6)


Этот уровень отвечает за представление данных, пересылаемых по сети. Он обеспечивает следующую функциональность: data formatting (presentation, то есть преобразование данных в понятный получателю формат), data encryption (шифрование), data compression (сжатие данных). Presentation layer выполняет одну или все эти функции во время передачи сообщений между 7-м и 5-м уровнями. Приведем пример использования уровня представлений.

Предположим, хост-получатель использует EBCDIC (кодировка, применяемая на крупных IBM-серверах для передачи символов в виде чисел), а хост- отправитель – ASCII (традиционная кодировка для персональных компьютеров). Presentation layer будет обеспечивать преобразование пересылаемых данных между этих машинами.

Для обеспечения безопасности при передаче частной информации через публичные сети необходимо шифрование данных. Один из распространенных протоколов, используемых для этой цели, – SSL (Secured Sockets Layer) – может быть отнесен к уровню представлений.

Если канал связи обладает низкой пропускной способностью, целесообразно применять компрессию данных. Представительский уровень, используя математические алгоритмы, позволяет уменьшить объем передаваемых данных. Что касается высокоскоростных каналов, то для них использование компрессии может потребовать значительных вычислительных мощностей при больших объемах трафика.



Протоколы ARP, RARP


Когда формируется пакет для отправления, на сетевом уровне закладывается IP-адрес получателя. Однако для передачи на нижестоящий канальный уровень также нужно знать MAC-адрес. Для определения соответствия IP-адресу MAC-адреса существует ARP-протокол (Address Resolution Protocol, протокол определения адресов). Он работает следующим образом.

Формируется специальный широковещательный (broadcast) запрос. Он рассматривался выше, его особенность в том, что его получают все устройства, подключенные к этой локальной сети. В таком запросе MAC-адрес получателя состоит из одних бинарных единиц, а в поле IP-адреса записывается именно тот адрес, для которого требуется определить MAC-адрес. Когда некий компьютер получает такой запрос, он сравнивает указанный IP-адрес со своим. Если они различаются, сообщение игнорируется. Если они равны, то формируется ответ, в котором по всем правилам указаны IP- и MAC-адреса отправителя, то есть искомой машины.

Для того, чтобы не нагружать широковещательными запросами сеть, ARP-протокол поддерживает специальную ARP-таблицу, которая находится в оперативной памяти и хранит соответствие между IP- и MAC-адресами. После удачного определения MAC-адреса какого-нибудь узла сети делается соответствующая запись в таблицу, чтобы при следующей отсылке пакета не пришлось снова рассылать broadcast-запросы. Спустя некоторое время запись удаляется. Это позволяет автоматически подстраиваться под изменения в сети, ведь у какого-то узла могли изменить MAC- или IP-адрес. Если отправитель не находит IP-адрес получателя в ARP-таблице, то снова формируется и отправляется ARP-запрос.

Протокол RARP (Reverse ARP – обратный ARP) действует наоборот – он известному MAC-адресу сопоставляет IP-адрес. Это необходимо, например, для работы таких протоколов, как BOOTP (Bootstrap Protocol, протокол автоматической настройки) и DHCP (Dynamic Host Configuration Protocol, протокол динамической конфигурации хостов). Их назначение – облегчить задачи системному администратору. Они позволяют не вводить IP-адрес в каждый компьютер локальной сети, а назначают их сами в автоматическом режиме. При загрузке очередной машины посылается broadcast-запрос – противоположный ARP-запросу. Если в ARP-запросе идет опрос "IP получателя известен, MAC получателя – ???", то в RARP-запросе "MAC получателя известен, IP - ???". Если в сети есть DHCP-сервер, он отвечает на RARP-запрос, указывая IP-адрес для этого компьютера (особенно это эффективно при большом количестве компьютеров).

Оба эти протокола работают в рамках лишь локальной сети, поскольку все пакеты, направляемые в другие сети, обрабатываются и маршрутизируются роутером, поэтому знать MAC-адрес не требуется (отправитель указывает MAC-адрес самого роутера).



Route


Для просмотра и редактирования таблицы маршрутов используется утилита route. Типичный пример таблицы маршрутизации на персональном компьютере:

Для ОС Windows:

route print


В таблице маршрутизации указывается сеть, маска сети, маршрутизатор, через который доступна эта сеть, интерфейс и метрика маршрута. Из приведенной таблицы видно, что маршрут по умолчанию доступен через маршрутизатор 192.168.1.1. Сеть 192.168.1.0 с маской 255.255.255.0 является локальной сетью.

При добавлении маршрута можно использовать следующую команду.

route ADD 157.0.0.0 MASK 255.0.0.0 157.55.80.1

157.0.0.0 – удаленная сеть, 255.0.0.0 – маска удаленной сети, 157.55.80.1 – маршрутизатор, через который доступна эта сеть. Примерно такой же синтаксис используется при удалении маршрута: route DELETE 157.0.0.0.

В ОС Solaris для просмотра таблицы маршрутизации используется немного другая команда – netstat –r.


Добавление и удаление маршрутов выполняется командой route: route add –net 157.6 157.6.1.20, где 157.6 – сокращенный адрес подсети, а 157.6.1.20 – маршрут, по которому эта сеть доступна. Также удаление маршрутов в таблице маршрутизации: route del –net 157.6.



Session layer (layer 5)


После транспортного уровня пакет поступает на уровень сессий. Когда приложения, запущенные на различных машинах, начинают взаимодействовать через сеть, то между ними происходит множество мини-"переговоров", обменов, диалогов, из которых и состоит сетевая сессия.

Session layer координирует установление и завершение соединений и сессий между приложениями.



TCP


TCP/IP представляет собой комбинацию двух уровней, TCP и IP. IP – протокол третьего уровня – обеспечивает наилучшую, но не гарантированную доставку данных через сеть. TCP – протокол четвертого уровня – позволяет эту гарантию обеспечить. Поэтому совместно они могут предоставить большее количество сервисов.

Работа по TCP-протоколу начинается с установления соединения. Два компьютера (один из них инициатор соединения, второй – принимающий) обмениваются специальными пакетами в три этапа. Условно их можно назвать "запрос", "подтверждение" и "подтверждение на подтверждение". Такая процедура необходима, чтобы при получении какого-нибудь старого пакета (например, делается вторая попытка установить соединение) не возникало никаких неоднозначностей.

После успешного установления соединения участники могут начать обмениваться данными. Рассмотрим пример HTTP-сервера, который отправляет HTML-страницу клиенту. Текст может быть слишком длинным, чтобы уместиться в один пакет, поэтому первая задача уровня TCP – разбить сообщение на несколько пакетов, а на стороне отправителя – собрать их опять в единое целое. Поскольку очередность пакетов несомненно важна, каждый получает порядковый номер.

Следующая задача протокола 2 – обеспечить гарантированную доставку. Делается это с помощью следующей процедуры. Отправитель посылает пакет с номером n и начинает ждать. Получатель в случае успешного прихода пакета n, отправляет подтверждение о получении ("квитанцию"), в котором также указывает номер n. Если отправитель в течение определеннного времени (тайм-аута) не получает подтверждения, он считает пакет n потерянным и отсылает его еще раз.

Разумеется, отправителю неэффективно просто ждать, пока получатель получит и обработает каждый пакет по одному. Поэтому процедура усложняется, вводится специальное понятие – "окно" (window). Окно имеет некоторый размер, предположим, 10. Это означает, что передача начинается с отсылки 10 первых пакетов. Получатель может принять их не в том порядке, в каком они были отосланы. Тем не менее, на каждый успешно полученный пакет отсылается подтверждение с указанием номера такого пакета. Если отправитель отослал уже все 10 пакетов, но квитанция о получении пакета 1 так и не пришла, то передача приостанавливается, а по прошествии тайм-аута первый пакет считается потерянным и пересылается еще раз. Если же подтверждения приходят регулярно, то отправляются новые пакеты, но не более 10 единовременно.

Этот алгоритм носит название "скользящего окна". Если представить, что все пакеты выстроены в ряд, то окно "скользит" по нему, определяя, какие пакеты готовы для отсылки. Такой подход обеспечивает гарантированную доставку при максимально возможной скорости передачи данных. Разумеется, протокол TCP работает не столь быстро, ведь часть пропускной способности сети тратится на пересылку квитанций и повторов потерянных пакетов. Однако, большое количество информации требуется доставлять именно таким образом. Понятно, что части, например, текста должны составляться в строгом порядке и без пропусков. Были разработаны специальные механизмы, автоматически регулирующие величины таких характеристик, как тайм-аут и размер окна, для достижения оптимальной производительности.



Traceroute


Утилита traceroute также использует протокол ICMP для определения маршрута прохождения пакета. При отсылке traceroute устанавливает значение TTL последовательно от 1 до 30. Каждый маршрутизатор, через который проходит пакет на пути к назначенному хосту, увеличивает значение TTL на единицу. С помощью TTL происходит предотвращение зацикливания пакета в "петлях" маршрутизации, иначе "заблудившиеся" пакеты окончательно перегрузили бы сеть. Однако, при выходе маршрутизатора или линии связи из строя требуется несколько дополнительных переходов для понимания, что данный маршрут потерян и его необходимо обойти. Чтобы предотвратить потерю датаграммы, поле TTL устанавливается на максимальную величину.

Когда маршрутизатор получает IP-датаграмму с TTL, равным 0 или 1, он уничтожает ее и посылает хосту, который ее отправил, ICMP-сообщение "время истекло" (time exceeded). Принцип работы traceroute заключается в том, что IP-датаграмма, содержащая это ICMP-сообщение, имеет в качестве адреса источника IP-адрес маршрутизатора.

Теперь легко понять, как работает traceroute. На хост назначения отправляется IP- датаграмма с TTL, равным единице. Первый маршрутизатор, который должен обработать датаграмму, уничтожает ее (так как TTL равно 1) и отправляет ICMP-сообщение об истечении времени (time exceeded). Таким образом определяется первый маршрутизатор в маршруте. Затем traceroute отправляет датаграмму с TTL, равным 2, что позволяет получить IP-адрес второго маршрутизатора. Так продолжается до тех пор, пока датаграмма не достигнет хоста назначения. Утилита traceroute может посылать в качестве такой датаграммы UDP-сообщение с номером порта, который заведомо не будет обработан приложением (порт выше 30000), поэтому хост назначения ответит "порт недоступен" (port unreachable). При получении такого ответа делается вывод, что удаленный хост работает корректно. В противном случае максимального значения TTL (по умолчанию 30) не хватило для того, чтобы его достигнуть.


Рассмотрим пример выполнения утилиты traceroute.

Пример 16.1.

(html, txt)

Первая строка содержит имя и IP-адрес хоста назначения, максимальное значение TTL и размер посылаемого пакета (38 байт). Последующие строки начинаются с TTL, после чего следует имя хоста, или маршрутизатора и его IP-адрес. Для каждого значения TTL отправляются три датаграммы. Для каждой возвращенной датаграммы определяется и выводится время возврата. Если в течение 3-х секунд на каждую из 3-х датаграмм не был получен ответ, то посылается следующая датаграмма, а вместо значения времени выводится звездочка. Время возврата – это время прохождения датаграммы от источника (хоста, выполняющего программу traceroute) до маршрутизатора. Если нас интересует время, потраченное на пересылку между, например, 5 и 6 узлом, необходимо вычесть из значения времени TTL 6 время TTL 5.

В каждой из операционных систем сетевая часть утилиты реализована практически одинаково, но реализация на уровне приложений различается.

В ОС Solaris используется утилита traceroute. В качестве параметра задается IP-адрес, или доменное имя удаленного хоста, связь до которого требуется проверить. В примере, приведенном выше, видно успешное выполнение traceroute и корректную работу сете- зависимых уровней (физический, канальный, сетевой).

В ОС Windows утилита называется tracert. Используется она так же, как и в ОС Solaris (tracert netserv1.chg.ru). Принципиального различия между утилитами tracert и traceroute нет. Особенностью traceroute является наличие большего количества функций (например, можно указать, начиная с какого TTL выводить информацию).

В случае какой-либо неполадки выводится соответствующее сообщение. Например, при недоступности сети на маршрутизаторе выдается сообщение !N (net unreachable):

Moscow-BNS045-ATM4-0-3.free.net (147.45.20.37) 947.327 ms !N 996.548 ms !N 995.257 ms

Это означает, что 147.45.20.37 – маршрутизатор, начиная с которого, последующий маршрут недоступен. Если недоступен сам хост, то сообщение будет выглядеть так:

msu-mipt-atm0.mipt.ru (212.16.1.1) 5.536 ms !H 5.993 ms !H 10.431 ms !H.

Ошибка !P означает недоступность протокола (protocol unreachable).


Transport layer (layer 4)


Рассмотрим протокол 4-го транспортного уровня модели OSI. Семейство TCP/IP включает в себя два таких протокола – TCP и UDP. TCP (Transmission Control Protocol, протокол управления передачей) обеспечивает виртуальные соединения между пользовательскими приложениями и гарантирует точную доставку данных. UDP (User Datagram Protocol, протокол передачи датаграмм пользователя) служит для быстрого обмена специальными сообщениям (датаграммами) без гарантии доставки.

Основные характеристики TCP и UDP показаны в табл. 16.3.

Таблица 16.3. Основные характеристики TCP и UDP.

TCPUDP
Для работы устанавливает соединениеРаботает без соединений
Гарантированная доставка данныхГарантий доставки нет
Разбивает исходное сообщение на сегментыПередает сообщения целиком в виде датаграмм
На стороне получателя сообщение заново собирается из сегментовПринимаемые сообщения не объединяются
Пересылает заново потерянные сегментыПодтверждений о доставке нет
Контролирует поток сегментовНикакого контроля потока датаграмм нет



UDP


В отличие от TCP, UDP не гарантирует доставку данных. UDP не устанавливает виртуального соединения, источник просто шлет специальные сообщения (в UDP они называются датаграммами) получателю. Если данные были доставлены некорректно, или вообще часть пакетов потерялась, UDP не позволяет их восстановить. Запрос на получение данных должен будет выполняться заново.

Казалось бы, недостатков у такого протокола довольно много, что ставит под сомнение его эффективность. Но есть сервисы, где UDP незаменим. Например, при передаче потокового аудио-видео если бы мы использовали TCP, то при потере одного пакета у нас была бы приостановлена трансляция для его повторной передачи. При использовании UDP один потерянный пакет – всего лишь незначительное (наверняка, вообще незаметное пользователю) ухудшение изображения/звука, при этом передача данных не прерывается. Также при использовании UDP не обязательно устанавливать виртуальное соединение, не нужно отсылать квитанции – все это ускоряет работу протокола.



Утилиты для работы с сетью


Рассмотрим основные программы, позволяющие читать и изменять сетевые параметры, диагностировать и выявлять ошибки при работе сети.

В различных ОС существуют свои наборы утилит. Сравним их для двух систем, например, Microsoft Windows NT и Sun Solaris. Какими бы разными ни были эти ОС, в каждой из них реализована модель OSI. Естественно, программная и аппаратная реализация стека этой модели у них различается, но взаимодействие всех уровней осуществляется по установленному стандарту.



В данном разделе были рассмотрены


В данном разделе были рассмотрены теоретические основы сети как одной большой взаимодействующей системы. Были описаны все уровни модели OSI и их функциональные назначения. Также были представлены основные утилиты, используемые для настройки и обнаружения неисправностей в сети. Затем были рассмотрены средства Java для работы с наиболее распространенными сетевыми протоколами. Приведен подробный пример и для более сложного случая – сервер, обслуживающий несколько клиентов одновременно.