Как стать автором
Обновить

Дополняя SQL. Часть 3. Жизнь расширений для Visual Studio. Работа с IO. Необычное использование SQL

Время на прочтение10 мин
Количество просмотров2.9K
Публикую на Хабр оригинал статьи, перевод которой размещен в блоге Codingsight.

Что будет в этой статье?


Это третья статья в цикле о жизни разработчиков IDE для баз данных. Ее структура будет похожа на первую и вторую, но здесь я уже не буду рассказывать о парсинге текста. В этой статье речь пойдет о некоторых трюках по работе с файлами и просто различными проблемами при создании большого настольного приложения на платформе .NET. Для понимания этой статьи не обязательно читать первую и вторую части полностью, но в первой статье цикла есть несколько параграфов, которые отлично погружают в контекст разработки. Мне кажется, эта часть цикла получилась интересна даже для большего круга людей, чем предыдущие. Их было бы полезно глянуть перед прочтением статьи, а если на это нет времени или желания, то вот несколько тезисов из прошлых статей:

  • Мы делаем линейку IDE для СУБД MySQL, SQL Server, Oracle, PostgreSQL
  • Это настольное приложение на .NET стеке со всеми вытекающими
  • Много функций завязаны на анализ SQL кода. Используем для этого сильно доработанный ANTLR
  • Парсинг SQL это сложная задача в плане производительности и памяти. Постоянно приходится применять разные трюки для оптимизации

По мере публикации буду добавлять ссылки на следующие части:

Часть 1. Сложности парсинга. Истории о доработке ANTLR напильником
Часть 2. Оптимизация работы со строками и открытия файлов
Часть 3. Жизнь расширений для Visual Studio. Работа с IO. Необычное использование SQL
Часть 4. Работа с исключениями, влияние данных на процесс разработки. Использование ML.NET



Какие сложности?


Все дальнейшие проблемы разворачиваются в контексте настольной разработки на .NET стеке.

Проблема 32-х бит


Одни пользователи предпочитают использовать standalone версии наших продуктов; другим более привычна работа внутри Visual Studio и SqlServer Management Studio, для них разрабатывается ряд расширений. Одно из таких расширений — SQL Complete, так он заменяет стандартный Code Completion SSMS и VS для SQL на более мощный, плюс добавляет еще ряд полезных функций, вроде постоянного бекапа документов. Часть из этих функций, по своей природе, затратны к ресурсам RAM, но VS и SSMS на заре 2020го года все еще 32 битные приложения. Даже в теории им доступно лишь 3.25ГБ оперативной памяти, на практике же куда меньше. Расширения загружаются в тот же процесс, а значит делят эту память. Парсинг SQL — процесс очень затратный, как по ресурсам CPU, так и по RAM. Чтобы без лишних обращений к серверу подсказывать список объектов в пользовательском скрипте в оперативной памяти храним кеш объектов. Чаще всего он не занимает много места, но среди наших пользователей есть и те, чьи базы насчитывают вплоть до четверти миллиона объектов.

Жесткое ограничения по RAM и особенности работы с SQL привели ряд задач, которые было действительно интересно решать. Дни или даже целые недели проходили с запущенными профилировщиками памяти и десятками разных экспериментов. Для меня стал откровением, случай, когда сэкономив 6 байт на экземпляре класса Token мы, начали экономить около 100МБ оперативной памяти, так много этих объектов было.

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

Работа с SQL отличается от работы с другими языками. В C# практически не встречаются файлы даже на тысячу строк кода, но в SQL разработчик может работать с дампом базы на несколько миллионов строк кода. В этом даже нет ничего необычного.

DLL-Hell внутри VS


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

Существует MAF Framework, что MS разрабатывали для решения проблемы создания дополнений к программам. При этом он настолько изолирует эти дополнения, что может даже отправить их в отдельный процесс и взять на себя все коммуникации, правда надо сказать, что решение очень громоздкое и не сыскавшее особой популярности.

К сожалению, по разным причинам, Microsoft Visual Studio и SqlServer Management Studio собранная поверх неё реализуют систему расширений иначе. Это, с одной стороны, сильно развязывает руки в плане доступа плагинов к хостовым приложениям, но заставляет их уживаться внутри одного процесса и домена с другой.

Как и любое другое приложение в 21м веке наше имеет ряд зависимостей. Большинство из них это хорошо известные, проверенные временем и популярные в .NET мире библиотеки. К сожалению, именно это и приводит к разного рода коллизиям. Был случай, когда в рамках одного из обновлений Microsoft обновили версию Newtonsoft.JSON используемую внутри VS и добавили соответствующий bindingRedirect в конфигурационный файл. Это сломало работу другой библиотеки что мы использовали у себя, которая зависела от более старой версии. Нам пришлось в экстренном порядке выпускать обновление.

