1.1. Разработка доменной модели | Tdg
1. Руководство по разработке приложений 1.1. Разработка доменной модели

1.1. Разработка доменной модели

Чтобы приступить к описанию доменной модели, ознакомьтесь со следующими понятиями по порядку и изучите приведенный пример:

1.1.1. Язык доменной модели

В терминологии системы TDG язык описания является языком доменной модели, при этом домен — синоним понятия предметная область.

Язык доменной модели состоит из двух элементов:

  • описания структуры объектов;

  • описания связей между объектами.

В качестве такого языка используется Avro Schema. Он не сложный и достаточно распространен в сообществе и разрабатываемых им сторонних приложениях.

В стандарте Avro Schema есть два «контейнера», содержащих описания типов: протокол и схема. Оба — JSON-файлы с расширением .avsc.

Приложение использует схему в качестве формата и понимает ее в виде массива типов:

[
    {"name": "TypeA", "type": "record", ...},
    {"name": "TypeB", "type": "record", ...}
]

Каждый тип соответствует стандарту Avro Schema, за исключением расширений для системы TDG. Расширения обратно совместимы, а модель, описанная с их помощью, должна успешно преобразовываться стандартными парсерами (синтаксическими анализаторами).

1.1.2. Объекты модели: агрегат, сущность, значение

В дополнение к типичной для UML (Unified Model Language) терминологии «агрегация и композиция», в TDG есть три дополнительных понятия:

  • Агрегат (Aggregate) — самостоятельный объект, имеющий идентичность;

  • Сущность (Entity) — несамостоятельный объект, имеющий идентичность;

  • Значение (Value Object) — несамостоятельный объект, не имеющий идентичности.

    Примечание

    Значение здесь — отдельная единица моделирования (значение-объект), а не атрибут класса объекта.

Эти понятия — часть словаря Domain Driven Design. Они описывают классы объектов с точки зрения идентичности и принадлежности.

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

Примером класса, объекты которого имеют идентичность, является класс клиент (бизнеса). Изменение имени клиента или его адреса не делает его другим клиентом — это тот же человек. С другой стороны, его адрес не имеет собственной идентичности, потому что смена улицы и дома сделает его другим адресом.

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

Примером сущности, которая не может существовать отдельно, является паспорт. Паспорта выдаются только существующим людям, и, даже если они были выданы «мертвым душам», полное удаление информации о «душах» потребует удаления информации об их паспортах.

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

Является ли объект агрегатом, сущностью или значением, важно не только для контроля жизненного цикла, но и для указания границ транзакционности и возможности ссылаться на объекты.

В пределах одного агрегата все его подчиненные сущности и значения можно обновить транзакционно и атомарно. Любая логика, работающая на уровне агрегата, может на это рассчитывать. Между агрегатами таких гарантий нет, то есть логика, обновляющая несколько агрегатов, является всегда согласованной в конечном счете (eventually consistent).

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

1.1.3. Пример описания на языке доменной модели

Предположим, в системе требуется хранить информацию о клиентах (Client), их паспортах (Passport), адресах (Address), договорах (Contract), счетах (Account) и операциях (Operation) по ним.

Рассмотрим следующую диаграмму объектов в качестве примера:

../../_images/domain-model.svg

В примере:

  • Агрегатами являются Client, Contract, Account и Operation. Информацию о каждом из них следует хранить вне зависимости от наличия других объектов, чтобы построить финансовую отчетность для любого временного среза;

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

  • ЗначениемAddress, так как он не обладает идентичностью. Система автоматически версионирует объекты, поэтому создавать массив для новых адресов не нужно, достаточно одного объекта-значения.

Из схемы также видны отношения:

  • Между Client и Address, а также множеством экземпляров Passport есть отношение владения;

  • Объекты Client, Contract, Account и Operation существуют отдельно. Cвязи между ними — ссылочного типа.

    Одному и тому же агрегату разрешено находиться в отношениях агрегации с другими объектами.

Опишем данную структуру на языке доменной модели:

