Post

Когда InputStream становится архитектурной проблемой

Когда InputStream становится архитектурной проблемой

Если открыть код большого Java-проекта, особенно связанного с интеграцией или обработкой файлов, рано или поздно можно встретить примерно такой класс:

1
2
3
4
5
class Image {

    private final InputStream stream;

}

На первый взгляд здесь нет ничего необычного. Вместо хранения содержимого файла в byte[] объект просто содержит поток, из которого эти данные можно прочитать. Кажется, что такое решение даже эффективнее: файл не загружается целиком в память и может обрабатываться постепенно.

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

В то же время существует множество классов стандартной библиотеки, которые сами содержат InputStream как поле. Более того, именно так построена значительная часть пакета java.io. Получается противоречие: в одном случае это считается хорошим решением, а в другом — архитектурной ошибкой.

Разобраться в этом противоречии можно, если посмотреть на InputStream немного под другим углом. Важно понять, что он представляет собой не просто способ получить последовательность байтов. Прежде всего это объект, который владеет внешним ресурсом и отвечает за его жизненный цикл.

Именно поэтому вопрос заключается не в том, можно ли хранить InputStream в поле класса. Гораздо важнее понять, какой именно класс хранит поток, зачем он это делает и кто в итоге отвечает за освобождение ресурса.

Кажется, что разницы почти нет

Если забыть на минуту про внутреннее устройство InputStream, то выбор между массивом байтов и потоком выглядит исключительно вопросом производительности. Например, модель изображения можно представить так:

1
2
3
4
5
class Image {

    private final byte[] data;

}

или так:

1
2
3
4
5
class Image {

    private final InputStream stream;

}

Интуитивно оба варианта описывают одно и то же — некоторое содержимое изображения. Разница лишь в том, как именно эти данные будут получены. В случае с byte[] содержимое файла уже полностью находится в памяти.

1
byte[] data = Files.readAllBytes(path);

Схематично это выглядит так:

1
2
3
4
5
6
7
8
9
Файл

↓

byte[]

↓

RAM

Во втором случае создается поток:

1
InputStream stream = Files.newInputStream(path);

При этом сам файл еще не читается целиком. InputStream лишь открывает доступ к источнику данных, а чтение происходит постепенно, по мере вызова методов read(). На практике это обычно выглядит примерно так:

1
2
3
4
5
6
7
8
9
10
11
try (InputStream in = Files.newInputStream(path);
     OutputStream out = socket.getOutputStream()) {

    byte[] buffer = new byte[8192]; // переиспользуется

    int bytesRead;

    while ((bytesRead = in.read(buffer)) != -1) {
        out.write(buffer, 0, bytesRead);
    }
}

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

На этом этапе кажется, что использование InputStream выглядит даже более удачным решением. Если поток позволяет читать данные постепенно, не загружая весь файл в память, почему бы просто не сделать его полем класса?

Ответ становится понятен только тогда, когда мы перестаем воспринимать InputStream как способ хранения данных и начинаем рассматривать его как объект, управляющий внешним ресурсом.

Данные против ресурса

Главное различие между byte[] и InputStream заключается вовсе не в способе чтения данных и не в объеме потребляемой памяти. Разница гораздо глубже:

  • byte[] представляет собой данные.
  • InputStream представляет собой ресурс, через который эти данные можно получить.

На первый взгляд это различие кажется абстрактным, но именно оно определяет практически все особенности работы с потоками в Java.

Что на самом деле хранит byte[]

Когда выполняется:

1
byte[] data = Files.readAllBytes(path);

операционная система читает файл, JVM создает массив нужного размера и копирует в него все содержимое. После этого связь с файлом практически исчезает. Полученный массив можно:

  • хранить сколько угодно;
  • передавать между объектами;
  • сериализовать;
  • читать повторно;
  • копировать;
  • кешировать.

Сам файл уже не участвует в дальнейшей работе программы. Схематично:

1
2
3
4
5
6
7
8
9
10
11
12
13
Файл

↓

чтение

↓

byte[]

↓

дальнейшая работа

Сам массив никак не связан с файловой системой. Это просто область памяти, содержащая байты.

Что на самом деле представляет собой InputStream

Теперь посмотрим на другой вариант.

1
InputStream stream = Files.newInputStream(path);

Здесь ситуация принципиально иная. На этом этапе байты еще не прочитаны. Вместо этого создается объект, через который программа сможет обращаться к файлу в будущем.