Много раз источником проблем становилась библиотека контролов DevExpress, на которой построен наш UI. Проблема в том, что эта библиотека хороша и нравится не только нам, но и другим создателям расширений под VS. Иногда пользователи устанавливают и наше приложение и приложение от других разработчиков. Очевидно, что у нас свой релизный цикл, у них свой, а у пользователя свое понимание того когда нужно какие продукты обновлять. В результате этого в одном домене(AppDomain) оказываются приложения собранные на разных версиях DevExpress. Возможно, .NET и смог бы разрулить такую ситуацию, но DevExpress сам берет на себя задачу разрешения сборок таким образом, что две версии этой библиотеки внутри одного процесса гарантировано приведут к шквалу исключений.

На нас обрушился просто шквал проблем, когда мы предприняли робкую попытку начать создавать все новые окна на WPF. Корнем всех этих проблем стало то, что WPF, по умолчанию не указывает StrongName сборок. Таким образом CLR просто возьмет первую попавшуюся, что может оказаться не той которая нужна. Это стало для нас большим разочарованием, так как ряд задач, что перед нами стояли решались бы сильно проще в геометрии WPF.

Часть из вышеупомянутых проблем удалось решить подпиской на хитрое событие AssemblyResolve у AppDomain.Current. Обработчик этого события менялся в ответ на жалобы пользователей с различными наборами расширений. После очередного изменения стало понятно, что мы уже начинаем терять какие-то из начальных случаев. Тогда на каждый из сценариев мы написали по модульному тесту, а в коде обработчика оставили комментарий, напоминающий разработчику написать тест при внесении любого изменения в код обработчика.

Прокачка сообщений внутри lock




Далеко не все знают, что .NET Framework будет перекачивать очередь сообщений Windows повиснув внутри какого-то WaitHandle. Это значит, что, по сути, внутри каждого lock может быть вызван любой обработчик любого события в приложении, если этот lock успеет уйти в режим ядра, а не будет освобожден во время фазы spin-wait. Это может приводить к re-entrancy в совсем уж неожиданных местах. Несколько раз это приводило к проблемам типа “Collection was modified during enumeration” и различных ArgumentOutOfRangeException. При этом требовалось немало времени, чтобы понять, что на самом деле произошло.

Крутые велосипеды решения


Добавление сборки в solution при помощи SQL


Если в руке молоток, то все похоже на гвоздь.
Народная мудрость

Когда проект разрастается, задача добавление сборок, простая вначале, превращается в десяток нетривиальных шагов. Хоть один из них да забудешь. Некоторые, думаю, можно было решить через создание шаблонов проектов, некоторые — явно нет.

Однажды нам нужно было добавить в solution около десятка различных сборок, мы делали очень большой рефакторинг. Дело в том, что на основе где-то 300 сборок, создано около 80 solution’ов, продуктовых и тестовых. На основе продуктовых solution’ов написаны InnoSetup файлы, в которых перечисляются сборки, что пакуются в инсталляцию, которую скачивает пользователь. Алгоритм добавления сборки в проект выглядел следующим образом:

  1. Создать новый проект
  2. Добавить к нему сертификат. Настроить подпись сборки
  3. Добавить файл версии
  4. Перенастроить пути, куда собирается проект
  5. Переименовать папку, чтобы она соответствовала внутренней спецификации.
  6. Заново добавить проект в solution
  7. Добавить пару сборок, ссылки на которые нужны всем проектам
  8. Добавить сборку во все необходимые solution’ы: тестовые и продуктовые
  9. Для всех продуктовых добавить сборку в инсталляцию

Эти 9 шагов, было необходимо повторить около 10 раз. 8 и 9 шаги не тривиальны и легко забыть добавить сборку именно везде. Такая большая и механическая задача в любом здоровом разработчике пробуждает желание ее автоматизировать. Так случилось и с нами. Только как указать в какие именно solution’ы и инсталляции прописывать вновь созданный проект? Сценариев ведь много, а часть из них вообще сложно предугадать. Тут в голову стрельнула шальная мысль. Solution’ы связаны с проектами как многие-ко-многим, проекты с инсталляциями — так же, а SQL это инструмент что создавался и более чем пол века вытачивался для решения именно таких задач.

Вот что мы решили сделать. Мы создали .Net Core Console App, что сканирует все .sln файлы в папке с исходниками, при помощи DotNet CLI извлекает из них список проектов и раскладывает в SQLite базу данных. Программа содержит в себе несколько режимов:

  • new — создает проект и все необходимые папки, добавляет сертификат, настраивает подпись, добавляет версию, минимально необходимые сборки
  • add-project — добавляет проект ко всем solution’ам, что удовлетворяют SQL запросу, что будет передан одним из параметров. Для добавления проекта в solution программа внутри использует DotNet CLI.
  • add-iss — добавляет проект ко всем инсталляциям, что удовлетворяют SQL запросу.

Идея указывать список solution’ов через SQL запрос может показаться громоздкой, но она полностью закрыла все существующие кейсы, а скорее всего и любой возможный в будущем.
Пример сценария использования. Создать проект “A” и добавить его ко всем solution’ам где используется проект “B”:

