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

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

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

DTO на основе класса Python

Рассмотрим пример DTO на основе класса Python. Представим, что у нас есть модель пользователя, которая содержит имя и фамилию:

class UserDTO:
   def __init__(self, **kwargs):
       self.first_name = kwargs.get("first_name")
       self.last_name = kwargs.get("last_name")
       self.validate_lastname()


   def validate_lastname(self):
       if len(self.last_name) <= 2:
           raise ValueError("last_name length must be more then 2")


   def to_dict(self):
       return self.__dict__


   @classmethod
   def from_dict(cls, dict_obj):
       return cls(**dict_obj)

Мы реализовали методы класса DTO для создания экземпляра класса и выгрузки данных в словарь, а так же метод валидации. Дальше посмотрим как это можно использовать:

>>> user_dto = UserDTO.from_dict({'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto.to_dict()
{'first_name': 'John', 'last_name': 'Doe'}
>>> user_dto = UserDTO.from_dict({'first_name': 'John', 'last_name': 'Do'})
ValueError: last_name length must be more then 2

Это максимально упрощенный пример. Таким образом можно реализовать любую функциональность. Единственный минус - нужно всё описывать руками и даже используя наследование будет много кода.

NamedTuple

Другой способ создания DTO в Python - использование NamedTuple

NamedTuple - это класс из стандартной библиотеки Python(начиная с версии Python 3.6), который представляет собой неизменяемый кортеж с доступом к свойствам по имени. Это типизированная и более читабельная версия класса namedtuple из модуля сollections.

Мы можем создать DTO на основе NamedTuple, содержащий имя и фамилию пользователя из примера с использованием классов:

from typing import NamedTuple

class UserDTO(NamedTuple):
    first_name: str
    last_name: str

Теперь мы можем создавать объекты UserDTO следующим образом, а так же выгружать объект в словарь и создавать объект из словаря:

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto.first_name
'John'
>>> user_dto
UserDTO(first_name='John', last_name='Doe'})
>>> user_dto._asdict()
{'first_name': 'John', 'last_name': 'Doe'}
>>> user_dto.first_name = 'Bill'
AttributeError: can't set attribute

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

Подробнее тут.

TypedDict

Еще одним вариантом для создания объектов DTO в Python является использование TypedDict, который добавлен в язык начиная с версии 3.8. Этот тип данных позволяет создавать словари с фиксированным набором ключей и аннотациями типов значений. Такой подход делает TypedDict хорошим выбором для создания объектов DTO, когда необходимо использовать словарь с определенным набором ключей.

Для создания объекта необходимо импортировать тип данных TypedDict из модуля typing. Давайте создадим TypedDict для модели пользователя:

from typing import TypedDict

class UserDTO(TypedDict):
   first_name: str
   last_name: str

В этом примере мы определяем класс UserDTO, который является подклассом TypedDict. Мы можем создать объект UserDTO и заполнить его данными:

>>> user_dto = UserDTO(**{first_name: 'John', last_name: 'Doe'})
>>> user_dto
{first_name: 'John', last_name: 'Doe'}
>>> type(user_dto)
<class 'dict'>

Мы можем использовать его для определения словарей с фиксированным набором ключей и аннотациями типов значений. Это делает код более читаемым и предсказуемым. Кроме того, TypedDict предоставляет возможность использования методов словарей, таких как keys() и values(), что может быть полезным в некоторых случаях.

Подробнее тут.

dataclass

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

Для создания DTO с помощью dataclass нужно добавить декоратор dataclass и определить поля с аннотациями типов. Например, мы можем создать DTO для модели пользователя с помощью dataclass следующим образом:

from dataclasses import asdict, dataclass

@dataclass
class UserDTO:
   first_name: str
   last_name: str = ''


   def __post_init__(self):
       self.validate_lastname()


   def validate_lastname(self):
       if len(self.last_name) <= 2:
           raise ValueError("last_name length must be more then 2")

Теперь мы можем легко создавать объекты UserDTO, выгружать их в словари и создавать новые объекты на основе словарей:

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> asdict(user_dto)
{'first_name': 'John', 'last_name': 'Doe'}
>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Do'})
ValueError: last_name length must be more then 2

Чтобы создать неизменяемый объект нужно в декаратор передавать аргумент frozen=True. Есть метод asdict для выгрузки в словарь. Дополнительно можно реализовать методы валидации. Можно использовать значения по умолчанию. В целом более компактные чем просто классы и более функциональные чем ранее рассмотренные варианты.

Подробнее тут.

Attr

Еще один способ создания DTO это модуль Attr. Работает точно так же как и dataclass, кроме того, является предком dataclass, но при этом более функциональное, а описание получается более компактное. Эту библиотеку можно установить с помощью команды pip install attrs

import attr


@attr.s
class UserDTO:
   first_name: str = attr.ib(default="John", validator=attr.validators.instance_of(str))
   last_name: str = attr.ib(default="Doe", validator=attr.validators.instance_of(str))

Здесь мы с помощью декоратора определили класс для описания DTO c атрибутами first_name и last_name, при этом сразу определили значения по умолчанию и валидацию.

>>> user_dto = UserDTO(**{'first_name': 'John', 'last_name': 'Doe'})
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> user_dto = UserDTO()
>>> user_dto
UserDTO(first_name='John', last_name='Doe')
>>> user_dto = UserDTO(**{'first_name': 1, 'last_name': 'Doe'})
TypeError: ("'first_name' must be <class 'str'>...

Таким образом, модуль attr предоставляет более мощные и гибкие инструменты для определения классов DTO, такие как валидация, значения по умолчанию, преобразования. Объект DTO можно также сделать неизменяемым с помощью аттрибута декоратора frozen=True. Так же может быть инициализирован через декоратор define.

Подробнее тут.

Pydantic

Библиотека Pydantic представляет собой инструмент для определения данных и конвертации данных в Python, который использует аннотации типов для определения схемы данных и преобразует данные из JSON в объекты Python. Pydantic используется для удобной работы с данными веб-запросов, конфигурационных файлов, баз данных и других мест, где необходимо проверять и преобразовывать данные. Может быть установлен с помощью команды pip install pydantic

from pydantic import BaseModel, Field, field_validator


class UserDTO(BaseModel):
   first_name: str
   last_name: str = Field(min_length=2, alias="lastName")
   age: int = Field(lt=100, description="Age must be a positive integer")
   
   @field_validator("age")
   def validate_age(cls, value):
       if value < 18:
           raise ValueError("Age must be at least 18")
       return value

Здесь мы определили модель UserDTO c базовой валидацией на длину строки и максимум возраста. Так же определили что данные для атрибута last_name будут приходить через параметр lastName. Так же, для примера, привел описание кастомного валидатора минимального возраста. 

>>> user_dto = UserDTO(**{'first_name': 'John', 'lastName': 'Doe', 'age': 31})
>>> user_dto
UserDTO(first_name='John', last_name='Doe', age=31)

>>> user_dto.model_dump()
{'first_name': 'John', 'last_name': 'Doe', 'age': 31}

>>> user_dto.model_dump_json()
'{"first_name":"John","last_name":"Doe","age":31}'


>>> user_dto = UserDTO(**{'first_name': 'John', 'lastName': 'D', 'age': 3})
pydantic_core._pydantic_core.ValidationError: 2 validation errors for UserDTO
lastName
    String should have at least 2 characters [type=string_too_short, input_value='D', input_type=str]
age
    Value error, Age must be at least 18 [type=value_error, input_value=3, input_type=int]

Pydantic это целый комбайн возможностей. Он используется по умолчанию в FastAPI для определениях схемы данныих и валидации. Упрощает сериализацию и десериализацию объектов в формат JSON c помощью встроенных методов. Имеет более читаемые подсказки во время выполнения. 

Подробнее тут.

Заключение

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

Надеюсь эта статья поможет тем кто ищет подходящие способы реализации DTO в Python.

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


  1. Evgeniy_Lyashov
    07.08.2023 08:21
    -1

    Интересно, не знал такие способы


  1. Ryav
    07.08.2023 08:21

    Не раскрыт вопрос быстродействия :)