Чистая архитектура в Python: пошаговая демонстрация. Часть 2

http://blog.thedigitalcatonline.com/blog/2016/11/14/clean-architectures-in-python-a-step-by-step-example/
  • Перевод
  • Tutorial

Содержание

Доменные модели


Git tag: Step02

Начнем с простого определения модели StorageRoom. Как было сказано ранее, модели в чистой архитектуре очень легкие, по крайней мере, легче, чем их ORM-аналоги в фреймворках.

Раз мы следуем методологии TDD, то первое, что мы напишем, это тесты. Создадим файл tests/domain/test_storageroom.py и поместим внутри него этот код:

import uuid
from rentomatic.domain.storageroom import StorageRoom


def test_storageroom_model_init():
    code = uuid.uuid4()
    storageroom = StorageRoom(code, size=200, price=10,
                         longitude='-0.09998975',
                         latitude='51.75436293')
    assert storageroom.code == code
    assert storageroom.size == 200
    assert storageroom.price == 10
    assert storageroom.longitude == -0.09998975
    assert storageroom.latitude == 51.75436293


def test_storageroom_model_from_dict():
    code = uuid.uuid4()
    storageroom = StorageRoom.from_dict(
        {
            'code': code,
            'size': 200,
            'price': 10,
            'longitude': '-0.09998975',
            'latitude': '51.75436293'
        }
    )
    assert storageroom.code == code
    assert storageroom.size == 200
    assert storageroom.price == 10
    assert storageroom.longitude == -0.09998975
    assert storageroom.latitude == 51.75436293

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

Пока же давайте напишем класс StorageRoom, разместив его в файле rentomatic/domain/storageroom.py. Не забываем создать файл __init__.pyв каждом подкаталоге проекта, которые Python должен воспринимать как модули.

from rentomatic.shared.domain_model import DomainModel


class StorageRoom(object):

   def __init__(self, code, size, price, latitude, longitude):
       self.code = code
       self.size = size
       self.price = price
       self.latitude = float(latitude)
       self.longitude = float(longitude)

   @classmethod
   def from_dict(cls, adict):
       room = StorageRoom(
           code=adict['code'],
           size=adict['size'],
           price=adict['price'],
           latitude=adict['latitude'],
           longitude=adict['longitude'],
       )

       return room


DomainModel.register(StorageRoom)

Модель очень проста и не требует пояснений. Одним из преимуществ чистой архитектуры является то, что каждый слой содержит небольшие кусочки кода, которые, будучи изолированы, должны выполнять простые задачи. В нашем случае, модель предоставляет API для инициализации и сохранения информации внутри класса.

Метод from_dict полезен при создании модели из данных, поступающих из другого слоя (такого как слой базы данных или из строки запроса в REST слое).

Может возникнуть соблазн попытаться упростить функцию from_dict, абстрагируя и предоставляя ее как метод класса Model. И учитывая, что определенный уровень абстракции и обобщения возможен и нужен, а инициализация моделей может взаимодействовать с различными иными сценариями, лучше реализовать её непосредственно в самом классе.

Абстрактный базовый класс DomainModel — это простой способ классифицировать модель для будущих сценариев, таких как проверка на принадлежности класса к модели в системе. Для получения дополнительной информации об использовании Абстрактных Базовых Классов в Python советую почитать этот пост.

Сериализаторы


Git tag: Step03

Если мы хотим вернуть нашу модель как результат вызова API, то её нужно будет сериализовать. Типичный формат сериализации это JSON, так как это широко распространённый стандарт, используемый для веб-API. Сериализатор не является частью модели. Это внешний специальный класс, который получает экземпляр модели и переводит её структуру и значения в некоторое представление.

Для тестирования JSON-сериализации нашего класса StorageRoom поместите в файл tests/serializers/test_storageroom_serializer.py следующий код

import datetime
import pytest
import json

from rentomatic.serializers import storageroom_serializer as srs
from rentomatic.domain.storageroom import StorageRoom


def test_serialize_domain_storageroom():
    room = StorageRoom('f853578c-fc0f-4e65-81b8-566c5dffa35a',
                       size=200,
                       price=10,
                       longitude='-0.09998975',
                       latitude='51.75436293')

    expected_json = """
        {
            "code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
            "size": 200,
            "price": 10,
            "longitude": -0.09998975,
            "latitude": 51.75436293
        }
    """

    assert json.loads(json.dumps(room, cls=srs.StorageRoomEncoder)) == json.loads(expected_json)


def test_serialize_domain_storageruum_wrong_type():
    with pytest.raises(TypeError):
        json.dumps(datetime.datetime.now(), cls=srs.StorageRoomEncoder)

Поместите в файл rentomatic/serializers/storageroom_serializer.py код, который проходит тест:

import json


class StorageRoomEncoder(json.JSONEncoder):

    def default(self, o):
        try:
            to_serialize = {
                'code': o.code,
                'size': o.size,
                'price': o.price,
                "latitude": o.latitude,
                "longitude": o.longitude,
            }
            return to_serialize
        except AttributeError:
            return super().default(o)

