Сетевое взаимодействие
Как я уже неоднократно писал, современный мир программирования невозможно себе представить без взаимодействия программ. И в подавляющем большинстве случаев это взаимодействие осуществляется через сеть.
Я е хотел бы глубоко погружаться в область устройства сети, но думаю, что несколько важных понятий все-таки надо проговорить.
Протоколы сетевого взаимодействия
Протокол — это по сути правила обмена информацией, которые описывают каким образом обмениваются информацией взаимодействующие стороны. Если вспомнить достаточно распространенную фразу “дипломатический протокол”, то суть та же — вы в определенных случаях должны говорить фразы из определенного набора слов, фраз и другая сторона делает то же самое. В ИТ-сфере все очень похоже -вы посылаете определенные байты и ждете в ответ определенные байты. Этот обмен и есть протокол. Если он соблюдается обеими сторонами, то они смогут о чем-нибудь договориться.
Если рассматривать полную сетевую модель OSI (Open System Interconnection — взаимодействие открытых систем), то прикладного программиста на Java затрагивают в основном протоколы Прикладного уровня — HTTP, FTP, SMTP, SNMP и протоколы Транспортного уровня — TCP и UDP. (там еще есть парочка, но они крайне редко встречаются)
В этой статье я хочу поговорить именно о транспортном уровне, а точнее о протоколе TCP — Transmission Control Protocol (Протокол Управления Передачей). Именно этот протокол является основой для очень широкого круга задач — подключения к базам данных, работа через Интернет, web-сервисы. Это очень важный протокол и на мой взгляд, крайне важно знать инструменты, которые позволяют с ним работать. Java имеет вполне зрелый инструментарий для этой работы и мы с ним сейчас будем знакомиться.
Что касается протокола UDP, то он тоже важен и нужен, но в моей практике он встречается реже. Хотя конечно же многое зависит от того, какую задачу вы решаете. Были у меня проекты, где мы работали с UDP достаточно плотно.
Работа с TCP — сокеты
Для прикладного программиста на Java работа с TCP — это работа с сокетами. Сокет — это специальная структура на уровне операционной системы, которая в упрощенном понимании может быть описана следующим образом:
В памяти выделяется структура, которая описывается двумя главными параметрами:
- IP-адрес — это по сути адрес компьютера в сети. Опять же — это упрощенно, но для первого знакомства вполне подойдет
- Порт — это число, которое должно быть уникально в рамках указанного компьютера. Только какое-то одно приложение должно владеть этим портом в рамках операционной системы
Можно провести достаточно простую аналогию, где компьютер — это многоквартирный дом, и каждое приложение может занять одну и более квартиру с телефоном. Дом — это IP-адрес, квартира с телефоном — порт.
TCP позволяет передавать данные из одного приложения на одном компьютере в приложение на другом компьютере путем указания пары IP-адрес + порт источника и пары IP-адрес + порт для приемника. Причем эта возможность обеспечивается операционной системой — обычная программа просто использует эту возможность. И java-программа не является исключением.
С точки зрения прикладного программиста все достаточно несложно — надо открыть сокет на своем компьютере и соединить его с сокетом на другом. У вас появится соединение (не физическое конечно,а виртуальное), но тем не менее — по этому соединению можно передавать байты в обе стороны.
Выглядит так, как-будто вы позвонили по телефону и кто-то на другом конце снял трубку. Если такого номера нет — соединения не будет. Точно так же — если на другом компьютере нет приложения, которое заняло указанный порт — вы при попытке соединиться получите ошибку.
Ну что же — давайт епоробуем написать программу, которая создает сокет и сделает запрос на какой-нибудь компьютер в Интернете. Наша программа будет клиентом — она подключается к существующему сокету (например к сайту java-course.ru) и попробует “поговорить” с ним.
Мы пока не говорили о Web-программировании, но для понимания примера нам потребуются некоторые дополнительные сведения.
Во-первых, нам потребуется порт — мы только что говорили об этом. По умолчанию номер порта для приема запросов от браузеров равен “80”.
Во-вторых — сайт java-course.ru имеет совершенно конкретный IP-адрес, который может быть найден с помощью системы DNS — Domain Name System (система доменных имен). В упрощенном варианте это большой список, в котором каждому имени в Интернете соответствует определенный IP-адрес.
Итак, адрес и порт у нас есть — осталось разобраться, что надо послать и что можно принять.
Давайте пока примем как данность, что для того, чтобы получить определенный текст с указанного сайта, нам надо послать определенную строку.
Кому любопытно — может попробовать почитать про протокол HTTP и попробовать разобраться, почему именно такая строка будет передана серверу в качестве запроса.
Сокет — пишем и читаем
Перед тем, как мы начнем смотреть код, скажу несколько слов о классе, который мы будем использовать — а именно о классе Socket. Что, не ожидали ?
Работать с этим классом достаточно просто. При создании вы передаете ему имя хоста и номер порта, с которым хотите соединиться. При таком варианте Java сама ищет нужный IP по DNS, самостоятельно получает порт на локальном компьютере (мы об этом говорили выше — соединение требует двух сокетов и каждый имеет адрес и порт) и делает соединение с указанным хостом.
Если все прошло успешно и соединение установлено, то дальше наступает очередь потоков ввода-вывода. Сокет предоставляет два потока: один на чтение — InputStream, другой на запись — OutputStream.
Вот и все — работа с потоками нам уже знакома. Давайте теперь смотреть код.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package edu.javacourse.net; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; public class SimpleClient { public static void main(String[] args) throws IOException { // Открываем сокет для доступа к компьютеру // по адресу "java-course.ru" (порт 80) Socket s = new Socket("java-course.ru", 80); // Открываем поток для чтения из сокета (информация будет // посылаться нам с удаленного компьютера InputStream in = s.getInputStream(); // Открываем поток для записи в сокет (информация будет // посылаться от нас на удаленный компьютер OutputStream out = s.getOutputStream(); // Готовим строчку с данными для запроса к серверу // Можно пока игнорировать смысл этого запроса String str = "GET /network.txt HTTP/1.1\r\n" + "Host:java-course.ru\r\n\r\n"; // Превращаем их в массив байт для передачи // Мы же используем поток, а он работает с байтами byte buf[] = str.getBytes(); // Пишем в поток вывода out.write(buf); // И читаем результат в буфер int size; byte buf_out[] = new byte[1024]; while ((size = in.read(buf_out)) != -1) { System.out.print(new String(buf_out, 0, size)); } s.close(); } } |
Советую просто внимательно почитать комментарии — там все написано. Ну и конечно же постараться исполнить этот код. Если все хорошо, то вы должны получить текст, который содержит
Congratulation !!!
Socket is working !!!
Серверный сокет
Предыдущий пример был посвящен ситуации, когда ваше приложение соединяется с удаленным сервером для запроса данных. Но как происходит работа на серверной стороне ? В общем так же, как и в случае клиента, но с некоторыми отличиями.
Для работы сервера используется специальный вид сокета — ServerSocket.
При его создании указывается порт, который он должен занять на локальном компьютере, что он и делает, если порт свободен и доступен. По поводу доступности — операционные системы могут ограничивать пользовательские приложения к некоторым номерам портов. Например уже упомянутый порт “80”. Он считается портом по умолчанию для HTTP-запросов. Или порт FTP — ”21”.
Также надо учитывать, что сервер, в отличии от клиента, ЖДЕТ запросы. Как только приходит запрос, серверный сокет создает соединение и по нему точно так же можно отправлять/принимать данные. Вы увидите, что при создании очередного соединения создается экземпляр класса Socket, с которым вы познакомились ранее.
Может создастся впечатление, что каждое соединение захватывает очередной порт (т.к. Создается объект типа Socket) на сервере, но на самом деле это не так. Если несколько упростить, то по сути серверный сокет работает как многоканальный телефон. Т.е. номер один, а клиентов можно обслужить сразу несколько. Все пакеты как-бы деляться по клиенту и все это выглядит так, что каждый объект типа Socket работает сам по себе.
Теперь мы напишем два приложения. Одно — клиент, который посылает строку с текстом. Второе — серверное, которое будет слушать запросы и отвечать на них.
Клиентское приложение мы уже в принципе разбирали, так что сложностей с чтением кода быть не должно (если конечно, вы поняли первый пример).
Есть некоторые отличия — во-первых, для записи мы открываем не OutputStream, а PrintWriter. Объект этого класса может принимать на вход строку и сам преобразовывает ее в байты. Есть смысл заглянуть в документацию и почитать про этот класс подробнее.
Во-вторых — алгоритм работы клиента достаточно простой, но требует пояснения.
Первым шагом мы пишем текстовую строку — она определена в начале программы. Потом мы читаем ответ сервера (ответ сервера предусматривает дублирование нашей строки с префиксом «Server returns: «) и посылаем вторую строку — “bye”. Это сигнал, по которому сервер должен понять, что мы хотим прекратить работу. Он нам тоже отвечает “bye” и закрывает сокет, мы эту строку читаем и заканчиваем работу. Если представить это в виде схемы, то диалог должен выглядеть наподобие такого:
1 2 3 4 |
Клиент: “Тестовая строка для передачи” Сервер: "Server returns: Тестовая строка для передачи" Клиент: “bye” Сервер: “bye” |
Пока только просмотрите код, но не запускайте приложение — еще рано.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
package edu.javacourse.net; import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.Socket; /** * Класс - клиент для отправки и получения данных */ public class Client { public static void main(String args[]) throws Exception { // Определяем номер порта, на котором нас ожидает сервер для ответа int portNumber = 1777; // Подготавливаем строку для запроса - просто строка String str = "Тестовая строка для передачи"; // Пишем, что стартовали клиент System.out.println("Client is started"); // Открыть сокет (Socket) для обращения к локальному компьютеру // Сервер мы будем запускать на этом же компьютере // Это специальный класс для сетевого взаимодействия c клиентской стороны Socket socket = new Socket("127.0.0.1", portNumber); // Создать поток для чтения символов из сокета // Для этого надо открыть поток сокета - socket.getInputStream() // Потом преобразовать его в поток символов - new InputStreamReader // И уже потом сделать его читателем строк - BufferedReader BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream())); // Создать поток для записи символов в сокет PrintWriter pw = new PrintWriter(socket.getOutputStream(), true); // Отправляем тестовую строку в сокет pw.println(str); // Входим в цикл чтения, что нам ответил сервер while ((str = br.readLine()) != null) { // Если пришел ответ “bye”, то заканчиваем цикл if (str.equals("bye")) { break; } // Печатаем ответ от сервера на консоль для проверки System.out.println(str); // Посылаем ему "bye" для окончания "разговора" pw.println("bye"); } br.close(); pw.close(); socket.close(); } } |
Второе приложение — сервер. Для его работы используется специальный класс серверного сокета — ServerSocket. Серверный сокет “открывается” на локальном компьютере и занимает определенный порт. В нашем случае порт 1777.
Дальше сервер входит в бесконечный цикл, в рамках которого и происходит обработка запросов от клиентских приложений.
Первым шагом внутри цикла сервер переходит в режим ожидания соединения — вызов accept(). При приходе запроса от клиентского приложения метод возвращает объект Socket, который используется так же как и клиентский сокет.
Дальше по коду можно видеть, что мы точно так же открываем два потока — на ввод и вывод и взаимодействуем с клиентом — принимается строка, которая анализируется, не равна ли она “bye”. Если “нет”, то возвращаем дубль строки от клиента с префиксом “Server returns: ”, если “да”, то тоже возвращаем “bye”, выходим из цикла общения с клиентом, закрываем потоки и сокет, который был нами получен из метода accept() и начинаем все заново — вызываем accept() и ждем нового соединения. Т.е схема работы серверного сокета упрощенно выглядит так:
- Создаем серверный сокет на определенном порту
- Входим в цикл, в котором:
- вызываем метод accept()
- при приходе соединения получаем объект типа Socket
- работаем с этим сокетом через потоки ввода-вывода
- по окончанию закрываем потоки и объект типа Socket
Если проводит бытовую аналогию серверного сокета — в офисе на телефоне сидит секретарь (вызов метода accept). Как только приходит звонок, он поднимает трубку (появляется объект типа Socket) и проводит разговор (использует потоки ввода-вывода). После окончания трубка кладется и цикл повторяется.
Обратите внимание, что я сделал вызов accept до блока try .. catch. Внутри этого блока я определил еще одну переменную типа Socket — localSocket. Она указывает на наш открытый сокет. Если вы помните, то такая конструкция позволяет автоматически закрывать ресурс.Таким образом наш сокет будет автоматически закрываться. И потоки ввода-вывода тоже.
В принципе можно было сделать вызов accept прямо в блоке try .. catch, но мне кажется, что так наш вариант становиться более наглядным и читабельным. Хотя тут можно спорить. Теперь предлагаю посмотреть код и прочитать комментарии.
И еще замечание для внимательных — я не закрыл серверный сокет. Это в общем не есть хорошо, в нашем случае это не является критичным, но для самостоятельной работы можете подумать, как сделать “закрытие” сокета.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
package edu.javacourse.net; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; /** * Класс - сервер, принимает запросы от клиентов и отдает данные */ public class Server { public static void main(String args[]) { // Определяем номер порта, который будет "слушать" сервер int port = 1777; try { // Открыть серверный сокет (ServerSocket) // Это специальный класс для сетевого взаимодействия с серверной стороны ServerSocket servSocket = new ServerSocket(port); // Входим в бесконечный цикл - ожидаем соединения while (true) { System.out.println("Waiting for a connection on " + port); // Получив соединение начинаем работать с сокетом Socket fromClientSocket = servSocket.accept(); // Работаем с потоками ввода-вывода try (Socket localSocket = fromClientSocket; PrintWriter pw = new PrintWriter(localSocket.getOutputStream(), true); BufferedReader br = new BufferedReader(new InputStreamReader(localSocket.getInputStream()))) { // Читаем сообщения от клиента до тех пор пока он не скажет "bye" String str; while ((str = br.readLine()) != null) { // Печатаем сообщение System.out.println("The message: " + str); // Сравниваем с "bye" и если это так - выходим из цикла if (str.equals("bye")) { // Тоже говорим клиенту "bye" и выходим из цикла pw.println("bye"); break; } else { // Посылаем клиенту ответ str = "Server returns: " + str; pw.println(str); } } } catch (IOException ex) { ex.printStackTrace(System.out); } } } catch (IOException ex) { ex.printStackTrace(System.out); } } } |
Теперь вы готовы к запуску нашего примера. И я настоятельно рекомендую заставить пример работать. Учтите несколько моментов:
- Запускать надо сначала сервер. Убедитесь, что он вывел надпись “Waiting for a connection on 1777”
- Теперь запускайте клиента. Программа должна послать/принять сообщения и закончить свою работу. Запускать можно много раз.
- Прервать работу сервера можно только “насильственным” путем. В консоли для этого нажмите Crtl+C. В IDE обычно есть кнопка “Stop” в окне, где выводится информация о запущенном приложении
Типичный вывод сервера такой:
1 2 3 4 5 6 7 8 |
Waiting for a connection on 1777 The message: Тестовая строка для передачи The message: bye Waiting for a connection on 1777 The message: Тестовая строка для передачи The message: bye Waiting for a connection on 1777 ... |
Для клиента покороче
1 2 |
Client is started Server returns: Тестовая строка для передачи |
Надеюсь, что вы попробовали запустить пример и у вас получилось. Обязательно добейтесь того, чтобы пример заработал — это важно. Всегда добивайтесь этого. Простое чтение может дать понимание, но только практика позволит закрепить материал.
Многопоточный вариант сервера
Ну что же, вы молодцы, если ваши клиент и сервер заработали. Но наш пример сервера имеет существенный недостаток.
Недостаток заключается в том, что обработка ВСЕХ запросов происходит последовательно. По сути, у нас только один секретарь на много канальном телефоне. Все запросы от всех клиентов выстраиваются в очередь. Крайне неэффективное решение.
Представим, что запрос от клиента может обрабатываться несколько секунд, а количество запросов — несколько десятков одновременно. Наш сервер будет обрабатывать запросы ужасно долго. Что же делать ?
На помощь приходит возможность многопоточной обработки. Здесь мы оперируем потоками исполнения (threads) — не путайте с потоками ввода-вывода (streams).
Идея и реализация достаточно простые — при приходе соединения мы “отстегиваем” отдельный поток, передаем туда полученный Socket и сразу возвращаемся в методу accept().
Теперь в отдельном потоке мы можем спокойно обработать запрос от клиента.
Наша система будет справляться, если количество запросов можно обработать каким-то количеством потоков за необходимый временной интервал. Пусть у вас в секунду приходит 20 запросов и каждый обрабатывается за 5 секунд. Значит для обработки вам потребуется 100 потоков. Для современных компьютеров вполне разумные цифры. Учитывая, что обработка не означает 100% загрузку процессора. Мы говорили об этом при обсуждении многопоточности.
Проводя аналогию с секретарем — теперь его задача принять звонок и сразу перенаправить его другому сотруднику. И наш секретарь снова может принимать новые звонки.
Итак, перейдем непосредственно к коду. Я написал два класса — один (Server) очень похож на реализацию нашего сервера, но попроще, т.к. обработка запроса здесь отсутствует.
Второй SocketThread — это класс для обработки клиентского запроса в отдельном потоке. Думаю, что вы должны просто внимательно прочитать код и потом запустить этот пример. Клиентское приложение остается тем же, что и было раньше.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
package edu.javacourse.net; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; /** * Класс - сервер, принимает запросы от клиентов и отдает данные */ public class ThreadServer { public static void main(String args[]) { // Определяем номер порта, который будет "слушать" сервер int port = 1777; try { // Открыть серверный сокет (ServerSocket) ServerSocket servSocket = new ServerSocket(port); while (true) { System.out.println("Waiting for a connection on " + port); // Получив соединение начинаем работать с сокетом Socket fromClientSocket = servSocket.accept(); // Стартуем новый поток для обработки запроса клиента new SocketThread(fromClientSocket).start(); } } catch (IOException ex) { ex.printStackTrace(System.out); } } } // Этот отдельный класс для обработки запроса клиента, // который запускается в отдельном потоке class SocketThread extends Thread { private Socket fromClientSocket; public SocketThread(Socket fromClientSocket) { this.fromClientSocket = fromClientSocket; } @Override public void run() { // Автоматически будут закрыты все ресурсы try (Socket localSocket = fromClientSocket; PrintWriter pw = new PrintWriter(localSocket.getOutputStream(), true); BufferedReader br = new BufferedReader(new InputStreamReader(localSocket.getInputStream()))) { // Читаем сообщения от клиента до тех пор пока он не скажет "bye" String str; while ((str = br.readLine()) != null) { // Печатаем сообщение System.out.println("The message: " + str); // Сравниваем с "bye" и если это так - выходим из цикла и закрываем соединение if (str.equals("bye")) { // Тоже говорим клиенту "bye" и выходим из цикла pw.println("bye"); break; } else { // Посылаем клиенту ответ str = "Server returns " + str; pw.println(str); } } } catch (IOException ex) { ex.printStackTrace(); } } } |
Код примера, на мой взгляд, вполне может быть разобран самостоятельно с учетом комментариев. Если же вам все-таки что-то неясно — пишите свои комментарии — с удовольствием внесу необходимые исправления и пояснения.
И теперь нас ждет следующая статья: С чего начинается Web.