1. Введение
Полтора года назад автор опубликовал на Хабре статью "Математические аспекты хорошего кода" [1]. Примерно в это же время автору случилось войти в команду, которой предстояло разработать микросервисную АСУ.
Изложенные в упомянутой статье принципы и идеи были в полной мере использованы при написании системы. Данная статья рассказывает, что из этого получилось, в надежде, что описанное может пригодиться коллегам-программистам в качестве готового паттерна или же повода для раздумий.
В вышеупомянутой статье использовалась "интегральная характеристика", описывающая, насколько хорош код. Её формула:
Надо признаться, что "интегральная характеристика" - долгопроизносимый и совершенно не информативный термин. Поэтому было решено назвать эту формулу "элегантностью". Звучит, в общем, разумно: хороший код = элегантный код.
Сам по себе термин "элегантность" применительно к программе звучит привлекательно, но что это значит с точки зрения разработки? Почему в элегантность вложено столько труда? Ответ: потому что элегантность позволяет сделать больше и лучше за меньшее время. Для достижения этой цели, элегантным надо делать не только код, но и весь процесс разработки.
2. Начало
Что же открылось взору в первый день?
Заказчик - крупная распределённая компания, которая сдаёт людям и организациям некоторые ресурсы в аренду. Имеет офисы в разных городах и странах, франшизу и протчая, и протчая. Необходимо этот бизнес автоматизировать. Наша статья будет касаться B/end-слоя системы.
Аналитика нам была предоставлена, наряду с возможностью с этим процессом взаимодействовать и влиять на него. Заказчик оказался на редкость адекватным, что стало огромным преимуществом. Вторым преимуществом было отсутствие легаси-кода. В наличии имелось решение Visual Studio с четырьмя начальными проектами на C# для одного микросервиса: API, Core, Data и Tests.
Вся предметная область разделена на ~15 доменов, каждому из которых, по задумке, соответствует микросервис (МС). У каждого своя база, свой репозиторий, и свой набор объектов (сущностей). Последних в системе на данный момент сто двадцать, не считая словарей.
Каждая сущность имеет некоторый МС в качестве базы: там она "живёт", создаётся и меняется. Однако многие сущности (или их варианты) нужны и в других МС, куда они должны в фоновом режиме реплицироваться.
Каждый МС должен выставлять обычный веб-интерфейс, спецификация которого была заботливо разработана заказчиком. Отсюда технология микросервиса была понятна: WebApi на Asp.Net Core.
Домены/микросервисы не являются полностью изолированными. Между ними необходимо установить довольно бодрое взаимодействие. Забегая вперёд, мы решили вызывать микросервисы по тому же интерфейсу WebApi. Вопрос целостности данных при таком взаимодействии был отдан на усмотрение команды.
3. Снижение когнитивной нагрузки на программиста
Мы устаём, когда пишем и читаем код. Каждое лишнее слово добавляет лишнюю нагрузку на мозг программиста. Поэтому - лаконичность и ещё раз лаконичность. Но без перегибов.
3.1. Свёртка декартовых произведений
Обычно программа имеет некоторый набор задающих множеств. Например: множество типов (Client
, Order
), множество операций (Create
, Update
), и так далее. Сама программа в этом случае представляет собой декартово произведение этих множеств. Самый простой способ построить программу в этом случае - это прописать всё в явном виде (CreateClient
, UpdateClient
, CreateOrder
, UpdateOrder
).
В нашем проекте негласное правило, что такая простота хуже воровства. Мы стараемся, чтобы каждый элемент множеств присутствовал в коде только один раз, а декартово произведение пусть строится во время работы программы.
Пример: надо провести поиск по нескольким полям БД с учётом collation. Исходный код выглядел как простыня:
queryable = queryable.Where(
s =>
(s.Name != null && EF.Functions.Collate(s.Name, Literals.L1).Contains(arg.Term)) ||
(s.City != null && EF.Functions.Collate(s.City, Literals.L1).Contains(arg.Term)) ||
(s.Country != null && EF.Functions.Collate(s.Country.Code, Literals.L1).Contains(arg.Term)) ||
(s.Country != null && EF.Functions.Collate(s.Country.Value, Literals.L1).Contains(arg.Term)) ||
(s.PostalCode != null && EF.Functions.Collate(s.PostalCode, Literals.L1).Contains(arg.Term)) ||
(s.Street != null && EF.Functions.Collate(s.Street, Literals.L1).Contains(arg.Term)) ||
(s.StreetNumber != null && EF.Functions.Collate(s.StreetNumber, Literals.L1).Contains(arg.Term)));
Этот ужасный код был заменён на следующий:
var searcher = new CollationSearcher<Site>()
.SearchText(s => s.Name,
s => s.City,
s => s.PostalCode,
s => s.Street,
s => s.StreetNumber)
.SearchDictionaries(s => s.Country);
//
queryable = queryable.Where(searcher.Search(arg.Term));
Так работает свёртка декартова произведения: каждый элемент множеств записан только один раз. Пришлось, конечно, повозиться, т.к. CollationSearcher
должен оперировать не значениями EF.Functions.Collate()
, а выражениями, которые будут транслироваться в SQL, однако результат того стоит (таких мест в коде порядка пятидесяти).
3.2. Кодирование строк
Сравните два способа закодировать строки:
public class Flowers
{
public const string Rose = "Rose";
public const string Violet = "Violet";
public const string Orchid = "Orchid";
}
//
string f = Flowers.Orchid;
public enum Flowers
{
Rose,
Violet,
Orchid,
}
//
string f = $"{Flowers.Rose}";
В первом случае нужно 5 (!) слов, чтобы закодировать одну строку, во втором всего одно. Если у вас одна строка в классе, то константа самое оно. Но когда нам понадобилось закодировать имена всех таблиц в системе, мы отбросили простыню и выбрали перечисление.
3.3. Именование
В нашем проекте есть ясное понимание, как давать имена объектам: имена нужны, чтобы отличать одно от другого. Если вы ссылаетесь на два процессора (это такой класс с бизнес-логикой), то уместные имена будут: ClientProcessor
, OrderProcessor
, чтобы отличить один процессор от другого. Но если в программе ссылка только на один процессор, то префикс Client
только впустую нагружает ваш разум.
Мы стараемся не называть одно и то же дважды, например: void ParseString(string toParse)
. Если смысл параметра ясен из названия метода, то мы пишем x
(самое популярное название параметра). Вообще, длинными сложными названиями (somethingToDoTonight
) не злоупотребляем. Если имя используется часто, оно должно быть короткое! Имена из одной-двух-трёх букв часто встречаются в нашем коде.
3.4. Методы расширения
Лаконичность - отличная причина, чтобы написать ещё один метод расширения. Например:
.Select(x => x.Name).ToArray()
сокращается до .ToArray(x => x.Name)
x == "a" || x == "b" || x == "c"
сокращается до x.In("a", "b", "c")
и т.д.
3.5. Маленькие удовольствия
Выпилить ключевое слово
private
- всё равно по умолчанию.Сократить
var x = new MyClass();
доMyClass x = new();
и возрадоватьсяСократить
x.ToString()
до$"{x}"
, аналогично
3.6. Явная типизация
Данный подход хотя и не сокращает код, но помогает программисту избежать лишних поисков глазами или в собственной памяти. Мы очень консервативно подходим к ключевому слову var
: пишем везде типы явно, кроме случаев, когда тип уже указан в вызове дженерика, например var c = _storage.Find<Client>(id);
. А также если имя типа вынужденно длинное.
3.7. Итого
Эти и многие другие приёмы, используемые вместе, дают очень хороший эффект. По нашим оценкам, в отдельных случаях сокращение объёма кода было до 80% против первоначального варианта. Теперь мы смотрим на метрики и радуемся.
4. "Квантовая" архитектура
С самого начала проекта было понятно, что микросервисы пересекаются по коду, но насколько, этого мы не знали. Однако практически сразу общий (или потенциально общий) код стал собираться в отдельную сборку под названием Shared
. По прошествии года, мы знаем, что этого общего кода примерно 70-80%. Общая сборка имеет объём ~8 тыс. строк, а средний МС - 2-3 тыс. строк (не считая миграций).
То есть, весь облик системы, её ядро, как раз в общей сборке и живёт. Сборки микросервисов содержат кастомизации общих классов - классы-наследники с перекрытыми виртуальными методами. А также сущности, модели, репозитории данных и т.п.
Поскольку каждый МС расположен в отдельном репозитории, общая сборка завёрнута в NuGet-пакет, который собирается в CI/CD и публикуется, дальше МС его подключает. Также имеются пакеты для справочников, ДТО и протокола API.
Итого, через год работы мы обнаружили, что наш проект обладает "микросервисно-монолитным дуализмом". С одной стороны, это истинные микросервисы (всё отдельное), с другой - полностью монолитное ядро. В силу этого дуализма, мы условно назвали такую архитектуру "квантовой".
Работать с ней чрезвычайно удобно. Если нужно создать новый функционал (напр. транзакции, см. ниже), то мы добавляем его в ядро, а в конечных проектах надо только поменять версию пакета.
5. Автоматическая обработка данных
Примером автоматической обработки данных является AutoMapper. Без него приходилось бы копировать поля объектов вручную. Это удобно, но не всегда достаточно.
Пример: пусть есть модель и сущность. В процессоре мы получаем модель на вход и должны создать по ней сущность в БД.
public class ClientModel : Model
{
public string Name { get; set; }
public string LastName { get; set; }
public KindModel Kind { get; set; }
}
public class Client : Entity
{
public string Name { get; set; }
public string LastName { get; set; }
public Kind Kind { get; set; }
}
AutoMapper прекрасно справляется с преобразованием ClientModel
→ Client
. Только вот, увы, записать полученный объект в базу нельзя, потому что Kind
это навигационное свойство, и при записи в базу его надо получить из репозитория, в противном случае EF Core начнёт создавать его заново, и будет нарушение ключа.
Поэтому после маппинга нужен этап восстановления, при котором навигационные свойства заполняются объектами, прочитанными из БД. Первое время мы писали код восстановления вручную, но очень быстро поняли, что код совершенно идентичный, и сделали восстановление автоматическим. Для этого нам понадобились две вещи: рефлексия и соглашения об именах.
Автор неоднократно сталкивался с мнением, что рефлексия это плохо. В частности, медленно. На самом деле, получение метаданных (напр. GetProperties
) действительно медленное (несколько миллисекунд) - но только в первый раз. Дальнейшие вызовы занимают уже микросекунды. То же касается работы с метаданными (GetValue
/SetValue
/Invoke
).
Чтобы автоматическая обработка могла работать, должен быть явный закон, который связывает имена соответствующих свойств. В нашем (простейшем) случае это равенство. Подобный закон имеет целью сделать преобразование по умолчанию максимально универсальным. Впрочем, для исключений есть override
. В ряде случаев даже довелось им воспользоваться, хотя не из-за имён, а из-за более сложной структуры.
С автоматической обработкой данных очень удобно применять обыкновенный (не виртуальный) полиморфизм. Например, для задания бизнес-логики. Мы применяем методы с одинаковой сигнатурой, но разными входными типами. При обработке в базовом классе делается проверка (через рефлексию): а нет ли в текущем типе требуемого метода? Если метод есть, то используется он, в противном случае - логика по умолчанию.
Другой пример - преобразование идентификаторов. В одном из случаев надо было отсортировать запрос в зависимости от поля, приходящего от фронта в виде underscore_case
. В первые полчаса кто-то написал код:
switch (sort)
{
case "first_name":
query = query.OrderBy(x => x.FirstName); break;
}
И потом эта история начала угрожающе расползаться. Через некоторое время, вместо этого был написан класс Phrase
, который обрабатывает идентификаторы в разных форматах, разбирает их на слова, и сравнивает между собой разные форматы имени. Плюс, он поддерживал артикль a/an и окончание множественного числа. После этого простынный код был удалён, а построение запроса стало автоматическим.
Итого, в 95% мест, где нужна одинаковая или похожая обработка для набора полей или коллекций каких-либо классов, мы применяем автоматическую обработку данных.
5% остаётся для всякой экзотики.
6. Аспектно-ориентированное программирование
Аспектно-ориентированное программирование (АОП) - это способ убрать под капот вторичные, не относящиеся к основной логике функции: логирование, безопасность и т.д. Используемый нами пакет PostSharp позволяет делать это при помощи аспектов: атрибутов, которые вешаются на классы, методы, свойства и пр. Магия этого происходит за счёт пост-компиляции вашей сборки. Удобно, что на сборочный конвейер (а в ряде случаев, и на студию) ставить софт не нужно, всё делается подключением пакета.
Таким образом, АОП - это элегантность на стероидах. Бесплатная версия PostSharp подойдёт 90% разработчиков. Ограничения: не более 10 аспектов на сборку + не более 1000 строк в каждом отдельном классе. На данный момент в Shared
5 аспектов, а самый длинный класс 330 строк.
В настоящее время разработчик PostSharp выкатывает новый продукт - Metalama; если PostSharp основан на анализе IL-кода, то Metalama - на анализаторе Roslyn. Надо будет оценить возможности его использования.
7. Генерация кода (T4)
Итак, в системе больше сотни сущностей. Исходя из реалий Entity Framework и Asp.Net Core, это 120 репозиториев и их интерфейсов, столько же базовых контроллеров, и т.д. Всё они, на 90%, отличаются только именами. Что же нам с ними делать? Представьте себе изменение, которое надо применить ко всем контроллерам во всех микросервисах.
Для более сложных случаев мы используем Roslyn (см. ниже), а все указанные элементы кода у нас генерятся в студии через Text Templates (T4). Создаваемые классы помечены как partial
, поэтому к ним можно добавлять расширяющие элементы.
В каждом микросервисе есть список сущностей (на C# в .ttinclude), который подаётся на вход шаблонам. При нажатии Ctrl+S шаблон (пере)создаёт все необходимые классы требуемого типа с нужными именами. Бонус от применения такого подхода тем больше, чем больше сущностей в отдельном микросервисе.
В коде наших шаблонов активно используется класс Phrase
, который позволяет вставлять требуемое слово в нужной форме. Например, шаблон, который генерирует контроллеры, получает на вход слово "Contract", а вставляет (в том числе) следующий код:
[SwaggerResponse((int)HttpStatusCode.OK, "Returns a contract object",
typeof(ContractResponse))]
При этом артикль "a" не закодирован жёстко, а вставлен классом Phrase
. Если бы вместо "Contract" было бы "Activity", то вставленный текст выглядел бы как "an activity object". В .tt-файле шаблона указанная строка закодирована как:
Phrase p = new (d); // d = имя сущности, напр. "Contract"
string single = p.ToText(Delimiter.Space, false, Flavor.Indefinite);
8. Метапрограммирование
Метапрограммирование, в нашем случае, это использование программирования для убыстрения задач, связанных с написанием/изменением собственно целевого кода. В нашем проекте по факту большинство объёмных задач так или иначе автоматизируется. Мы уже рассмотрели генерацию T4 как один из примеров; рассмотрим остальные.
8.1. Трансляция документации
Типовой случай: аналитики подготовили в Confluence ТЗ на некоторый тип данных. В него входит описание полей сущности, а также запрос/ответ API. Понятно, что любой вменяемый аналитик будет готовить описания в едином формате, в таблице, или в виде списка, например, такого:
id (Guid) mandatory
name (string) optional
...
Когда таких списков сотни, а каждый содержит десяток-другой полей, то идея набирать код руками или в лучшем случае копировать/вставлять названия, быстро теряет популярность. Надо медленно спуститься и ̶п̶о̶к̶р̶ы̶т̶ь̶ ̶в̶с̶ё̶ ̶с̶т̶а̶д̶о̶ построить требуемый код за одну операцию. Для этого написан код, который генерирует описание класса, принимая для этого текст всего описания, скопированный из Confluence.
8.2. Планирование объёмных задач и анализ по рефлексии
Как-то раз встала необходимость переписать тесты из начально избыточной реализации в нормальную. В нашем проекте тесты проверяют работу т.н. процессоров, т.е. классов, отвечающих за бизнес-логику. Процессоры имеют базовый класс, в котором определены операции над объектами, а наследники описывают, собственно, бизнес-логику через переопределённые методы.
В самом начале работы были второпях написаны тесты, которые тестировали бизнес-логику через вызов внешних методов процессора, например Create
, Update
, Delete
. При этом, уже когда был выделен базовый класс процессора, тесты всё равно продолжали тестировать весь процессор в целом. Это было жутко неэффективно и трудно поддерживать.
И вот мы собрались переписать тесты, тестируя по отдельности override
-методы бизнес-логики. Подступаясь к этой задаче, мы провели анализ существующих классов процессоров через рефлексию, и получили статистику, какие методы перекрыты в каких процессорах. Получилось, что несколько методов перекрыты во всех классах, некоторые в почти всех, некоторые во многих и некоторые в единичных. Это сразу дало нам картину того, что мы должны сделать.
Методы, которые перекрыты в большинстве процессоров, получилось сделать автоматическими, и тем избежать написания тестов на них вовсе (т.е. автоматические методы проверяются в тестах базового класса). Это дало возможность сразу ~70% работы сократить.
Ещё о значительной части методов мы пришли к выводу, что она покрывается случаями ручного тестирования, и написание тестов на них было бы излишне объёмным и дублировало бы работу QA.
В результате, у нас осталось примерно 10% работы относительно того, если бы мы принялись писать тесты на все методы по одному. Это сделало работу выполнимой.
8.3. Автоматическое преобразование кода (Roslyn)
Код эволюционирует. Сначала в голове, затем в компьютере. В последнем случае, эволюция происходит не так легко. Что же имеется в распоряжении разработчика?
Анализ по рефлексии очень гибкий, но, к сожалению, он не позволяет изменять код.
Visual Studio позволяет делать замены, в т.ч. с Regex, по дереву файлов. Однако, потребности при эволюции кода не ограничиваются заменами: они поистине безграничны. Наконец, объём кода может сделать трудозатраты на массовую трансформацию кода вручную - запретительными.
Перепробовав различные инструменты, мы поняли, что наш "родной" язык программирования есть самое гибкое и технологичное средство, чтобы решать сложные задачи трансформации кода. Таковы предпосылки к тому, чтобы использовать Roslyn.
Roslyn строит дерево объектов, соответствующее исходному коду. Данное дерево можно анализировать в программе, а самое главное - можно его изменять. Добавлять, переставлять местами, удалять, и делать всё что угодно. Когда модификация закончена, вы получаете новый текст программы, который остаётся только записать на диск.
Один из членов команды приобрёл экспертизу по Roslyn, что заняло примерно два дня. После этого нам "карта и пошла". Целый ряд задач мгновенно стало возможно решить за разумные небольшие сроки. Примеры таких задач:
добавить параметр
ILogger<T>
с конкретным типом в конструктор сотни классов и передать их в конструктор базового классапереименовать те же классы из
AbcService
вAbcProcessor
, притом, что слово Service употребляется в коде огромное количество раз по разным поводамперенести инициализацию зависимостей (DI), не специфичных для конкретных микросервисов, из них в ядро
9. Термины DAL и Unit of work
Как не надо называть вещи. Подключаем Entity Framework, создаём контекст, репозитории, метод Save()
... и называем полученный класс/интерфейс чем-то вроде UnitOfWork
. Именно это и было обнаружено при первичном осмотре кода. Почему это кривизна?
Прежде всего, паттерн Unit of work в исходном смысле [3] это транзакционность. Сама фраза unit of work суть смысловое наполнение термина "транзакция" - набор действий (work), который выполняется как целое (unit). Нужно отметить, что EF Core уже реализует указанный паттерн. Метод DbContext::SaveChanges()
обладает свойством транзакционности.
Теперь обратимся к термину Data access layer (DAL). Он означает доступ к указанным данным в соответствии с их структурой (типом). Поэтому коллекция репозиториев относится к этому паттерну, а не к Unit of work.
Таким образом, упомянутый в начале класс включает в себя оба паттерна, как Unit of work (через использование EF Core), так и DAL. Мы называем данный комбинированный паттерн постоянным хранилищем, класс PersistentStorage
.
10. Межсервисное взаимодействие
В нашей АСУ есть три случая, когда микросервисы так или иначе взаимодействуют друг с другом. Это собственно межсервисные вызовы по API, а также репликация и компенсирующие транзакции. Репликация - это процесс переноса изменений объектов из одних МС/БД в другие, зависимые для этого типа объектов.
10.1. Межсервисные вызовы
Микросервисы показывают самое обычное API средствами Asp.Net Core. Рассмотрим сигнатуру типичного метода контроллера:
[HttpPost($"{{id}}/upload")]
public async Task<ActionResult<string>> Upload(Guid id, [FromForm] UpRequest request)
Когда было принято решение, что микросервисы взаимодействуют между собой по тому же API, что и с фронтом, встал вопрос о внутреннем клиенте.
Естественным желанием было описывать параметры вызова, как если бы мы вызывали обычный метод C#. Между тем, поиск по фразе "C# WebApi client" показывает, что предлагается оперировать напрямую протоколом HTTP, или заворачивать работу с этим протоколом в бесчисленные методы, парные точкам API.
Нам нужен был единый компонент, позволяющий вызывать любую точку, в соответствии с сигнатурой реализующего метода. Довольно быстро выяснилось, что его надо написать самим. Что мы и сделали.
У нас есть три определения:
public enum Verb { Get, Post, Put, Delete, Patch }
public enum Disposition { Text, Argument, Body, Form }
public class Parameter
{
public object Value;
public Disposition Placement;
}
Всё взаимодействие с API выполняет класс ProtocolClient
, у которого есть основной метод:
public async Task<TResponse?> Invoke<TRequest, TResponse>(
Verb verb, MS service, TN kind, params Parameter[] parameters)
Здесь service
это указание на МС, а kind
- вид изменяемого объекта. Эти два параметра позволяют сформировать часть URL, определяющую контроллер. ProtocolClient
также определяет статические методы AsText
, AsArg
, AsBody
и AsForm
, позволяющие завернуть некие данные в экземпляр класса Parameter
. Два последних соответствуют атрибутам [FromBody]
и [FromForm]
в методах контроллеров.
Для примера, вызов указанного выше метода/точки Upload
будет выглядеть так:
using static ProtocolClient;
ProtocolClient c = new();
string s = await c.Invoke<UpRequest, string>(
Verb.Post, MS.Content, TN.Files, AsArg(id), AsText("upload"), AsForm(form));
Из кода видно, что первые два аргумента Parameter
участвуют в формировании URL запроса, причём первый участвует как параметр метода контроллера (id
), а второй - просто часть URL. Третий аргумент в точности соответствует параметру request
метода Upload
, имеющему атрибут [FromForm]
.
Вся работа с протоколом HTTP ведётся под капотом ProtocolClient
. Понятно, что, исходя из вариантов написания контроллеров, данный единственный класс покрывает любые случаи вызова API.
10.2. Работа с данными
Для остальных случаев межсервисного взаимодействия - репликации и транзакций - в качестве среды взаимодействия выбрана RMQ, хотя вся система знает её как обобщённый интерфейс IQueuedCommunicator
. Сам интерфейс предельно простой: два метода, Read
и Write
.
Нам очень помогло полезное свойство EF Core: она позволяет получить список изменений, совершённых с момента инициализации контекста. Поэтому в методе PersistentStorage::Save()
можно обработать этот список, создать новые записи журналов, и добавить их в базу в рамках той же локальной транзакции.
Собственно, репликация и транзакции используют свой независимый журнал в каждой БД.
10.3. Репликация
Журнал репликации сквозной, по одной записи на операцию над одним объектом. Фактически, это полный журнал всех действий над избранными типами объектов. Если для объекта есть ДТО, он преобразуется в него и записывается в журнал. Все записи в журнале нумерованы (IDENTITY), что позволяет при получении запроса переслать порцию новых записей. Каждая запись содержит JSON-сериализованную форму данных.
Микросервис, выполняющий роль источника, слушает очередь, в которую получатели отправляют запросы на репликацию (handshake). При обнаружении нового клиента ему отправляют все новые записи, начиная с той, что указывает клиент. По мере появления следующих записей они рассылаются клиентам, которые подписаны на соответствующий тип объектов.
После того, как репликация через RMQ была разработана, появилась необходимость в т.н. оффлайн-репликации, при которой бы делался полный перенос данных (например, в новую базу). Для этого был написан соответствующий компонент, который анализирует схему данных источника и приёмника, и автоматически строит SQL-запросы на перенос.
Состав реплицируемых данных, а также клиентская бизнес-логика описаны в т.н. плане репликации, который используется как при онлайн-репликации (RMQ), так и в оффлайн-варианте.
10.4. Транзакции
Для начала уточним терминологию. Транзакции SQL-сервера у нас называются "локальными транзакциями". Этот механизм доступен из-под капота EF Core и требует совсем немного дополнительного программирования - разве что для эмуляции вложенных транзакций, которые EF Core не поддерживает.
Если бизнес-операция включает в себя несколько МС со своими БД, то у нас появляется то, что мы называем просто "транзакция" - т.е. механизм, позволяющий откатывать изменения в нескольких БД. Если локальная транзакция защищает от сбоя СУБД, то наш механизм позволяет "защититься" от разнесения данных между разными БД.
Мы не используем термин "сага", поскольку он навевает мысль о ручном написании компенсирующих транзакций (необходимость этого относят [2] к недостаткам саг.) Подобно тому, как SQL-транзакции являются автоматическими (но требующими явного подтверждения - commit), нам потребовался подобный автоматический функционал для распределённых операций. Только в нашем случае явно требуется вызвать отмену (rollback).
Как это работает?
Начнём с того, что в наших HTTP-запросах предусмотрен заголовок Transaction
. Если API вызывается фронтом, этого заголовка нет. Но если один МС вызывает другой, то класс ProtocolClient
создаёт имя транзакции, записывает его в заголовок, и запрос уходит. На принимающей стороне есть специальная middleware, которое проверяет заголовок, и если он не пуст, записывает его в специальный AsyncLocal
.
После всех middleware управление переходит в контроллер, далее в процессор, внутри которого работает бизнес-логика. Отметим, что бизнес-логика ничего не знает ни про какие транзакции (кроме локальных). Она просто вносит требуемые изменения в данные.
Далее, наступает момент, когда бизнес-логика вызывает PersistentStorage::Save()
. Если этот метод обнаруживает, что имеет место распределённая транзакция, он создаёт необходимые записи журнала транзакций. Как он устроен?
Каждая транзакция имеет несколько "историй" (Story
), а каждая история состоит из "страниц" (Page
). Каждая история - это отдельный вызов метода Save()
, а страница - это отдельное действие с какой-то сущностью. Собственно записи журнала транзакций описываются в EF Core как класс Story
.
Имеется три колонки: имя транзакции, номер (IDENTITY) и строковое поле Text
, которое представляет собой JSON-представление массива страниц. В отличие от журнала репликации, который чаще читается, чем записывается, журнал транзакций не имеет индексов, т.к. отменяются далеко не все транзакции (чаще пишется, чем читается).
В свою очередь, класс Page
содержит следующую информацию: вид действия, старые и новые данные, а также информация (класс и сборка), необходимые для загрузки типа для десериализации. Данные хранятся в JSON-сериализованной форме.
Прежде чем обрабатывать список изменений, его необходимо отсортировать, дабы при откате транзакции, например, не попытаться создать потомка раньше его родителя, или не удалить родителя раньше потомков. Такая сортировка необходима, потому что EF Core выдаёт список изменений в порядке, который никак не формализован и не гарантирован.
На этом завершается работа вызываемого МС. Если после получения положительного ответа от него, вызывающий МС обнаружит какую-то ошибку, требующую отмены всей распределённой транзакции, вступает в дело класс RollbackClient
.
Для такой отмены он отправляет сообщение с именем транзакции в специальную очередь RMQ, которую слушают все МС. При получении сообщения об отмене, ответная частьRollbackClient
читает соответствующие истории и выполняет компенсирующие действия для списка историй задом наперёд. Для каждой истории, сначала создаются удалённые объекты, потом восстанавливаются изменённые, наконец удаляются созданные. После чего проигранные записи журнала тоже удаляются.
Описанный подход позволил прозрачно реализовать механизм, аналогичный саге, но полностью автоматический. В нашей архитектуре, бизнес-логика, местами весьма обильная, сильно структурирована и разделена на различные методы; в этой ситуации явное написание/поддержка компенсирующих транзакций превратилась бы в ад. Но, как бы ни была сложна бизнес-логика, мы нашли место, где все изменения представлены простым списком, который легко может быть откручен назад.
11. А что, так можно было?
После полутора лет на проекте, появляется возможность рассмотреть проведённую работу с расстояния, и задаться вопросом - как можно было бы "с нуля" написать эту систему лучше? Для этого надо проанализировать процесс разработки в целом, и найти точки улучшения. А каждая точка улучшения - это наличествующая проблема.
Отметим, что описанные здесь проблемы являются настолько общепривычными аспектами большой коммерческой разработки, что даже не воспринимаются как проблемы. Что не отменяет того факта, что на эти аспекты тратится просто чудовищное количество времени.
11.1. Явный код микросервисов
"Квантовая архитектура" позволяет по максимуму устранить дублирование кода. Хотя ядро и занимает 70-80% одного микросервиса, самих МС двенадцать (пока). Двенадцать! Это означает 48 проектов C#, а также означает, что вся структура МС воспроизводится 12 раз. Конечно, не дублируется один в один, везде разные данные и логика, однако по сути - это всё равно одно и то же, и кода всё равно очень много. Гораздо больше, чем кода ядра. Такое количество кода объективно тяжело поддерживать и совершенно невозможно - держать в голове, в отличие от того же ядра, именно за счёт линейного однообразия.
Данная ситуация кричит о проблеме качества (декартовы произведения). Одна и та же структура повторяется снова и снова. Необходима свёртка, радикальное повышение качества: структура микросервиса должна описываться один раз!
11.2. Дублирование знаний
Если мы устранили большинство случаев прямого дублирования кода, то знания о системе, увы, дублируются. А именно, одно и то же - структура данных и бизнес-алгоритмы - описаны дважды! Один раз - в документации (Confluence), второй раз - в коде. С учётом 12 МС и 120 объектов, это ОЧЕНЬ много. В Confluence это сотни и сотни страниц, в Jira - тысячи задач. Среди такого объёма информации, крайне трудно сопоставлять один вид записи с другим.
Рассмотрим, как здесь тратится время.
Написать своё видение в Confluence - аналитик.
Обсудить (не один раз) со всей командой - PM, аналитик, программисты, QA.
Перевести написанное в Confluence с английского на C# - программисты.
Повторить весь цикл по мере развития системы и внесения изменений.
В пунктах 1 и 3 записываются ОДНИ И ТЕ ЖЕ знания, то есть ВСЁ описание системы дублируется в памяти два раза, а во времени целых в три раза, за счёт пункта 2.
Отметим, что, по сравнению с C# и Visual Studio, Confluence является никудышным инструментом. Если для кода доступны многочисленные варианты структурирования, анализа, навигации, автоматизации, преобразования и сокращения, то в Confluence единственное доступное действие - редактировать и писать текст с картинками, таблицами и списками.
Хуже того, если для кода доступна человеческая версионность (Git), то система версий Confluence оставляет желать лучшего. По крайней мере, мы пробовали ей пользоваться и нашли, что даже цвет текста - лучший способ представления версий. Да-да. Исходная версия набрана чёрным, вторая - зелёным, третья жёлтым, четвёртая голубым:
Представили себе процесс сопоставления цветов с задачами в Jira и далее с кодом?
11.3. Что же делать?
Первый шаг к решению указанных проблем - осознать их в качестве проблем, которые нужно и можно решить, а не в качестве неизбежного зла. Как только этот скачок осознания пройден, решение становится очевидным: надо избавиться от явного кода МС и устранить дублирование знаний, то есть описывать систему один раз, при помощи инструмента, более удобного, чем Confluence. Тем самым значительно увеличив качество как описания системы, так и процесса её разработки и развития.
Для этого необходимо:
Хранить описание системы один раз, в формализованном виде, в базе данных
Код микросервисов генерировать по этому описанию
Для работы с описанием использовать специализированный UI
Предварительно можно сказать, что в базе данных будут храниться такие объекты, как имена, логические значения, цепочки и стрелочки. Плюс информация, специфичная для программирования, такая как типы данных и комментарии. Более сложные объекты собираются из них, как из деталей: например таблица (класс) это цепочка имён и типов данных, от родительской таблице к дочерней ведёт стрелочка, и т.д. Этими абстракциями можно описывать не только данные, но и алгоритмы бизнес-логики.
Использование столь простых базовых абстракций позволяет полностью исключить дублирование. Например, часты случаи, когда сущность B определяется (в уме аналитика и программиста) как сущность A плюс два-три поля. Таких может быть несколько: B1, B2 и т.д. Если сущность A большая, то при стандартном подходе будет уж совсем нехорошее дублирование. В описываемой архитектуре этого можно избежать, описывая сущности именно так, как о них думают люди.
Таким образом, за счёт использования подходящего набора базовых объектов, становится возможным формализовать всё, что только может быть написано в Confluence. А ведь это описание является исходным, первичным, с ним сверяется код.
Имея такое строгое описание, задача написания генератора кода МС становится принципиально выполнимой. Можно создавать текст на C# и компилировать прямо на лету в момент запуска процесса МС. Созданный образ можно кешировать в БД, чтобы последующие запуски микросервиса были быстрее.
Наконец, для редактирования описания в БД нужен специальный графический UI, по своему удобству сопоставимый с Visual Studio, позволяющий метапрограммирование и автоматизацию. Он должен быть достаточно удобен для не-программистов, например позволять редактировать алгоритмы в виде блок-схем и структуру данных в виде диаграмм.
Конечно, существующий и в полном разгаре проект с небольшой командой, которому полтора года, трудно конвертировать в подобное решение. Однако, один раз разработав необходимый код и отладив его, можно создать платформу для неограниченного числа микросервисных систем.
Заключение
Как видно из данного текста, программирование микросервисной АСУ имеет множество аспектов, вдоль которых можно оптимизировать как собственно систему (код), так и процесс разработки и представление знаний о бизнес-процессах. Именно рост качества по многим аспектам является залогом хорошего значения элегантности, а следовательно - достижения цели: делать больше и лучше за меньшее время.
В статье [1] указывалось, что мышление состоит из памяти и времени, поэтому оба этих понятия должны учитываться во всех разрезах: память - как ЭВМ (код, RAM), так и память сотрудников; время - как сотрудников, так и исполнения программы.
Помимо качества, на элегантность влияет сбалансированность системы и её целостность.
Примером балансировки является, например, золотая середина между линейными размерами и глубиной структуры. Скажем, размер - это строки кода или число файлов в папке, а структура - дерево наследования или структура папок/подпапок.
Что касается целостности, то современные приёмы программирования устраняют большинство случаев противоречивости. Нам, однако, понадобилось убедиться, что реплицируемый объект не стал бы перезаписываться через цепочку вторичных репликаций.
Ссылки
[1] Математические аспекты хорошего кода (Хабр) https://habr.com/ru/articles/656773/
[2] Паттерн: Сага (Хабр) https://habr.com/ru/articles/427705/
[3] Unit of work (Мартин Фаулер) https://martinfowler.com/eaaCatalog/unitOfWork.html