Клиент-серверное программирование

Java — язык, ориентированный в первую очередь для создания сетевой инфраструктуры. И он действительно силен в этом. Большинство известных сервисов и порталов частично написаны на Java — Одноклассники, Мail.ru, Вконтакте, Google и многие другие. Java хороша тем, что отлично справляется с высокой нагрузкой, при условии, что сервер написан правильно и умеет обрабатывать массу запросов за единицу времени.

Сокеты и их особенности

Для того, чтобы обращаться друг к другу в сети, узлы должны как-то идентифицироваться. Причем идентификатор должен быть уникален. Для этого используются IP адреса. Физически это просто ряд чисел от 0 до 255, разделённых точками. Каждый узел в сети, будь то сайт или роутер имеют свой уникальный IP.

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

Порт — это просто число от 0 до 65535. Часть диапазона зарезервирована под нужды протоколов. И чтобы обратиться к определённой программе на определённом узле нужно иметь полный набор данных — IP и порт. И вот этот набор, по сути, и является сокетом, с помощью которого программы могут обмениваться данными.

Для программирования сетевых приложений клиент-сервер используют два основных класса:

  • Socket,
  • ServerSocket.

Socket

Socket(String hostname, int port) throws Unknown Host exception, IOException
Socket(InetAddressipAddress, int port) throws Unknown Host exception

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

  • InetAddressgetInetAddress(). Возвращает объект типа InetAddress, который содержит в себе имя хоста, ipадреса и много других данных;
  • intgetPort(). Вернет порт, по которому происходит соединение;
  • intgetLocalport(). Интересный метод, который вернет тот порт, который привязан к этому сокету;
  • booleanisConnected(). Проверка на имеющееся соединение. Вернет true, если оно установлено;
  • void connect(SocketAddress address). Создает новое соединение по указанному адресу.

У сокета есть интерфейс AutoCloseable, так что можно реализовать автозакрытия в случае необходимости.

ServerSocket

Серверный сокет запускается в серверной части приложения. Он имеет конструкторы:

  • ServerSocket() throws IOException
  • ServerSocket(int port) throws IOException
  • ServerSocket(int port, int maxConnections) throws IOException
  • ServerSocket(int port, int maxConnections, InetAddresslocalAddress) throws IOException

maxConnections здесь — это максимальный размер очереди клиентов, стоящих в очереди. По умолчанию устанавливается размер в 50.

Создание простейшего клиент-сервера

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

Для начала нужно весь код, связанный с сетевыми подключениями завернуть в блок try. Можно стартовать сервер прямо в его параметрах. Конструктору передаётся номер порта — 2222.

try(ServerSocketmyServer = newServerSocket(2222)) {

Теперь сервер нужно подготовить к тому, что к нему будут подключаться клиенты. Сделать это можно с помощью простого Socket и метода ServerSocket.accept. Accept сообщает программе о том, что к серверу теперь может подключиться новый сокет.

SocketmyClient = myServer.accept();

Можно сообщить об этом в консоль:

System.out.println(“Соединение принято”);

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

DataOutputStream out = new DataOutputStream(myClient.getOutputStream());
DataInputStream in = new DataInputStream(myClient.getInputStream());

Были созданы два потока — входной и выходной. Можно сообщить об этом в консоль:

System.out.println(“Поток ввода и вывода созданы”);

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

while (!myClient.isClosed()) {
System.out.println(“Чтение данных“);
String message = in.readUTF();
System.out.println(“Получено сообщение от клиента: “+message);

Здесь необходимо реализовать механизм выхода из обмена сообщениями. Например, если клиент отправит символ «q», то сервер поймёт, что нужно разрывать соединение. В качестве выражения блоку if можно отдать проверку message на соответствие символу.

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

If(message.equalsIgnoreCase(“q”)) {
System.out.println(“Клиент отключился от сервера”);
out.writeUTF(“Ответ сервера” + message + “ — OK”);
out.flush;
Thread.sleep(5000);
break;
}

В штатном режиме сервер работает как ожидается — пересылает полученные сообщения.

out.writeUTF(«Ответ сервера — » + message + » — OK»);
System.out.println(«Сервер отправил данные клиенту»);
out.flush();
}

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

System.out.println(«Клиент отключен»);
System.out.println(«Закрытие соединений и потоков»);

in.close();
out.close();
myClient.close();
System.out.println»Закрытие соединений и потоков завершено»);

