Для 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)
Andrey_Solomatin
18.09.2022 01:02реализуем сначала абстрактный класс для объекта хранилища.
Посмотрите на модуль https://docs.python.org/3/library/abc.html
DX28
18.09.2022 17:06А не проще было остановиться на factory, и не ломать мозг миддлам которые потом это будут дебажить чтобы найти решение таски? А facade оставить в его классической реализации как в wiki. Или это чисто академическое упражнение?
Destriery Автор
18.09.2022 19:31Всё верно, это лишь пример, а не часть реального проекта.
Насчёт factory. Безусловно, у представленного подхода есть свои минусы по сравнению с "фабрикой". Но есть и плюсы, которые я как раз и описываю.
Опять же, статья - не призыв использовать код "как есть", а лишь возможность почерпнуть какие-то идеи при необходимости.
seonn
Спасибо за статью, в целом очень наглядная реализация. Два вопроса:
1) применительно к boto3 почему был выбран resource, который сам является фасадом класса boto3.client, а не собственно сам client. Было это осмысленно, с целью не перегружать статью интерфейсом client или с какой-то другой целью.
В данном случае, наверное, нет ничего плохого в том, чтобы накладывать слой абстракции на слой абстракции, и вы правомерно имеете "полное право" ничего не знать о реализации boto3.resource, но обычно такой подход ведет к рискам багов вызванных ограничениями реализации первой абстракции (resource). Такие баги сложно отслеживать из-за длинных и не явных трейсов и очень некрасиво исправлять, т.к. для обхода ограничений придется обращаться напрямую к родительскому классу и код нашей библиотеки по мере расширения функционала и багфиксов рискует превратиться в спагетти
2) часто (хотя это не какое-то 100% универсальное требование) Фасад как паттерн на уровне требований умеет возвращать экземпляр родителя. Конечно это частично нарушает инкапсуляцию, но позволяет решить ряд проблем быстро и красиво конечному пользователю библиотеки. В том числе приведенную выше - вы просто перекладываете ответственность на решение проблем с ограничениями слоя с автора библиотеки-фасада на пользователя библиотеки.
Destriery Автор
Спасибо! По вопросам:
Да, все верно, я хотел сосредоточиться на непосредственно реализации фасада и по максимуму упростить взаимодействие с исходной библиотекой. Так что это скорее пример проектирования, чем описание способа работы именно с boto3.
Хорошее замечание, согласен.
Все еще сильно зависит от изначальной задачи и области применения паттерна. По крайней мере на моей практике в разных проектах даже с похожей функциональностью реализация может сильно отличаться. Поэтому в примере я старался поймать баланс между требованиями и простотой.