Схематично:

1
2
3
4
5
6
7
8
9
Файл

↓

InputStream

↓

ваш код

Сам поток содержит не данные, а способ их получения.

Каждый вызов read() заставляет InputStream обратиться к источнику данных, получить очередную порцию байтов и передать ее вызывающему коду. Именно поэтому InputStream нельзя рассматривать как обычное значение, подобное строке, массиву или числу. Он представляет собой объект, тесно связанный с внешним миром.

Полезно мысленно разделить большинство объектов Java на две большие категории:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
                 Java-объекты

        ┌────────────────────────┐
        │                        │
        ▼                        ▼

      ДАННЫЕ                  РЕСУРСЫ

      String                  InputStream
      byte[]                  OutputStream
      UUID                    Socket
      Instant                 Connection
      BigDecimal              ResultSet
      Money                   ZipFile

      ▼                        ▼

  хранят состояние      владеют внешним ресурсом

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

Почему close() меняет все

Самое важное отличие становится заметно, когда возникает вопрос:

Что произойдет после завершения работы с объектом?

Для byte[] ответ очень простой. Когда массив больше никому не нужен, сборщик мусора освободит занимаемую память. Никаких дополнительных действий выполнять не требуется.

С InputStream ситуация совсем другая. Поток владеет внешним ресурсом:

  • открытым файлом;
  • сетевым соединением;
  • сокетом;
  • архивом;
  • HTTP-ответом;
  • BLOB из базы данных.

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

1
stream.close();

Именно этот вызов освобождает файловый дескриптор, закрывает соединение или сообщает операционной системе, что ресурс больше не используется. Получается принципиальная разница:

  • byte[] достаточно перестать использовать;
  • InputStream необходимо корректно завершить.

Именно поэтому появление InputStream в качестве обычного поля класса почти автоматически порождает следующий вопрос:

Кто отвечает за вызов close()?

Если ответ очевиден — скорее всего архитектура построена правильно. Если же ответ приходится долго искать по коду, это уже повод задуматься о границах ответственности между объектами.

Главный вопрос: кто владелец ресурса?

Предположим, мы снова встречаем уже знакомый класс:

1
2
3
4
5
class Image {

    private final InputStream stream;

}

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

Само по себе наличие поля InputStream еще ни о чем не говорит. Гораздо важнее понять, кто владеет этим ресурсом и кто отвечает за его жизненный цикл. Именно поэтому при чтении подобного кода полезно задать несколько простых вопросов.

Кто открыл поток?

Предположим, объект создается так:

1
2
InputStream stream = Files.newInputStream(path);
Image image = new Image(stream);

Возникает первый вопрос, кто теперь считается владельцем потока? Код, который его открыл? Или объект Image, который получил его через конструктор? Пока это не определено явно, ответственность оказывается размытой.

Кто обязан вызвать close()?

Допустим, изображение больше не нужно. Кто должен закрыть поток?

1
stream.close();

Вызывающий код? Сервис? Сам объект Image? Или это сделает кто-то еще? Если ответ на этот вопрос не очевиден, очень легко получить утечку ресурсов. Причем проблема может проявиться далеко не сразу. Во время тестирования все будет работать корректно, а спустя несколько часов работы приложение неожиданно перестанет открывать новые файлы или устанавливать сетевые соединения.

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

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

Можно ли использовать объект повторно?

Обычный объект данных можно использовать сколько угодно раз. Например:

1
2
3
4
5
Image image = repository.find(id);

sendToClient(image);
saveToCache(image);
writeBackup(image);

Если внутри находится byte[], никаких проблем не возникает. С InputStream ситуация уже не такая очевидная. После того как поток был полностью прочитан, его состояние изменилось. Повторная попытка чтения может вернуть конец потока (EOF), завершиться ошибкой или вообще оказаться невозможной — все зависит от конкретной реализации.

Представим, что один из компонентов решил “быстро заглянуть” в поток, прочитал несколько байтов для анализа и не вернул его в исходное состояние. Или воспользовался mark()/reset(), не проверив, поддерживает ли поток эти операции. Для самого компонента все может выглядеть корректно, но следующий этап обработки неожиданно получит поток, уже сдвинутый вперед.

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

Получается, что поведение объекта начинает зависеть не только от его состояния, но и от истории использования.

Можно ли сериализовать такой объект?