В реальном сервере каждое исключение нужно будет ловить и соответствующим образом обрабатывать. Здесь же можно просто ловить все подряд и выводить.

} catch (IOExceptione) {
e.printStackTrace();
}
}

Сервер готов и ждет сигнала от клиента, которого ещё нет. Надо приготовить и его. Для начала, аналогично серверу мы всё заворачиваем в блок try. В качестве параметра передаём простой сокет. А в конструктор сокета передаём адрес сервера — localhost и порт, на котором он ждёт сообщения — 2222. Далее, также, как и в сервере необходимо подготовить потоки ввода и вывода. Сразу после запуска и создания можно вывести в консоль информацию об успешном подключении.

try(Socket mySocket = new Socket(«localhost», 2222);
BufferedReader reader =new BufferedReader(new InputStreamReader(System.in));
DataOutputStream out = new DataOutputStream(mySocket.getOutputStream());
DataInputStream in = new DataInputStream(mySocket.getInputStream()); )
{
System.out.println(«Клиент подключен к сокету»);
System.out.println();
System.out.println(«Потоки ввода и вывода проинициализированы»);

Затем запускается цикл, в котором и будет крутится вся логика. Работать клиент будет пока он существует. Затем reader будет ждать получения данных. И как только они появятся, считает их в строку и отдаст в поток вывода out.

while(!mySocket.isOutputShutdown()){
if(reader.ready()){
System.out.println(«Клиент передает данные…»);
Thread.sleep(1000);
String clientMessage = reader.readLine();

out.writeUTF(clientMessage);
out.flush();
System.out.println(«Передано сообщение» + clientMessage + » серверу»);
Thread.sleep(1000);

Здесь опять реализуем механизм выхода по символу «q». Перед тем, как закрывать соединение, нужно прочитать, что прислал сервер с помощью in.read. Если что-то есть, выводим данные в консоль. В любом случае, после всех операций вызываем break для цикла.

if(clientMessage.equalsIgnoreCase(«q»)){

System.out.println(«Клиент закрыл соединение»);
Thread.sleep(2000);

if(in.read() > -1) {
System.out.println(«Чтение…»);
String innerIn = in.readUTF();
System.out.println(innerIn);
}
break;
}

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

System.out.println(«Клиент отправил сообщение и ждет данных от сервера»);
Thread.sleep(2000);

if(in.read() > -1) {
System.out.println(«Чтение…»);
String innerIn = in.readUTF();
System.out.println(innerIn);
}
}
}
System.out.println(«Закрытие потоков»);

} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

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

Клиент сервер на Java NIO

Весь предыдущий код клиент-сервера был реализован с помощью морально устаревшего подхода с использованием пакета java.io. Устаревший — не значит неиспользуемый, так как legacy кода существует еще масса. К тому же, знать, как работает механизм серверной и клиентской части под капотом просто необходимо.

Более современные реализации используют фреймворки или пакет java.nio. Последний предлагает более оптимизированный и экономичный в плане ресурсов механизм работы с данными. Самый простой клиент-сервер на NIO довольно прост и лаконичен.

Для начала создается селектор. Это инструмент управления каналами. Он позволяет блокировать, регистрировать и передавать управление новым или уже зарегистрированным каналам. Также нужно создать сокетный канал — специальный канал, работающий как сокет. В качестве точного адреса сети используем класс InetSocketAddress и передаем его конструктору localhost и порт 2222.

Метод bind привязывает этот адрес к сокетному каналу. Далее с помощью метода configureBlocking задаём возможность блокировки. Наш сервер будет работать бесконечно, поэтому создаем бесконечный цикл. В нем сначала отправляем сообщение в консоль о том, что сервер готов принимать новые соединения и сообщения. Для этого используется один простейший метод log, который создан отдельно.

