Fibery: why Clojure?

ashotkin
10 min readOct 22, 2017

В Targetprocess пилится проект Fibery, позиционирующийся как платформа для управления работой.

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

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

Ядро Fibery реализуется на Clojure. Вопрос — почему Clojure?

Коммент на стероидах

Этот пост появился как вылезший за приличные рамки размера комментарий в фейсбуке для ответа на вопрос Андрей Хмылова вот тут.
Процитирую опорные реплики из треда комментов.

Миша Дубаков: “Нам был нужен динамический язык. … В основе Fibery — гибкий домен. Можно создавать свою композицию сущностей. Нам нужна динамика через всю систему. Нет необходимости вводить и фиксировать доменные типы, например.”

Андрей ХмылОв : “я вот кстати не могу никак понять как из требования динамического домена следует потребность в динамической типизации языка, описывающего мета-уровень. Кажется, что понятия вроде “сущность”, “связь”, “таблица”, “поле”, “команда” как раз неплохо описываются в терминах статической системы типов.”

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

Спойлер, отвечающий на вопрос Андрея Х

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

Тут вообще отношения *строгого* следования из серии если-то — если динамический домен, то динамически типизированный язык — не наблюдается.

Речь идет о том, что удобнее (довольно расплывчатая категория) при решении поставленной задачи, ведь я могу писать в динамическом стиле и на статически типизированном языке, будут летать у меня свалки со string и objects в Java, например, или объектно-ориентированно на Си, руками собирая виртуальные таблицы методов и разрешая вызовы.

Про то, что удобнее и в чём удобность я раскрою ниже.

Задача

Для начала очерчу контекст, проблему и опишу концептуальное видение решения, не зависящее от языка.

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

Data model schema in Fibery

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

Например, в программе на статически типизированном языке материализация описания модели данных заключается в факторизации типа в объект, то есть тип сущности становится first-class object с возможностью его создания, изменения.

Пример на статически типизированном псевдоязыке:

Person p = new Person(“Пол”, “Пот”) 

превращается в

EntityType personType = new EntityType(…) Entity e = new Entity(personType, “Пол”, “Пот”) 

Это классическое решение известно очень давно, например, паттерн проектирования TypeObject (первая ссылка в гугле неожиданно в геймдев ) или концептуальные идеи reflection в различных языках программирования.

По сути экземпляр типа (p ofType Person) разворачивается в пару: экземпляр типа (e ofType Entity) и объект типа (personType of EntityType), то есть по системе летает экземпляр типа вместе с информацией о своем типе и появляется понятие схемы типов как контейнера типов (слово тип 4 раза написал).

Аналогичный подход реализован во всех СУБД — схема, таблицы, атрибуты таблиц и т. д.

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

Набор требований к операции записи включает в себя возможность композиции операций записи вдоль двух осей — по данным и по операциям:

  • у десяти UserStory поменять State на Open
  • у одной UserStory поменять Feature и State

В связи с тем, что мы на design time не знаем с каким типами сущностями мы будем работать (они буду создаваться в run-time) и с учётом требований, изложенных выше, представим операции чтения и записи вокруг сущностей в виде языков: язык запросов на чтение и язык команд на запись (СУБД style).

Таким образом, имеются следующие понятия и операции:

  • типы сущностей, атрибуты сущности, связи между сущностями, схема как контейнер типов сущностей
  • сама сущность, значения её атрибутов
  • операции чтения/записи элементов метамодели (типы сущностей и др.), это выразимо через язык запросов и команд
  • операция чтения сущностей через язык запросов
  • операции создания/изменения/удаления сущностей через язык команд

Ещё раз повторю.

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

С экземплярами сущностей я могу работать на чтение через язык запросов, сформулированный вокруг типов сущностей, а на запись через язык команд, опять же сформулированный вокруг схемы, позволяющий задать, например, команду записать в обьект типа E в свойство P значение V.

Очень похоже на интерфейс СУБД, но на порядки проще и с доменной спецификой.

Боль выбора или выбор боли

На разницу между статически типизированным (далее static typed = ST) и динамически типизированным (далее dynamic typed = DT. ) языками можно посмотреть с точки зрения момента связывания, а именно раннего или позднего.

Статически типизированный язык начинает со статики (раннее связывание везде) и добавляет динамику по требованию (зоны позднего связывания через полиморфизм).

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

Однако, на ST языке можно писать в динамическом стиле, тогда, например, в программе все типы object или property bags и вызовы операций происходят по имени через reflection, а на DT языке можно писать в квази-статическом стиле, тогда, например, каждая операция проверяет типы своих аргументов, правда всё равно на этапе выполнения, этап компиляции не сэмулируешь.