Предоставляя класс, унаследованный от JSON.JSONEncoder, используем json.dumps(room, cls = StorageRoomEncoder) для сериализации модели.

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

Сценарии (часть 1)


Git tag: Step04

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

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

Вот основа для простого теста сценария, который выводит список всех складских помещений. Поместите этот код в файл tests/use_cases/test_storageroom_list_use_case.py

import pytest
from unittest import mock

from rentomatic.domain.storageroom import StorageRoom
from rentomatic.use_cases import storageroom_use_cases as uc


@pytest.fixture
def domain_storagerooms():
    storageroom_1 = StorageRoom(
        code='f853578c-fc0f-4e65-81b8-566c5dffa35a',
        size=215,
        price=39,
        longitude='-0.09998975',
        latitude='51.75436293',
    )

    storageroom_2 = StorageRoom(
        code='fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
        size=405,
        price=66,
        longitude='0.18228006',
        latitude='51.74640997',
    )

    storageroom_3 = StorageRoom(
        code='913694c6-435a-4366-ba0d-da5334a611b2',
        size=56,
        price=60,
        longitude='0.27891577',
        latitude='51.45994069',
    )

    storageroom_4 = StorageRoom(
        code='eed76e77-55c1-41ce-985d-ca49bf6c0585',
        size=93,
        price=48,
        longitude='0.33894476',
        latitude='51.39916678',
    )

    return [storageroom_1, storageroom_2, storageroom_3, storageroom_4]


def test_storageroom_list_without_parameters(domain_storagerooms):
    repo = mock.Mock()
    repo.list.return_value = domain_storagerooms

    storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
    result = storageroom_list_use_case.execute()

    repo.list.assert_called_with()
    assert result == domain_storagerooms

Тест прост. Сначала мы подменили хранилище так, чтобы он предоставлял метод list(), возвращающий список заранее созданных выше моделей. Затем мы инициализируем сценарий с хранилищем и выполняем его, запоминая результат. Первое, что мы проверяем, это что метод хранилища был вызван без какого-либо параметра, а второе, это правильность результата.

А вот реализация сценария, которая проходит тест. Поместите код в файл rentomatic/use_cases/storageroom_use_case.py

class StorageRoomListUseCase(object):
    def __init__(self, repo):
        self.repo = repo

    def execute(self):
        return self.repo.list()

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

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

Запросы и ответы


Git tag: Step05

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

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

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

Ну а пока нам просто необходим StorageRoomListRequestObject, который может быть инициализирован без параметров, так что, давайте создадим файл tests/use_cases/test_storageroom_list_request_objects.py и поместим в него тест для этого объекта.

from rentomatic.use_cases import request_objects as ro


def test_build_storageroom_list_request_object_without_parameters():
    req = ro.StorageRoomListRequestObject()

    assert bool(req) is True


def test_build_file_list_request_object_from_empty_dict():
    req = ro.StorageRoomListRequestObject.from_dict({})

    assert bool(req) is True

На данный момент объект запроса пуст, но он нам пригодится сразу, как только у нас появятся параметры для сценария, выдающего список объектов. Код для класса StorageRoomListRequestObject находится в файле rentomatic/use_cases/request_objects.py и имеет следующий вид:

class StorageRoomListRequestObject(object):
    @classmethod
    def from_dict(cls, adict):
        return StorageRoomListRequestObject()

    def __nonzero__(self):
        return True

Запрос так же довольно прост, так как на данный момент нам необходим только успешный ответ. В отличие от запроса, ответ не связан с каким-либо конкретным сценарием, так что файл теста можно назвать tests/shared/test_response_object.py

from rentomatic.shared import response_object as ro


def test_response_success_is_true():
    assert bool(ro.ResponseSuccess()) is True

и фактический объект ответа находится в файле rentomatic/shared/response_object.py

class ResponseSuccess(object):
    def __init__(self, value=None):
        self.value = value

    def __nonzero__(self):
        return True

    __bool__ = __nonzero__

Продолжение следует в Части 3.

Поделиться публикацией

