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

Конечно, в относительно простом приложении проблему константных аргументов можно решить при помощи functools.partial или вообще поместить повторяющийся код в отдельную функцию.

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

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

Фасад (Facade) - структурный паттерн проектирования, реализующий простой интерфейс для работы со сложным модулем, библиотекой, фреймворком.

В этой статье для иллюстрации данного паттерна я бы хотел показать реализацию небольшого синтетического проекта.

Основная задача - создание модуля для работы с файловыми хранилищами. И в качестве примера сторонней библиотеки - boto3 - официальную python библиотеку для работы с AWS API. Нас, в частности, интересует работа с s3.

Для начала определим функциональные требования.

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

И если конкретнее, то реализовать класс, являющийся моделью файла в хранилище данных и имеющий три метода: read(), write() и delete()

Также опишем требования к архитектуре.

  • Отсутствие повторяемости низкоуровневого кода (низкоуровнего по отношению к проекту)

    Преимущество: Изменения реализации необходимо производить только в одном месте

  • Одна точка инициализации доступа к хранилищу

    Преимущество: Доступ определяется на уровне конфигурации приложения, а не где-то в коде

  • Одна точка для запросов к хранилищу

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

  • Конкретный тип хранилища не привязан жестко к модели файла

    Преимущество: Возможность использовать разные типы хранилищ

Данные требования немного выходят за рамки реализации "фасада", но полагаю, что так будет немного интереснее.

И прежде чем перейти к реализации давайте разберемся, как вообще такая функциональность реализуется "в лоб". В boto3 есть много способов ее имплементировать, но для примера возьмем один. Также будем считать, что ключи доступа к AWS хранятся в переменных окружения как AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY.

В первую очередь получим s3 как ресурс:

import boto3
import os

AWS_ACCESS_KEY_ID = os.environ['AWS_ACCESS_KEY_ID']
AWS_SECRET_ACCESS_KEY = os.environ['AWS_SECRET_ACCESS_KEY']

resource = boto3.resource(
    's3',
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY
)

Теперь определим объект хранилища, на котором будет тестировать работу программы. Для этого нам необходим тестовый bucket и имя используемого объекта.

bucket_name = 'test_bucket'
path_to_file = 'test_folder/test_object.txt'

Сохранить файл путем передачи контента:

content = b'test_object_data'

bucket = resource.Bucket(bucket_name)
file_object = bucket.Object(path_to_file)

file_object.put(Body=content)

Получить контент:

content = file_object.get()['Body'].read()

Удалить файл:

file_object.delete()

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

Приступим к реализации архитектуры.

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

class StorageObject:

    def __init__(self, path: str, base_path: str, resource: Any = None) -> None:
        """
        path: путь к файлу или же какой-либо другой идентификатор
        base_path: базовый путь
        resource: некий исходный объект хранилища, реализуемый сторонней библиотекой,
                  необходим для дальнейшего вызова методов
        """
        raise NotImplementedError

    def read(self) -> bytes:
        raise NotImplementedError

    def write(self, content: bytes) -> None:
        raise NotImplementedError

    def delete(self) -> None:
        raise NotImplementedError

Далее перейдем уже непосредственно к реализации объекта файла с s3.

class S3StorageObject(StorageObject):
    _object: BotoS3Object

    def __init__(self, path: str, base_path: str, resource: BotoS3Resource) -> None:
        """
        в данном случае base_path - это название бакета
        """
        self._object = resource.Bucket(base_path).Object(path)

    def read(self) -> bytes:
        return self._object.get()['Body'].read()

    def write(self, content: bytes) -> None:
        self._object.put(Body=content)

    def delete(self) -> None:
        self._object.delete()

Небольшое замечание насчет типа ресурса: BotoS3Resource . Это результат выполнения функции boto3.resource('s3', *args, **kwargs)