Здесь присутствует ассиметрия на ST языке я могу писать в динамическом стиле, а на DT языке в статическом нет.

Итак, система, реализованная на ST языке, позволяет зафиксировать в статике набор понятий и операций вокруг них (контракт) и в соответствии с требованиями добавлять динамику по необходимости на design time этапе.

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

Из этого программа решения оформляется вокруг спроектированных типов.

Решение на ОО ST языке

Прикинем очень навскидку, во что оформится реализация вышеуказанной задачи на ST языке, например, объектно-ориентированом.

Метамодель будет представлена в виде типов EntityType, EntityProperty, EntitySchema с операциями вида AddType, RemoveType, AddProperty и т д.
Тип Entity будет классическим property bag с тэгом EntityType, содержащимся в EntitySchema. Property bag обладает всеми последствиями вида ручного “type erasure” на границе взаимодействия (чтение, запись) с данными, хранящимися в них.

По сути тип Entity представляет собой Expando, что в ST языке обеспечивает динамическую природу (своего рода мапой строчка на объект), содержащий ссылку на EntityType в EntitySchema (ну или тэг EntityType).

Логика запросов оформится в виде QueryProcessor, парсящего строку или QueryObject в дерево выражений и далее запускающего Visitor, собирающего, например, sql запрос.

Например,

Причём выхлоп QueryProcessor-а имеет форму QueryResult (property bag) ввиду того, что в запросе можно указать форму запрашиваемых данных. Открытый вопрос что собой представляет QueryObject — например, возможно это будет агрегат с полем для каждого терма в языке запросов и каждый терм поднят в свой тип SelectClause, WhereClause и др. Одно ясно, QueryObject определен вокруг имен типов и атрибутов динамического домена, формируемого в run-time, что наделяет его в динамикой, проходящей мимо type checker (например, проверка, а есть ли запрашиваемое в запросе имя атрибута X в типе Y).

Логика команд поднимется в интерфейс ICommand (возможно в ряд CommandHandler-ов, если команда представит собой сообщение с аргументами, без поведения). Сигнатура методов в интерфейсе команд открытый вопрос: какие параметры принимает конструктор команды или метод Execute. Возможно некоторые команды буду возвращать результаты, у которых так же будет свой тип. В широком смысле через команду существует возможность запустить логику, касающуюся не только изменения/удаления сущностей, но и например, запросить сущность (сделать query).

Реестр команд (CommandRegistry) с возможностью получения команды по имени, а может и по аргументам (возможно выразится через DI контейнер).

В итоге мы имеем микс из двух миров:

  • статический (EntityType, EntityProperty, EntitySchema, QueryProcessor, QueryExpressionTree, SqlGeneratingVisitor, ICommand)
  • динамический (QueryObject, QueryResult, Entity (property bags), CommandRegistry (~DI container), CommandResult, CommandArgs, QueryResult)

Везде, где мы касаемся данных динамической модели (при запросе или команде вокруг Entity), мы сталкиваемся с динамическим стилем в статическом мире, потому что получаем на руки property bag, а в случае команды ещё и находим/создаем экземпляр команды по имени, организуя её вызов с переданными параметрами. Другими словами — это регионы позднего связывания.

Везде, где мы трогаем метамодель на чтение, например, при обработке запроса за данными проверяем соответствие запроса схеме типов, мы вынимаем из метамодели тип по имени из схемы или проперти по имени из типа, то есть у нас происходит разыменование строкового значения в объект — регион позднего связывания.

Имея в решении на ST языке сопоставимое кол-ва мест позднего связывания не через механизмы языка (я про виртуальные методы — проверяемое на type check time позднее связывание) и кол-во мест раннего связывания с плюсами type check, решили не пытаться на ST языке загибать решение в динамическом стиле, а попробовать сделать его на DT языке целиком.

Другими словами, было принято решение, что первична динамика, а статика вторична.