publicstaticvoidmain(String[] args) throwsIOException {
Selector selector = Selector.open();
ServerSocketChannelmyServerSocket = ServerSocketChannel.open();
InetSocketAddressmyAddr = new InetSocketAddress(«localhost», 2222);
myServerSocket.bind(myAddr);
myServerSocket.configureBlocking(false);
int ops = myServerSocket.validOps();
SelectionKeyselectionKey = myServerSocket.register(selector, ops, null);
while (true) {
log(«Сервер ждёт новых соединений…»);

Теперь нужно выбрать ключи тех каналов, которые готовы к отправке данных и подключению. Перебираем их итератором и пока есть ключи сортируем. С помощью if выбираем ключи каналов готовых к присоединению и создаем для него сокет. Если у канала уже есть готовые к чтению данные, то просто читаем их.

selector.select();
Set<SelectionKey>myKeys = selector.selectedKeys();
Iterator<SelectionKey>myIterator = myKeys.iterator();
while (myIterator.hasNext()) {
SelectionKeymyKey = myIterator.next();
if (myKey.isAcceptable()) {
SocketChannelmySocket = myServerSocket.accept();

mySocket.configureBlocking(false);
mySocket.register(selector, SelectionKey.OP_READ);
log(«Соединение разрешено: » + mySocket.getLocalAddress() + «\n»);
} else if (myKey.isReadable()) {
SocketChannelmySocket = (SocketChannel) myKey.channel();
ByteBuffermyBuffer = ByteBuffer.allocate(256);
mySocket.read(myBuffer);
String result = new String(myBuffer.array()).trim();
log(«Сообщение получено: » + result);

Далее мы реализуем механизм закрытия клиента. Маркером будет слово «Пять». И очищаем итератор.

if (result.equals(«Пять»)) {
mySocket.close();
log(«\nПосле получения значения Пять можно закрывать соединение с клиентом»);
log(«\nНо сервер продолжит свою работу»);
}
}
myIterator.remove();
}
}

На этом код сервера готов. Теперь нужно создать клиент. Сразу добавляем к методу main возможность выброса исключений. И по аналогии с сервером создаём объекты с адресов и каналом сокета. В теле метода создаём простенький списочный массив с названиями чисел.

publicstaticvoidmain(String[] args) throwsIOException, InterruptedException {
InetSocketAddressmyAddr = new InetSocketAddress(«localhost», 2222);
SocketChannelmySocket = SocketChannel.open(myAddr);
log(«Соединение с сервером через порт 2222…»);
ArrayList<String>nums = new ArrayList<String>();
nums.add(«Один»);
nums.add(«Два»);
nums.add(«Три»);
nums.add(«Четыре»);
nums.add(«Пять»);

Затем перебираем в цикле foreach и каждый элемент перекодируем в байты, затем в буфер с байтами и передаем его с помощью метода write нашего канала. В каждой итерации очищаем буфер и выставляем задержку в пару секунд с помощью метода sleep класса Thread. После выхода из цикла закрываем канал.

for (Stringnum :nums) {

byte[] message = new String(num).getBytes();
ByteBuffer buffer = ByteBuffer.wrap(message);
mySocket.write(buffer);

log(«отправка» + num);
buffer.clear();
Thread.sleep(2000);
}
mySocket.close();
}

Как видно этот код немного проще. Операции выполняются асинхронно, не блокируя друг друга. Небольшой метод log, который служит для вывода выглядит так:

private static void log(String str) {
System.out.println(str);
}

Немного об особенностях NIO

В NIO были введены каналы и селекторы. Канал — логический объект, представляющий собой абстрактное представление структуры — сокета или файла. С каналами работает селектор. Он умеет их регистрировать, определять, какой канал в данный момент блокирован, а какой работает и готов к передаче чего-либо.

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

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

Заключение

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

Добавить комментарий

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