В данной статье будет рассказано как отправлять данные по сети. Будет обсуждаться как сделать эхо-сервер: он читает сообщение полученное от пользователя и отправляет его в ответ.

Несмотря на то, что сервер и клиент две программы и обычно они запускаются на разных компьютерах - их удобно разрабатывать в одном проекте и тестировать на одном компьютере. Например можно создать классы MainServer и MainClient, где в каждом классе будет своя main-функция. Чтобы запустить какую-то одну из них - можно нажать правой кнопкой на класс, в котором она объявлена и там Run, или просто когда открыт один из этих классов нажать Ctrl+Shift+F10, что приведет к запуску main-функции из открытого на данный момент файла.

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

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

Задача сервера: ожидать подключения клиента и отвечать на все входящие сообщения тем же текстом, который прислал клиент.

0) Про работу с потоками данных

Обмен данными по сети происходит почти так же, как и работа с файлами или консолью. Поэтому сначала для практики рекомендуется вспомнить как решается простая “псевдо-олимпиадную” задачка - чтобы вспомнить как работать с потоками данных.

Здесь написано о том, как это делается.

1) Сетевое взаимодействие

Представьте что у каждого компьютера есть несколько тысяч виртуальных разъемов. Это так называемые порты - они обладают значениями от 0 до 65536 (рекомендую для использования любой из пространства 1024—49151, например 2391). Они позволяют установить соединение между двумя компьютерами (т.е. сообщения будут идти между одним из портов первого компьютера через интернет к порту второго компьютера).

Чтобы в интернете или локальной сети можно было обращаться к другим компьютерам - у каждого есть свой адрес, так называемый ip-адрес. Это уникальный идентификатор имеющий вид a.b.c.d, где каждая буква - число от 0 до 255 (например: 5.18.192.135).

Если есть компьютер \(А\) с ip-адресом \(ipA\) и есть компьютер \(Б\) с ip-адресом \(ipB\), то чтобы \(А\) смог общаться с \(Б\) - им нужно установить связь. Для этого компьютер \(А\) стучится (пытается установить соединение) по адресу \(ipB\) в конкретный порт, например в нашем случае пусть это будет порт 2391.

Чтобы соединение успешно установилось - необходимо чтобы на компьютере \(Б\) была запущена программа-сервер, которая ожидает подключающихся клиентов по порту 2391.

Итого если на компьютере \(Б\) с адресом \(ipB\) запущена программа, которая ожидает подключение клиентов по порту 2391, и есть компьютер \(А\), на котором запустилась программа, которая пытается установить соединение с компьютером по адресу \(ipB\) и порту 2391 - то будет установлено двустороннее соединение, по которому можно обмениваться информацией.

2) Простой сервер

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

В Java есть специальный класс Socket, который представляет “разъем”/”сокет” соединения, т.е. один из концов канала обмена сообщениями.

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

В Java таким швейцаром является специальный класс ServerSocket.

Создаем его в main-функции:

ServerSocket serverDoorMan = new ServerSocket(port);

Обратите внимание на параметр конструктора - нужно указать порт (т.е. номер двери выходящей наружу - на улицу), на котором наш сервер (швейцар/привратник) ожидает подключающихся клиентов. Так как порт уже может быть занят другим сервером (швейцаром), то этот конструктор может упасть с исключением IOException, об этом IDEA вам подскажет ошибкой Unhandled exception: java.io.IOException, мы просто явно пробросим эту ошибку наружу - пусть наша программа упадет (это делается через клик по этому конструктору -> Alt+Enter -> Add exception to method signature). Об этой же ошибке упомянуто в конце в секции “частые проблемы”.

Теперь надо сказать швейцару ждать гостей (accept = принимать/ожидать):

Socket socketToClient = serverDoorMan.accept();

Функция accept() приостанавливает исполнение программы до первого подключения клиента по этому порту (первого человека который придет в гости).

Когда клиент подключится и соответственно соединение будет установлено - эта функция вернет сокет который описывает установленное соединение (т.е. объект класса Socket), поэтому результат исполнения функции accept надо сохранить в переменную типа Socket - например логично назвать такую переменную socketToClient, т.к. это сокет соединения с клиентом.