Многие модели данных легко сериализуются:

  • в JSON;
  • в XML;
  • по сети;
  • в кэш.

Но что означает сериализация открытого InputStream? Передать сам файловый дескриптор невозможно. Передать сетевое соединение тоже. Даже если сериализация технически пройдет, после восстановления объекта поток уже не сможет продолжить чтение с прежнего места.

Это еще один признак того, что InputStream плохо вписывается в роль обычного значения.

Почему проблема проявляется не сразу

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

  • кто отвечает за освобождение ресурса;
  • можно ли читать поток повторно;
  • допустимо ли передавать объект между слоями приложения;
  • сколько вообще должен жить этот поток.

Именно в этот момент становится ясно, что настоящая проблема заключается не в InputStream, а в размытых границах ответственности.

Появление поля типа InputStream вовсе не означает ошибку. Но почти всегда это хороший повод остановиться и спросить себя:

Кто владеет этим ресурсом и кто отвечает за его жизненный цикл?

Пока мы рассматривали классы предметной области: изображения, документы, модели данных. Однако существует целая категория объектов, для которых управление потоком и является основной обязанностью. Именно они позволяют понять, почему стандартная библиотека Java настолько активно использует InputStream как поле класса.

Но ведь стандартная библиотека Java так делает постоянно

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

Например:

  • BufferedInputStream
  • DataInputStream
  • FilterInputStream
  • PushbackInputStream
  • CheckedInputStream
  • CipherInputStream
  • DigestInputStream
  • InflaterInputStream
  • GZIPInputStream
  • ZipInputStream

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

1
2
3
4
5
public class FilterInputStream extends InputStream {

    protected volatile InputStream in;

}

Возникает закономерный вопрос. Если наличие поля InputStream считается подозрительным, почему разработчики самой Java используют его настолько активно? Неужели стандартная библиотека нарушает собственные архитектурные принципы?

Ответ — нет.

На самом деле здесь мы имеем дело совсем с другой категорией объектов. До сих пор речь шла о моделях данных — объектах, задача которых состоит в хранении некоторого состояния. Теперь же перед нами инфраструктурные классы, предназначенные не для хранения данных, а для работы с потоками. Именно поэтому одинаковое поле:

1
private final InputStream stream;

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

1
2
3
4
5
class Image {

    private final InputStream stream;

}

В другом — внутренней реализацией нового потока:

1
2
3
4
5
class BufferedInputStream extends InputStream {

    protected InputStream in;

}

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

Именно это различие делает один вариант спорным, а другой — классическим примером хорошего объектно-ориентированного дизайна.

Чтобы понять, почему так происходит, нужно познакомиться с одним из самых известных структурных паттернов проектирования — Decorator.

Паттерн Decorator и владение ресурсом

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

Рассмотрим пример:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class CountingInputStream extends InputStream {

    private final InputStream source; // поток, которым мы владеем

    private long count;

    public CountingInputStream(InputStream source) {
        this.source = source;
    }

    @Override
    public int read() throws IOException {
        int value = source.read();

        if (value != -1) {
            count++;
        }

        return value;
    }

    @Override
    public void close() throws IOException {
        source.close();
    }

    public long getCount() {
        return count;
    }
}

На первый взгляд ситуация почти не отличается от уже знакомого класса Image. Внутри снова находится поле типа InputStream. Однако назначение этого класса совершенно иное.

Поток, который оборачивает другой поток

CountingInputStream не хранит поток как часть некоторой модели данных. Он сам является новым потоком. Любой код, работающий с CountingInputStream, воспринимает его как обычный InputStream:

1
2
3
4
InputStream stream =
        new CountingInputStream(
                Files.newInputStream(path)
        );

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

1
stream.read();

Разница лишь в том, что теперь каждое чтение проходит через дополнительный слой логики.

Делегирование чтения

Предположим, вызывается:

1
stream.read();

Что происходит внутри? Сначала управление получает CountingInputStream. Затем он обращается к внутреннему потоку:

1
int value = source.read();

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

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

Делегирование close()

Самое интересное происходит при закрытии потока. Обычно декоратор реализует этот метод примерно так:

1
2
3
4
@Override
public void close() throws IOException {
    source.close();
}

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

1
2
3
4
5
6
InputStream stream =
        new BufferedInputStream(
                new CountingInputStream(
                        Files.newInputStream(path)
                )
        );

то единственного вызова

1
stream.close();

