Основы работы с сетью на примере консольного чата
Если вы начали читать эту статью, то, скорее всего, имеете какое-то отношение к IT-и понимаете что такое IP-адрес – уникальный адрес, который определяет компьютер в сети.
Но достаточно ли такой адресации для полноценной работы? Предположим, что на некотором компьютере запущены одновременно две программы, которые взаимодействуют с интернетом – получают и/или отсылают какие-то данные. Программы никак между собой не связаны и общаются с разными интернет-сервисами. Но они расположены на одном компьютере, следовательно, имеют один IP-адрес. Если они одновременно должны получить данные от двух разных серверов, как же они определят, кому какие данные предназначались?
Для того, чтобы решить эту проблему, нужно вместе с самими данными передавать информацию о том, какой программе они предназначаются. Эта информация и есть порт.
В нашем случае каждая из программ (точнее, программисты, написавшие их) должна определить, по какому порту она хочет взаимодействовать с сетью. Сервер, в свою очередь, должен тоже знать этот порт и посылать данные именно на него.
Что же собой представляет этот загадочный порт? Вы можете взять отвёртку и перебрать весь компьютер, но портов так и не найдёте. Это просто число, которое передаётся вместе с данными. Теоретически, оно может находиться в диапазоне от 1 до 65535, но порты 1..1024 используются системными программами и занимать их не стоит. Поэтому порт следует выбирать из диапазона 1025..65535.
Итак, IP-адрес это адрес компьютера в сети; порт – «адрес» программы на этом компьютере. А сокет – это их объединение, т.е. адрес программы в сети. Именно сокеты мы и будем использовать, чтобы указать программе, куда же стоит отправлять сообщения.
ПланируемДаже в таких простых программах, как чат, не нужно сразу лезть в IDEи писать что-то невнятное. Для начала стоит осмыслить теорию, с которой мы ознакомились (вы же её не пропустили, правда?) и понять, что она значит в контексте нашей программы.
Во-первых, в проектах, которые предназначены для работы с сетью, нужны две программы (не будет же она одна слать сама себе сообщения). Одна из них является сервером, а другая – клиентом. Отсюда первое следствие:
- Необходимо два режима работы программы – серверный и клиентский
При этом можно создать либо две разные программы, либо одну и спрашивать пользователя, в каком режиме её запустить. Первый способ лучше тем, что серверу не придётся хранить файлы для клиентской части. С другой стороны, так чтобы проверить работу сервера и клиента придётся качать и запускать две разные программы. Остановимся на втором способе – в одной программе в зависимости от желания пользователя, будем запускать серверный или клиентский режим.
Дальше самое время подумать об архитектуре. С двумя пользователями всё легко – они просто пересылают друг другу свои сообщения. Но как мы помним, чат у нас планируется многопользовательский. Хранить у каждого клиента список всех остальных пользователей бессмысленно (следует понимать, что в данном случае клиент и пользователь – одно и то же). Поэтому сделаем так:
- Сервер будет один, а пользователей – любое количество. Каждый пользователь подключается к этому серверу и посылает ему свои сообщения. Сервер хранит список подключенных пользователей и рассылает им все полученные сообщения.
И последнее, с чем нужно разобраться это протокол – алгоритм взаимодействия программ в сети. Очевидно, что и сервер и клиент должны слать друг другу сообщения по одному алгоритму. Ведь если клиент отправит строку, в то время как сервер будет ждать число, они друг друга не поймут, и будут работать неправильно.
Раз пользователей будет много, неплохо бы их как-то идентифицировать. Для этого сразу после подключения клиент должен отправить серверу свой ник. Дальше клиент посылает сообщения в чат до тех пор, пока очередное сообщение не будет равно «exit». Это означает, что пользователь хочет покинуть чат и закрыть программу.
Написание программыТеперь, когда все подготовительные момент ясны, можно преступить к самому интересному – написанию программы.
Файлы и структура пакетовВы, конечно, знаете, что любая программа на Javaначинается с метода main(String[] args). Для большей наглядности не будем добавлять его к другим классам, а создадим отдельный класс Mainи пакет для него – main. В любой программе наверняка будут какие-то константы. Я предпочитаю выносить их в отдельный файл в виде publicstaticполей, поэтому создам класс Constи также добавлю его в пакет main.
Как мы помним, программа должна работать в режиме клиента или сервера. Создадим два соответствующих класса Clientи Server.
В итоге дерево пакетов выглядит так:
Выбор режима работыДля начала нужно выбрать, в каком режиме запускать программу – сервер или клиент. Это нам и нужно первым делом узнать у пользователя, поэтому в метод main(…) пишем следующее:
Здесь всё достаточно просто – спрашиваем, как запускать программу, считываем букву ответа и запускаем соответствующий класс. Стоит пояснить только по поводу класса Scanner – это класс стандартной библиотеки, который облегчает работу с вводом данных из консоли. Он инициализируется стандартным потоком ввода.
Режим клиентаПойдём от простого к сложному и сначала реализует клиентский режим работы.
Если сервер просто запускается и ждём пользователей, то клиентам приходится проявлять некоторую активность, а именно – подключиться к серверу. Для этого нужно знать его IPи порт подключения. Порт является константой, поэтому зададим его в Const.java:
Constобъявлен как abstract, т.к. содержит только статические данные и создавать его экземпляр ни к чему.
IPдолжен ввести пользователь, поэтому в конструкторе Client пишем:
Теперь у нас есть все необходимые данные – ip, порт, режим работы программы. Можно подключаться к серверу. Сначала создадим сокет:
При этом сразу же производится подключение и можно передавать и считывать данные. Но как это сделать, если данные передаются только через потоки? Каждый Socketсодержит входной и выходной потоки класса InputStreamи OutputStream. Можно работать прямо с ними, но лучше для удобства «завернуть» их во что-то более функциональное:
Любые операции с потоками и сокетами должны выполняться внутри блока try..catch, для того, чтобы обрабатывать ошибки.
Вот теперь можно начать обмен сообщениями с сервером. Как мы помним, по протоколу, нужно сначала передать имя пользователя а затем каждое введённое (в консоль) сообщение отправлять серверу. Так и сделаем:
Метод println() объекта outотправляет данные на сервер, а метод readLine() объекта in– считывает полученные данные. Но как нам печатать в консоль полученные от сервера сообщения? Ведь нам нужно одновременно ожидать сообщений из консоли (от пользователя) и сообщений из потока (от сервера). Придётся для этого создать дополнительную нить.
Thread– класс Java, который реализует такую незаменимую вещь, как многопоточность. Это возможность программы одновременно выполнять разные наборы действий. Как раз то, что нам сейчас нужно.
Создадим внутренний класс, который будет получать сообщения от сервера и выводить их в консоль.
До тех пор, пока поток не будет остановлен, он просто считывает все сообщения сервера и выводит их в консоль.
В конструкторе создадим объект этого класса и запустим поток:
Итоговый файл Client.java вместе с остальными приведён в конце статьи.
Режим сервераСервер, в отличие от клиента, работает не с классом Socket, а с ServerSocket. При создании его объекта программа никуда не подключается, а просто создаётся сервер на порту, переданном в конструктор.
Вся логика работы с конкретным пользователем будем находиться во внутреннем класса Connection, а Serverбудет только принимать новые подключения и оперировать существующими. Начнём «снизу» и создадим класс Connection, который должен в отдельной нити принимать от пользователя сообщения и рассылать их остальным клиентам:
connection это массив со всеми соединениями пользователей. Когда необходимо отправить какое-то сообщение всем, мы перебираем этот массив и обращаемся к каждому клиенту.
Конструктор, как и в классе Client, преобразовывает потоки, связанные с сокетом. Метод run() запускается в отдельной нити и выполняется параллельно с остальной частью программы. В нём, согласно протоколу, сначала считывается имя пользователя, а потом все остальные его сообщения рассылаются всем клиентам чата. Когда приходит сообщение “exit”, пользователь отключается от чата, а все связанные с ним потоки закрываются методом close().
Теперь осталось только создать сервер, который будет принимать подключения, создавать объекты Connectionи добавлять их в массив. В конструкторе класса Server пишем:
Метод server.accept() указывает серверу ожидать подключения. Как только какой-то клиент подключится к серверу, метод вернёт объект Socket, связанный с этим подключением. Дальше создаётся объект Connection, инициализированный этим сокетом и добавляется в массив. Не забываем про try..catchи в конце закрываем все сокеты вместе с потоками методом closeAll();
ИсходникиГде-то на середине статьи я вдруг осознал, что худшего учителя найти сложно, поэтому вот вам хотя бы исходники с комментариями. Может там что-то будет понятно. Спойлеров что-то нету, так что лучше выложу на bitbucket.