[
    {
        "name": "Passport",
        "type": "record",
        "logicalType": "Entity",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "passport_series", "type": "string"},
            {"name": "passport_number", "type": "string"},
            {"name": "expired_flag", "type": "boolean"}
        ],
        "indexes": [
            "id",
            {
                "name": "passport",
                "parts": ["passport_series", "passport_number"]
            }
        ]
    },
    {
        "name": "Address",
        "type": "record",
        "logicalType": "ValueObject",
        "fields": [
            {"name": "country", "type": "string"},
            {"name": "city", "type": "string"},
            {"name": "street", "type": "string"},
            {"name": "building", "type": "int"}
        ]
    },
    {
        "name": "Client",
        "type": "record",
        "logicalType": "Aggregate",
        "doc": "Клиент",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "first_name", "type": "string"},
            {"name": "last_name", "type": "string"},
            {"name": "passports", "type": {"type": "array", "items": "Passport"}},
            {"name": "address", "type": "Address"}
        ],
        "indexes": ["id"],
        "relations": [
            {"name": "contracts", "to": "Contract", "count": "many",
             "from_fields": "id", "to_fields": "client_id"}
        ]
    },
    {
        "name": "Contract",
        "type": "record",
        "logicalType": "Aggregate",
        "doc": "Договор",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "client_id", "type": "long"},
            {"name": "header", "type": "string"},
            {"name": "body", "type": "string"},
            {"name": "date", "type": {"type": "string", "logicalType": "Date"}}
        ],
        "indexes": [
            "id",
            "client_id"
        ],
        "relations": [
            {"name": "accounts", "to": "Account", "count": "many",
             "from_fields": "id", "to_fields": "contract_id"}
        ]
    },
    {
        "name": "Account",
        "type": "record",
        "logicalType": "Aggregate",
        "doc": "Счет",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "contract_id", "type": "long"},
            {"name": "date", "type": {"type": "string", "logicalType": "Date"}}
        ],
        "indexes": [
            "id",
            "contract_id"
        ],
        "relations": [
            {"name": "operations", "to": "Operation", "count": "many",
             "from_fields": "id", "to_fields": "account_id"}
        ]
    },
    {
        "name": "Operation",
        "type": "record",
        "logicalType": "Aggregate",
        "doc": "Операция",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "account_id", "type": "long"},
            {"name": "amount", "type": "double"},
            {"name": "type", "type": "string"},
            {"name": "timestamp", "type": {"type": "string", "logicalType": "DateTime"}}
        ],
        "indexes": [
            "id",
            "account_id"
        ]
    }
]

Примечание

Если какое-то поле является опциональным, в доменной модели его тип описывают с помощью union — массива, содержащего основной тип для этого поля и тип null. Например:

{"name": "amount", "type": ["null","double"]}

При описании мы использовали расширения для TDG. Следующий раздел подробно описывает каждое расширение.

1.1.4. Расширения доменной модели для TDG

Расширения дополняют спецификацию Avro Schema и позволяют воспользоваться функциональностью приложения.

TDG понимает следующие расширения:

1.1.4.1. Расширение для агрегатов, сущностей и значений

Для указания признака агрегата, сущности или значения используется логический тип Avro Schema (Logical Type). Тип указывает приложению трактовать себя специальным образом.

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

Поэтому даты, например, задаются так:

{ "type": "string", "logicalType": "Date"}

Стандарт Avro Schema не предписывает ничего по поводу допустимых значений logicalType, поэтому мы можем его использовать, чтобы придать типам дополнительный смысл.

TDG понимает следующие допустимые значения logicalType для типов модели:

  • Aggregate (агрегат);

  • Entity (сущность);

  • ValueObject (значение).

В коде модели мы задали типы всем классам объектов. Например, клиенту:

 {
     "name": "Client",
     "type": "record",
     "logicalType": "Aggregate",
     "fields": [...]
 }

Если logicalType не указан, по умолчанию подразумевается ValueObject.

1.1.4.2. Расширение для задания отношений между объектами

Явное задание отношений между объектами нужно для двух целей:

  • Валидация внешних ключей при вставке объектов;

  • Запрос связанных объектов через graphql.

Для задания связи используется поле relations в теле описания класса объекта. Это поле не является стандартным, но игнорируется существующими парсерами Avro Schema. Связь — логическая конструкция. Связываемые поля (внешние ключи в терминологии SQL) должны быть объявлены на уровне классов.

Например, для связи между клиентом (Client) и его контрактом (Contract) требуются следующие поля:

{
   "name": "Client",
   "type": "record",
   "logicalType": "Aggregate",
   "fields": [
       {"name": "id", "type": "long"},
       {"name": "first_name", "type": "string"},
       {"name": "last_name", "type": "string"},
       {"name": "passports", "type": {"type": "array", "items": "Passport"}},
       {"name": "address", "type": "Address"}
   ],
   "indexes": ["id"],
   // Здесь должно быть поле "relations", формат которого описан ниже.
},
{
   "name": "Contract",
   "type": "record",
   "logicalType": "Aggregate",
   "doc": "Договор",
   "fields": [
       {"name": "id", "type": "long"},
       {"name": "client_id", "type": "long"},
       {"name": "header", "type": "string"},
       {"name": "body", "type": "string"},
       {"name": "date", "type": {"type": "string", "logicalType": "Date"}}
   ],
   "indexes": [
       "id",
       "client_id"
   ],
}