оказывается достаточно. Закрытие происходит последовательно:

1
2
3
4
5
6
7
8
9
BufferedInputStream.close()

↓

CountingInputStream.close()

↓

FileInputStream.close()

Каждый объект отвечает только за тот ресурс, которым непосредственно владеет. Именно поэтому пользователю библиотеки не приходится думать о том, сколько слоев оберток находится внутри.

Почему декоратор отличается от обычной модели

Теперь становится понятно, почему класс вроде

1
2
3
4
5
class Image {

    private final InputStream stream;

}

вызывает вопросы. Он получает поток, но не расширяет его поведение. Не предоставляет нового интерфейса. Не управляет жизненным циклом. Фактически объект просто хранит ссылку на чужой ресурс. Совсем другая ситуация у декоратора — он сам становится новым InputStream. Именно он отвечает за чтение, обработку ошибок и корректное освобождение внутреннего ресурса. Поэтому наличие поля InputStream здесь — не архитектурная ошибка, а естественная часть реализации.

Получается интересное правило. Если класс хранит InputStream, стоит спросить себя:

Этот класс просто содержит поток или сам является новым потоком?

Во втором случае перед нами, скорее всего, классическая реализация паттерна Decorator. В первом — стоит внимательно посмотреть, действительно ли этому объекту необходимо владеть открытым ресурсом.

Почему вокруг InputStream появляются цепочки оберток

После знакомства с декораторами становится понятно, почему в крупных Java-проектах вокруг InputStream постепенно вырастает целая цепочка оберток. Представим, что приложению необходимо обработать входящий XML-документ. Самый простой поток умеет только одно — читать байты. Но по мере развития системы появляются новые требования.

Например:

  • посчитать количество прочитанных байтов;
  • записать содержимое в лог;
  • проверить контрольную сумму;
  • распаковать архив;
  • расшифровать данные;
  • отфильтровать часть XML;
  • преобразовать кодировку;
  • ограничить скорость чтения.

Каждую из этих задач можно решить отдельным декоратором. Вместо одного большого класса получается цепочка небольших специализированных компонентов:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Файл

↓

FileInputStream

↓

BufferedInputStream

↓

CheckedInputStream

↓

GZIPInputStream

↓

FilteringInputStream

↓

ваш код

Каждый слой отвечает только за одну задачу и ничего не знает о внутреннем устройстве остальных. Это соответствует одному из важнейших принципов проектирования — Single Responsibility Principle (SRP). Если завтра потребуется добавить еще одну стадию обработки, существующий код не придется переписывать. Достаточно добавить еще один декоратор.

Что обычно делают такие классы

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

Например:

  • CountingInputStream — считает количество прочитанных байтов;
  • LoggingInputStream — логирует чтение;
  • FilteringInputStream — изменяет поток данных “на лету”;
  • DigestInputStream — вычисляет хэш;
  • CipherInputStream — расшифровывает данные;
  • GZIPInputStream — распаковывает архив.

Во многих интеграционных системах можно встретить и собственные реализации:

1
2
3
4
5
6
7
TempInputStream

XmlFilteringInputStream

LimitingInputStream

...

Несмотря на разные названия, идея остается одинаковой. Каждый такой класс получает существующий поток, добавляет небольшое поведение и передает управление дальше.

Почему это эффективнее материализации данных

Может возникнуть вопрос: почему бы не прочитать документ целиком, выполнить все необходимые операции и только потом передать результат дальше? Такой подход действительно возможен и во многих случаях вполне оправдан. Однако при работе с большими файлами или непрерывными потоками данных он быстро начинает расходовать значительный объем памяти.

Декораторы позволяют избежать этой проблемы. Например, поток может читать данные небольшими порциями:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
XML

↓

8 KB

↓

фильтрация

↓

распаковка

↓

вычисление хэша

↓

следующие 8 KB

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

Она позволяет одновременно сохранить низкое потребление памяти, хорошую расширяемость и четкое разделение ответственности между компонентами.

Важно понимать, что декоратор не обязан быть “легковесным”. Например, BufferedInputStream сам использует внутренний byte[], а некоторые специализированные декораторы могут временно кэшировать данные или даже создавать временные файлы. Но для внешнего кода они по-прежнему остаются обычным InputStream, сохраняя единый контракт работы с потоком.

Когда InputStream как поле класса — хорошее решение