Но так как она явно никакой конкретный тип не возвращает, для лучшего понимания мы определили наследника typing.Protocol. И там же нам нужен нативный тип объекта s3: BotoS3Object . Опять же явно в boto3 такого типа нет, потому что он формируется динамически при помощи фабрики, поэтому пишем свой.

class BotoS3Resource(Protocol):
    """Результат вызова boto3.resource('s3', ...)"""
    def Bucket(self, bucket_name: str): ...

class BotoS3Object(Protocol):
    """Результат boto3.resource('s3', ...).Bucket(...).Object(...)"""
    def get(self) -> dict: ...
    def put(self, Body: bytes) -> dict: ...
    def delete(self) -> dict: ...

Таким образом мы инкапсулировали в S3StorageObject все вызовы к более низкоуровневой библиотеке и закрыли первое архитектурное требование. С таким классом уже можно работать, то есть частично функциональные требования выполнены:

resource = boto3.resource(
    's3',
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY
)
file_object = S3StorageObject(path_to_file, bucket_name, resource)

content = file_object.read()
file_object.write(content)
file_object.delete()

Что дальше? Далее нам необходимо масштабироваться до использования разных типов хранилищ. Соответственно, нужно для каждого написать свой StorageObject.

К примеру, для доступа к файлам операционной системы можно написать что-то вроде этого:
class OSStorageObject(StorageObject):
    _path: str

    def __init__(self, path: str, base_path: str = '', resource: Any = None) -> None:
        self._path = os.path.join(base_path, path)

    def read(self) -> bytes:
        with open(self._path, 'rb') as file:
            return file.read()

    def write(self, content: bytes) -> None:
        os.makedirs(os.path.dirname(self._path), exist_ok=True)

        with open(self._path, 'wb') as file:
            file.write(content)

    def delete(self) -> None:
        os.remove(self._path)

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

  • во-первых, proxy будет сам решать, объект какого типа ему создавать, а так же выступать для пользователя одной точкой входа для работы с файлами

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

Выглядеть он будет примерно так:

class File:
    storage: Storage

    def __init__(
        self,
        path: str,
        base_path: str | None = None,
        storage: Storage | None = None
    ) -> None:
        # хранилище можно задать при инициализации, либо заранее добавить в класс
        if storage: self.storage = storage

        self._object = self.storage.build_object(path, base_path)

    def read(self) -> bytes:
        return self._action('read')

    def write(self, content: bytes) -> None:
        self._action('write', content)

    def delete(self) -> None:
        self._action('delete')

    def _action(self, action: str, *args, **kwargs) -> Any:
        return getattr(self._object, action)(*args, **kwargs)

С его помощью мы закрываем четвёртое архитектурное требование.

Но тут еще надо разобраться с несколькими вопросами. Во-первых, что такое Storage? До этого такого класса у нас не было. И правильно, потому что каждый StorageObject мог принимать какой-то resource для формирования объекта и нам было не особо важно, откуда этот resource берётся. Сейчас же мы предполагаем, что хранилищ может быть множество и они могут меняться. Соответственно, работу по их инициализации и построению StorageObject есть смысл вынести непосредственно в хранилища Storage. Интерфейс у такого класса очень простой. Фактически нам требуется только один метод для создания StorageObject: build_object:

class Storage:
    base_path: str
    resource: Any
    object_type: type[StorageObject]

    def __init__(self, base_path: str | None = None, *args, **kwargs) -> None:
        self.resource = self._build_resource(*args, **kwargs)

        if base_path: self.base_path = base_path

    def build_object(self, path: str, base_path: str | None = None) -> StorageObject:
        return self.object_type(path, base_path or self.base_path, self.resource)

    def _build_resource(*args, **kwargs) -> Any:
        return None


class S3Storage(Storage):
    object_type = S3StorageObject

    def _build_resource(self, *args, **kwargs) -> BotoS3Resource:
        return boto3.resource('s3', *args, **kwargs)  # type: ignore