Комментарии 14

    +2
    Никаких притензий к переводчику, так как уровень моего англиского стремится к нулю, но…
    Метод from_dict полезен при создании модели из данных, поступающих из другого слоя (такого как слой базы данных или из строки запроса в REST слое)

    очень полезен, особенно если у меня 100500 атрибутов… как насчет распаковки аргументов в конструкторе?
    storageroom_1 = StorageRoom(**kwarg)
    

    to_serialize = {
                    'code': o.code,
                    'size': o.size,
                    'price': o.price,
                    "latitude": o.latitude,
                    "longitude": o.longitude,
                }
    

    Очень грамотно, особенно если учесть что завтра у меня появится еще одно поле, и мне его надо будет добавить (по коду автора):
    — в модель,
    — в сериалайзер
    — anywere

    Не ставлю под сомнение пользу статьи, но не уж то практика с базовыми схемами, для моделей, валидаторов, сериализаторов настолько плоха, что нужно писать такие самокаты? (прошу агрументированно проправить если я не прав)
      0
      Думаю, что распаковка аргументов в конструкторе — это здорово и питонично. Но мы тут её не видим, потому, что всякие концепции (чистого кода, DDD и прочее) описывались Робертом Мартиным, Эриком Эвансом, Мартиным Фаулером и прочими для статически типизированных языков как Java или C#. Просто опыт работы с данными методологиями ещё не обтесался и не питонизировался. Надеюсь, всё ещё впереди.
        +2
        Вот в этом и проблема: нести неизменные идеи/стратегии из других языков, в частности как Вы отметили с Java/C#.
        Я бы сказал, что даже в тексте чувствуется подход из другого языка, в использовании адаптеров/интерфейсов/кучей наследования и других, нужных и не очень, прокси-элементов.
        В питоне нет интерфейсов (в привычном понимании) но я думаю люди не испытывают от этого какие-то значиимые неудобства (исключение быть может — те кто переучивается), зато есть другие средства которые упрощают и улучшают программирование на данном языке, и используя данные инструменты, строятся свои архитектурные особенности, свойственны именно данному языку(встроенные методы, широкое использование интроспекции, мета программирование и прочее.).

        +1
        Распаковка аргументов багоопасна в проектах с длительным сроком жизни.
          0
          Быть может, не самый удачный пример:
          class File:
              """
              User's data type
              """
              def __init__(self, path):
                  self.path = path
          
              def __str__(self):
                  return self.path
          
          
          class Scheme():
              """
              Abstract scheme of model
              """
              _scheme = {'doo': int, 'foo': str, 'bar': File}
          
          
          class Model(Scheme):
          
              """
              Data model
              """
              def __init__(self, **kwargs):
                  self.__dict__.update(self._scheme)
                  for k, v in kwargs.items():
                      setattr(self, k, v)
          
              def __setattr__(self, key, value):
                  if not hasattr(self, key):
                      raise AttributeError
                  elif not issubclass(type(value), self._scheme[key]):
                      raise ValueError
                  object.__setattr__(self, key, value)
          
              def serial(self):
                  return {x: getattr(self, x) for x in self._scheme}
          


          Схема, модель, валидатор сереализатор и управление данными в 40 строк кода… разве не к этому мы все стремимся — к простоте?
          +1
          Это красиво и удобно. Но если на той стороне появилось еще одно поле? Конструктор получит неизвестный аргумент и все сломается? Или он это тихо проглотит и сломается в десятке других мест?
            0

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

              0
              Да, в этом случае получается, что работоспособность кода полностью зависит от внешнего приложения. Например, выкатили обновление API и весь код поломался, потому что начал получать новое поле.
                0
                Если это поле нигде не учитывается в коде, то ничего нигде и не сломается. Новое поле просто будет игнорироваться.
                А, к примеру, если в де/сериализаторе учитывалась контрольная сумма по полученным полям, и с новым полем у вас получаются другие суммы, то, скорее всего, неверен код сериализатора. Или же он ожидал конкретную структуру, значит, вы должны строго придерживаться её, значит, должна быть валидация получаемых данных. Приложение ругнётся, но вылетать ему необязательно.
                  0
                  Это вопрос EAFP vs LBYL. В Python обычно EAFP предпочтительнее. Поэтому код «кричит и валится» при первой такой возможности, чтобы сразу обратить на себя внимание.

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

                  class A:
                      def __init__(self, a=None, b=None, *args, **kwargs):
                          self.a = a
                          self.b = b
                          
                  


                  Тогда, неизвестные аргументы при вызове A(**a_dict) будут игнорироваться без выброса исключения
                0
                Так тут же TDD, всё сломается в десятке других мест при прогоне тестов, так что, ничего страшного.
                  0
                  Если у вас честно все покрыто тестами, то да, вы сможете отловить эту ситуацию.
                  Причем надо понимать, что она может возникнуть в любой момент — даже когда вы ничего не меняли, а изменилось удаленное API и оно начало выдавать что-то еще.
              +1
              Уже несколько месяцев «болею» clean architecture. Но примеров для Python было мало, а когда пытался реализовать в описанном стиле появлялось ощущение что пишу на Java. Какой тогда смысл использовать Python? В поисках решений выяснил что очень схожие идеи предлагают DDD(Domain Driven Design) и Hexagonal Architecture. Вот хорошая подборка примеров DDD для динамических языков .

              Еще интересный пример который встретился https://gist.github.com/justanr/1f38e09caad47bd0d927

              Cjay а Вам удалось применить данную методику на практике?

              Но примером Но применить по делу так и не пришлось.
                0
                Нет, пока не применил. Пытаюсь постичь. В том числе смотрю и на DDD. Спасибо за подборку.

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

              Самое читаемое