Здесь:

  • Первичный ключ клиента доступен через обращение к его классу — User.id;

  • Внешний ключ находится в классе Contract и доступен аналогично — Contract.client_id.

Чтобы сделать связь явной, определим поле relations в следующем формате:

"relations": [
    {
        // Все параметры обязательные.
        "name": "<имя_отношения>",
        "to": "<класс_объекта>",
        "count": <"one"|"many">, // один к одному или один к многим
        "from_fields": <спецификация_первичного_ключа>,
        "to_fields": <спецификация_внешнего_ключа>
    },
    ...
]

где:

  • name — имя виртуального поля, через которое можно будет получить связанные объекты в graphql-запросах;

  • to — имя класса, с которым устанавливается связь;

  • count — вид связи: один к одному или один ко многим;

  • from_fields — спецификация поля, которое содержит первичный ключ;

  • to_fields — спецификация поля, которое содержит внешний ключ.

Спецификация обоих ключей (from_fields и to_fields) должна быть задана в следующем формате:

"index_name" | "field_name" | ["field_name", "field_name", "field_name", ...]

То есть в полях from_fields и to_fields можно указывать имя индекса, имя поля (если оно одно) или список имен полей (если их больше).

Поле relations можно указать как с одной стороны отношения, так и с обеих. Односторонние отношения имеют смысл тогда, когда запрашивать данные в «обратном направлении» не требуется.

Полный пример задания отношения один ко многим между Client и Contract с возможностью запроса данных в обоих направлениях:

[
    {
      "name": "Client",
      "type": "record",
      "logicalType": "Aggregate",
      "fields": [
          {"name": "id", "type": "long"},
          ...
      ],
      "indexes": ["id"],
      "relations": [
          {"name": "contracts", "to": "Contract", "count": "many",
           "from_fields": "id", "to_fields": "client_id"}
      ]
    },
    {
      "name": "Contract",
      "type": "record",
      "logicalType": "Aggregate",
      "fields": [
          {"name": "id", "type": "long"},
          {"name": "client_id", "type": "long"},
          ...
      ],
      "indexes": [
          "id",
          "client_id"
      ],
      "relations": [
          {"name": "client", "to": "Client", "count": "one",
           "from_fields": "client_id", "to_fields": "id"}
      ]
    },
]

1.1.4.3. Расширение для задания ключей (индексов)

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

Ключи описываются в следующем формате:

[<index1>, <index2>, <index3>, ...]

Где каждый ключ может быть:

  • В виде строки:

    "<field_name>"
    

    где field_name — имя поля, по которому будет сделан ключ.

  • Либо в виде словаря (для составных ключей):

    {
      "name": "<index_name>",
      "parts": ["<field1_name>", "<field2_name>", ...]
      [, "collation"="binary"|"case_sensitivity"|"case_insensitivity"]
    }
    

    где:

    • index_name — имя составного ключа, которое не должно совпадать с именами существующих полей;

    • field<X>_name — имя одного из полей, по которому строится индекс;

    • collation — способ сравнения строк. По умолчанию способ binary — бинарный 'A' < 'B' < 'a'. Значение case_sensitivity включит регистрозависимое сравнение 'a' < 'A' < 'B'. Значение case_insensitivity включит регистронезависимое сравнение 'a' = 'A' < 'B' и 'a' = 'A' = 'á' = 'Á'.

Для указания первичности ключа нет отдельного явного признака. Первичным ключом признается первый ключ в списке.

Примечание

Нужно отметить исключения для так называемого мультиключевого индекса (multikey index), т.е. индекса по полю, содержащему массив:

  • мультиключевой индекс не может быть первичным;

  • мультиключевой индекс нельзя строить по полю, содержащему сложные типы, такие как DateTime, Date, Time, Decimal и др.

Запрос к данным по мультиключевому индексу также имеет специфику (см. подробнее).

Полный пример задания ключей:

{
    "name": "Passport",
    "type": "record",
    "logicalType": "Entity",
    "fields": [
        {"name": "id", "type": "long"},
        {"name": "passport_series", "type": "string"},
        {"name": "passport_number", "type": "string"},
        {"name": "expired_flag", "type": "boolean"}
    ],
    "indexes": [
        "id",
        {
            "name": "passport",
            "parts": ["passport_series", "passport_number"]
        }
    ]
}

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

