В предыдущей статье «Конструирование типов» была описана идея, как можно сконструировать типы, похожие на классы. Это даёт возможность отделить хранимые данные от метаинформации и сделать акцент на представлении самих свойств сущностей. Однако описанный подход оказывается довольно сложным из-за использования типа HList. В ходе развития этого подхода пришло понимание, что для многих практических задач линейная упорядоченная последовательность свойств, как и полнота набора свойств, не является обязательной. Если ослабить это требование, то конструируемые типы значительно упрощаются и становятся весьма удобны для использования.
В обновлённом варианте библиотеки synapse-frames исключительно просто описываются иерархические структуры данных и представляются любые подмножества таких структур.
Двусторонне-типизированные отношения
Свойство объекта обычно рассматривают в привязке к самому объекту и в таком случае свойство имеет тип данных. Один тип — только для ограничения данных, которые могут в свойстве содержаться. Логичным поэтому выглядело представить свойство как Slot[T]
. Однако свойство также привязано к типу объекта, в котором это свойство объявлено, хотя и не очень явным способом. В вышеупомянутой статье для установления такой связи конструировался новый суррогатный тип из набора свойств.
Если же выразить отношение к типу контейнера непосредственно в типе самого свойства, то это позволяет избежать создания суррогатного типа и пользоваться гораздо более удобными средствами. Итак, представим свойство как двустороннее отношение между двумя типами:
sealed trait Relation[-L,R] case class Rel[-L, R](name: String) extends Relation[L, R]
(значок -L означает «контравариантность», т.е. свойство будет доступно и у потомков типа L. А тип R объявлен инвариантным, т.к. для свойства мы планируем использовать и getter'ы и setter'ы)
Класс Rel позволяет нам описать атрибуты, доступные у типа L. Например,
class Box val width = Rel[Box, Int]("width") val height = Rel[Box, Int]("height")
(эти же свойства будут доступны у потомков типа Box).
Кроме просто имени, к свойству можно привязать любую метаинформацию, которая требуется приложению — домен базы данных, текстовое описание свойства, сериализатор/десериализатор, ограничение на размер хранимых данных, ширину колонки в таблице, формат отображения (для дат) и т.д. Метаинформацию, в случае необходимости, можно привязать и внешним связыванием с помощью map'а.
Для типа L нам надо иметь какой-то реальный тип. В предыдущем варианте мы этот тип конструировали как HList над входящими в этот тип свойствами. Здесь же в качестве типа L можно использовать произвольный тип, доступный в Scala. Например, любой примитивный тип, или любой type alias, можно использовать trait'ы, abstract и final классы, object.type'ы. Благодаря контравариантности L мы можем использовать отношение наследования между типами, которые используем в качестве носителей свойств. По-видимому, удобно отразить отношение наследования в виде набора abstract class'ов, trait'ов и final class'ов в соответствии с логикой предметной области.
abstract class Shape trait BoundingRectangle final class Rectangle extends Shape with BoundingRectangle final class Circle extends Shape with BoundingRectangle val width = Rel[BoundingRectangle, Int]("width") val height = Rel[BoundingRectangle, Int]("height") val radius = Rel[Circle, Int]("radius")
Отдельный атрибут можно рассматривать как один компонент, позволяющий переходить от родительского объекта к дочернему. Если дочерний имеет свои атрибуты, то можно осуществить навигацию по любому из них. Пара таких атрибутов может быть объединена в путь от «дедушки» к «внуку» и будет получено новое отношение (Rel2(attr1, attr2)).
case class Rel2[-L, M, R](_1: Relation[L, M], _2: Relation[M, R]) extends Relation[L, R]
В DSL добавлен метод `/`, конструирующий Rel2, тем самым осуществляя композицию отношений.
Также хотелось бы отметить, что такие отношения являются неотъемлемой частью троек, составляющих основу онтологий RDF/OWL. А именно, отношения представляют собой средний компонент тройки:
(идентификатор объекта типа L, идентификатор свойства Relation[L,R], идентификатор значения свойства типа R).
Строго типизированные идентификаторы
При использовании неполного описания объекта через набор атрибутов, весьма важным оказывается вопрос сопоставления разных наборов атрибутов с одним и тем же экземпляром. Необходимо каким-либо образом отразить свойство аутентичности экземпляра самому себе. В ООП для этой цели может использоваться факт принадлежности значений атрибутов одному и тому же объекту. В БД обычно используется какой-либо способ идентификации. Равенство идентификаторов объектов позволяет вывести аутентичность рассматриваемых объектов.
Мы также можем использовать идентификаторы для того, чтобы соотносить наборы атрибутов с одним экземпляром. Поскольку атрибуты в нашем случае связаны с типом объекта, то и идентификатор должен быть связан с тем же типом. Это позволит на этапе компиляции проверять согласованность типов идентифицируемого объекта и приписываемых атрибутов.
В простейшем случае мы могли бы использовать такой тип идентификатора:
trait Id[T]
Однако, такой способ идентификации оказывается не универсальным. Во-первых, многие объекты идентифицируются только в пределах родительских объектов; во-вторых, многие типы объектов могут иметь сразу несколько способов идентификации. Для отражения первого явления мы можем использовать описанный выше тип Rel[-L,R], рассматривая его уже как способ перехода от родительского объекта к конкретному экземпляру дочернего объекта. Если вспомнить, что дочерние объекты зачастую объединяются в типизированные коллекции, то идентификатор дочернего объекта оказывается составным — вначале выбирается коллекция, а затем по целочисленному индексу выбирается элемент этой коллекции:
val children = Rel[Parent, Seq[Children]]("children") case class IntId[T](id: Int) extends Relation[Seq[T], T] val child123 = children / IntId(123)
(здесь используется DSL-метод `/`, объединяющий два отношения в одно (композиция отношений)).
Такой способ идентификации позволяет однозначно перейти от родительского объекта к требуемому дочернему элементу. Что делать, если мы хотим воспользоваться альтернативным способом идентификации? Например, мы знаем, что некоторое свойство дочернего объекта обладает свойством уникальности в пределах родительского объекта, и, следовательно, может использоваться для выбора дочернего объекта. В таком случае мы можем воспользоваться идентификацией через индекс:
trait IndexedCollection[TId, T] case class Index[TId, T](keyProperty: Relation[T, TId]) extends Relation[Seq[T],IndexedCollection[TId, T]] case class IndexValue[TId, T](value:TId) extends Relation[IndexedCollection[TId, T], T]
Например:
val name = Rel[Child, String]("name") val childByName = name.index val childVasya = parent / children / childByName / IndexValue("Vasya")
Таким образом, тип Rel[-L, R], расширенный порядковым номером в коллекции и индексом по свойству дочернего объекта, позволяет осуществлять навигацию в иерархической структуре данных.
Чтобы идентифицировать объекты, находящиеся на самом верхнем уровне и не имеющие родительского объекта, можно ввести специальный тип Global, который будет содержать все коллекции высокоуровневых объектов:
final class Global val persons = Rel[Global, Seq[Person]]("persons") val otherTopLevelObjects = Rel[Global, Seq[OtherTopLevelObject]]("otherTopLevelObjects")
Схема данных
Отношения сами по себе являются кирпичиками, позволяющими строить как сами структуры данных, так и схемы этих данных. Для описания схемы данных можно использовать реляционный подход — сущность-связь. В этом случае схема представляет собой коллекцию описаний сущностей и коллекцию описания связей между сущностями. Для сущностей указывается набор атрибутов, а для отношений — 1-0, 1-1, 1-*, *-*
Также можно использовать объектно-ориентированный подход, описывающий сущность, свойства и коллекции дочерних объектов, для которых, в свою очередь, описываются свойства и коллекции.
Реляционная схема, понятное дело, прекрасно подходит для представления данных в БД, а объектно-ориентированная может использоваться для создания объектно-ориентированных сервисов (web-services?).
Для описания типа T в объектно-ориентированном варианте схемы используется один из потомков Schema[T]
.
SimpleSchema
— для простых типов, не содержащих атрибуты;
RecordSchema
— составные типы, содержащие указанные атрибуты;
CollectionSchema
— для типов Seq[T] позволяет привязать схему элементов коллекции.
Хранение данных
Метаинформация сама по себе не содержит данных. Для хранения необходимо использовать другие структуры. Такие структуры зависят от потребностей приложения:
- обычные классы с обычными свойствами, доступ к которым осуществлятся с помощью reflection'а по именам свойств;
- специальные классы для хранения данных, содержащие также и метаинформацию — наследники
Instance[T]
(SimpleInstance, RecordInstance, CollectionInstance
). Эти типы упрощают работу с данными, описываемыми схемой, т.к. хранение данных напрямую соответствует схеме; - линейный кортеж, «список списков» (
List[Any]
). Иерархическую структуру вложенных Record'ов можно разложить в линейную структуру — последовательность примитивных типов. Вложенные коллекции превращаются в списки-списков простейших типов. Такое представление может использоваться для передачи по сети и для взаимодействия с БД (т.к. кортеж прямо соответствует строке таблицы). Для конвертации Instance'ов в плоские списки и обратно используется пара операций align/unalign (flatten); - таблицы БД, данные из которых извлекаются с помощью RecordSet'а;
- JSON-объекты;
- XML.
Конструирование данных
При создании экземпляров данных наиболее важное ограничение, которое мы хотим проверять на этапе компиляции, заключается в том, чтобы свойства можно было указывать только для тех типов, для которых они объявлены (ради этого, в основном, в свойстве имеется generic-тип для левой стороны отношения). Из этого следует, что в процессе создания экземпляра данных, удовлетворяющего схеме, необходимо пользоваться специальным инструментарием. Например:
val b1 = empty[Box] .set(width, simple(10)) .set(height, simple(20))
Здесь используется immutable тип Instance[Box]
, в который добавляются пары — (свойство, значение). В случае, если данных немного, такой подход достаточен. Если требуется собирать много данных, то более эффктивно использовать mutable билдер, внутри которого постепенно формируется требуемый комплект атрибутов. По окончании сборки билдер преобразуется в Instance[Box]
:
val boxBuilder = new Builder(boxSchema) boxBuilder.set(width, simple(10)) boxBuilder.set(height, simple(20)) val b1 = boxBuilder.toInstance
Также билдер обеспечивает две runtime-проверки —
- недопустимость использования свойств, не входящих в схему;
- обеспечение полноты формируемого объекта.
Для представления данных в строках таблиц в БД необходимо преобразовать вложенные Record'ы в плоскую структуру. Для этого используется пара методов align/unalign.
Заключение
Изложенный подход позволяет
- описывать сложные предметные области с явным сохранением метаинформации;
- оперировать свойствами строго типизированным образом (с проверкой типов на этапе компиляции);
- представлять произвольные иерархические структуры данных (наподобие json'а) с проверкой типов на всех уровнях;
- представлять неполные данные и проверять степень полноты (например, можно иметь
smallSchema[T]
иfullSchema[T]
, с помощью которых проверять экземпляры данных).
В отличие от подхода, описанного в предыдущей статье, мы ослабляем требование обеспечения проверки полноты данных на этапе компиляции. Взамен получается гораздо более простой и удобный подход. Допустимость использования свойства на указанном типе проверяется компилятором без построения громоздких суррогатных типов на базе HList. В то же время, мы не скованы объектно-ориентированным подходом в плане представления данных и ограничения состава атрибутов сущности.
Автор: primetalk