Всем привет! Я Олег, ведущий разработчик в Тинькофф. Расскажу историю про сорс-генераторы и почему именно генерацию кода мы выбрали для решения нашей задачи. Будут неожиданности и открытия, которые мотивировали нас двигаться дальше.
Как пришли к логированию и маскированию
У нас есть приложение, которое является своего рода фасадом, т. е. у приложения много интеграций с другими сервисами — проведение платежей, назначение встреч, проверка документов и контактов, получение финансовой информации и так далее.
Наше же приложение предоставляет интерфейс для работы с этими системами. Само приложение выступает посредником между всеми сервисами: данные в формате JSON пересылаются от одного сервиса в другой. Дополнительно накручиваем немного логики сверху.
Особенность нашего приложения в том, что объект, который рассылается в разные сервисы, большой. У нас есть доменный объект «Сделка», который содержит всю информацию по сделке — контакты, встречи, платежи, операции, документы и так далее. При таком большом количестве интеграций с разными системами периодически возникали проблемы, например когда из-за человеческой ошибки Json начал «ломаться».
Дело в том, что у объекта «Сделка» есть внутренний инвариант. Например, если в сделке есть идентификатор контакта, то и в списке контактов обязательно должен быть контакт с таким идентификатором. Наш Json ломался не в синтаксическом плане, а в семантическом — с точки зрения бизнеса некоторые инварианты нарушались.
Нам потребовался удобный инструмент, чтобы отслеживать изменения в сделке и понимать, какая система выполнила деструктивные изменения. Мы перебрали несколько вариантов, как решить проблему, и придумали логировать данные в четыре шага.
Если мы отправляем сделку целиком во внешний сервис и целиком получаем обратно — давайте будем ее логировать: залогировали до отправки, отправили во внешнюю систему, получили обновленную сделку, опять ее залогировали, чтобы понимать, что мы отправили и что получили в ответ.
Дополнительно подключаем библиотеку — nuget-пакет, который возьмет два объекта любой структуры и с помощью рефлексии сравнит значения их полей и свойств.
В итоге мы получаем некий дифф, понимаем, что у нас изменилось, и отдельной записью логируем эти изменения. Это нужно, потому что объект большой: примерно восемь уровней вложенности, массивы, вложенные объекты и так далее. Благодаря отдельному логированию изменений легче понимать, когда и кем было изменено то или иное свойство.
Решение логировать имеет ограничения — нельзя логировать все подряд. У нас хранятся персональные данные, документы, информация по автомобилям и так далее. Все это в лог писать нельзя, несмотря на то, что сами логи находятся во внутренней системе и доступ к ним ограничен.
Персональные данные мы не можем писать как минимум по требованию безопасности, как максимум — потому что они не нужны. Неважно, какое значение свойства Name было до и каким стало после, важен факт изменения — имя изменено системой в конкретный момент времени.
Используемая библиотека дает дифф между объектами, но мы не можем логировать все, что вернулось. Например, изменился номер документа — это персональные данные. Если смотреть весь объект, он будет содержать слишком много информации, которую логировать запрещено.
Если нельзя логировать данные, помогает маскирование, когда часть текста заменяется звездочками. Например, логируем не LastName: Сафонов, а LastName: “С*******”. Еще можно использовать шифрование — применить некий алгоритм и получить вместо фамилии бессмысленный набор символов, который можно расшифровать и получить данные обратно.
Получается, с одной стороны, что мы пишем логи, но можем писать туда чувствительные данные. А с другой стороны, есть механизм, который позволит писать чувствительные данные, и мы можем его использовать, чтобы обогатить логи, которые урезаны (из-за чего не всегда можно найти то, что нужно).
Поначалу мы могли жить без полноценных логов, но со временем росли сложность систем, количество изменений и операции и т. д. и проблема неполных логов становилась актуальнее. Раньше мы могли тратить время на восстановление цепочки данных, но постепенно его становилось все меньше — и мы решили запилить свою библиотеку.
Примерная постановка задачи:
public interface Comparer
{
MaskedDiff Compare<T>(T first, T second);
Masked Mask<T>(T input);
}
Мы решили, что нам нужна библиотека, которая предоставит интерфейс с двумя методами — Compare и Mask. Compare — принимает два объекта одного типа, сравнивает их и возвращает какой-то маскированный дифф, а Mask — принимает объект и возвращает маскированный результат.
Требования к библиотеке разделили на функциональные и нефункциональные.
Нам важна возможность с помощью библиотеки логировать объекты с учетом чувствительных данных — они должны маскироваться. Еще нужно сравнивать объекты и получать диф в замаскированном виде. В плане работы с персональными данными нужна железобетонная гарантия, что у нас ничего не утечет никуда. Это очень важно.
Также не хотелось вносить постоянно изменения в код и хотелось, чтобы мы могли по дефолту настроить поведение для конкретных типов. Например, числа мы никогда не маскируем, и хотелось, чтобы при добавлении новых числовых полей нам не приходилось ничего менять в самой библиотеке.
При этом сущность достаточно часто изменяется, где-то что-то добавляется, удаляется, и не хотелось каждый раз переписывать библиотеку, хотелось, чтобы это все было безопасно. А значит, никакой работы с Json, Runtime-проверок с регулярными выражениями, никаких проверок суффиксов и так далее.
С точки зрения производительности не хотелось бы сужать бутылочное горлышко, ведь фактически мы впиливаемся в достаточно нагруженный участок приложения (внешние запросы).
Итак, определили контекст — нам нужна библиотека, которая позволит сравнивать объекты и получать дифф между сущностями в маскированном виде.
Рефлексируем
Кажется, что сравнение объектов с маскированием результата выглядит тяжелее, чем просто маскирование объекта целиком, поэтому будем рассматривать именно метод Compare.
Сначала была рефлексия, потому что мы уже использовали готовую библиотеку, которая позволяет сравнивать объекты и получать дифф, и мы хотели запилить функционал с минимальными затратами.
Первым вариантом мы рассматривали, что можно взять существующую библиотеку и расширить ее. Но впилиться нормально не получилось: библиотека принимает на вход два объекта и вычисляет дифф. Мы не можем замаскировать данные до того, как отправить в библиотеку, потому что библиотека неправильно сравнит данные. Не можем впилиться в сам процесс сравнения, потому что библиотека сравнивает объекты и не позволяет внутри запилить какую-то логику.
Был еще вариант впилиться после сравнения, и он в целом рабочий, потому что библиотека дает обширную информацию о том, что поменялось, но с точки зрения объема кода это тоже оказался нерабочий вариант.
Второй вариант был впилиться в логи — в Json — и с помощью регулярок найти места, где нужно замаскировать данные. Вариант рабочий для небольших объектов в три-четыре поля. Такой вариант нам не подошел, потому что слишком много данных и слишком сложно постоянно следить за регулярками, названиями полей и так далее.
Третий вариант — форкнуть существующую либу или напилить свою. Казалось бы, сравнение объектов — это не суперсложная операция, поэтому вариант рабочий. Мы решили абстрагироваться от существующих решений и писать с нуля. В голове держали существующее решение и поглядывали одним глазом периодически, что и как там сделано.
Вот так выглядит код, написанный с помощью рефлексии:
public MaskedDiff Compare<T>(T first, T second) => GetDiff(first, second);
private MaskedDiff GetDiff(object first, object second)
{
// if (first.GetType() != second.GetType())
// ...
foreach (var property in input.GetType().GetProperties())
{
var maskStrategy = GetMaskStrategy(property);
result[property.Name] = Compare(property, first, second, maskStrategy);
}
return result;
}
Есть реализация изначального интерфейса, который принимает два объекта и вызывает вложенный метод сравнения. Так сделано, чтобы компилятор гарантировал, что мы передали объекты одного типа, потому что приватный метод уже принимает два объекта.
Получается, в runtime в момент выполнения получаем информацию о типах, в данном случае берем входной объект First, из него достаем GetType, проверяем, какой у него тип, получаем все свойства и начинаем их перебирать. Для каждого свойства выполняем некую логику.
Плюсы и минусы подхода | |
+ Мало кода, и он простой. Мы используем декларативный подход, никакого императивного кода, и главное — все наглядно: открываешь сущности, и все видно | — Нельзя посмотреть итоговый код. Может выглядеть как придирка, но комфортнее работать с кодом, который видно |
+ Универсальный метод — пишем один раз, покрываем тестами, и он гарантированно работает | — Производительность: конечно, можно компилировать выражение и в целом рефлексию можно дотюнить и она будет работать хорошо, но это требует больше затрат |
+ Удобная настройка атрибутами. Логику маскирования можно задавать атрибутами, т. к. мы к ним можем получить доступ рефлексией | — На этапе компиляции нельзя проверить корректность настроек. Компилятор не сообщит, что мы обращаемся не к тому объекту или неправильно проставили атрибут, и ошибки вывалятся уже в момент выполнения |
+ Минимум правок при изменении структуры. Если изменяется какое-то поле, то библиотеку трогать не нужно | — Все еще могут быть ошибки при выполнении, в этом случае теряется целиком запись в лог и это неприятно. Даже в существующей либе было куча issue’s на Гитхабе, когда либа некорректно обрабатывала какие-то хитрые кейсы — у нас с большой долей вероятности могло возникнуть подобное |
+ Можно задавать поведение по умолчанию для разных типов |
Мы подумали, что рефлексия — крутой вариант. Но основная проблема в том, что мы хотим видеть, как выглядит итоговый код, и решили попробовать написать его руками.
Как писали код руками
Мы написали немного кода для конкретного объекта:
public MaskedDiff Compare(Contact? first, Contact? second)
{
var result = new MaskedDiff();
if (first?.LastName != second?.LastName)
{
result[nameof(Contact.LastName)] = new Diff(
MaskLastName(first?.LastName),
MaskLastName(second?.LastName),
);
}
// Сравнение других свойств
return result;
}
Метод Compare возвращает MaskedDiff и принимает на вход два контакта, тоже формирует некий MaskedDiff. Мы проверяем: если first name у первого объекта lastName не равен lastName у второго, то записываем результат и маскируем как фамилию. Так пробегаем по всем свойствам и возвращаем результат
Плюсы и минусы подхода | |
+ Можно посмотреть код. Мы не работаем с object, нет вызовов как в рефлексии. Мы явно обращаемся к объекту, к свойству и так далее | — На каждую сущность нужен свой метод |
+ Производительность максимальная, и нет ограничений | — Трудозатраты — все поля нужно смотреть вручную |
+ На этапе компиляции можно проверить корректность настроек | — Неудобная настройка. Все находится в коде, и чтобы понять, что и как маскируется, это нужно искать. Поэтому есть вероятность плавающего бага или незамаскированных данных |
+ Больше проверок во время компиляции | — При изменении структуры новые сущности и свойства не попадут в лог |
— Данные могут не замаскироваться, если где-то что-то будет перепутано. Много кода — много потенциальных мест для ошибок. |
С одной стороны, есть некий удобный алгоритм, который мы использовали в рефлексии. Мы сгенерировали логику сравнения объектов, и там немного кода. С другой стороны — у нас есть написанный руками код, который этот алгоритм реализует для каждого объекта. Мы решили попробовать скомбинировать оба варианта.
Как попробовали сорс-генераторы
Не помню, кто первым предложил попробовать относительно новую технологию майкрософта, но мы посмотрели и решили — почему нет?
Вот так выглядит сорс-генератор:
Специально вставил картинкой, дабы редактор хабра не подсветил лишнего :)
Здесь sourceCode — это StringBuilder, мы в него начинаем добавлять строки — формировать итоговый результат. Описываем алгоритм, как описывали его в рефлексии, но делаем это на этапе компиляции. И на выходе имеем код, который в итоге похож на тот, что мы писали руками.
Плюсы |
+ Универсальный метод. Пишем алгоритм как в рефлексии |
+ Можно посмотреть код, как если бы мы писали его руками |
+ Удобная настройка |
+ Производительность |
+ На этапе компиляции можно проверить корректность настроек, получаем доступ ко всем сорсам и можем проверить, атрибут находится там, где должен, или нет |
+ Ошибки в compile time, никаких кастов в рантайме, и все максимально безопасно |
+ Минимум правок при изменении структуры. Если добавили новое поле, сорс-генератор просто пересоборет все автоматически и ничего не нужно делать руками |
Минусы рассмотрим чуть дальше.
В рефлексии удобно работать с атрибутами, и там все автоматически обновляется — подключил и работает. С кодом так не получится, но с сорс-генами все устроено как в рефлексии.
В рефлексии нет compile-time-проверок, но есть небезопасные обращения к полям. Производительность в рефлексии будет чуть ниже, хотя можно подтюнить.
Написанный руками код не работает с атрибутами и автоматически не обновляется. Значит, мы не можем выкатить готовое решение, которое можно стабилизировать и больше не трогать: нам постоянно придется лезть в код, и это плохо.
Мы решили работать с сорс-генераторами, но и там оказалось не все так просто.
Проблемы выбранного подхода
При первом запуске внезапно развалилась сразу все. Я не смог даже запустить сорс-гены и заставить сгенерить что-то. Пошел читать документацию, но это помогло мало: технология относительно новая, быстро развивается, какие-то версии библиотек изменились, какие-то статьи и руководства стали неактуальными. Я потратил пару дней на то, чтобы хоть что-то запустить.
Когда у меня получилось запустить сорс-ген, он сгенерировал что-то и я попробовал опубликовать его в nuget — ничего не вышло. Там очень много специфики, он публикуется не совсем как обычный пакет, сорс-ген подключается как анализатор, и нужно в csproj руками вносить некие изменения. В настройке очень много нюансов.
Совет. Перед началом работы лучше найти кого-то, кто уже работал с сорс-генераторами, чтобы, если заходишь в тупик, человек выдернул из него и подсказал, как поступить. Это экономит кучу времени.
Поддержка IDE не то чтобы ограниченная, но неполная, и что-то может не компилироваться. Код не генерировался, хотя ошибок нет. Нужно время, чтобы разобраться.
Единственный вариант, который мне тогда помог, — очистить весь кэш, выполнить очистку проекта и перезагрузить IDE. Если пытаться просто вызвать очистку, почистить кэш или просто перезапустить IDE — это помогает не всегда. Почему так происходит — не знаю. Даже на сайте Майкрософта написано, что пока не совсем стабилизированная технология, поэтому поддерживается ограниченно.
Тестирование масштабирования. Когда я написал свой первый сорс-генератор и раскатал его на проект — у меня ничего не заработало, хотя ошибок не было. При этом я добавил много тестовых случаев, чтобы проверять на них генератор, и они работали. Как только подключили реальный проект — все развалилось.
Нужно учитывать, что сорс-ген получает на вход компиляцию целиком — написали сорс-ген, подключаете в какой-нибудь проект с тысячью классов и типов, и все они придут на вход в сорс-ген. То есть у сорс-гена на вход приходит огромное количество всего, фактически вся компиляция — все, что есть вообще в проекте, и с этим нужно работать, поэтому вероятность того, что что-то развалится, высокая, если к этому не быть готовым.
Тестировать придется больше обычного и на двух уровнях — тестировать, что именно генерирует генератор, и тестировать сам сгенерированный код, что он работает корректно. И там тоже могут быть проблемы.
Однажды я нагенерил тестовых случаев для разных типов, все работало, а потом оказалось, что некоторая комбинация типов все ломает. За счет того, что в реальном приложении сорс-генератор получает огромное количество информации, нужно очень аккуратно все кейсы обрабатывать и стараться в коде предвосхищать проблемы.
Например, получаем класс и знаем, что у него должен быть namespace. Метод получения namespace возвращает nullable-объект, и я знал (думал, что знал), что там не может быть null, и решил это не проверять. А потом оказывалось, что класс можно вообще положить не в namespace и у него не будет namespace. Или можно получить на вход тип с file-scoped namespace, для которого получение namespace выглядит уже немного по-другому, и у меня весь проект развалился.
Совет. Нужно использовать диагностики, чтобы проект не разваливался. Написать warning или error, если что то не удалось получить или удалось получить не то, что ожидалось. И все эти кейсы нужно обрабатывать, потом это сэкономит уйму времени. Важно не пропускать ошибку и не бросать исключение без подробностей — совет универсальный не только для генераторов.
И еще одна проблема — новая абстракция. Я думал, что возьму то, что написал на рефлексии, и перенесу в сорс-ген. Абсолютно нерабочий вариант.
Потому что у сорс-гена есть свой уровень абстракции, там нет system.Type, работа идет с синтаксическим деревом, фактически с кодом в виде отдельных токенов. Получается, что нужно взять синтаксическое дерево, найти в нем, что нужно, вытащить кусок кода и получить семантическую модель этого куска кода. И только с ней работать.
Две новые абстракции выбивают из колеи, когда начинаешь писать код. К ним нужно привыкнуть, и лучше сначала написать код руками. Сначала я пытался сразу писать сорс-ген и параллельно думал, как будет выглядеть итоговый код и как он будет работать, фактически я работал сразу на двух уровнях — писал генератор для кода, которого я пока в глаза не видел, а только представлял. Работать на двух уровнях сразу — пустая трата времени.
Совет. Лучше написать код руками, а потом уже генерировать его. И этот же написанный код потом можно использовать для тестирования.
Ошибки во время разработки
Ошибка 1 — протекают абстракции. Я делал абстракцию, в которой была работа с полями и свойствами.
Например, свойство FirstName имеет тип string, а string — это nullable-тип. Возник вопрос, куда лучше поместить флаг nullable: в тип или в абстракцию свойства? Тогда у свойства тип string, но само свойство имеет флаг nullable.
Другой похожий кейс — свойство с массивом строк. Тут тоже два варианта: тип — строка, свойство — массив. Или свойства могут содержать сразу тип, который называется ArrayOfString. Я выбрал неправильный подход, и в реальном проекте пришел тип double[][][], мой сорс-ген развалился сразу же. Он такой: «ребят, я не знаю что за тип, что за дабл дабл дабл» ?
Я переписал половину сорс-гена, ввел абстракцию типа, описал, что он внутри содержит и что он может содержать другой тип. Оказалось, что в сорс-генератор можно запихнуть что угодно — массив массивов, массив словарей и вообще все что угодно. Это нужно учитывать.
Ошибка 2 — я думал, что могу безопасно использовать свойства. Я генерю новый класс на основе существующего и думал, что в сгенерированном коде могу сгенерить такое же свойство. Хотел взять публичное свойство и сгенерировать его в другом классе. Казалось бы, что может пойти не так? Максимально простой код:
Оказалось, что произойти может все что угодно. Мне пришел объект, у которого свойство называется event. Event — зарезервированное слово, его нельзя использовать без символа @. Я этот момент не учел, и компилятор начал ругаться — ничего не сгенерировалось и не получилось посмотреть что произошло, пришлось дебажить. А после дебага пришлось переписать свои абстракции, потому что я не учел некоторые особенности, которыми может обладать свойство.
Ошибка 3 — не учел вложенные классы. Я писал код, который, по моему мнению, создаст экземпляр класса. Но не учел, что может быть вложенный класс, которому нужно полностью составлять весь путь.
Опять все развалилось и пришлось переписывать добрую половину своих абстракций, которые я наворотил поверх синтаксического дерева и семантической модели. И это было, с одной стороны, очень круто (такую ошибку в обычном коде, скорее всего, не встретишь), потому что интересно такой кейс получить и пытаться его обработать, но это тоже занимает время.
Ошибка 4 — генерации return-switch-выражения, которое проверяет тип и в зависимости от типа вызывает у него метод. В коде перебирались все классы и добавлялись кейсы для switch. Но оказалось, что нужно учитывать порядок: если в блоке switch сначала обработать общий тип object, а потом более специфичный string, то компилятор скажет «так нельзя».
Эта ситуация тоже развалила мои абстракции, и пришлось закладывать иерархию, которую я сначала пропустил.
Ошибка 5 — когда я просто пытался что-то сгенерить. У меня было название класса, и я пытался сгенерить для него builder с названием #ClassName#Builder.g.cs.
Я не учел, что классы неуникальные и в разных namespace могут быть классы с одинаковым названием. Что в этом случае будет с сорс-геном? В моем случае ничего — он просто не сгенерил мне ничего и мне пришлось все дебажить и снова тратить уйму времени на это. Так что лучше сразу генерить классы с учетом namespace.
Итоговое сравнение подходов
Если сравнивать подходы после всего, что мы прошли, картина немного меняется.
Сорс-генераторы просто использовать, так же как и рефлексию — выкатить готовую либу и запаковать как отдельный пакет. Но если учитывать сложность написания кода, масштабирование, тестирование, поддержку и дебаг — сорс-гены тяжелые. Нужно время, чтобы со всем разобраться, и поначалу все идет плохо — часто все разваливается и непонятно почему.
Поддерживать сорс-генераторы непросто. Во всяком случае я не нашел паттернов или практик, как писать сорс-ген. Каждый делает все по-своему, и я пока не нашел общих подходов к разработке генераторов.
Скорее всего, что-то появится потом как дополнительный уровень абстракции, но пока все очень тяжело. И даже если я кому-то свой код покажу, скорее всего, человеку будет сложно разобраться. Дебажить тоже трудно, потому что периодически что-то отваливается.
Нам подошли сорс-генераторы из-за простого алгоритма. Мы сфокусировались на формализации алгоритма и легко переложили его в сорс-ген. Еще было много однообразного кода, и нам подошла работа с атрибутами. При этом, если есть требования к производительности, сорс-генераторы будут максимально производительными в этом плане в общем случае.
Не стоит использовать сорс-генераторы в сложных алгоритмах, потому что тогда придется работать на двух уровнях — писать сорс-ген и в момент его написания накручивать итоговый алгоритм. И лучше дождаться стабилизации требований, чтобы не переписывать сорс-ген, который будет генерить новый код: это очень затратно.
Что я понял про сорс-генераторы
Работа с сорс-генами — крутой опыт для разработчика. Он переворачивает мышление — сначала пишешь код для компилятора, а потом он еще и будет выполняться.
Сорс-генераторы вытаскивают из зоны комфорта — все новое, и с этим интересно работать, потому что еще нет сформировавшихся практик и можно придумывать что-то свое.
И напоследок пример лога, про которые много говорилось. Вот так выглядит наш лог сейчас — есть объект deal, в нем details и в нем свойство summary. Видно, что изменился регион регистрации и флаг confirmed. У контакта с идентификатором 1234567 поменялся e-mail. При этом не видно, какой e-mail, видно, что поменялся домен. Диф может ничего не показать, если поменялось что-то в замаскированной части, но мы точно уверены, что тут что-то поменялось. Хотя мы и не видим, что именно.
Надеюсь, это было интересно, и если у вас есть вопросы или свои кейсы — добро пожаловать в комментарии!