Например, представим, что операция по счету (Operation) в нашем примере производится по протоколу, требующему определенный заголовок. Создадим соответствующий объект-значение для него и сложный индекс в агрегате операции:

[
    {
        "name": "Header",
        "type": "record",
        "doc": "Заголовок операции",
        "logicalType": "ValueObject",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "header_body", "type": "string"}
        ]
    },
    {
        "name": "Operation",
        "type": "record",
        "logicalType": "Aggregate",
        "doc": "Операция",
        "fields": [
            {"name": "id", "type": "long"},
            {"name": "header", "type": "Header"},
            {"name": "account_id", "type": "long"},
            {"name": "amount", "type": "double"},
            {"name": "type", "type": "string"},
            {"name": "timestamp", "type": {"type": "string", "logicalType": "DateTime"}}
        ],
        "indexes": [
            "id",
            "account_id",
            {"name": "header_id", "parts": ["header.id"]}
        ]
    }
]

Header как объект-значение включается непосредственно в агрегат Operation, поэтому индекс может сослаться на поле из Header. Если бы Header был сущностью или агрегатом, модель не прошла бы валидацию.

1.1.4.4. Расширение для задания распределения объектов по хранилищам

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

Для явного указания ключей для этой функции используется поле affinity с форматом:

"affinity": <index_name>[, "affinity": index_name, ...]

Директива может содержать только ключи, входящие в первичный ключ.

Например, для распределенного хранения операций по счетам укажем:

{
  "name": "Operation",
  "type": "record",
  "logicalType": "Aggregate",
  "doc": "Операция",
  "fields": [
      {"name": "id", "type": "long"},
      {"name": "account_id", "type": "long"},
      {"name": "amount", "type": "double"},
      {"name": "type", "type": "string"},
      {"name": "timestamp", "type": {"type": "string", "logicalType": "DateTime"}}
  ],
  "indexes": [
      {"name":"pkey", "parts": ["id", "account_id"]},
      "account_id",
  ],
  "affinity": "account_id"
}

Таким образом, операции одного и того же счета будут размещены физически на одном и том же хранилище.

1.1.4.5. Расширение атрибутов полей

Помимо стандартных атрибутов полей, определенных в спецификации Avro Schema, в TDG есть несколько дополнительных атрибутов:

  • default_function — используется для задания динамического значения по умолчанию (в отличие от стандартного атрибута default, задающего статическое значение). В качестве значения атрибута указывается функция, определенная в секции functions в файле конфигурации системы config.yml. При вставке в спейс новой записи будет вызываться указанная функция, и результат ее работы будет записан как значение для данного поля;

  • auto_increment — позволяет сделать поле автоинкрементным (auto-incremental). Может использоваться для задания числового идентификатора, который будет уникальным для сущностей данного типа, даже при шардировании базы данных. Атрибут является флагом; значение true включает автоинкремент.

    Важно

    • Поле с автоинкрементом обязательно должно иметь тип long;

    • Атрибут auto_increment несовместим с атрибутами default и default_function.

    Пример:

    {
      "name": "Contract",
      "type": "record",
      "logicalType": "Aggregate",
      "fields": [
          {"name": "id", "type": "long", "auto_increment": true},
          ...
      ],
      ...
    

1.1.5. Работа с датой и временем

Внутри TDG для представления даты/времени используется строковый формат ISO 8601. Этот формат позволяет делать поля даты/времени индексируемыми, и при этом имеющими правильный порядок сравнения.

Все даты, поступающие в приложение, должны быть приведены к UTC.

Допустимые форматы записи:

  • дата: YYYY-MM-DDZ;

  • время: HH:MM:SSZ;

  • дата/время с миллисекундами: YYYY-MM-DDTHH:MM:SS.sssZ;

  • дата/время с микросекундами: YYYY-MM-DDTHH:MM:SS.ssssssZ;

  • дата/время с наносекундами: YYYY-MM-DDTHH:MM:SS.sssssssssZ.

Например: 2018-03-24T10:20:48Z.

Чтобы объявить поле типа Date, Time или DateTime, используйте механизм логических типов (logicalType) Avro Schema. Базовый же тип для этих полей — всегда строковый. Пример объявления поля даты/времени:

{"name": "timestamp", "type": {"type": "string", "logicalType": "DateTime"}}
Found what you were looking for?
Feedback