Перейти к основному содержимому

Java IO против NIO

· 7 мин. чтения

1. Обзор

Обработка ввода и вывода — обычные задачи для Java-программистов. В этом руководстве мы рассмотрим исходные библиотеки java.io ( IO ) и более новые библиотеки java.nio ( NIO ) , а также их различия при обмене данными по сети.

2. Основные характеристики

Давайте начнем с рассмотрения ключевых особенностей обоих пакетов.

2.1. ИО — java.io

Пакет java.io был представлен в Java 1.0 , а Reader — в Java 1.1. Это обеспечивает:

  • InputStream и OutputStream — которые предоставляют данные по одному байту за раз.
  • Reader и Writer — удобные обертки для потоков
  • режим блокировки – ждать полного сообщения

2.2. НИО — java.nio

Пакет java.nio был представлен в Java 1.4 и обновлен в Java 1.7 (NIO.2) с улучшенными файловыми операциями и ASynchronousSocketChannel . Это обеспечивает:

  • Буфер для чтения фрагментов данных за раз
  • CharsetDecoder — для отображения необработанных байтов в/из читаемых символов.
  • Канал — для связи с внешним миром
  • Селектор — для включения мультиплексирования в SelectableChannel и предоставления доступа к любым каналам , которые готовы к вводу-выводу
  • неблокирующий режим — читать все, что готово

Теперь давайте посмотрим, как мы используем каждый из этих пакетов, когда отправляем данные на сервер или читаем его ответ.

3. Настройте наш тестовый сервер

Здесь мы будем использовать WireMock для имитации другого сервера, чтобы мы могли запускать наши тесты независимо.

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

Давайте добавим зависимость Maven для WireMock с областью тестирования :

<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!\" }")));
}

Теперь, когда у нас настроен фиктивный сервер, мы готовы запустить некоторые тесты.

4. Блокировка ввода-вывода — java.io

Давайте посмотрим, как работает исходная модель блокирующего ввода-вывода, прочитав некоторые данные с веб-сайта. Мы будем использовать java.net.Socket , чтобы получить доступ к одному из портов операционной системы.

4.1. Отправить запрос

В этом примере мы создадим запрос GET для получения наших ресурсов. Во-первых, давайте создадим сокет для доступа к порту , который прослушивает наш сервер 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();

4.2. Дождитесь ответа

Давайте откроем InputStream в сокете для доступа к ответу, прочитаем поток с помощью BufferedReader и сохраним его в StringBuilder :

InputStream serverInput = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));
StringBuilder ourStore = new StringBuilder();

Давайте используем reader.readLine() для блокировки, ожидая полной строки, а затем добавляем строку в наше хранилище. Мы будем продолжать чтение, пока не получим нуль, который указывает на конец потока:

for (String line; (line = reader.readLine()) != null;) {
ourStore.append(line);
ourStore.append(System.lineSeparator());
}

5. Неблокирующий ввод-вывод — java.nio

Теперь давайте посмотрим, как работает неблокирующая модель ввода-вывода пакета nio на том же примере.

На этот раз мы создадим java.nio.channel . SocketChannel для доступа к порту на нашем сервере вместо java.net.Socket и передать ему InetSocketAddress .

5.1. Отправить запрос

Во-первых, давайте откроем наш 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")));

5.2. Прочитать ответ

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

Поскольку мы будем обрабатывать текст, нам понадобится 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();

5.3. Хранение данных из нашего буфера

Ответ от сервера будет содержать заголовки, из-за чего объем данных может превысить размер нашего буфера. Итак, мы будем использовать 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();
}

6. Заключение

В этой статье мы увидели, как исходная модель java.io блокирует , ожидает запроса и использует Stream для управления полученными данными.

Напротив, библиотеки java.nio обеспечивают неблокирующую связь с использованием Buffer и Channel и могут обеспечивать прямой доступ к памяти для повышения производительности. Однако с такой скоростью возникает дополнительная сложность работы с буферами.

Как обычно, код для этой статьи доступен на GitHub .