![](https://habrastorage.org/getpro/habr/upload_files/1d8/c91/e05/1d8c91e0504a5aa816777af34a21c9c1.jpg)
Знакомо ли вам чувство, когда на поддержке есть сервис, о принципах работы которого знает буквально пара человек? В таких условиях очередная задача по миграции с одного решения на другое эквивалентна по-дурацки спродюсированному квесту из ролевой игры: ищем документацию, просматриваем глазами код, вызваниваем тех немногих, кто посвящен в таинства организации компонента системы.
В какой-то момент порог негодования в нашей команде достиг критической отметки. Количество сервисов на поддержке приближалось к двум десяткам. Сами же сервисы не развивались, а просто существовали как есть. Более того, никакой общей доменной области, никаких актуальных описаний архитектуры.
Мы решили навести порядок и разметить сервисы для понимания их архитектурных компонентов. После обсуждения взяли прицел на автоматизируемый процесс описания системы, а не на ручную поддержку документации. Добро пожаловать под кат — рассказываю о нашем пути, а в конце делюсь ссылкой на библиотеку.
Идея
После сеанса обсуждения между участниками команды был намечен ряд требований, которым должно удовлетворять наше решение. Стоит упомянуть следующие.
Функциональные:
Человекочитаемый формат. В конечном итоге наши архитектурные компоненты должны быть описаны в формате, пригодном для последующей обработки и чтения человеком.
Автоподдерживаемость. При изменении архитектуры приложения новое состояние должно подхватываться автоматически.
Нефункциональные:
Минимум влияния на Runtime. Среди услуг, предоставляемых нашими сервисами, есть критичные. Снижения Latency, неконтролируемый захват соединений из пула БД, RuntimeExceptions через часы после запуска — такого нам не надо.
Неинвазивность. Переписывание стагнирующих приложений, просто «чтобы документацию улучшить», непозволительно с точки зрения бэклога команды.
Расширяемость. Хотелось заложить точки для расширения решения. Например, описывать не только архитектурные зависимости, но и resilience-паттерны с их настройками, которые используются вместе в каждой интеграцией с внешними системами.
Каждый архитектурный компонент, каждая архитектурная зависимость имеет собственное представление внутри кода. Возьмем базу данных. Чтобы оперировать ее сущностями внутри приложения, используются ORM- или FRM-библиотеки. Механизмы этих библиотек оборачиваются в более высокоуровневые интерфейсы, которые как-то манипулируют данными уже на уровне приложения. Аналогично и для других компонентов: хранилищ данных, брокеров сообщений, интеграций со сторонними системами.
class KafkaDeduplicationConsumer(handler: EventHandler) {
def handle(event: Event) = parse(event)
.flatMap(e => handler.transferData(rip = e.ripId, alive = e.aliveId))
private def parse(event: Event): Task[DeduplicationEvent] = ???
}
final case class DeduplicationEvent(ripId: Id, aliveId: Id)
Интерфейс выглядит интуитивно понятным. Слушаем Kafka-топик, при получении сообщения как-то парсим его и переносим данные со сдедублицированного ID на активный.
Фактически это и есть пример того, как мы работаем с архитектурными зависимостями на уровне приложения.
Эмпирическая документация. Раз у нас есть код, мы могли бы попробовать обычным поиском по файлам репозитория собрать все наши классы, связанные с данными и архитектурными компонентами, и оформить в табличку.
Такой эмпирический подход может сработать, когда есть единый codestyle, но уже в репозитории другой команды все политики придется настраивать заново и тщательно следить, чтобы нейминг не разъехался. Подобное решение звучит как способное сработать здесь и сейчас, но в дальнейшем велик риск, что все превратится в тыкву.
Структурная документация. Что, если задокументировать код изнутри? Через рефлексию или макросы у нас есть возможность достучаться до структуры исходного кода. Если же заготовить разметку классов по принципу принадлежности к какому-то архитектурному компоненту, получится буквально «видеть код изнутри кода».
Можно было бы использовать фреймворки для вайринга, работающие во время Runtime по примеру Distage, но это нарушило бы требование неинвазивности. Пришлось бы вручную перелопачивать каждый сервис, чтобы затащить подобный фреймворк. Хочу отметить, что это неплохая идея, просто не подходящая для нас.
Решение
Мы взяли за основу решения структурную документацию. В какой-то момент получилось реализовать прототип на основе рефлексии и макроаннотаций, но был ряд серьезных ограничений.
Например, нельзя было размечать интерфейс внутри trait-ов. Так как в наших старых сервисах вайринг был построен через Cake Pattern, пришлось придумывать костыли, чтобы все работало.
Потом коллеги подсказали интересный вариант — реализовать плагин компилятора. В отличие от макросов, которые могут оперировать только конкретным кусочком исходного кода в месте вызова макроса, плагины компилятора позволяют манипулировать всей структурой кода.
Что такое плагины компилятора. Компилятор Scala (Scalac в 2.X и Dotty в 3.X) — это конвейер, который на вход принимает текстовые файлики кода с указанием параметров компиляции, прогоняет через пару десятков стадий обработки и на выходе выдает JVM-байткод.
![Вот описание фаз компилятора, взятое с typelevel.org Вот описание фаз компилятора, взятое с typelevel.org](https://habrastorage.org/getpro/habr/upload_files/6de/50d/fa1/6de50dfa1c5fb7a0c011e0509c96f16e.png)
Плагины компилятора позволяют как-то изменять представленный набор фаз компиляции, в частности встраивать туда новые фазы. Подобный инструмент дает возможность достичь самых безумных результатов: от новых синтаксических правил и конструкций (kind-projector, better-monadic-for) до альтернативных конечных представлений кода (Scala Native, Scala.js).
План работы с архитектурными зависимостями
Работать с архитектурными зависимостями можно так:
Обойти весь наш код и найти нужные классы.
Сохранить информацию о найденных классах в единую структуру.
Обойти весь наш код еще раз в поисках мест, где необходима информация об архитектурных компонентах.
Достать из единой структуры информацию и вставить ее в эти места.
Так получится «видеть код изнутри кода». Стоит обратить внимание, что для реализации такого плана необходимо сначала разметить классы, связанные с архитектурными компонентами.
Проектирование API. В первую очередь мы подумали о том, как должна выглядеть разметка классов, представляющих архитектурные компоненты. Варианты есть самые разные, но за основу взяли аннотации, так как они неинвазивны и позволяют легко навигироваться по IDE.
Наш пример из требований можно записать так:
@arch(Kafka("user.deduplication", env = Test, direction = Consumer))
class KafkaDeduplicationConsumer(handler: EventHandler) {
def handle(event: Event) = parse(event)
.flatMap(e => handler.transferData(rip = e.ripId, alive = e.aliveId))
private def parse(event: Event): Task[DeduplicationEvent] = ???
}
@archModel[KafkaDeduplicationConsumer]
final case class DeduplicationEvent(ripId: Id, aliveId: Id)
Аннотация @arch размечает архитектурный компонент. Внутри нее указывается тип архитектурного компонента и какая-то дополнительная информация вроде топика, среды запуска и указания, консьюмер это или продюсер.
Аннотация @archModel нужна для указания интерфейса, связанного с архитектурной зависимостью, который оперирует этой моделью данных. В данном случае DeduplicationEvent обрабатывается в KafkaDeduplicationConsumer. Стоит помнить о случаях, когда одна и та же модель нужна в разных интерфейсах или один и тот же интерфейс оперирует несколькими моделями.
Еще нужно подумать, как будет выглядеть обратная сторона библиотеки — работа с уже собранными архитектурными зависимостями. В the-seer используется подобный интерфейс:
object TheSeerAPI {
def summonMeta: ArchGraph = ???
}
case class ArchGraph(
dependencies: Set[(BelongsToFQCN, MetaInfoEnriched)],
models: Set[ArchModelEnriched]
)
ArchGraph — это упомянутая в плане единая структура, которая хранит в себе архитектурные зависимости и соответствующие им модели. Через метод summonMeta эти зависимости можно достать внутри кода и как-то обработать в Runtime, например конвертировать в JSON и вывести на этапе сборки приложения в пайплайне.
Реализация плагина компилятора
Чтобы наш проект был именно плагином компилятора, а не каким-то приложением, необходимо удовлетворять некоторым требованиям:
-
Добавить Scala-компилятор как зависимость в проект.
libraryDependencies += scalaOrganization.value % "scala-compiler" % scalaVersion.value
-
Наследовать интерфейс Plugin и определить все нужные параметры, в частности список новых фаз компилятора (components), название, описание, параметры плагина.
class TheSeer(val global: Global) extends Plugin { override val name: String = "the-seer" val graph: ArchGraphInner = new ArchGraphInner override val components: List[PluginComponent] = { new TheSeerMetaInliner(global, graph) :: new TheSeerMetaCollector(global, graph) :: Nil } override def init(options: List[String], error: String => Unit): Boolean = true override val description: String = """"/╲/\\╭༼ ººل͟ºº ༽╮/\\╱\\""" }
-
Создать файл scalac-plugin.xml и определить в нем входную точку в плагин.
<plugin> <name>marker</name> <classname>prototype.the.seer.compiler.plugin.TheSeer</classname> </plugin>
Вуаля! У нас есть скелет для плагина компилятора.
В The-seer-prototype дополнительно есть еще sbt-проект examples, в котором можно оперативно тестировать работу плагина. Чтобы examples зависел от sbt-проекта the-seer-compiler-plugin именно как от плагина, а не как от обычного модуля, необходимо прописать дополнительные настройки компилятора. Так новые фазы компиляции будут учтены при компилировании examples.
scalacOptions ++= Seq(
"-Xplugin:" + packageBin.in(Compile).in(`the-seer-compiler-plugin`).value,
"-P:the-seer:enabled"
)
Реализация TheSeerMetaCollector
В первой новой фазе компилятора хочется обойти весь наш код и найти все классы, аннотированные через @arch и @archModel, и сохранить в инстанс структуры ArchGraph.
В этом нелегком деле нам помог интерфейс Traverser. Создадим свой Traverser, который будет путем паттерн матчинга по Abstract Syntax Tree (AST) кода искать сущности вроде classes, objects, traits, vals, defs, аннотированные через prototype.the.seer.api.arch. Наш CallerTraverser применяется к одной единице компиляции (compilation unit), представляющей собой обычно один файл исходного кода.
class CallerTraverser(val oldCaller: String) extends Traverser {
override def traverse(tree: Tree): Unit = {
tree match {
case cd: ClassDef
if cd.symbol.annotations
.exists(info => info.symbol.fullName == "prototype.the.seer.api.arch") =>
val metasEnriched = refineImplDefToMetaInfoEnriched(cd)
metasEnriched.foreach(metaEnriched =>
graph.putDependency(cd.symbol.fullName, metaEnriched)
)
super.traverse(cd.impl)
case md: ModuleDef
if md.symbol.annotations
.exists(info => info.symbol.fullName == "prototype.the.seer.api.arch") =>
val metasEnriched = refineImplDefToMetaInfoEnriched(md)
metasEnriched.foreach(metaEnriched =>
graph.putDependency(md.symbol.fullName, metaEnriched)
)
super.traverse(md.impl)
case valOrDef: ValOrDefDef
if valOrDef.symbol.annotations
.exists(info => info.symbol.fullName == "prototype.the.seer.api.arch") =>
val metasEnriched = refineValOrDefToMetaInfoEnriched(valOrDef)
metasEnriched.foreach(metaEnriched =>
graph.putDependency(valOrDef.rhs.tpe.typeSymbol.fullName, metaEnriched)
)
super.traverse(valOrDef.rhs)
case cd: ClassDef
if cd.symbol.annotations
.exists(info => info.symbol.fullName == "prototype.the.seer.api.archModel") =>
val archModelsEnriched = refineClassDefToArchModelEnriched(cd)
archModelsEnriched.foreach(archModelEnriched => graph.putModel(archModelEnriched))
super.traverse(cd.impl)
case tr =>
super.traverse(tr)
}
}
}
Допустим, мы нашли один из размеченных классов. А далее его структура разбирается с помощью Quotation Syntax в методах вроде refineImplDefToMetaInfoEnriched.
def refineImplDefToMetaInfoEnriched(classOrModuleDef: ImplDef): Set[Tree] = {
val annotations =
classOrModuleDef.symbol.annotations
.filter(_.symbol.fullName == "prototype.the.seer.api.arch")
val (_, name, _, _, _, typeParams) = classOrModuleDef match {
case q"..$mods class $className[..$typeParams](..$fields) extends ..$parents { ..$body }" =>
(mods, className, fields, parents, body, typeParams.toVector.map(_.tpe.toString))
case q"..$mods class $className[..$typeParams](..$fields)(..$implicitFields) extends ..$parents { ..$body }" =>
(mods, className, fields, parents, body, typeParams.toVector.map(_.tpe.toString))
//здесь проблема с typeParams.map(_.tpe), так как это, оказывается, не type-ы
case q"..$mods trait $traitName[..$typeParams] extends ..$parents { ..$body }" =>
(
mods,
traitName,
Seq.empty,
parents,
body,
typeParams.toVector.map(typeName => typeName.toString().replace("type ", ""))
)
case q"..$mods object $objectName extends ..$parents { ..$body }" =>
(mods, objectName, Seq.empty, parents, body, Vector.empty)
case other => throw new UnmatchedTreeException(other.toString())
}
val enriched = annotations.map(annotation =>
matchOnAnnotationArgument(annotation.args.head, name, typeParams)
)
enriched.toSet
}
Здесь есть несколько интересных моментов:
-
Quotation Syntax. Фактически это инструмент, позволяющий устраивать Pattern Matching на структуре кода. Например, код
class MyShinyClass[MyTypeParam](myArg: Arg) {def hello = "world"}
будет соответствовать ветке
case q"..$mods class $className[..$typeParams](..$fields) extends ..$parents { ..$body }"
Выходной тип refineImplDefToMetaInfoEnriched (и его аналогов) — Set[Tree], где Tree — это AST кода. Почему именно такой тип, а не какой-то красивый класс с информацией об аннотациях вроде Kafka, Database, ExternalService и так далее? Потому что при разборе структур в Compile Time мы вынуждены работать с AST — и ничем другим. Код нашего приложения — это просто набор хитростуктурированного текста. У нас под рукой нет JVM, которая могла бы запустить код и инстанциировать классы. Мы вынуждены здесь отпустить попытки в строгую типизацию и просто протаскивать везде AST.
Затем мы складируем найденную информацию в ArchGraphInner.
metasEnriched.foreach(metaEnriched =>
graph.putDependency(md.symbol.fullName, metaEnriched)
)
Реализация ArchGraphInner
Если присмотреться к API, который мы объявляли ранее, можно заметить, что там используется структура ArchGraph.
case class ArchGraph(
dependencies: Set[(BelongsToFQCN, MetaInfoEnriched)],
models: Set[ArchModelEnriched]
)
Но в TheSeerMetaCollector речь идет о некой структуре ArchGraphInner.
class ArchGraphInner {
private val dependencies: mutable.Set[(String, Any)] = mutable.Set.empty
private val models: mutable.Set[Any] = mutable.Set.empty
def putDependency(className: String, metaTree: Any): Boolean =
dependencies.add(className -> metaTree)
def getDependencies: mutable.Set[(String, Any)] = dependencies
def putModel(archModelEnrichedTree: Any): Boolean =
models.add(archModelEnrichedTree)
def getModels: mutable.Set[Any] = models
}
Зачем нам понадобилось промежуточное представление? На самом деле разница между этими объектами довольно концептуальная.
![](https://habrastorage.org/getpro/habr/upload_files/b2d/7f8/d01/b2d7f8d0143fd9bcdd6f97a621316202.png)
То есть TheSeerMetaCollector заполняет ArchGraphInner сырыми данными (AST кода, отвечающего архитектурным зависимостям). А во время следующей фазы, о которой поговорим чуть ниже, ArchGraphInner превращается в не что иное, как в инстансы ArchGraph и вставляется в места вызова. Потом уже во время Runtime приложения мы можем оперировать инстансами ArchGraph как самыми обычными классами.
Реализация TheSeerMetaInliner
Согласно плану наш следующий шаг — вторичный проход исходного кода с целью обнаружения точек, в которых используется TheSeerAPI.summonMeta. Мы хотим обнаружить все подобные места и буквально заменить вызов метода самим телом инстанса ArchGraph.
Сейчас имплементация summonMeta выглядит так:
def summonMeta: ArchGraph = ???
Вопрос: как же заменить пустышку на настоящую имплементацию? Ответ: никак, нет необходимости что-либо менять.
@compileTimeOnly("Should be erased during compile time!")
def summonMeta: ArchGraph = ???
Столь странный стиль работы с кодом характерен для метапрограммирования. Нам не надо менять имплементацию, потому что в ходе TheSeerMetaInliner мы хотим найти все точки вызова summonMeta и в буквальном смысле заменить их методом ArchGraph.apply:
q"""prototype.the.seer.api.model.ArchGraph.apply(dependencies = $deps.asInstanceOf[scala.collection.immutable.Set[(String, prototype.the.seer.api.model.enriched.MetaInfoEnriched)]], models = $models.asInstanceOf[scala.collection.immutable.Set[prototype.the.seer.api.model.enriched.ArchModelEnriched]])"""
Чтобы провернуть такой фокус, нам уже не хватит просто Traverser из предыдущей созданной нами фазы TheSeerMetaCollector, потому что на этот раз нам необходимо не просто пройтись по коду, но и изменить его отдельные участки. Благо на такой случай заготовлен интерфейс AstTransformer.
Код TheSeerMetaInliner говорящий. Отлавливаем вызовы TheSeerAPI.summonMeta, берем нашу структуру ArchGraphInner и вставляем данные из нее в место вызова метода через quotation-синтаксис, предварительно типизировав его через localTyper.typed, так как наши фазы объявлены уже после главной фазы "typer".
override protected def newTransformer(unit: CompilationUnit): AstTransformer =
new TypingTransformer(unit) {
override def transform(tree: Tree): Tree = {
tree match {
case (tr @ q"prototype.the.seer.api.TheSeerAPI.summonMeta") =>
val deps = graph.getDependencies.asInstanceOf[mutable.Set[(String, Tree)]].toSet
val models = graph.getModels.asInstanceOf[mutable.Set[Tree]].toSet
localTyper.typed(
q"""prototype.the.seer.api.model.ArchGraph.apply(dependencies = $deps.asInstanceOf[scala.collection.immutable.Set[(String, prototype.the.seer.api.model.enriched.MetaInfoEnriched)]], models = $models.asInstanceOf[scala.collection.immutable.Set[prototype.the.seer.api.model.enriched.ArchModelEnriched]])"""
)
case _ =>
super.transform(tree)
}
}
}
Реализация структуры плагина
Теперь, когда есть представление об устройстве основных компонентов плагина, настало время поговорить, как связать все воедино.
При объявлении плагина мы обязаны указать все фазы компилятора, которые должны примениться во время компиляции. В TheSeer мы объявляем эти компоненты, предварительно создав пустой инстанс ArchGraphInner и передав его в инстансы TheSeerMetaCollector и TheSeerMetaInliner.
val graph: ArchGraphInner = new ArchGraphInner
override val components: List[PluginComponent] = {
new TheSeerMetaInliner(global, graph) ::
new TheSeerMetaCollector(global, graph) :: Nil
}
Сами же новые фазы обязаны наследовать интерфейс PluginComponent. Более того, в них необходимо написать название фазы и фазу, после которой ее следует запускать. В данном случае, "meta-collector" запускается после "typer", а "meta-inliner" — после "meta-collector".
Стоит заметить, что sbt запускает компиляцию на каждом подпроекте в соответствии с их зависимостями между собой. Компиляция каждого подпроекта начинается заново, так что таким наивным способом передать ArchGraphInner не получится. Можно записывать промежуточное представление в какой-нибудь файл, но в прототипе такое не реализовано.
За рамками статьи
Полученная диаграмма — один из способов представления ArchGraph. Для документации наших архитектурных зависимостей сделали два вспомогательных модуля:
-
SBT-плагин, позволяющий на этапе пайплайнов прогонять компиляцию проекта и интерпретацию в UML-код. Этот код кладется в артефакты пайплайна и затем попадает в Gitlab Pages, где автоматически рендерится в диаграмму. Таким образом, можно автоматически на этапе мерджа в master-ветку генерировать диаграммы.
-
Модуль, интерпретирующий ArchGraph в лейблы метрик. Метрики затем попадают на специальный дашборд Grafana и отображают информацию в виде таблиц.
Более того, получилось без каких-либо препятствий разметить код в ряде наших сервисов. Серьезных проблем, кроме минорных багов и увеличения продолжительности компиляции на проектах на время порядка десятка секунд (~50K LOC), замечено не было.
Демонстрация
Чтобы «потрогать руками» работу плагина, можно вызвать ExampleMain из директории examples. В консоль должно выйти UML-представление примеров из examples.suits
Если отрендерить выведенную в консоль UML-нотацию примеров, то диаграмма должна выглядеть примерно так:
![](https://habrastorage.org/getpro/habr/upload_files/a27/d42/31b/a27d4231b845eb7e1dfbdd14165c38b1.png)
Библиотека, о которой я столько рассказал. Если у вас остались вопросы по реализации или идеи — залетайте в комментарии!
Комментарии (4)
0xBAB10
31.01.2025 15:00начали за здравие, закончили за упокой..
так где описание взаимодействий со смежными системами? где процессы, где протоколы? где предусловия, постусловия, инварианты?
на выходе картинка DI графа. это полнейшая СТАТИКА. а где динамика?
из картинки можно извлечь что userService вызвал phoneService - офигеть какое великое знание
а он эксепшены бросит? какие, в каких случаях? а в каком контексте вызов произведен? его внешний сервис дёрнул или cron? может быть это часть саги? может требуется компенсирующая операция?
Ivoya Автор
31.01.2025 15:00Динамика заключается в том, что при каждом коммите в master эти картинки у нас перегенерируются. Ценнее даже не сама схема сервиса, а информация о моделях, которыми приложение оперирует (на скрине с Grafana видны поля моделей).
"офигеть какое великое знание" - это действительно знание. Когда сервис достается в наследство от другой команды, разработчики могут еще по коду соориентироваться. А что делать системным аналитикам? Технологам? QA-инженерам?
Но в любом случае, это просто рабочий прототип. Хочется учесть saga, outbox, inbox, cron, хочется посмотреть какие ошибки, какие resilience-паттерны на интеграциях - это все реализуемо, пока в коде есть обвязки над этими структурами. Ничто не мешает точно так же отлавливать в коде их использование и анализировать структуру.
Dhwtj
и много ли на скала кода?
Ivoya Автор
У нас в команде около пятнадцати. Несколько покрупнее - порядка 50к строк кода, остальные поменьше. А по всей группе компаний слышал оценку в несколько сот scala-сервисов.