Обычно происходит наоборот, статика первична, а динамика вторична, и в ST языках конструируется инфраструктура для бесшовного взаимодействия с динамическим окружающим миром (dynamic в .NET,) или адаптации внешней динамики к статике (F# TypeProvider ).

При совершении этого выбора упомяну несколько факторов:

  • динамика как сквозное свойство целевой системы и, как следствие, закономерное свойство решения: работа с сущностями, типы которых известны на этапе выполнения
  • низкая концептуальная размерность: решение имеет намеренно минимальное кол-во ответственностей — манипуляция с моделью (сущности, связи) и манипуляция данными, экземплярами типов из модели.
  • инкапсулированность динамики — функциональность наружу выставляется через два интерфейса: язык запросов вокруг схемы и язык команд (квази-GraphQL). Интерфейсы торчат наружу через API или как public interface для in-proc call. Решение о первичности динамики на клиента public interface не распространяется, поэтому клиент ядра может быть написан на ST языке, как результат, эволюционировать по своим принципам и правилам.
  • микронский размер команды разработки, 2 человека.

Clojure way

Подобного рода задачи мы решали в рамках проекта Targetprocess и столкнулись с рядом феноменов случайной сложности, которую в Fibery мы хотим в худшем случае сократить, а в лучшем избежать именно за счёт сдвига точки зрения на проблему во всех плоскостях: в требованиях, архитектуре, реализации.

Выбор нового языка является всегда компромиссом между сокращением сложности итогового решения через использование “родных” концепций языка и обучении этому языку с его экосистемой.
Альтернатива — сделать решение на привычном языке, где 70 процентов составит реализацию своего понятийного аппарата, уже поверх которых строится непосредственно решение. Цель была эти 70 процентов сократить на порядок, а не решать самим.

Вобщем, мы решили пойти clojure-way минималистичным путём и выразить все понятия решения через функции и данные (рекурсивная композиция гетерогенных мап), организованные в модули.

Clojure обладает отличной поддержкой стандартных структур данных (в частности нас интересовали мапы), функциями вокруг этих структур данных (из коробоки есть, например, расчет разницы между двумя рекурсивно вложенными мапами) и keywords (first-class имена). Плюс ко всему functional, immutable, jvm-hosted (интероп в jvm экосистему).

Из минусов довольно крутая кривая обучения и по началу непривычный синтаксис. В принципе, Clojure можно продать как javascript со скобками.

В Clojure удобно начать с функциональной декомпозиции вокруг мапов (гетерогенных) и при разрастании логики решения свернуть их, например, в типы (записи, протоколы). С учётом отсутствия безумной логики (вариаций по килотонне параметров), у нас процентов 90 решения прямолинейное функциональное программирование вокруг мап.

Возникает вопрос как с этим жить и различать что-где летает. Ответ — keywords как first-class names, то есть сквозь систему у нас летают мапы, с которыми мы общаемся или через селекторы в виде keywords (:type obj-link) или через интерфейс модуля (schema/get-type-name type), зависит от того, пересекает ли мапа границу модуля (вылетает ли в окружающий мир).

Отобразим решение в типах на Clojure:

  • EntityType, EntityProperty, EntitySchema — мапы, инкапсулированные в модули, так как используется сквозь решение (то есть в разных модулях)
  • Command — мап:
{:command :core/create
:args {:type :kanban/user-story
:kanban/title “111”}}
  • Обработчик команды в виде реализации мультиметода:
 (defmethod exec-command :core/create [command args exec-ctx])
  • СommandArgs — мап:
 :args {:type :kanban/user-story
:kanban/title “111”}
{:q/from :kanban/user-story
:q/select {:id :core/id
:name :core/name}}
  • QueryResult — мап
  • Entity — мап

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

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

Отдельные операции реифицированы в команды, запускающиеся через механихм predicate-based диспетчеризации вызова функции (мультиметоды).

Итого

На вопрос “из требования динамического домена следует потребность в динамической типизации языка, описывающего мета-уровень” ответ — не следует.

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

В нашем случае, с учётом специфики задачи, скоупа требований, размера команды, мы решили писать решение на динамически типизированном языке для однородной работы с понятиями в коде без умножения сущностей (оккам) для снижения случайной сложности (в виде ненаписания-использования обвязки для динамики в ST языке), при этом платя за это отсутствием type check.

В связи с отсутствием статических типов контракты любого элемента в решении описываюся явно логикой (либы prismatic scheme, clojure.spec) и отслеживаются *только* в run-time, в итоге решение проектируется таким образом, чтобы внятно сообщить что и где с ней пошло не так. Имея на руках first-class контракт, их можно компоновать, транслировать в документацию, API spec (swagger) и т. д.

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

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

Мы преследуем цель в написании предельно меньшего кол-ва адекватного кода, удовлетворяющего требованиям, минимальными силами.

Сейчас размер кодовой базы ~6000 LOC без *учёта* тестов (влазит в моноголову), размер команды — 2 человека (дисциплина и методология внесения изменений синхронизирована, shared mindset, откалиброванное видение).

Навскидку, увеличение в 3–4 раза любого из этого факторов серьезно усложнит развитие системы с учетом её реализации на динамически типизированном языке при наличии очень coupled решения, где изменения будут каскадом расходиться через всю систему (type checker не поможет).

Ответ на это — не делать так, не пухнуть, а разделять и властвовать.

Как-то так.

--

--