Introduction
С этой главы начинается описание внутреннего устройства фреймворка LeanES и примеров его использования для построения приложений.
Был пройден долгий путь и не одна реализация концепций, положенных в основу фреймворка LeanES. Note: когда появится время и душевные силы, можно вернуться к этой главе и добавить ретроспективу этапов разработки, однако на данный момент сфокусируемся на текущих реалиях.
Предыдушие реализации испытали на себе влияние многих других технологий, языков программирования и фреймворков, по сути фреймворк LeanES является воплощением "в камне" накопленного за годы работы опыта, поэтому старался впитывать в себя все смое лучшее.
Так сложилось что путь становления начинается с языка программирования Ruby и фреймворка Ruby on Rails - это прекрасный ЯП, содержащий в себе огромное количество полезных и удобных концепций, фич, реализаций, подходов, библиотек. И так как спустя несколько лет, после того как мы его уже перестали использовать в коммерческой разработке и решили начать сохранять накопленный опыт в виде реиспользуемого программного кода, для описания базовых сущностей - Классов мы обратились к базовым концепциям Ruby чтобы воплотить некоторые удобные вещи на CoffeeScript.
Да, именно CoffeeScript. На тот момент только недавно выпустили стандарт ES5, мыслей о ES6 даже в воздухе не витало, JavaScript пестрил "проблемными местами", а CoffeeScript был наиболее технологичным решением, для написания серьезных программ.
Поэтому многие семантические и синтаксические концепции из Ruby были преемлимо реализованы средставми языка CoffeeScript:
RC::CoreObject
- это базовый класс, в котором собрана основная низкоуровневая
логика объявления атрибутов и методов инстанса класса и самого класса (статических),
а так же логика, позволяющая работать механизмам примесей и интерфейсов.
Классы разрабатываемых модулей (приложений) должны наследоваться от класса RC::Module
,
по сути он объявляет неймспейс в рамках которого можно обратиться к любому объявленному
классу из любого другого (центральная точка, регистр классов модуля).
Класс RC::Mixin
нужен для того, чтобы от него можно было унаследовать классы "примесей",
в которых может объявляться общий код, который может быть подмешан в несколько
других классов. Концепция примесей взята из языка программирования Ruby и
решает проблему "Множественного наследования" в тех языках программирования
где это запрещено. На самом деле эта идея является более продвинутой, лаконичной,
простой, детерминированной и лишена язъянов присущих подходу с "Множественным наследованием".
Класс RC::Interface
нужен для того, чтобы от него можно было унаследовать классы "интерфейсов".
Данная идея не присуща классам в языках CoffeeScript или Ruby, а превалирует в
строготипизированных языках (например TypeScript, ActionScript, Dart, C++, Haxe, JAVA, PHP, Scala),
в частных случаях например в C++ и Ruby реализуется через абстрактные классы с
виртуальными методами (виртуальным метод - метод инстанса класса, который объявлен
в классе с некоторым именем и возможно с типами входных и выходных аргументов,
но в нем отсутствует тело-реализация. Класс который содержит хотябы один виртуальный
метод инстанса автоматически считается виртуальным. От виртуальных классов
нельзя инстанцировать объекты, но можно унаследовать дочерний класс в котором
будет объявлена реализация виртуального метода, после чего от него можно инстанцировать объекты).
Однако паттерн "Интерфейс" является одним из самых базовых паттернов проектирования
ПО (мета-паттерн), т.к. позволяет дать описание класса в общем (мета) виде без
конкретной реализации и является самым мощным инструментом при описании нескольких
классов со схожим функционалом (поведением), реализуя таким образом один из базовых
принципов ООП - "Полиморфизм".
(этот принцип позволяет делать классы, или целые модули, взаимозаменяемыми,
что повышает компонуемость приложения - огромные приложения можно писать из небольших
частей-компонентов, главное условие которых - они должны удовлетворять заявленному интерфейсу)
Класс RC::Class
так же является базовым и нужен для конструирования классов "на лету",
если это необходимо. Так же он нужен чтобы близко соответствовать объектной модели в ЯП Ruby -
т.е. все классы в приложении и в т.ч. базовые являются инстансами класса RC::Class.
Поэтому он может быть использовать при объявлении типов, если аргумент функции
или проперти в качестве value будут содержать не объект (или примитив), а некоторый класс.
Класс RC::MetaObject
- это служебный класс, от него не требудется наследоваться, единственная его задача - это сохранение всей мета- информаци класса в виде связанных между собой "словарей". Когда имеет место быть "Наследование", инстанс класса RC::MetaObject
в классе-наследнике получает ссылку на инстанс класса RC::MetaObject
в классе-родителе. Когда необходимо получить любую мета- информацию, происходит поиск этой информации по всей цепочке связанных мета-объектов в цепочке наследования конкретного класса.
Базовый класс CoreObject
Так как вся магия скрыта именно в этом классе, а принципы внутренней организации не существенны, покажем простой пример унаследования целевого класса от CoreObject, а так же попутно покажем как использовать "примеси" и "интерфейсы"
Но сперва надо отметить какие фичи реализованы в CoreObject:
- Объявление публичных, приватных и защищенных проперти и методов инстанса класса
- Объявление статических проперти и методов класса
- Объявление виртуальных проперти и методов
- Обеспечение статической типизации как для проперти и методов, так и для входящих и выходящих аргументов методов. (проверка типов)
- Наследование защищенных проперти и методов
- Подмешивание примесей к классам
- Безопасный вызов "super" - реализации метода из родительского класса
- Использование указателей для приватных и защищенных проперти и методов (обращение к ним только через указатели доступные только в области видимости текущего класса)
- Непереопределяемость проперти и методов извне класса ("защита от дурака")
- Расширяемость класса в будущем полезными методами из класса Object в ЯП Ruby
Пример объявления интерфейса
Здесь мы видим, что класс интерфейса надо унаследовать от RC::Interface
, после чего вызвать специальный метод, для наследования защищенных проперти и методов @inheritProtected()
Так же надо не забыть прокинуть ссылку на модуль внутырь класса @Module: App
Затем объявляем нужные методы или проперти. В интерфейсе можно объявлять только публичные виртуальные методы и проперти, однако могут быть объявлены не только методы инстанса, но и статические методы самого класса.
Следует обратить внимание что первый аргумент это объект где key
- имя объявляемого проперти/метода, а value
- это тип. (отсылка к строгой типизации)
При объявлении методов, тип всегда Function
, а во втором аргументе обязательно должны быть объявлены ключи args
и return
. В args
всегда массив типов аргументов, в return
- всегда одно значение типа выходного аргумента (т.к. в js и в coffeescript функция всегда возвращает одно значение)
Как и любой класс унаследованный от CoreObject в конце после объявления он должен быть проинициализирован вызовом метода initialize()
Пример объявления примеси
Здесь мы видим схожее с интерфейсом объявление, однако миксины могут содержать только НЕ виртуальные объявления методов и пропертей (для виртуальных существуют интерфейсы), а так же объявления в них могут быть только публичные и защищенные. Если объявляемый метод не объявлен в подключенном к миксину интерфейсу, или если миксин не имплементирует интерфейс в котором есть объявление этого метода, то при объявлении в нем обязательно наличие ключей args
и return
Пример целевого класса
Дериктива @implements APP::TestInterface
сообщает классу, что он имплементирует Некоторый интерфейс.
Дериктива @include App::TestMixin
подмешивает в класс Некоторую примесь.
API класса CoreObject
Исходя из описанного выше можно сделать вывод - CoreObject как минимум
реализует весь необходимый функционал, так что после объявления классов в коде
прикладного приложения, все будет работать "из-коробки" (достаточно использовать
все эти @inheritProtected
, @Module
, @public
, @static
, @protected
, @initialize
в прикладном коде, а CoreObject сделает всю остальную работу за сценой).
Однако надо отметить какими еще методами из CoreObject программист может воспользоваться в коде своего приложения:
@super()
- внутри методов инстанса и внутри статических методов@wrap()
- статический и метод инстанса - чтобы обернуть любую функцию, так что она будет выполняться с текущим контекстом@inheritProtected()
- статический метод чтобы корректно закончить операцию наследования в т.ч. приватных и защищенных методов и свойств@new()
- статический метод эквивалентный оператору new, т.е. MyClass.new() === new MyClass()@include()
- статический метод для подмешивания примесей@implements()
- статический метод, чтобы указать какой нитерфейс имплементирует класс@freeze()
- статический метод чтобы указать классу, что он "заморожен", т.е. больше не может быть изменен как снаружи так и изнутри@isSupersetOf()
- статический метод, возвращает true если текущий класс имеет совместимый интерфейс (наследник в "широком смысле")@subtypeOf()
- статический метод, возвращает true если подтип некоторого более абстрактного типа@initialize()
- статический метод, вызывается последним в теле объявления класса чтобы указать классу, что его объявление закончено.@initializeMixin()
- статический метод, вызывается последним в теле объявления миксина чтобы указать, что его объявление закончено.@async()
- статический метод чтобы указать что объявляемая функция асинхронная@static()
- статический метод чтобы указать что объявляемая функция статическая@public()
- статический метод чтобы определить публичный метод класса (как статический так и инстанса)@protected()
- статический метод чтобы определить защищенный метод класса (как статический так и инстанса)@private()
- статический метод чтобы определить приватный метод класса (как статический так и инстанса)@const()
- статический метод чтобы определить в классе константу@module()
- статический метод, используется в теле класса, чтобы явно указать классу, что он находится в неймспейсе конкретного модуля.@moduleName()
- статический и метод инстанса - возвращает имя модуля@superclass()
- только статический метод класса, возвращает ссылку на родительский класс@class()
- статический и метод инстанса - возвращает ссылку на конструктор@restoreObject()
- чтобы восстановить инстанс данного класса из снапшота@replicateObject()
- чтобы на основе инстанса данного класса создать json-снапшотinit()
- во всех унаследованных классах определение кода, который должен быть в конструкторе, необходимо определять в этом методе вместоconstructor
Module
- свойство класса и инстанса, возвращает ссылку на текущий модуль (неймспейс) в котором находится классmixins
- статическое свойство возвращает массив подмешенных миксинов (на протяжение всей цепочки наследования)interfaces
- статическое свойство возвращает массив имплементированных интерфейсов (на протяжение всей цепочки наследования)classMethods
- статическое свойство возвращает массив методов класса (на протяжение всей цепочки наследования)instanceMethods
- статическое свойство возвращает массив методов инстанса (на протяжение всей цепочки наследования)classVirtualMethods
- статическое свойство возвращает массив виртуальный методов класса (на протяжение всей цепочки наследования)instanceVirtualMethods
- статическое свойство возвращает массив виртуальный методов инстанса (на протяжение всей цепочки наследования)classImplemenedMethods
- статическое свойство возвращает массив имплементированных методов класса (на протяжение всей цепочки наследования)instanceImplemenedMethods
- статическое свойство возвращает массив имплементированных методов инстанса (на протяжение всей цепочки наследования)constants
- статическое свойство возвращает массив определенных констант (на протяжение всей цепочки наследования)instanceVariables
- статическое свойство возвращает массив свойств инстанса (на протяжение всей цепочки наследования)classVariables
- статическое свойство возвращает массив свойств класса (на протяжение всей цепочки наследования)instanceVirtualVariables
- статическое свойство возвращает массив виртуальных свойств инстанса (на протяжение всей цепочки наследования)classVirtualVariables
- статическое свойство возвращает массив виртуальных свойств класса (на протяжение всей цепочки наследования)instanceImplemenedVariables
- статическое свойство возвращает массив имплементированных свойств инстанса (на протяжение всей цепочки наследования)classImplemenedVariables
- статическое свойство возвращает массив имплементированных свойств класса (на протяжение всей цепочки наследования)
Использование всех этих методов обеспечивает следующие основные функции:
- Полная интроспекция всей кодовой базы как "снаружи", так и в runtime.
- В runtime есть полный доступ ко всем объявленным методам и свойствам, типам аргументов и типам результатов методов
- Полная проверка типов в процессе выполнения в development окружении, проверки типов опускаются когда код приложения работает в production окружении. Реализованы следующие типы:
AnyT
,ArrayT
,AsyncFunctionT
,BooleanT
,BufferT
,ClassT
,DateT
,DictT
,EnumT
,ErrorT
,EventEmitterT
,FunctionT
,FunctorT
,GeneratorFunctionT
,GeneratorT
,GenericT
,IntegerT
,InterfaceT
,IntersectionT
,LambdaT
,ListT
,MapT
,MaybeT
,MixinT
,ModuleT
,NilT
,NumberT
,ObjectT
,PointerT
,PromiseT
,RegExpT
,SetT
,StreamT
,StringT
,StructT
,SymbolT
,TupleT
,TypeT
,UnionT
- Полная проверка интерфейсов так же в development окружении
- Реализованы модификаторы доступа
private
,public
,protected
, что позволяет организовать полную инкапсуляцию методов и свойств. - Кодовая база может быть организована в Модули, обращение к классам, константам и утилитам модуля происходит непосредственно через Модуль. Что так же решает проблему циклических зависимостей, позволяет организовать кеширование и ленивую подгрузку любых сущностей внутри модуля.
- Каждая единица (класс) Модуля экспортируется в виде шаблонной функции, принимающей в качестве аргумента Модуль -
by design
. Это обеспечивает полную компонуемость (подключаемость) любых единиц (классов) Модуля внутырь любых других модулей, иными словами - можно любой класс подключить в любой прикладной модуль, из любого подгруженного модуля. - Базовый модуль библиотеки можно унаследовать в прикладной модуль приложения и иметь сразу все классы, находящиеся в составе базового модуля. так же в прикладной модуль приложения можно импортировать и подключать любыми модули расширений (плагинов), чтобы потом использовать их в программном коде.
- Помимо Интерфейсов в библиотеке реализованы специальные методы объявления Типов и Генериков, в том числе готовые специальные генерики:
Declare
,Generic
,Mixin
и генерики общего назначения:AccordG
,AsyncFuncG
,FuncG
,DictG
,EnumG
,InterfaceG
,IntersectionG
,IrreducibleG
,ListG
,MapG
,MaybeG
,NotSampleG
,SampleG
,SetG
,StructG
,SubsetG
,SubtypeG
,TupleG
,UnionG
Так же в базовом модуле имплементировано несколько концепций:
- Машина состояний - при разработке практически каждого приложения рано или поздно встает задача "реализовать некоторый функционал через машину состояний", это может быть "управление соединениями к базе данных" или "оплата ордера" или "отправка письма", это может быть что угодно. Но так как задание подразумевает переходы между некоторым оганиченным набором состояний и выполнение некоторой логики в момент перехода, то самым простым решением для такой задачи является именно Машина состояний. В библиотеке реализован Миксин для подключения Машины состояний с несколькими служемными классами. Подробнее это будет описано в отдельной главе.
- Очень часто инверсию управления, в некотором классе, удобнее всего реализовать через такую концепцию как
Hook
. Что требуется сделать программисту - всего лишь определить имена "Хуков" в данном классе, по сути будут созданы пустые методы, которые могут быть переопределены в унаследованном классе, либо могут быть инъектированны извне. Концепция "Хуков" предоставляет так же еще один инструмент, если при объявлении куков указать имена методов класса, которые должны быть вызваны при выполнении "Хуков". Таким образом мы всего лишь описываем декларативное представление последовательного выполения некоторых методов класса, композиция выполнения будет произведена за "сценой", а программисту достаточно лишь описать "поштучно" эти методы в коде класса. Подробнее это будет описано в отдельной главе.
Модуль "расширенной логики" содержит следующие несколько концепций:
- Имплементация спецификации фреймворка PureMVC
- Pipes для коммуникации между микро-ядрами мультитонами в составе PureMVC
- Концепция DelayedJob чтобы можно было назначать любой метод класса для обработки в фоновом потоке.
- В чстности DelayedJob реализован как частный случай концепции Resque. Можно определять такую абстракцию как Очередь и Обработчик очереди. Черед подмешивание миксина Очередь и Обработчик могут работать с конкретным бекэндом хранения очередей (база данных, менеджер очередей,...)
- Концепция Switch-медиатора в котором запускается http-сервер и он посылает приложению сигнал на обработку приходящего к серверу запроса
- Концепция прокси-Коллекции, как абстракция надо работой с таблицей в базе данных
- Конецпция Data-Mapper для работы с сериализацией записей из таблицы в базе данных
- Концепция Router класса содержащего декларативную карту роутов-урлов, которые может обрабатывать http-сервер
- Концепция Миграций, для проведения изменений с таблицами в базе данных и/или изменения данных в таблицах
- Концепция прокси-Конфигуркции - как центральной точки в приложении, хранящей все конфиги (получающей конфиги из файла на жестком диске, из переменных окружения или любым другим способом)
- Концепция прокси-Гейтвея хранящего все описания эндпоинтов http-сервера для предоставления json- метаданных в SwaggerUI в формате OpenAPI
- Концепция Query-объекта, он в декларативном виде содержит описание запроса в формате MongoQuery для того чтобы формат запроса не зависил от того, с какой базой данных идет работа в приложении
- Концепция Курсора как абстракция над итератором списка данных, возвращаемых из базы данных. Если возвращается массив за один раз, курсор предоставляет то же самое API для итерации по этому списку, но если возвращается поток данных, может быть создан полиморфный курсор с тем же интерфейсом для работы с потоком чанков - в программном коде приложения ничего менять не требуется.
- Концепция полиморфного Рендерера, по умолчанию все ответы от http-сервера проходят через дефолтный json- рендерер, который всего лишь делать
JSON.stringify(result)
, но могут быть объявлены любые другие рендереры и они будут использоваться в зависимости от accept type в реквесте к серверу. Иными словами, если это html запрос, то рендеринг будет делегирован html- рендереру, если xml запрос, то xml- рендереру, ... - Концепция Ресурса реализует стандартный класс, который содержит обработчики запросов http-сервера. Именно к нему делегируется обработка запросов, которые получает Switch-медиатор.
- Концепция Application класса как входной точки для запуска всего приложения. Этот класс унаследован от PipeAwareModule класса в составе Pipes из PureMVC - таким образом Application класс является еще и Shell медиатором, через который происходит коммуникация через Pipes cо всеми другими мультитонами в составе приложения. Таким образом "большое" приложение может состоять из независимых микро- приложений, коммуницирующих только через Pipes.
- Реализовано большое количество миксинов для подмешивания специальной расширенной логики по необходимости.
Появление ES6 и последующих стандартов
Ничто не вечно под луной. К 2015 году в черновик ES6 стандарта перенесли очень большое количество удобных языковых конструкций из CoffeeScript, некоторые частично, некоторые не перенесены до сих пор. Однако это повлияло на экосистему JavaScript координальным образом и ускорило как внедрение новых стандартов, так и пополнение рядов девелоперов, которые стали переходить на JavaScript из других языков и диалектов. К сожалению на данный момент CoffeeScript скорее мертв, чем жив. Продолжается его использование в закрытых корпоративных экосистемах, но скорее как legacy, новые фичи давно уже не внедряются в этот язык программирования.
Поэтому было принято решение адаптировать/переписать старую кодовую базу с CoffeeScript на современный JavaScript.
Статическую/статическую в рантайме типизацию было принято решение не переносить, чтобы не загромождать прикладной код обвязками в функциональном стиле, как это было в CoffeeScript варианте (там за счет более лаконичного синтаксиса это не вызывало никаких проблем).
Для статической проверки типов был выбран Flow.js, так как не вызывает обременения и просто вырезается Babel'ом в момент компиляции оставляя чистый JavaScript. Через дополнительный плагин к Babel все Flow типы превращаются в проверки черезе assert()
в runtime - что так же удобно как на CoffeeScript и не требуется изобретать велосипед.
Так же к Babel'у был подключен плагин для использования в коде приложений декораторов, потому как они предоставляют прекрасный виразительный языковой инструмент, с помощью которого можно реализовать весь тот функционал, который был ранее реализован в CoffeeScript.
Так же было принято решение объедитить Базовый модуль и Расширяющий модуль в одной библиотеке - фреймворке, тк. показала практика Базовый модуль (предоставляющий только Объектную модель) практически не используется отдельно и проще сконцентрировать свои силы на одной библиотеке-фреймворке и проще сопровождать.
Однако Расширяющий модуль сильно перегружен заточенной под серверную разработку логикой, поэтому было решено произвести декомпозицию и вынести специализированный функционал в отдельные подключаемые модули - чтобы их можно было подключать по необходимости. Это положительно сказалось на архитектуре - были выявлены проблемные места с подключением плагинов и откровенные баги - и то и другое было исправлено. Отдельные модули так же стали легче и проще, как для понимания функционала так и для покрытия тестов и использования.
Было решено исправить ситуацию с использованием this.facade.retrive...
, т.к. это вырождалось в использование фасада в качестве ServiceLocator, поэтому была проделана работа, по внедрению библиотеки inversifyJS в ядро фреймворка, чтобы инъектировать зависимости через геттер (или конструктор).
Таким образом больше не требуется предоставлять сложный перегруженный фасад в тестах, а код классов с использованием инструкций для инъекции стал по-настоящему гибким и изолированным.
Так же стоит отметить, что добавление inversifyJS было выполнено с сохранением обратной совместимости с API PureMVC, т.е. при написании кода в старом "стиле" все будет так же работать без ошибок. Это стало возможным за счет внедрения механизмов inversifyJS "под капотом". Для инъекции любых "старых" или "новых" классов, были добавлены специальные фабрики, они инъектируют нужные инстансы (а в некоторых случаях и классы) только в момент необходимого использования в runtime.
К сожалению, на последних этапах реализации было обнаружено, что в Flow.js нет возможности объявить интерфейсы для проверки статических методов классов, т.е.
- можно проверить итерфейсом инстанс класса
- можно указать $Class в качестве типа аргумента
НО нельзя:
- сделать проверку интерфейсом самого класса (проверить статические методы)
- срезы и "простые" структуры с публичными методами тоже не могут выполнить проверку класса
С одной стороны понятно, что необходимость в проверке интерфейса и самих статических методов классов возникает в том случае, когда классы активно передаются как значения, однако Flow.js позиционирует классы как вспомогательный инструмент - для проверки инстансов (оъектов). С другой стороны программисту "виднее" как он хочет спроектировать классы и нужны ли ему статические методы, а если возникает потребность в передаче классов по значению (как объектов класса Class), то все таки у него должна быть возможность описать проверки, тем более что JavaScript явно позволяет передавать в качестве значений что-угодно, в т.ч. и классы.
На данный момент нет никаких сил и времени что либо переписывать, тем более настолько масштабно.
Однако если появятся желающие внести свою лепту в общее дело создания и поддержки фреймворка - стратегически и меньше всего потребует ресурсов - адаптировать имеющуюся кодовую базу на TypeScript. В нынешнем состоянии она более чем на 99% должна быть совместима с TypeScript. Обратить внимание стоит на использование специальных директив Flow.js, начинающихся с $
, т.к. в TypeScript для них будут скорее всего другие аналогии.
На данный момент каждый модуль покрыт типами и внутри себя самодостаточно типизирован. Сборка каждого модуля в development режиме так же сохраняет все проверки типов в runtime. А следовательно при разработке любого прикладного приложения при использовании development сборок модулей - все проверки аргументов выполняются и в runtime приложения. Все модули покрыты тестами (или почти все), а следовательно все модули можно безопасно использовать в разработке приложений.