/ Java

Java и Abstract Factory

Введение

Статей по паттернам проектирования полно, и большую часть из них лучше никогда не читать. Полно статей, которые рассматривают как создавать объекты котиков и собачек, или писать hello world-ы. Решил написать ещё одну статью про паттерн, про самый избитый, но всё же.

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

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

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

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

Описание задачи и проблем

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

Есть некоторый класс, который выполняет обработку некоторого документа, назовём его DocumentProcessor, и умеет этот процессор, как не удивительно, обрабатывать некоторый документ, интерфейс будет выглядеть как-то так:

public class DocumentProcessor {
    public InMemoryFile process(Document document) {
        // ... do domething 
        return new InMemoryFile(name, byteArray);
    }
}

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

Проблемы: Предположим у нас обрабатывается большое количество таких документов, они собираются в какой-то batch и потом они передаются куда-то дальше (куда нас не волнует). Пока мы их собираем, у нас всё находится в памяти, и понятное дело мы так делать не хотим.

Решение без фабрики

Простое решение, давайте сохранять файл на диск. И просто перепишем все места где создаётся объект InMemoryFile на StoredFile. Я думаю вы начинаете видеть проблему, нам придётся лезть во внутренности многих классов, мы не можем как-то изменить поведение, не исправляя класс DocumentProcessor. В реальных же системах это может касаться множества компонент. К тому же, если вы начнёте передавать файлы сохраняемые на диск, вы получите ещё одну проблему, станет сложнее тестировать код. И к тому же, может так случиться, что может понадобиться вернуться к другой реализации. Я думаю вы понимаете проблему, каждый раз править код, не лучшая идея, вы не ходите ломать каждый раз тесты, и вообще сделать изменения локализованными.

Тут можно вспомнить ещё один из принципов SOLID, а именно Open-Closed принцип: Нужно писать код закрытый для изменений, и открытый для расширений.

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

Значит так, нам нужно добавить InMemoryFile и StoredFile, и всё это по большому счёту некий DocFile. Не долго думая мы можем создать:

public interface DocFile {
    String getName();
    byte[] getContent();
}

public class InMemoryFile implements DocFile {
   // реализация, где мы просто храним данные в массиве 
}

public class StoredFile implements DocFile {
   // реализация где, нам передают в конструктор дополнительно путь, куда сохранять файл, и при создании он записывается на диск, а при получении контента, прочитается с диска.  
}

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

Реализация DocumentProcessor немного изменится:

public class DocumentProcessor {
    public DocFile process(Document document) {
        // ... do domething 
        return new InMemoryFile(name, byteArray);
        // или 
        return new StoredFile(path, name, byteArray);
    }
}

Теперь стало лучше, мы везде в системе будем работать с объектом DocFile, и для всей системы будет не важно, что за реализация там на самом деле. Мы всё ещё не решили проблему создания объектов. Мы явно создаём, мы добавляем эту логику в DocumentProcessor, и он должен заботится о том, где взять все необходимые параметры.

Добавляем AbstractFactory

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

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

class DocFileFactory {
     DocFile create(String name, byte[] content) {
        // Создание объекта
        // например 
        return new InMemoryFile(name, content);
    }
}

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

public class DocumentProcessor {
    
    /**
    * Конструктор 
    */ 
    public DocumentProcessor(DocFileFactory fileFactory /*, другие параметры если нужно*/) {
        // реализация конструктора
    }

    public DocFile process(Document document) {
        // ... do domething 
        // !!! Внимание 
        return fileFactory.create(path, name, byteArray);
    }
}

Здорово, давайте посмотрим какие проблемы мы решили, а какие ещё остались. Теперь наш код в DocumentProcessor вообще не зависит от того, какой файл нам нужно возвращать, это не его ответственность, DocumentProcessor теперь знает, что если ему нужно создать DocFile, то он просто просит об этом docFileFactory и передаёт данные о файле (заметьте без каких-то специфических параметров, типа пути, где нужно хранить файл).

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

Улучшаем AbstractFactory

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

Итак, мы получим финальную версию наших классов:

// Интерфейс описывающий абстрактную фабрику 
public interface DocFactory {
    DocFile create(String name, byte[] content);
}

// Две её реализации 
public class InMemoryDocFactory implements DocFactory {
         DocFile create(String name, byte[] content) {
        return new InMemoryFile(name, content);
    }
}

/**
* В этом классе будет вся логика по сохранению этого файла на диск и по прочтению, можно добавить разные дополнительные улучшения по желанию. 
*/
public class StoredDocFactory implements DocFactory {
   
      DocFile create(String name, byte[] content) {
      
        return new StoredFile(path, name, content);
    }
}


Ну а дальше уже знакомый код:
public interface DocFile {
    String getName();
    byte[] getContent();
}

public class InMemoryFile implements DocFile {
 // ...
}

public class StoredFile implements DocFile {
 // ...
}

public class DocumentProcessor {
    
    /**
    * Конструктор 
    */ 
    public DocumentProcessor(DocFileFactory fileFactory /*, другие параметры если нужно*/) {
        // реализация конструктора
    }

    public DocFile process(Document document) {
        // ... do domething 
        // !!! Внимание 
        return fileFactory.create(path, name, byteArray);
    }
}

и клиентский код будет выглядеть:

DocFileFactory factory = new StoredDocFactory(path);
// ...
DocumentProcessor processor = new DocumentProcessor(factory);

И так, что же за проблемы мы решили, теперь вся логика по созданию файла вынесена отдельно, и не нужно лезть в DocumentProcessor, чтобы изменить тип создаваемых файлов. Например для Unit тестирования нам будет удобно использовать InMemory реализацию, и мы это можем легко сделать, просто передав в конструктор DocumentProcessor нужную фабрику. Также мы получаем возможность дополнительную гибкость, и возможность добавить ещё какой-то тип файлов, ну пусть например это будут какие-то специфичные кеширующиеся файлы. Для этого мы просто добавим ещё одну реализацию DocFile

public class CachedDocFile implements DocFile {
  // ...
}

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

public class CachedDocFactory implements DocFileFactory {
  // ...
}

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

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

Дополнительно

Как организовать видимость классов

Например, мы хотим заставить клиентов использовать Factory а не прямой вызов конструктора, мы можем это легко сделать использую пакетный спецификатор доступа. Тогда клиент будет только одну возможность порождать объекты DocFile. Лучше всегда так делать, если код будет доступен нескольким командам, потому что обязательно появятся места, где кто-то попытался использовать конструктор напрямую.

Пример структуры пакетов:

my
 |_super
   |___ pack
        |___DocFile
        |___InMemoryFile (нет публичного конструктора)
        |___StoredFile (нет публичного конструктора)
        |___InMemoryDocFactory
        |___StoredDocFactory 

Управление созданными объектами

Одно не явное преимущество, которое даёт использование фабрики: возможность контролировать созданными объектами

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

Например мы хотим не массив байт отдавать, а поток.

public interface DocFile {
    String getName();
    OutputStream getContent();
}

Например наша StoredFactory должна при закрытии удалять сохранённые файлы, но при этом вы не можете быть уверены, что все потоки были закрыты. Мы можем сделать простую модификацию, сохранять ссылки на все выданные объекты:


public StoredDocFactory {
    private List<DocFile> docFiles = new ArrayList<>();

    public DocFile create(String name, byre[] content) {
     DocFile docFile = new DocFile(path, name, content);
     docFiles.add(docFile);
     return docFile;
}
}

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

Java и Abstract Factory
Share this

Subscribe to Yet another blog