W3docs

Сокеты Java

Открывайте TCP-соединения в Java с классом Socket: чтение, запись и таймауты.

Под HTTP и любым другим прикладным протоколом находится сокет — необработанное двунаправленное TCP-соединение между двумя конечными точками. java.net.Socket в Java — это клиентская сторона: вы создаёте его, подключаетесь к хосту и порту, а затем читаете и записываете байты через обычные InputStream/OutputStream. Это самый низкоуровневый сетевой инструмент, к которому обращаются при реализации пользовательского протокола или взаимодействии со службой, не использующей HTTP.

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

Что даёт сокет

Подключённый Socket — это канал с двумя потоками:

  • socket.getOutputStream() — байты, которые вы записываете, передаются на другой конец.
  • socket.getInputStream() — байты, записанные другим концом, поступают сюда.

TCP гарантирует доставку байтов надёжно и в правильном порядке. При этом он не накладывает никакой структуры сообщений — сокет представляет собой поток байтов, а не поток сообщений. Разграничение (где заканчивается одно сообщение и начинается следующее) — ваша задача: используйте символы новой строки, префиксы длины или высокоуровневый протокол. Если вам нужны чёткие границы сообщений вместо потока, это задача датаграммных (UDP) сокетов, которые жертвуют упорядоченностью и надёжностью ради дискретных пакетов.

Потоки приходят напрямую из системы ввода-вывода Java: getInputStream() возвращает обычный InputStream, а getOutputStream() — обычный OutputStream, поэтому все известные вам обёртки — BufferedReader, PrintWriter, DataInputStream — работают через сокет точно так же, как и с файлом.

Установка соединения

// Style 1: connect in the constructor
Socket socket = new Socket("example.com", 80);

// Style 2: create then connect with a timeout (preferred)
Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 80), 2000);

Вторая форма позволяет задать таймаут соединения — без него недостижимый хост может заблокировать поток на стандартное время ОС (нередко минуту и более). После подключения оберните потоки в буферизованные читатели/писатели и явно укажите кодировку символов.

Чтение и запись текста

var out = new PrintWriter(
        new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
var in = new BufferedReader(
        new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));

out.println("ping");                 // autoFlush=true sends it immediately
String reply = in.readLine();        // blocks until a line arrives

readLine() блокируется до получения данных (или конца потока) — отличительная черта классического блокирующего API сокетов. Всегда закрывайте сокет (try-with-resources), чтобы освободить соединение.

Чтение сырых байтов

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

try (Socket socket = new Socket()) {
    socket.connect(new InetSocketAddress("example.com", 80), 2000);
    OutputStream out = socket.getOutputStream();
    InputStream in = socket.getInputStream();

    out.write("PING".getBytes(StandardCharsets.UTF_8));
    out.flush();                          // streams are not auto-flushed

    byte[] buffer = new byte[4096];
    int n = in.read(buffer);              // bytes read, or -1 at end-of-stream
    if (n > 0) {
        String chunk = new String(buffer, 0, n, StandardCharsets.UTF_8);
        System.out.println(chunk);
    }
}

Два факта определяют каждое чтение на уровне байтов:

  • read(byte[]) возвращает фактическое количество прочитанных байтов, которое не обязательно совпадает с запрошенным. Одна запись на другом конце может прийти несколькими операциями чтения, а несколько записей — одной операцией чтения: TCP объединяет и разбивает данные по своему усмотрению. Чтобы получить фиксированное число байтов, необходимо использовать цикл или обернуть поток в DataInputStream и вызвать readFully().
  • Возвращаемое значение -1 означает, что партнёр закрыл свой конец (конец потока), а не «данных сейчас нет». Это сигнал прекратить чтение.

Важные таймауты

Блокирующий сокет может зависнуть в двух различных местах, и для каждого из них нужен отдельный таймаут:

