Сокеты 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 arrivesreadLine() блокируется до получения данных (или конца потока) — отличительная черта классического блокирующего 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 без внешней сети.
Что стоит вынести из запуска:
- Клиентская сторона — всего три шага: создать
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 бесплатно. Соотношение такое же, как между сырыми байтовыми потоками и высокоуровневыми ридерами, построенными поверх них: опускайтесь на более низкий уровень только тогда, когда высокий не позволяет выразить то, что вам нужно.