Обработка ввода и вывода – обычные задачи для Java-программистов. В этом руководстве рассмотрим исходные библиотеки java.io (IO) и более новые библиотеки java.nio (NIO) и их различия при обмене данными по сети.
Основные характеристики
Начнем с рассмотрения ключевых особенностей обоих пакетов.
IO – java.io
Пакет java.io появился в Java 1.0, а Reader – в Java 1.1. Он предоставляет:
- InputStream и OutputStream – предоставляют данные по одному байту за раз
- Reader и Writer – удобные обертки для потоков
- Режим блокировки – ждать полного сообщения
NIO – java.nio
Пакет java.nio был представлен в Java 1.4 и обновлен в Java 1.7 (NIO.2) с улучшенными файловыми операциями и ASynchronousSocketChannel. Он предоставляет:
- Buffer – для чтения фрагментов данных за раз
- CharsetDecoder – для отображения необработанных байтов в/из читаемых символов.
- Channel – для связи с внешним миром
- Selector – для включения мультиплексирования в SelectableChannel и предоставления доступа к любым каналам, которые готовы к вводу-выводу
- Неблокирующий режим – читать все, что готово
Теперь посмотрим, как используем каждый из этих пакетов, когда отправляем данные на сервер или читаем его ответ.
Настройка тестового сервера
Будем использовать WireMock для имитации другого сервера, чтобы запускать наши тесты независимо.
Настроим его для прослушивания запросов и отправки ответов, как это сделал бы настоящий веб-сервер. Мы также будем использовать динамический порт, чтобы не конфликтовать ни с какими службами на локальном компьютере.
Добавим зависимость Maven для WireMock с областью видимости test:
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<version>2.26.3</version>
<scope>test</scope>
</dependency>
В тестовом классе определим JUnit @Rule для запуска WireMock на свободном порту. Затем настроим его так, чтобы он возвращал ответ HTTP 200, когда запрашиваем предопределенный ресурс, с телом сообщения в виде текста в формате JSON:
@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort());
private String REQUESTED_RESOURCE = "/test.json";
@Before
public void setup() {
stubFor(get(urlEqualTo(REQUESTED_RESOURCE))
.willReturn(aResponse()
.withStatus(200)
.withBody("{ \"response\" : \"It worked!\" }")));
}
Теперь, когда настроен фиктивный сервер, можно запустить некоторые тесты.
Блокировка ввода-вывода – java.io
Посмотрим, как работает исходная модель блокирующего ввода-вывода, прочитав некоторые данные с веб-сайта. Будем использовать java.net.Socket, чтобы получить доступ к одному из портов операционной системы.
Отправка запроса
В этом примере создадим запрос GET для получения наших ресурсов. Во-первых, создадим Socket для доступа к порту, который прослушивает наш сервер WireMock:
Socket socket = new Socket("localhost", wireMockRule.port())
Для обычной связи HTTP или HTTPS порт будет 80 или 443. Однако в этом случае мы используем wireMockRule.port() для доступа к динамическому порту, который установили ранее.
Теперь откроем OutputStream в сокете, завернутый в OutputStreamWriter, и передадим его в PrintWriter для написания сообщения. И убедимся, что очищаем буфер, чтобы запрос был отправлен:
OutputStream clientOutput = socket.getOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput));
writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n");
writer.flush();
Ожидание ответа
Откроем InputStream в сокете для доступа к ответу, прочитаем поток с помощью BufferedReader и сохраним его в StringBuilder:
InputStream serverInput = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));
StringBuilder ourStore = new StringBuilder();
Давайте используем reader.readLine() для блокировки, ожидая полной строки, а затем добавим строку в наше хранилище. Будем продолжать чтение, пока не получим null, который указывает на конец потока:
for (String line; (line = reader.readLine()) != null;) {
ourStore.append(line);
ourStore.append(System.lineSeparator());
}
Неблокирующий ввод-вывод – java.nio
Теперь посмотрим, как работает неблокирующая модель ввода-вывода пакета nio на том же примере.
На этот раз создадим java.nio.channel.SocketChannel для доступа к порту на нашем сервере вместо java.net.Socket и передадим ему InetSocketAddress.
Отправка запроса
Во-первых, откроем SocketChannel:
InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port());
SocketChannel socketChannel = SocketChannel.open(address);
А теперь закодируем в стандартную кодировку UTF-8 и напишем сообщение:
Charset charset = StandardCharsets.UTF_8;
socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));
Получение ответа
После отправки запроса можно прочитать ответ в неблокирующем режиме, используя необработанные буферы. Поскольку будем обрабатывать текст, нам понадобится ByteBuffer для необработанных байтов и CharBuffer для преобразованных символов (с помощью CharsetDecoder):
ByteBuffer byteBuffer = ByteBuffer.allocate(8192);
CharsetDecoder charsetDecoder = charset.newDecoder();
CharBuffer charBuffer = CharBuffer.allocate(8192);
В CharBuffer останется место, если данные отправляются в многобайтовом наборе символов.
Обратите внимание, что если нужна высокая производительность, то можно создать MappedByteBuffer в собственной памяти, используя ByteBuffer.allocateDirect(). Однако в нашем случае достаточно использовать allocate() из стандартной кучи.
При работе с буферами нужно знать, насколько велик буфер (емкость), где находимся в буфере (текущая позиция) и как далеко можем пройти (предел).
Давайте прочитаем из SocketChannel, передав ему ByteBuffer для хранения данных. Чтение из SocketChannel завершится с текущей позицией ByteBuffer, установленной на следующий байт для записи (сразу после последнего записанного байта), но с неизменным пределом:
socketChannel.read(byteBuffer)
SocketChannel.read() возвращает количество прочитанных байтов, которые можно записать в буфер. Это будет -1, если сокет был отключен.
Если в буфере не осталось места, потому что мы еще не обработали все его данные, тогда SocketChannel.read() вернет ноль прочитанных байтов, но buffer.position() все равно будет больше нуля.
Чтобы убедиться, что мы начинаем чтение с правильного места в буфере, будем использовать Buffer.flip(), чтобы установить текущую позицию ByteBuffer в ноль и ее ограничение на последний байт, который был записан SocketChannel. Затем сохраним содержимое буфера, используя метод storeBufferContents, который рассмотрим позже. Наконец, будем использовать buffer.compact(), чтобы сжать буфер и установить текущую позицию, готовую для следующего чтения из SocketChannel.
Поскольку данные могут поступать частями, завершим код чтения буфера в цикл с условиями завершения, чтобы проверить, подключен ли еще сокет или мы были отключены, но в буфере все еще остались данные:
while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
byteBuffer.flip();
storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore);
byteBuffer.compact();
}
Не забудем закрыть() сокет (если только мы не открыли его в блоке try-with-resources):
socketChannel.close();
Хранение данных из буфера
Ответ от сервера будет содержать заголовки, из-за чего объем данных может превысить размер буфера. Поэтому будем использовать StringBuilder для создания полного сообщения по мере его поступления.
Чтобы сохранить сообщение, сначала декодируем необработанные байты в символы в CharBuffer. Затем перевернем указатели, чтобы прочитать символьные данные, и добавим их в расширяемый StringBuilder. Наконец, очистим CharBuffer, готовый к следующему циклу записи/чтения.
Теперь реализуем полный метод storeBufferContents(), передающий буферы, CharsetDecoder и StringBuilder:
void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer,
CharsetDecoder charsetDecoder, StringBuilder ourStore) {
charsetDecoder.decode(byteBuffer, charBuffer, true);
charBuffer.flip();
ourStore.append(charBuffer);
charBuffer.clear();
}
Заключение
В этой статье рассмотрели, как исходная модель java.io блокирует, ожидает запроса и использует потоки для управления получаемыми данными. Напротив, библиотека java.nio обеспечивает неблокирующую связь с использованием буферов и каналов и могут обеспечивать прямой доступ к памяти для повышения производительности. Однако с такой скоростью возникает дополнительная сложность работы с буферами.
Код для этой статьи доступен на GitHub.
Оригинал