Socket socket = new Socket();
socket.connect(new InetSocketAddress("example.com", 80), 2000); // connect timeout
socket.setSoTimeout(5000);                                       // read timeout
  • Таймаут соединения (второй аргумент connect) ограничивает время TCP-рукопожатия. Без него недостижимый хост блокирует поток на стандартное время ОС — нередко минуту и более.
  • Таймаут чтения (setSoTimeout) ограничивает время, в течение которого любой отдельный вызов read/readLine может блокироваться в ожидании данных. По истечении таймаута вызов генерирует SocketTimeoutException без закрытия сокета, что позволяет решить: повторить попытку или отказаться. Без него молчащий партнёр блокирует вас навсегда.

В реальном сетевом коде следует устанавливать оба таймаута. Два исключения, которые необходимо перехватывать: UnknownHostException (DNS не смог разрешить имя) и широкое семейство IOException (соединение отклонено, сброшено или превышено время ожидания); см. исключения Java с примерами обработки.

Полный пример: клиент, взаимодействующий с эхо-сервером на петлевом адресе

Эта программа запускает одноразовый эхо-сервер в фоновом потоке, привязанном к петлевому адресу, а затем — главная тема главы — подключает клиентский Socket, отправляет строку и читает ответ. Это полный TCP-диалог внутри одной JVM без внешней сети.

java— editable, runs on the server

Что стоит вынести из запуска:

  • Клиентская сторона — всего три шага: создать Socket, вызвать connect() с адресом и портом, затем читать и записывать его потоки. Всё, что HTTP делал за вас в предыдущих главах — строки запроса, заголовки, коды состояния — исчезает; сокет передаёт сырые байты и ничего больше.
  • connect(new InetSocketAddress(...), 2000) установил таймаут соединения 2 секунды. Конструктор без таймаута new Socket(host, port) заблокировался бы на стандартное время ОС при недостижимом хосте, поэтому явное указание таймаута — более безопасная привычка для любой реальной сети.
  • Протокол был соглашением, а не возможностью: клиент записал одну строку и прочёл одну строку, потому что обе стороны договорились, что строки — это сообщения. TCP доставил упорядоченный поток байтов; разграничение по символу новой строки, превратившее его в «сообщения», было полностью определено на прикладном уровне.
  • readLine() заблокировался до получения ответа сервера. Эта модель «один поток на соединение, блокировка до получения данных» проста и корректна, и именно её стоимость виртуальные потоки призваны сделать дешёвой при большом числе соединений.
  • getRemoteSocketAddress() и getLocalSocketAddress() показали оба конца активного соединения — петлевой порт сервера и назначенный ОС локальный порт клиента. Каждое TCP-соединение идентифицируется этой парой конечных точек. Серверный ServerSocket создаёт слушатель, принявший это соединение.

Когда использовать сырой сокет

Socket — правильный инструмент, когда не существует библиотеки, реализующей ваш протокол:

  • Вы реализуете или потребляете пользовательский TCP-протокол (игровой сервер, проводной формат брокера сообщений, текстовый административный порт).
  • Вам нужно взаимодействовать со службой, не использующей HTTP, — SMTP, текстовым протоколом в стиле Redis, устаревшим строчным сервером.

Для всего, что является HTTP, не стоит вручную формировать запросы через сокет. Используйте современный HttpClient, который предоставляет пул соединений, перенаправления, HTTP/2 и TLS бесплатно. Соотношение такое же, как между сырыми байтовыми потоками и высокоуровневыми ридерами, построенными поверх них: опускайтесь на более низкий уровень только тогда, когда высокий не позволяет выразить то, что вам нужно.

Практика

Практика
Клиент читает данные от сервера через 'socket.getInputStream()', обёрнутый в 'BufferedReader', отправляя одну команду с символом новой строки в конце и ожидая один ответ с символом новой строки. Иногда ответ приходит в двух TCP-сегментах, и клиент неправильно его читает. Что является правильным пониманием ситуации?
Клиент читает данные от сервера через 'socket.getInputStream()', обёрнутый в 'BufferedReader', отправляя одну команду с символом новой строки в конце и ожидая один ответ с символом новой строки. Иногда ответ приходит в двух TCP-сегментах, и клиент неправильно его читает. Что является правильным пониманием ситуации?
Was this page helpful?