Первая часть - Для полноты картины советую начать с неё.

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

  • __init__ - Когда у нас нет логики, кроме инициализации полей, посредством передачи полученных аргументов в состояние класса.

  • Static factory method(если можно создавать подклассы одинаково, то можно и без "static".), когда, прежде чем установить значения в состояние класса, над переданными аргументами следует провести несложную "математику")

  • Отдельная фабрика объекта, как самостоятельный механизм(class/function), когда сложность создания значительно возрастает, или объекту требуются другие механизмы, или нужно несколько вариантов создания объекта, а оставлять это в самом объекте уже нельзя(слишком много знаний и ответственности).

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

Прошлый раз мы остановились на отдельной фабрике(возьму пример с функцией):

def make_price_request_data(
    method: str,
    path: str,
    host: str,
    content_type: ContentTypeEnum,
    data: dict[str, Any] | None = None,
    headers: dict[str, Any] | None = None,
) -> PriceRequestData:
    if method not in ["POST", "GET"]:
        raise PriceRequestDataException("Method name not valid.")
    if method == "POST" and not headers.get("X-authorization-id"):
        raise PriceRequestDataException("Authorization headers not found.")
    
    return PriceRequestData(
        method=method,
        url=urljoin(host, path),
        content_type=content_type,
        data=data,
        headers=headers,
    )

Мы убрали всю сложность создания объекта в отдельный механизм фабрики, которая забрала на себя ВСЮ сложность создания объекта. Эта фабрика может использовать другие механизмы, иметь несколько вариантов создания(пример с классом из прошлой статьи).

Возможно, у вас возникает вопрос: "Куда ещё 'расти', если это уже отдельный механизм, который не скован особыми запретами в рамках своей ответственности в создании объекта?".

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

  • Валидировать входные/выходные данные

  • Создавать экземпляры объекта, за создание которого она ответственна

  • Использовать другие механизмы, которые требуются для помощи в процессе работы фабрики

  • Производить "математику" для получения требуемых данных

  • и т. п.

Конечно, чем сложнее на деле оказывается любая из этих возможностей, тем больше стоит задуматься о новых уровнях абстракции, но всё же фабрика может делать разные вещи, чтобы достичь результата - создать полностью ВАЛИДНЫЙ объект.

Возвращаясь к вопросу выше: "Куда ещё 'расти', ...", а главное "Зачем?". Ответ прост: чтобы упростить себе жизнь, но главное, сохранить масштабируемость, поддерживаемость и переиспользование нашего механизма.

Следующие два этапа эволюции, скорее являются развилкой, нежели встают друг за другом в линейный порядок как предыдущие этапы.

5.1. Требуется, чтобы создаваемые нами объекты были тесно связаны с клиентом. То есть, нам нужно создавать объекты под конкретные клиенты.

Какие ситуации могут быть?

  1. Разные клиенты могут предоставить цены на разные продукты. У каждого клиента могут быть свои требования к headers, data и т. п.

  2. Разный интерфейс взаимодействия с нашим приложением(json, xml, ...). Разные форматы входных данных нужно преобразовывать в одинаковую структуру.

  3. Всё вместе.

Для создания семейств тесно связанных объектов, нам подойдёт такой паттерн, как abstract factory.

class AbstractPricingFactory(ABC):
    @abstractmethod
    def make_client(self) -> AbstractPricingClient:
        """Создаёт клиент для получения цен."""
        
    @abstractmethod
    def make_factory(self) -> AbstractPriceRequestDataFactory:
        """Создаёт фабрику для запроса цены."""


class HTTPPricingFactory(AbstractPricingFactory):
    def make_client(self) -> HTTPPricingClient:
        ...
    
    def make_factory(self) -> HTTPPriceRequestDataFactory:
        ...


class RPCPricingFactory(AbstractPricingFactory):
    def make_client(self) -> RPCPricingClient:
        ...
    
    def make_factory(self) -> RPCPriceRequestDataFactory:
        ...

Реализация конкретных классов тут не важна. Важна сама идея, что каждая фабрика должна уметь создавать объекты из одного семейства(подходящих для работы с друг другом или относящиеся к родственной категории тесно связанных объектов для бизнес-логики).

Насколько глубоко можно "засунуть" абстракцию от конкретного типа?

  1. Захардкодить в бизнес-логике

class GetPriceForProductUseCase:
    def __init__(self) -> None:
        self._pricing_client = HTTPPricingFactory.make_client()
        self._pricing_request_data_factory = HTTPPricingFactory.make_factory()
        
    def execute(self, data: Any) -> Decimal:
        request_data = self._pricing_request_data_factory.make(data)
        return self._pricing_client.get_price(request_data)
  1. Вынести в контроллер

@app.get("/get_price")
def get_price(data: DataModel) -> Response:
    use_case = GetPriceForProductUseCase(
        pricing_client = HTTPPricingFactory.make_client(),
        pricing_request_data_factory = HTTPPricingFactory.make_factory(),
    )
    result = use_case.execute(data)
    ...
  1. DI Container - 1

class DIContainer(AnyDILibrary):
    pricing_client = HTTPPricingFactory.make_client
    pricing_request_data_factory = HTTPPricingFactory.make_factory