Подход с хранилищем хорош тем, что можно определить его на уровне конфигурации приложения так:

# данный способ следует использовать с осторожностью
File.storage = S3Storage(
    
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY
)

file = File(path_to_file, bucket_name)

или так:

# при необходимости меняется класс хранилища, но всё продолжает работать
storage = S3Storage(
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY
)

file = File(path_to_file, bucket_name, storage)

Таким образом закрыто второе архитектурное требование.

И последний момент, касающийся File, это метод _action.

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

Им мы закрываем третье архитектурное требование.

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

storage = S3Storage(
    bucket_name,
    aws_access_key_id=AWS_ACCESS_KEY_ID,
    aws_secret_access_key=AWS_SECRET_ACCESS_KEY
)

file = File(path_to_file, storage=storage)

content = file.read()
file.write(content)
file.delete()

P.S. Конечно, этот пример - лишь иллюстрация. Что-то для конкретной задачи подойдёт, что-то нет, но возможно кому-то он будет интересен в качестве отправной точки для решения его задач.

Код целиком: https://github.com/Destriery/file-storages

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


  1. seonn
    16.09.2022 10:26
    +2

    Спасибо за статью, в целом очень наглядная реализация. Два вопроса:
    1) применительно к boto3 почему был выбран resource, который сам является фасадом класса boto3.client, а не собственно сам client. Было это осмысленно, с целью не перегружать статью интерфейсом client или с какой-то другой целью.
    В данном случае, наверное, нет ничего плохого в том, чтобы накладывать слой абстракции на слой абстракции, и вы правомерно имеете "полное право" ничего не знать о реализации boto3.resource, но обычно такой подход ведет к рискам багов вызванных ограничениями реализации первой абстракции (resource). Такие баги сложно отслеживать из-за длинных и не явных трейсов и очень некрасиво исправлять, т.к. для обхода ограничений придется обращаться напрямую к родительскому классу и код нашей библиотеки по мере расширения функционала и багфиксов рискует превратиться в спагетти
    2) часто (хотя это не какое-то 100% универсальное требование) Фасад как паттерн на уровне требований умеет возвращать экземпляр родителя. Конечно это частично нарушает инкапсуляцию, но позволяет решить ряд проблем быстро и красиво конечному пользователю библиотеки. В том числе приведенную выше - вы просто перекладываете ответственность на решение проблем с ограничениями слоя с автора библиотеки-фасада на пользователя библиотеки.


    1. Destriery Автор
      16.09.2022 14:22

      Спасибо! По вопросам:

      1. Да, все верно, я хотел сосредоточиться на непосредственно реализации фасада и по максимуму упростить взаимодействие с исходной библиотекой. Так что это скорее пример проектирования, чем описание способа работы именно с boto3.

      2. Хорошее замечание, согласен.

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


  1. Maksim-Burtsev
    16.09.2022 11:47

    Где можно посмотреть программу целиком?


    1. Destriery Автор
      16.09.2022 14:22
      +1

      Добавил ссылку на github в конец статьи.


  1. Andrey_Solomatin
    18.09.2022 01:02

    реализуем сначала абстрактный класс для объекта хранилища.


    Посмотрите на модуль https://docs.python.org/3/library/abc.html


  1. DX28
    18.09.2022 17:06

    А не проще было остановиться на factory, и не ломать мозг миддлам которые потом это будут дебажить чтобы найти решение таски? А facade оставить в его классической реализации как в wiki. Или это чисто академическое упражнение?


    1. Destriery Автор
      18.09.2022 19:31

      Всё верно, это лишь пример, а не часть реального проекта.
      Насчёт factory. Безусловно, у представленного подхода есть свои минусы по сравнению с "фабрикой". Но есть и плюсы, которые я как раз и описываю.
      Опять же, статья - не призыв использовать код "как есть", а лишь возможность почерпнуть какие-то идеи при необходимости.