Строго типизированное представление неполных данных

    В предыдущей статье «Конструирование типов» была описана идея, как можно сконструировать типы, похожие на классы. Это даёт возможность отделить хранимые данные от метаинформации и сделать акцент на представлении самих свойств сущностей. Однако описанный подход оказывается довольно сложным из-за использования типа 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-проверки —
    1. недопустимость использования свойств, не входящих в схему;
    2. обеспечение полноты формируемого объекта.


    Для представления данных в строках таблиц в БД необходимо преобразовать вложенные Record'ы в плоскую структуру. Для этого используется пара методов align/unalign.

    Заключение



    Изложенный подход позволяет
    • описывать сложные предметные области с явным сохранением метаинформации;
    • оперировать свойствами строго типизированным образом (с проверкой типов на этапе компиляции);
    • представлять произвольные иерархические структуры данных (наподобие json'а) с проверкой типов на всех уровнях;
    • представлять неполные данные и проверять степень полноты (например, можно иметь smallSchema[T] и fullSchema[T], с помощью которых проверять экземпляры данных).


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

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 2

    • UFO just landed and posted this here
        +1
        Добрый день.
        Конечно, можно. Лицензия — BSD-like.
        Мы сейчас этот подход используем и развиваем. В целом — достаточно удобно. При наличии неполных данных в БД (nullable колонок) — очень удобно.

      Only users with full accounts can post comments. Log in, please.