У Socket (объект в переменной socketToClient) уже можно получить входной и выходной поток (через методы getInputStream и getOutputStream) - они обладают тем же типом, что и System.in и System.out. Поэтому о том, как считывать из входного потока (который возвращает getInputStream - т.е. это поток данных которые идут от клиента к серверу) и как писать в выходной поток (т.е. в результат getOutputStream - т.е. в поток данных от сервера к клиенту) в пункте 0 предложена условная задача на понимание этого (единственная разница - вторым аргументом в конструктор PrintWriter нужно передать true - это включит автоматическую отправку буфера на каждое сообщение).

Server figure

Когда клиент закроет канал - метод readLine() класса BufferedReader вернет null - и тогда сервер должен будет вернуться обратно на стадию ожидания нового клиента - т.е. опять вызвать метод accept(). (т.е. швейцар опять вернулся на свою позицию ожидания гостей)

Но как и при работе с файлами - сокет соединения с клиентом надо закрывать, т.е. после того как readLine() вернул null - требуется вызвать метод close() у объекта класса Socket, который представляет соединение с клиентом (т.е. то, что вернул метод accept()).

3) Простой клиент

Итак нам нужно написать программу которая подключается по порту 2391 к компьютеру с каким-то ip-адресом и шлет ему сообщения + получает на них ответы.

Мы сервер и клиент будем запускать на одном и том же компьютере, и для подобного случая есть зарезервированный специальный ip адрес - 127.0.0.1 (так же известный как localhost), поэтому можно считать что сервер всегда существует по такому адресу.

В Java есть специальный класс Socket, который представляет “разъем”/”сокет” соединения, т.е. один из концов канала обмена сообщениями. Для того чтобы инициировать соединение с сервером - надо создать сокет с адресом сервера к которому мы стучимся (127.0.0.1) и портом на котором нас ожидает сервер (2391). Здесь рпяит возникнет Unhandled exception: java.io.IOException, которая обсуждалась выше (конструктор ServerSocket) и ниже (в Частых проблемах).

Создаем такой сокет в main-функции:

String host = "127.0.0.1";
int port = 2391;
Socket socketToServer = new Socket(host, port);

Как и у сервера код работающий с потоками такой же как и при работе с консольным вводом-выводом. Методы получения потоков у socketToServer такие же как и у socketToClient, который обсуждался выше. Только теперь getInputStream - канал от сервера к нам (т.е. к клиенту), а getOutputStream - от нас к серверу:

Client figure

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

P.S. не забудьте вызвать метод close() у сокета соединения.

Доп. задание - почти полноценный чатик

Пусть мы хотим чтобы сервер пересылал все входящие сообщения всем подключенным клиентам.

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

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

Этот пункт добровольный - на ачивку, поэтому инструкция не подробная.

Частые проблемы

Ситуация: При запуске сервера выводится исключение java.net.BindException: Address already in use (Bind failed)

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

Ситуация: Не компилируется с исключением Unhandled exception: java.io.IOException

Это означает что вызов может упасть с исключением по работе с IO, т.е. с Input-Output (т.е. ввод-вывод). Чтобы начало компилироваться - достаточно пробросить эту ошибку наружу, т.к. такая прблема в нашем случае должна приводить к падению программы. Подробнее об этом написано выше рядом с описанием вызова конструтора ServerSocket.

Ситуация: При запуске клиента не устанавливается соединение с исключением java.net.ConnectException: Connection refused: connect

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

Ситуация: Подсвечивает красным код с ошибкой “Unhandled exception: java.io.IOException”

Это означает, что данная строка кода может кинуть исключительную ситуацию. Исключение называется IOException, т.к. IO - InputOutput, т.е. исключение связанное с потоком данных. Например в случае работы с файлом это может быть - “файл не найден”, а в случае работы по сети - “соединение было разорвано”. Ее можно либо обработать (например напечатав в консоль “проблемы с соединением”) с помощью блока try-catch (Alt+Enter, затем Surround with try/catch), либо пробросить дальше из этого метода, чтобы смог обработать тот, кто вызвал метод, в котором мы сейчас находимся (Alt+Enter, затем Add exception to method signature).