К этому моменту уже можно сформулировать правило. Само по себе поле типа InputStream не является ни хорошим, ни плохим архитектурным решением. Все зависит от того, какую ответственность несет класс. Существует несколько ситуаций, где хранение потока внутри объекта не только допустимо, но и является наиболее естественным решением.

Декораторы потоков

Это самый очевидный случай. Класс получает существующий поток, расширяет его поведение и сам становится новым InputStream.

Например:

1
2
3
4
5
public class LoggingInputStream extends InputStream {

    private final InputStream source;

}

или

1
2
3
4
5
public class CountingInputStream extends InputStream {

    private final InputStream source;

}

Такой класс полностью контролирует жизненный цикл внутреннего потока и делегирует ему все операции чтения и закрытия. Именно так построена большая часть пакета java.io.

Инфраструктурные объекты

Иногда класс представляет собой не данные предметной области, а часть инфраструктуры приложения. Например, адаптер, отвечающий за передачу файлов между системами. В этом случае объект существует ровно столько, сколько живет сам поток, а управление ресурсом является его основной обязанностью. Здесь наличие поля InputStream выглядит вполне естественно.

Например, адаптер, который на лету фильтрует содержимое входящего потока:

1
2
3
4
5
6
7
8
9
10
11
12
public class XmlFilteringAdapter {

    private final InputStream source;

    public XmlFilteringAdapter(InputStream source) {
        this.source = source;
    }

    public void writeTo(OutputStream target) throws IOException {
        // чтение, фильтрация и запись данных
    }
}

Такой класс не описывает пользователя, документ или изображение. Его единственная задача — обработать поток данных и передать результат дальше. Объект существует ровно столько, сколько длится операция обработки, а управление потоком является его основной обязанностью. Здесь наличие поля InputStream выглядит вполне естественно.

Объекты, владеющие ресурсом

Иногда поток действительно становится частью контракта объекта.

Например:

1
2
3
4
5
6
7
8
9
10
class DownloadFile implements AutoCloseable {

    private final InputStream stream;

    private final String fileName;

    private final long length;

    // ...
}

Такой класс уже нельзя считать обычной моделью данных. Он представляет собой объект, владеющий открытым ресурсом. Поэтому именно он должен отвечать за его освобождение. Обычно подобные классы реализуют Closeable или AutoCloseable:

1
2
3
4
@Override
public void close() throws IOException {
    stream.close();
}

Теперь пользователь класса получает понятный контракт:

1
2
3
4
5
try (DownloadFile file = service.download(id)) {

    // работа с файлом

}

Вопрос “кто закроет поток?” больше не возникает. Ответ уже встроен в саму модель.

Что объединяет все эти случаи

Несмотря на различия, все рассмотренные примеры обладают одной важной особенностью. Объект не просто содержит InputStream. Он осознает, что владеет внешним ресурсом, и берет на себя ответственность за его жизненный цикл. Именно поэтому наличие поля InputStream здесь не вызывает вопросов.

Напротив, оно делает ответственность класса более явной. Получается простое практическое правило. Если класс содержит InputStream, спросите себя:

Является ли управление этим потоком основной обязанностью данного класса?

Если ответ положительный, такое решение вполне может оказаться правильным. Если же поток оказался внутри объекта “между делом”, стоит внимательно пересмотреть его архитектуру.

Когда InputStream как поле класса обычно оказывается плохим решением

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

DTO

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

Entity

Сущность описывает бизнес-объект. Например:

  • пользователя;
  • заказ;
  • изображение;
  • документ.

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

Value Object

Value Object представляет собой неизменяемое значение. Например:

1
2
3
4
5
Money

Address

Coordinates

Наличие внутри открытого ресурса нарушает саму идею объекта-значения.

Простое практическое правило

Если класс отвечает на вопрос:

“Что представляет собой объект?”

то поле InputStream в нем обычно выглядит подозрительно. Если же класс отвечает на вопрос:

“Как получить эти данные?”

или

“Как обработать этот поток?”

то использование InputStream вполне может оказаться правильным.

В конечном итоге вопрос даже не в InputStream.

Любой объект, представляющий внешний ресурс — файл, сокет, соединение с базой данных или сетевой поток — требует явного понимания того, кто им владеет и кто отвечает за его жизненный цикл.

Именно этот вопрос часто оказывается важнее, чем выбор конкретного API или библиотеки.

This post is licensed under CC BY 4.0 by the author.