@app.get("/get_price")
def get_price(data: DataModel) -> Response:
    use_case = GetPriceForProductUseCase(
        pricing_client = DIContainer.pricing_client,
        pricing_request_data_factory = DIContainer.pricing_request_data_factory,
    )
    result = use_case.execute(data)
    ...
  1. DI Container - 2(конкретный вариант фабрики выбирается на основе переменных окружений или ещё какой-либо информации).

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

Здесь нам на помощь придёт паттерн builder(строитель).

Из wiki: порождающий шаблон проектирования предоставляет способ создания составного объекта.

class PriceRequestDataBuilder:
    def __init__(self) -> None:
        self._request = {}
        
    def with_method(self, method: HTTPMethod = HTTPMethod.GET) -> PriceRequestDataBuilder:
        self._request["method"] = method
        return self
        
    def update_headers(self, headers: dict[str, Any]) -> PriceRequestDataBuilder:
        if not self._request.get("headers"):
            self._request["headers"] = {}
        self._request["headers"] |= headers
        return self
    
    def add_product(self, product_data: dict[str, Any]) -> PriceRequestDataBuilder:
        if not self._request.get("data"):
            self._request["data"] = {}
            
        if not self._request["data"].get("products"):
            self._request["data"]["products"] = []
        
        self._request["data"]["products"].append(product_data)
        return self
    
    # Не буду добавлять все методы, думаю, суть понятна
    
    # Делать ли проверки валидации в каждом из методов или в build стоит решать вам.
    # Мой совет: Чем сложнее проверки, тем лучше вынести их в методы добавления данных,
    # чтобы build в основном строил, а не проверял :)
    def build(self) -> PriceRequestData:
        # Для простоты представим, что все данные провалидированны.
        return PriceRequestData(
            method=self._request["method"],
            url=self._request["url"],
            content_type=self._request["content_type"],
            data=self._request["data"],
            headers=self._request["headers"],
        )

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

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

Получается, что вместо более общего привычного интерфейса с методом "создать", мы получаем более глубокий и гибкий механизм для конструирования объектов.

В одном из наших сервисов этот паттерн ОЧЕНЬ помогает в тестах:

# Пара примеров использования из реальных тестов

# Сборка запроса в сервис
requests_data = (
    RequestBuilder(method="get_prices")
    .with_currency()
    .with_wizard_type(wizard_type="domain")
    .with_domain(dname="premium.com", period=1)
    .with_domain(dname="hello.ru", period=2)
    .with_promocode(promocode="HELLO")
    .with_ssl_certificate("ssl_dns", ActionType.NEW)
    .build()
)

# Создание промокода
promo_code = (
    PromoBuilder()
    .add_grant()
    .set_conditions([ServtypeCondition("hosting")])
    .set_effects([MakeDiscountEffect(20)])
    .build()
)

# Сборка скидки, для дерева скидок
discount_tree_leaf = (
    DiscountLeafBuilder()
    .default_leaf()
    .with_operation(OperationCase(FixedPrice(Decimal("10"))))
    .with_max_period(3)
    .build()
)

5.2.1 Нужно переиспользовать сложные варианты построения объектов с помощью строителя.

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

Для такой ситуации вам сможет помочь такое дополнение к строителю, как "директор"(помогает соблюдать dry)

По сути, директор, это механизм с заранее подготовленным алгоритмом управления строителем.

В зависимости от уровня требуемой абстракции, можно применять разные подходы к директорам:

# Удобно для использования в тестах и инфраструктуре, 
# где можно позволить пренебречь абстракцией и зависимостями
class PriceRequestDataDirector:
    def __init__(self, builder: AbstractPriceRequestDataBuilder) -> None:
        self._builder = builder
    
    # Могут быть переданы требуемы параметры
    def make_three_test_domains_price_request(self) -> PriceRequestData:
        return (
            self._builder
            .add_product({"service": "domain", "name": "test_one.com"})
            .add_product({"service": "domain", "name": "test_two.com"})
            .add_product({"service": "domain", "name": "test_three.com"})
            .with_method(HTTPMethod.GET)
            .build()
        )

    def make...


# Удобно, когда важно сохранить уровень абстракции и использовать 
# механизм в бизнес-логике
class ThreeTestDomainsPriceRequestDataDirector(AbstractDomainsPriceRequestDataDirector):
    def __init__(self, builder: AbstractPriceRequestDataBuilder) -> None:
        self._builder = builder
    
    # Могут быть переданы требуемы параметры
    def make(self) -> PriceRequestData:
        return (
            self._builder
            .add_product({"service": "domain", "name": "test_one.com"})
            .add_product({"service": "domain", "name": "test_two.com"})
            .add_product({"service": "domain", "name": "test_three.com"})
            .with_method(HTTPMethod.GET)
            .build()
        )

Вот мы и подобрались к концу второй части статьи об эволюции в создании объектов. Я надеюсь, многим из вас пригодится какой-то из этапов эволюции, начиная c __init__ и заканчивая абстракными фабриками и строителями с директорами.

Это не все порождающие паттерны проектирования. Это один из путей эволюции.

Если вам понравилась статья или вы хотели бы узнать ещё больше о порождающих паттернах проектирования, то голосуйте за статью и пишите комментарии. Я с радостью разберу для вас и другие подходы в создании объектов!?

Комментарии (2)


  1. AlexMatveev
    28.05.2024 17:26

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


    1. KarmanovichDev Автор
      28.05.2024 17:26

      Я очень рад, что смог заинтересовать вас! Опыта в написании у меня мало, но стараюсь вкладывать душу :)