dbforgeasm add-project Folder1\Folder2\A "SELECT s.Id FROM Projects p JOIN Solutions s ON p.SolutionId = s.Id WHERE p.Name = 'B'"

Ошибки


Проблема с LiteDB


Пару лет назад перед нами поставили задачу разработать функцию фонового сохранения пользовательских документов. Она имела два основных флоу применения: возможность мгновенно закрыть IDE и уйти, а вернувшись начать с места, где остановились, возможность восстановления в исключительных ситуациях вроде отключений света или крешей программы. Для ее реализации нужно было где-то сбоку сохранять содержимое документов, делать это часто и быстро. Кроме содержимого нужно было сохранять еще немного метаданных, это делало прямое хранение в файловой системе не слишком привлекательным. Тогда подвернулась библиотека LiteDB, что впечатлила своей простотой и производительностью. LiteDB это быстрая легковесная встраиваемая база данных, полностью написанная на C#. Перед использованием мы подробно ее исследовали, скорость и простота работы нас подкупили. Я подробнее писал об этом здесь.

В процессе разработки вся команда была довольна опытом работы с LiteDB, к сожалению, основные проблемы начались уже после релиза новой версии, когда у некоторых пользователей возникали те проблемы, что ни разу не появлялись на этапе тестирования. Потребовалось много общения с клиентами, анализа логов и стеков, а еще несколько maintenance релизов, чтобы стабилизировать продукт. В чем же было дело? В официальной документации гарантировалось, что база обеспечивает корректную работу при конкурентном доступе как из нескольких потоков, так и из нескольких процессов. Агрессивные синтетические тесты показали, что база работает некорректно в многопоточной среде. Для быстрого исправления проблемы, мы синхронизировали процессы между собой при помощи самописного межпроцессного ReadWriteLock, к счастью параллельная запись из нескольких процессов не была нужна. Немного позднее было заведено около 10 различных issue в GitHub, сделано пару pull-requests, чтобы исправить ситуацию. Сейчас, спустя почти три года LiteDB работает значительно лучше, впрочем, урок был извлечен и теперь любые новые third-party в проекте подвергаются более тщательному исследованию.

StreamStringList


Это проблема противоположна случаю с “частичным лексическим разбором”, описанным в прошлой части статьи.

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

Идея была следующая:

  1. Вычитать файл построчно. Запомнить offset’ы в файле.
  2. При требовании выдать очередную строку, установить необходимый offset и вернуть данные.

Главная задача выполнена. Такая структура в памяти не занимает практически ничего относительно размера файла. На этапе тестирования тщательно проверяется объем занимаемой памяти для больших и для очень больших файлов. Очевидно, что большие файлы будут обрабатываться долго, а небольшие обрабатываются мгновенно. Нет никакого эталона для проверки времени работы. RAM не зря называют Random Access Memory это ее конкурентное преимущество перед SSD и особенно HDD. Эти носители начинают крайне плохо работать при не последовательном обращении к ним. Не помню уже как это выяснилось, но оказалось, что такой подход замедлил работу почти в 40 раз в сравнении с полной загрузкой файла в память. Кроме этого выяснилось, что мы читаем файл 2.5-10 полных раз в зависимости от контента.

Решение оказалось несложным, а прирост достаточным, чтобы операция выполнялась лишь немного дольше, чем при полной загрузки файла в память. Расход оперативной памяти тоже был незначительным. Источником вдохновения стал принцип загрузки данных из RAM в кеши процессора: когда вы обращаетесь к элементу массива, процессор копирует себе в кеш десяток соседних элементов, ведь часто так выходит, что нужные элементы оказываются рядом. Есть даже ряд структур данных использующие эту оптимизацию процессоров, для получения максимальной производительности. Именно из-за этой особенности произвольный доступ к элементам массива значительно медленнее чем последовательный. Аналогичный механизм реализовали и мы: вычитали окно в тысячу строк, заодно запомнили их смещения в файле. При обращении к 1001 строке, выбросим первые 500 строк, загрузим следующие 500. Если вдруг окажется, что нужна какая-то из первых 500 строк, то сходим за ней отдельно, ведь offset уже есть.

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

Заключение


Промежуточные итоги могут быть следующими:

  • VS и SSMS до сих 32 битные приложения. Расширениям для них приходится уживаться в 32 битном адресном пространстве
  • Все сборки, всех расширений грузятся в один AppDomain. С этим могут быть проблемы при использовании общих ThirdParty
  • Необходимо тщательно исследовать каждую ThirdParty библиотеку, включаемую в проект, не только на предмет удобства и производительности, но и стабильности
  • Повторю вывод из прошлой статьи. После успешной оптимизации производительности, снимите профиль использования оперативной памяти и наоборот

До встречи в заключительной части!
Теги:
Хабы:
+7
Комментарии0

Публикации

Истории

Работа

Ближайшие события